forked from ScoDoc/ScoDoc
544 lines
18 KiB
Python
Executable File
544 lines
18 KiB
Python
Executable File
# -*- mode: python -*-
|
||
# -*- coding: utf-8 -*-
|
||
|
||
##############################################################################
|
||
#
|
||
# Gestion scolarite IUT
|
||
#
|
||
# Copyright (c) 1999 - 2024 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 multiprocessus / monothread.
|
||
"""
|
||
import datetime
|
||
import html
|
||
import io
|
||
import os
|
||
import queue
|
||
import re
|
||
import threading
|
||
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 import log
|
||
from app.scodoc.sco_exceptions import ScoGenError, ScoPDFFormatError, ScoValueError
|
||
from app.scodoc import sco_preferences
|
||
from app.scodoc import sco_utils as scu
|
||
from app.scodoc.sco_utils import CONFIG
|
||
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)
|
||
# And substitute unicode characters not supported by ReportLab
|
||
s = s.replace("‐", "-")
|
||
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, field_name=None
|
||
) -> 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
|
||
try:
|
||
result = [Paragraph(SU(s), style) for s in paras]
|
||
except AttributeError as exc:
|
||
raise ScoPDFFormatError("PDF: paragraphe avec balisage invalide") from exc
|
||
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:
|
||
# récupère le nom de la préférence
|
||
if field_name:
|
||
p = sco_preferences.BasePreferences(g.scodoc_dept_id)
|
||
pref = p.prefs_dict.get(field_name)
|
||
if pref:
|
||
field_name = pref["title"]
|
||
result = [
|
||
Paragraph(
|
||
SU(
|
||
f"""<font color="red"><i>Erreur: format invalide (voir préférence {field_name or ""})</i></font>"""
|
||
),
|
||
style,
|
||
)
|
||
]
|
||
# si on voulait interrompre la génération raise ScoPDFFormatError("Erreur: format invalide")
|
||
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: dict = None,
|
||
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 + " " + (self.preferences["pdf_footer_extra"] or ""),
|
||
)
|
||
canv.restoreState()
|
||
|
||
def footer_string(self) -> str:
|
||
"""String contenu du pied de page"""
|
||
d = _make_datetime_dict()
|
||
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(10 * cm, 21.0 * cm)
|
||
canv.rotate(36)
|
||
canv.scale(7, 7)
|
||
canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
|
||
canv.drawCentredString(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
|
||
"""
|
||
|
||
def __init__(self, *args, **kw):
|
||
super().__init__(*args, **kw)
|
||
self.current_footer = ""
|
||
self.filigranne = None
|
||
|
||
# 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 _make_datetime_dict() -> dict:
|
||
"a dict with date elements for templates"
|
||
now = datetime.datetime.now()
|
||
return {
|
||
"day": now.day,
|
||
"month": now.month,
|
||
"year": now.year,
|
||
"Year": now.year,
|
||
"hour": now.hour,
|
||
"minute": now.minute,
|
||
}
|
||
|
||
|
||
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
|
||
|
||
try:
|
||
document.build(objects)
|
||
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
|
||
raise ScoPDFFormatError(str(exc)) from exc
|
||
|
||
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()
|