ScoDoc/app/scodoc/sco_pdf.py

506 lines
16 KiB
Python
Raw Permalink Normal View History

2020-09-26 16:19:37 +02:00
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
2022-01-01 14:49:42 +01:00
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
2020-09-26 16:19:37 +02:00
#
# 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.
2020-09-26 16:19:37 +02:00
"""
import html
import io
2021-01-04 13:53:19 +01:00
import os
import queue
import re
import threading
import time
2020-09-26 16:19:37 +02:00
import traceback
import unicodedata
2021-07-11 22:56:22 +02:00
2020-09-26 16:19:37 +02:00
import reportlab
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph, Frame
2020-09-26 16:19:37 +02:00
from reportlab.platypus.flowables import Flowable
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
2021-01-01 18:40:47 +01:00
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
2020-09-26 16:19:37 +02:00
from reportlab.lib.units import inch, cm, mm
from reportlab.lib import styles
2020-09-26 16:19:37 +02:00
2021-08-21 15:17:14 +02:00
from flask import g
from app.scodoc import sco_utils as scu
from app.scodoc.sco_utils import CONFIG
2021-08-29 19:57:32 +02:00
from app import log
from app.scodoc.sco_exceptions import ScoGenError, ScoValueError
2021-08-21 17:07:44 +02:00
import sco_version
2020-09-26 16:19:37 +02:00
PAGE_HEIGHT = defaultPageSize[1]
PAGE_WIDTH = defaultPageSize[0]
DEFAULT_PDF_FOOTER_TEMPLATE = CONFIG.DEFAULT_PDF_FOOTER_TEMPLATE
def SU(s: str) -> str:
2021-07-13 17:00:25 +02:00
"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é XML/HTML
# reportlab ne les supporte pas non plus.
s = html.unescape(s)
return s
2020-09-26 16:19:37 +02:00
def get_available_font_names() -> list[str]:
"""List installed font names"""
can = canvas.Canvas(io.StringIO())
return can.getAvailableFonts()
2020-09-26 16:19:37 +02:00
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 makeParas(txt, style, suppress_empty=False):
"""Returns a list of Paragraph instances from a text
with one or more <para> ... </para>
"""
result = []
2020-09-26 16:19:37 +02:00
try:
paras = _splitPara(txt)
if suppress_empty:
r = []
for para in paras:
2021-01-04 13:53:19 +01:00
m = re.match(r"\s*<\s*para.*>\s*(.*)\s*<\s*/\s*para\s*>\s*", para)
2020-09-26 16:19:37 +02:00
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:
2020-09-26 16:19:37 +02:00
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 as exc2: # probleme font ? essaye sans style
# recupere font en cause ?
m = re.match(r".*family/bold/italic for (.*)", e.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
2020-09-26 16:19:37 +02:00
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
"""
2022-03-13 23:00:50 +01:00
def __init__(self, bookmark=None, filigranne=None, footer_content=None):
self.bookmark = bookmark
self.filigranne = filigranne
2022-03-13 23:00:50 +01:00
self.footer_content = footer_content
super().__init__()
class ScoDocPageTemplate(PageTemplate):
2020-09-26 16:19:37 +02:00
"""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
2021-11-19 11:51:05 +01:00
2020-09-26 16:19:37 +02:00
self.preferences = preferences
self.pagesbookmarks = pagesbookmarks or {}
2020-09-26 16:19:37 +02:00
self.pdfmeta_author = author
self.pdfmeta_title = title
self.pdfmeta_subject = subject
self.server_name = server_name
self.filigranne = filigranne
self.page_number = 1
2020-09-26 16:19:37 +02:00
self.footer_template = footer_template
if self.preferences:
self.with_page_background = self.preferences["bul_pdf_with_background"]
else:
self.with_page_background = False
2020-10-21 22:56:25 +02:00
self.background_image_filename = None
2020-09-26 16:19:37 +02:00
# 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])
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
def beforeDrawPage(self, canv, doc):
2020-10-21 22:56:25 +02:00
"""Draws (optional) background, logo and contribution message on each page.
2020-09-26 16:19:37 +02:00
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
2020-09-26 16:19:37 +02:00
"""
if not self.preferences:
return
canv.saveState()
2020-10-21 22:56:25 +02:00
# ---- Background image
if self.background_image_filename and self.with_page_background:
canv.drawImage(
2020-10-21 22:56:25 +02:00
self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1]
)
# ---- Logo: a small image, positionned at top left of the page
2020-09-26 16:19:37 +02:00
if self.logo is not None:
# draws the logo if it exists
2021-01-04 13:53:19 +01:00
( # pylint: disable=unpacking-non-sequence
(width, height),
image,
) = self.logo
canv.drawImage(image, inch, doc.pagesize[1] - inch, width, height)
2020-09-26 16:19:37 +02:00
# ---- Add some meta data and bookmarks
if self.pdfmeta_author:
canv.setAuthor(SU(self.pdfmeta_author))
2020-09-26 16:19:37 +02:00
if self.pdfmeta_title:
canv.setTitle(SU(self.pdfmeta_title))
2020-09-26 16:19:37 +02:00
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)
2022-03-13 23:00:50 +01:00
def draw_footer(self, canv, content):
2022-03-13 23:00:50 +01:00
"""Print the footer"""
canv.setFont(
2020-09-26 16:19:37 +02:00
self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"]
)
canv.drawString(
2020-09-26 16:19:37 +02:00
self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm,
2022-03-13 23:00:50 +01:00
content,
2020-09-26 16:19:37 +02:00
)
canv.restoreState()
2020-09-26 16:19:37 +02:00
2022-03-13 23:00:50 +01:00
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
2022-03-13 23:00:50 +01:00
# ---- 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):
2022-03-13 23:00:50 +01:00
self.current_footer = ""
if flowable.bookmark:
2022-03-13 23:00:50 +01:00
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
2020-09-26 16:19:37 +02:00
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(
2021-08-19 10:28:35 +02:00
objects,
title="",
preferences=None,
2020-09-26 16:19:37 +02:00
): # 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
2020-09-26 16:19:37 +02:00
document = BaseDocTemplate(report)
document.addPageTemplates(
ScoDocPageTemplate(
2020-09-26 16:19:37 +02:00
document,
title=title,
2021-08-21 17:07:44 +02:00
author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION),
2020-09-26 16:19:37 +02:00
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
2021-07-09 23:31:16 +02:00
class PDFLock(object):
2020-09-26 16:19:37 +02:00
def __init__(self, timeout=15):
self.Q = queue.Queue(1)
2020-09-26 16:19:37 +02:00
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():
2020-09-26 16:19:37 +02:00
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():
2020-09-26 16:19:37 +02:00
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()
2020-09-26 16:19:37 +02:00
self.nref = 1
log("PDFLock: granted to %s" % self.current_thread)
2021-08-22 15:36:17 +02:00
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"
2021-08-22 15:36:17 +02:00
def __init__(self, timeout=None):
pass
def acquire(self):
pass
def release(self):
pass
PDFLOCK = FakeLock()