MonScoDocEssai/app/scodoc/sco_pdf.py

478 lines
16 KiB
Python
Raw 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.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
from reportlab.platypus.flowables import Flowable
from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate
from reportlab.lib.styles import getSampleStyleSheet
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.colors import pink, black, red, blue, green, magenta, red
from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
from reportlab.lib import styles
from reportlab.lib.pagesizes import letter, A4, landscape
2021-08-21 15:17:14 +02:00
from flask import g
import app.scodoc.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):
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 _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
2020-09-26 16:19:37 +02:00
except Exception as e:
log(traceback.format_exc())
log("Invalid pdf para format: %s" % txt)
result = [
2020-09-26 16:19:37 +02:00
Paragraph(
SU('<font color="red"><i>Erreur: format invalide</i></font>'),
2020-09-26 16:19:37 +02:00
style,
)
]
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:
x = L[k]
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):
self.bookmark = bookmark
self.filigranne = filigranne
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, canvas, 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
2020-09-26 16:19:37 +02:00
canvas.saveState()
2020-10-21 22:56:25 +02:00
# ---- Background image
if self.background_image_filename and self.with_page_background:
canvas.drawImage(
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
2020-09-26 16:19:37 +02:00
canvas.drawImage(image, inch, doc.pagesize[1] - inch, width, height)
# ---- Add some meta data and bookmarks
if self.pdfmeta_author:
canvas.setAuthor(SU(self.pdfmeta_author))
if self.pdfmeta_title:
canvas.setTitle(SU(self.pdfmeta_title))
if self.pdfmeta_subject:
canvas.setSubject(SU(self.pdfmeta_subject))
bookmark = self.pagesbookmarks.get(doc.page, None)
if bookmark:
canvas.bookmarkPage(bookmark)
canvas.addOutlineEntry(SU(bookmark), bookmark)
2020-09-26 16:19:37 +02:00
# ---- Footer
canvas.setFont(
self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT_SIZE_FOOT"]
)
d = _makeTimeDict()
2021-08-21 17:07:44 +02:00
d["scodoc_name"] = sco_version.SCONAME
2020-09-26 16:19:37 +02:00
d["server_url"] = self.server_name
footer_str = SU(self.footer_template % d)
canvas.drawString(
self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm,
footer_str,
)
canvas.restoreState()
def afterDrawPage(self, canvas, doc):
if not self.preferences:
return
# ---- 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:
canvas.saveState()
canvas.translate(9 * cm, 27.6 * cm)
canvas.rotate(30)
canvas.scale(4.5, 4.5)
canvas.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
canvas.drawRightString(0, 0, SU(filigranne))
canvas.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):
if flowable.bookmark:
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:
2020-09-26 16:19:37 +02:00
raise ScoGenError(msg="Traitement PDF occupé: ré-essayez")
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)
pass
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()