516 lines
17 KiB
Python
Executable File
516 lines
17 KiB
Python
Executable File
# -*- mode: python -*-
|
|
# -*- coding: utf-8 -*-
|
|
|
|
##############################################################################
|
|
#
|
|
# Gestion scolarite IUT
|
|
#
|
|
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; either version 2 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
|
#
|
|
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
|
#
|
|
##############################################################################
|
|
|
|
"""Generation de PDF: définitions diverses et gestion du verrou
|
|
|
|
reportlab n'est pas réentrante: il ne faut qu'une seule opération PDF au même moment.
|
|
Tout accès à ReportLab doit donc être précédé d'un PDFLOCK.acquire()
|
|
et terminé par un PDFLOCK.release()
|
|
|
|
En ScoDoc 9, ce n'est pas nécessaire car on est multiptocessus / monothread.
|
|
"""
|
|
import html
|
|
import io
|
|
import os
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
import traceback
|
|
import unicodedata
|
|
|
|
import reportlab
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.platypus import Paragraph, Frame
|
|
from reportlab.platypus.flowables import Flowable
|
|
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
|
|
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
|
|
from reportlab.lib.units import inch, cm, mm
|
|
from reportlab.lib import styles
|
|
|
|
|
|
from flask import g
|
|
|
|
from app.scodoc import sco_utils as scu
|
|
from app.scodoc.sco_utils import CONFIG
|
|
from app import log
|
|
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
|
|
import sco_version
|
|
|
|
PAGE_HEIGHT = defaultPageSize[1]
|
|
PAGE_WIDTH = defaultPageSize[0]
|
|
|
|
|
|
DEFAULT_PDF_FOOTER_TEMPLATE = CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE
|
|
|
|
|
|
def SU(s: str) -> str:
|
|
"convert s from string to string suitable for ReportLab"
|
|
if not s:
|
|
return ""
|
|
else:
|
|
# Remplace caractères composés
|
|
# eg 'e\xcc\x81' COMBINING ACUTE ACCENT par '\xc3\xa9' LATIN SMALL LETTER E WITH ACUTE
|
|
# car les "combining accents" ne sont pas traités par ReportLab mais peuvent
|
|
# nous être envoyés par certains navigateurs ou imports
|
|
# (on en a dans les bases de données)
|
|
s = unicodedata.normalize("NFC", s)
|
|
# Remplace les entités XML/HTML
|
|
# reportlab ne les supporte pas non plus.
|
|
s = html.unescape(s)
|
|
# Remplace les <br> par des <br/>
|
|
s = re.sub(r"<br\s*>", "<br/>", s)
|
|
return s
|
|
|
|
|
|
def get_available_font_names() -> list[str]:
|
|
"""List installed font names"""
|
|
can = canvas.Canvas(io.StringIO())
|
|
return can.getAvailableFonts()
|
|
|
|
|
|
def _splitPara(txt):
|
|
"split a string, returns a list of <para > ... </para>"
|
|
L = []
|
|
closetag = "</para>"
|
|
l = len(closetag)
|
|
start = 0
|
|
e = -1
|
|
while 1:
|
|
b = txt.find("<para", start)
|
|
if b < 0:
|
|
if e < 0:
|
|
L.append(txt) # no para, just return text
|
|
break
|
|
e = txt.find(closetag, b)
|
|
if e < 0:
|
|
raise ValueError("unbalanced para tags")
|
|
L.append(txt[b : e + l])
|
|
start = e
|
|
# fix para: must be followed by a newline (? Reportlab bug turnaround ?)
|
|
L = [re.sub("<para(.*?)>([^\n\r])", "<para\\1>\n\\2", p) for p in L]
|
|
|
|
return L
|
|
|
|
|
|
def make_paras(txt: str, style, suppress_empty=False) -> list[Paragraph]:
|
|
"""Returns a list of Paragraph instances from a text
|
|
with one or more <para> ... </para>
|
|
"""
|
|
result = []
|
|
try:
|
|
paras = _splitPara(txt)
|
|
if suppress_empty:
|
|
r = []
|
|
for para in paras:
|
|
m = re.match(r"\s*<\s*para.*>\s*(.*)\s*<\s*/\s*para\s*>\s*", para)
|
|
if not m:
|
|
r.append(para) # not a paragraph, keep it
|
|
else:
|
|
if m.group(1): # non empty paragraph
|
|
r.append(para)
|
|
paras = r
|
|
result = [Paragraph(SU(s), style) for s in paras]
|
|
except OSError as e:
|
|
msg = str(e)
|
|
# If a file is missing, try to display the invalid name
|
|
m = re.match(r".*\sfilename=\'(.*?)\'.*", msg, re.DOTALL)
|
|
if m:
|
|
filename = os.path.split(m.group(1))[1]
|
|
if filename.startswith("logo_"):
|
|
filename = filename[len("logo_") :]
|
|
raise ScoValueError(
|
|
f"Erreur dans le format PDF paramétré: fichier logo <b>{filename}</b> non trouvé"
|
|
) from e
|
|
else:
|
|
raise e
|
|
except Exception as exc:
|
|
log(traceback.format_exc())
|
|
log(f"Invalid pdf para format: {txt}")
|
|
try:
|
|
result = [
|
|
Paragraph(
|
|
SU('<font color="red"><i>Erreur: format invalide</i></font>'),
|
|
style,
|
|
)
|
|
]
|
|
except ValueError: # probleme font ? essaye sans style
|
|
# récupère font en cause ?
|
|
m = re.match(r".*family/bold/italic for (.*)", exc.args[0], re.DOTALL)
|
|
if m:
|
|
message = f"police non disponible: {m[1]}"
|
|
else:
|
|
message = "format invalide"
|
|
scu.flash_once(f"problème génération PDF: {message}")
|
|
return [
|
|
Paragraph(
|
|
SU(f'<font color="red"><b>Erreur: {message}</b></font>'),
|
|
reportlab.lib.styles.ParagraphStyle({}),
|
|
)
|
|
]
|
|
return result
|
|
|
|
|
|
def bold_paras(L, tag="b", close=None):
|
|
"""Put each (string) element of L between <b>
|
|
L is a dict or sequence. (if dict, elements with key begining by _ are unaffected)
|
|
"""
|
|
b = "<" + tag + ">"
|
|
if not close:
|
|
close = "</" + tag + ">"
|
|
if hasattr(L, "keys"):
|
|
# L is a dict
|
|
for k in L:
|
|
if k[0] != "_":
|
|
L[k] = b + L[k] or "" + close
|
|
return L
|
|
else:
|
|
# L is a sequence
|
|
return [b + (x or "") + close for x in L]
|
|
|
|
|
|
class BulMarker(Flowable):
|
|
"""Custom Flowables pour nos bulletins PDF: invisibles, juste pour se repérer"""
|
|
|
|
def wrap(self, *args):
|
|
return (0, 0)
|
|
|
|
def draw(self):
|
|
return
|
|
|
|
|
|
class DebutBulletin(BulMarker):
|
|
"""Début d'un bulletin.
|
|
Element vide utilisé pour générer les bookmarks
|
|
"""
|
|
|
|
def __init__(self, bookmark=None, filigranne=None, footer_content=None):
|
|
self.bookmark = bookmark
|
|
self.filigranne = filigranne
|
|
self.footer_content = footer_content
|
|
super().__init__()
|
|
|
|
|
|
class ScoDocPageTemplate(PageTemplate):
|
|
"""Our own page template."""
|
|
|
|
def __init__(
|
|
self,
|
|
document,
|
|
pagesbookmarks={},
|
|
author=None,
|
|
title=None,
|
|
subject=None,
|
|
margins=(0, 0, 0, 0), # additional margins in mm (left,top,right, bottom)
|
|
server_name="",
|
|
footer_template=DEFAULT_PDF_FOOTER_TEMPLATE,
|
|
filigranne=None,
|
|
preferences=None, # dictionnary with preferences, required
|
|
):
|
|
"""Initialise our page template."""
|
|
# defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
|
|
from app.scodoc.sco_logos import find_logo
|
|
|
|
self.preferences = preferences
|
|
self.pagesbookmarks = pagesbookmarks or {}
|
|
self.pdfmeta_author = author
|
|
self.pdfmeta_title = title
|
|
self.pdfmeta_subject = subject
|
|
self.server_name = server_name
|
|
self.filigranne = filigranne
|
|
self.page_number = 1
|
|
self.footer_template = footer_template
|
|
if self.preferences:
|
|
self.with_page_background = self.preferences["bul_pdf_with_background"]
|
|
else:
|
|
self.with_page_background = False
|
|
self.background_image_filename = None
|
|
# Our doc is made of a single frame
|
|
left, top, right, bottom = [float(x) for x in margins]
|
|
content = Frame(
|
|
10.0 * mm + left * mm,
|
|
13.0 * mm + bottom * mm,
|
|
document.pagesize[0] - 20.0 * mm - left * mm - right * mm,
|
|
document.pagesize[1] - 18.0 * mm - top * mm - bottom * mm,
|
|
)
|
|
super().__init__("ScoDocPageTemplate", [content])
|
|
self.logo = None
|
|
logo = find_logo(
|
|
logoname="bul_pdf_background", dept_id=g.scodoc_dept_id
|
|
) or find_logo(
|
|
logoname="bul_pdf_background", dept_id=g.scodoc_dept_id, prefix=""
|
|
)
|
|
if logo is None:
|
|
# Also try to use PV background
|
|
logo = find_logo(
|
|
logoname="letter_background", dept_id=g.scodoc_dept_id
|
|
) or find_logo(
|
|
logoname="letter_background", dept_id=g.scodoc_dept_id, prefix=""
|
|
)
|
|
if logo is not None:
|
|
self.background_image_filename = logo.filepath
|
|
|
|
def beforeDrawPage(self, canv, doc):
|
|
"""Draws (optional) background, logo and contribution message on each page.
|
|
|
|
day : Day of the month as a decimal number [01,31]
|
|
month : Month as a decimal number [01,12].
|
|
year : Year without century as a decimal number [00,99].
|
|
Year : Year with century as a decimal number.
|
|
hour : Hour (24-hour clock) as a decimal number [00,23].
|
|
minute: Minute as a decimal number [00,59].
|
|
|
|
server_url: URL du serveur ScoDoc
|
|
|
|
"""
|
|
if not self.preferences:
|
|
return
|
|
canv.saveState()
|
|
# ---- Background image
|
|
if self.background_image_filename and self.with_page_background:
|
|
canv.drawImage(
|
|
self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
|
|
)
|
|
|
|
# ---- Logo: a small image, positionned at top left of the page
|
|
if self.logo is not None:
|
|
# draws the logo if it exists
|
|
( # pylint: disable=unpacking-non-sequence
|
|
(width, height),
|
|
image,
|
|
) = self.logo
|
|
canv.drawImage(image, inch, doc.pagesize[1] - inch, width, height)
|
|
|
|
# ---- Add some meta data and bookmarks
|
|
if self.pdfmeta_author:
|
|
canv.setAuthor(SU(self.pdfmeta_author))
|
|
if self.pdfmeta_title:
|
|
canv.setTitle(SU(self.pdfmeta_title))
|
|
if self.pdfmeta_subject:
|
|
canv.setSubject(SU(self.pdfmeta_subject))
|
|
|
|
bookmark = self.pagesbookmarks.get(doc.page, None)
|
|
if bookmark:
|
|
canv.bookmarkPage(bookmark)
|
|
canv.addOutlineEntry(SU(bookmark), bookmark)
|
|
|
|
def draw_footer(self, canv, content):
|
|
"""Print the footer"""
|
|
try:
|
|
canv.setFont(
|
|
self.preferences["SCOLAR_FONT"],
|
|
self.preferences["SCOLAR_FONT_SIZE_FOOT"],
|
|
)
|
|
except KeyError as exc:
|
|
raise ScoValueError(
|
|
f"""Police invalide dans pied de page pdf: {
|
|
self.preferences['SCOLAR_FONT']
|
|
} (taille {self.preferences['SCOLAR_FONT_SIZE_FOOT']})"""
|
|
) from exc
|
|
canv.drawString(
|
|
self.preferences["pdf_footer_x"] * mm,
|
|
self.preferences["pdf_footer_y"] * mm,
|
|
content,
|
|
)
|
|
canv.restoreState()
|
|
|
|
def footer_string(self) -> str:
|
|
"""String contenu du pied de page"""
|
|
d = _makeTimeDict()
|
|
d["scodoc_name"] = sco_version.SCONAME
|
|
d["server_url"] = self.server_name
|
|
return SU(self.footer_template % d)
|
|
|
|
def afterDrawPage(self, canv, doc):
|
|
if not self.preferences:
|
|
return
|
|
# ---- Footer
|
|
foot_content = None
|
|
if hasattr(doc, "current_footer"):
|
|
foot_content = doc.current_footer
|
|
self.draw_footer(canv, foot_content or self.footer_string())
|
|
# ---- Filigranne (texte en diagonal en haut a gauche de chaque page)
|
|
filigranne = None
|
|
if hasattr(doc, "filigranne"):
|
|
# filigranne crée par DebutBulletin
|
|
filigranne = doc.filigranne
|
|
if not filigranne and self.filigranne:
|
|
if isinstance(self.filigranne, str):
|
|
filigranne = self.filigranne # same for all pages
|
|
else:
|
|
filigranne = self.filigranne.get(doc.page, None)
|
|
if filigranne:
|
|
canv.saveState()
|
|
canv.translate(9 * cm, 27.6 * cm)
|
|
canv.rotate(30)
|
|
canv.scale(4.5, 4.5)
|
|
canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
|
|
canv.drawRightString(0, 0, SU(filigranne))
|
|
canv.restoreState()
|
|
doc.filigranne = None
|
|
|
|
def afterPage(self):
|
|
"""Called after all flowables have been drawn on a page.
|
|
Increment pageNum since the page has been completed.
|
|
"""
|
|
self.page_number += 1
|
|
|
|
|
|
class BulletinDocTemplate(BaseDocTemplate):
|
|
"""Doc template pour les bulletins PDF
|
|
ajoute la gestion des bookmarks
|
|
"""
|
|
|
|
# inspired by https://www.reportlab.com/snippets/13/
|
|
def afterFlowable(self, flowable):
|
|
"""Called by Reportlab after each flowable"""
|
|
if isinstance(flowable, DebutBulletin):
|
|
self.current_footer = ""
|
|
if flowable.bookmark:
|
|
self.current_footer = flowable.footer_content
|
|
self.canv.bookmarkPage(flowable.bookmark)
|
|
self.canv.addOutlineEntry(
|
|
SU(flowable.bookmark), flowable.bookmark, level=0, closed=None
|
|
)
|
|
if flowable.filigranne:
|
|
self.filigranne = flowable.filigranne
|
|
|
|
|
|
def _makeTimeDict():
|
|
# ... suboptimal but we don't care
|
|
return {
|
|
"day": time.strftime("%d"),
|
|
"month": time.strftime("%m"),
|
|
"year": time.strftime("%y"),
|
|
"Year": time.strftime("%Y"),
|
|
"hour": time.strftime("%H"),
|
|
"minute": time.strftime("%M"),
|
|
}
|
|
|
|
|
|
def pdf_basic_page(
|
|
objects,
|
|
title="",
|
|
preferences=None,
|
|
): # used by gen_table.make_page()
|
|
"""Simple convenience fonction: build a page from a list of platypus objects,
|
|
adding a title if specified.
|
|
"""
|
|
StyleSheet = styles.getSampleStyleSheet()
|
|
report = io.BytesIO() # in-memory document, no disk file
|
|
document = BaseDocTemplate(report)
|
|
document.addPageTemplates(
|
|
ScoDocPageTemplate(
|
|
document,
|
|
title=title,
|
|
author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
|
|
footer_template="Edité par %(scodoc_name)s le %(day)s/%(month)s/%(year)s à %(hour)sh%(minute)s",
|
|
preferences=preferences,
|
|
)
|
|
)
|
|
if title:
|
|
head = Paragraph(SU(title), StyleSheet["Heading3"])
|
|
objects = [head] + objects
|
|
document.build(objects)
|
|
data = report.getvalue()
|
|
return data
|
|
|
|
|
|
# Gestion du lock pdf
|
|
|
|
|
|
class PDFLock(object):
|
|
def __init__(self, timeout=15):
|
|
self.Q = queue.Queue(1)
|
|
self.timeout = timeout
|
|
self.current_thread = None
|
|
self.nref = 0
|
|
|
|
def release(self):
|
|
"Release lock. Raise Empty if not acquired first"
|
|
if self.current_thread == threading.get_ident():
|
|
self.nref -= 1
|
|
if self.nref == 0:
|
|
log("PDFLock: release from %s" % self.current_thread)
|
|
self.current_thread = None
|
|
self.Q.get(False)
|
|
return
|
|
else:
|
|
self.Q.get(False)
|
|
|
|
def acquire(self):
|
|
"Acquire lock. Raise ScoGenError if can't lock after timeout."
|
|
if self.current_thread == threading.get_ident():
|
|
self.nref += 1
|
|
return # deja lock pour ce thread
|
|
try:
|
|
self.Q.put(1, True, self.timeout)
|
|
except queue.Full as e:
|
|
raise ScoGenError(msg="Traitement PDF occupé: ré-essayez") from e
|
|
self.current_thread = threading.get_ident()
|
|
self.nref = 1
|
|
log("PDFLock: granted to %s" % self.current_thread)
|
|
|
|
|
|
class WatchLock:
|
|
"Surveille threads (mais ne verrouille pas)"
|
|
|
|
def __init__(self, timeout=None):
|
|
self.timeout = timeout
|
|
t = threading.current_thread()
|
|
self.native_id = t.native_id
|
|
self.ident = t.ident
|
|
|
|
def acquire(self):
|
|
t = threading.current_thread()
|
|
if (self.native_id != t.native_id) or (self.ident != t.ident):
|
|
log(
|
|
f"Warning: LOCK detected several threads ! (native_id {self.native_id} -> {t.native_id}, ident {self.ident} -> {t.ident}"
|
|
)
|
|
self.native_id = t.native_id
|
|
self.ident = t.ident
|
|
|
|
def release(self):
|
|
t = threading.current_thread()
|
|
assert (self.native_id == t.native_id) and (self.ident == t.ident)
|
|
|
|
|
|
class FakeLock:
|
|
"Pour ScoDoc 9: pas de verrou"
|
|
|
|
def __init__(self, timeout=None):
|
|
pass
|
|
|
|
def acquire(self):
|
|
pass
|
|
|
|
def release(self):
|
|
pass
|
|
|
|
|
|
PDFLOCK = FakeLock()
|