Report PDF su Windows: MathJax locale per Kaleido (niente CDN offline) e test con WeasyPrint/Edge veri (TieMeasureFlow#78)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from src.backend.utils.tempo import to_local, utcnow
|
from src.backend.utils.tempo import to_local, utcnow
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -25,6 +26,23 @@ from src.backend.services.spc_service import (
|
|||||||
compute_summary,
|
compute_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Kaleido 1.x aggiunge alla pagina di render uno <script> MathJax dal CDN (cdnjs) anche se i grafici
|
||||||
|
# non usano LaTeX: senza Internet (stand, PC di produzione) ogni grafico aspetta il timeout di rete
|
||||||
|
# (~19 s con rete che scarta i pacchetti, TieMeasureFlow#78). I grafici qui sotto non hanno formule:
|
||||||
|
# si passa a Kaleido un file locale vuoto al posto del CDN. Il file sta nella cartella temporanea.
|
||||||
|
def _mathjax_vuoto() -> str:
|
||||||
|
"""Percorso di un .js vuoto da usare come MathJax: nessuna richiesta di rete per i grafici."""
|
||||||
|
path = Path(tempfile.gettempdir()) / "tmf-mathjax-vuoto.js"
|
||||||
|
if not path.exists():
|
||||||
|
path.write_text("/* MathJax non usato: grafici senza LaTeX */", encoding="utf-8")
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
pio.defaults.mathjax = _mathjax_vuoto()
|
||||||
|
except (AttributeError, OSError): # plotly vecchio senza pio.defaults o temp non scrivibile: resta il CDN
|
||||||
|
pass
|
||||||
|
|
||||||
# Jinja2 environment for report templates
|
# Jinja2 environment for report templates
|
||||||
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
_templates_dir = Path(__file__).parent.parent / "templates" / "reports"
|
||||||
_jinja_env = Environment(
|
_jinja_env = Environment(
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""Report PDF veri su Windows (WeasyPrint + DLL GTK + Kaleido con Edge/Chrome), offline.
|
||||||
|
|
||||||
|
Pacchetto Windows (TieMeasureFlow#78): senza Internet i report devono uscire lo stesso.
|
||||||
|
Il test gira solo su Windows, con le DLL di packaging/windows/pdf/prendi-gtk.ps1 e un browser
|
||||||
|
Chromium (Edge o Chrome) installati; altrimenti viene saltato.
|
||||||
|
|
||||||
|
Il conftest dei test sostituisce WeasyPrint con un mock: qui serve quello vero, quindi la
|
||||||
|
generazione gira in un sottoprocesso che importa weasyprint PRIMA del conftest (il mock scatta
|
||||||
|
solo se weasyprint non e' gia' in sys.modules).
|
||||||
|
|
||||||
|
Come preparare le DLL: powershell -File packaging/windows/pdf/prendi-gtk.ps1 -Destinazione packaging/windows/pdf/gtk
|
||||||
|
(oppure WEASYPRINT_DLL_DIRECTORIES gia' impostata su una cartella con le DLL).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[3]
|
||||||
|
GTK_DEFAULT = REPO / "packaging" / "windows" / "pdf" / "gtk"
|
||||||
|
|
||||||
|
# Browser Chromium cercati in ordine (Edge e' presente su ogni Windows 10/11).
|
||||||
|
_BROWSER_CANDIDATI = [
|
||||||
|
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
||||||
|
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||||
|
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _cartella_gtk() -> Path | None:
|
||||||
|
"""Cartella DLL: WEASYPRINT_DLL_DIRECTORIES (primo elemento) o quella di prendi-gtk.ps1."""
|
||||||
|
env = os.environ.get("WEASYPRINT_DLL_DIRECTORIES", "").split(";")[0]
|
||||||
|
for c in (Path(env) if env else None, GTK_DEFAULT):
|
||||||
|
if c and (c / "libpango-1.0-0.dll").exists():
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _browser() -> str | None:
|
||||||
|
env = os.environ.get("BROWSER_PATH")
|
||||||
|
if env and Path(env).exists():
|
||||||
|
return env
|
||||||
|
return next((b for b in _BROWSER_CANDIDATI if Path(b).exists()), None)
|
||||||
|
|
||||||
|
|
||||||
|
def _tz_ok() -> bool:
|
||||||
|
"""Windows non ha il database IANA: serve il pacchetto tzdata (to_local usa ZoneInfo)."""
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
ZoneInfo("Europe/Rome")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.skipif(sys.platform != "win32", reason="solo Windows"),
|
||||||
|
pytest.mark.skipif(_cartella_gtk() is None, reason="DLL GTK assenti: lancia prendi-gtk.ps1"),
|
||||||
|
pytest.mark.skipif(_browser() is None, reason="nessun Edge/Chrome installato"),
|
||||||
|
# Senza tzdata i report falliscono su Windows (ZoneInfoNotFoundError): il pacchetto deve dichiararlo.
|
||||||
|
pytest.mark.skipif(not _tz_ok(), reason="manca tzdata: aggiungere 'tzdata; sys_platform == \"win32\"' a pyproject"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sottoprocesso: dati veri nel DB SQLite in memoria del conftest, poi i due report reali.
|
||||||
|
_WORKER = r"""
|
||||||
|
import asyncio, json, sys
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
import weasyprint # VERO, prima del conftest (che altrimenti lo sostituisce con un mock)
|
||||||
|
assert not type(weasyprint.HTML).__module__.startswith("unittest"), "WeasyPrint mockato"
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from src.backend.tests.conftest import Base, TestSessionFactory, test_engine, _create_user, create_test_recipe
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
|
from src.backend.services import report_service
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
VALORI = [10.02, 9.98, 10.05, 10.11, 9.94, 10.0, 10.07, 9.96, 10.03, 10.09, 9.99, 10.04, 10.38, 10.01, 9.97]
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with test_engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with TestSessionFactory() as db:
|
||||||
|
user = await _create_user(db, "metrologo", roles=["Metrologist"])
|
||||||
|
recipe = await create_test_recipe(db, user.id)
|
||||||
|
version = recipe.versions[0]
|
||||||
|
sub = (await db.execute(select(RecipeSubtask))).scalars().first()
|
||||||
|
t0 = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
for i, v in enumerate(VALORI):
|
||||||
|
pf = "warning" if v > 10.3 else "pass"
|
||||||
|
db.add(Measurement(subtask_id=sub.id, version_id=version.id, measured_by=user.id,
|
||||||
|
value=v, pass_fail=pf, measured_at=t0 + timedelta(minutes=i)))
|
||||||
|
await db.commit()
|
||||||
|
spc = await report_service.generate_spc_report(db, recipe.id, sub.id)
|
||||||
|
mis = await report_service.generate_measurement_report(db, recipe.id)
|
||||||
|
open(sys.argv[2] + "/spc.pdf", "wb").write(spc)
|
||||||
|
open(sys.argv[2] + "/misure.pdf", "wb").write(mis)
|
||||||
|
# MathJax locale: senza, Kaleido chiede lo script al CDN e senza Internet ogni grafico attende ~19 s.
|
||||||
|
from pathlib import Path
|
||||||
|
mj = report_service.pio.defaults.mathjax
|
||||||
|
print(json.dumps({"ok": True, "mathjax_locale": bool(mj) and Path(mj).is_file()}))
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pdf_generati(tmp_path_factory):
|
||||||
|
"""Genera i due PDF in un sottoprocesso con proxy finto: se serve Internet, fallisce."""
|
||||||
|
out = tmp_path_factory.mktemp("report_win")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(
|
||||||
|
WEASYPRINT_DLL_DIRECTORIES=str(_cartella_gtk()),
|
||||||
|
BROWSER_PATH=_browser(),
|
||||||
|
# Proxy morto per la parte Python (Chrome/Edge usano invece il proxy di sistema: vedi LEGGIMI.md).
|
||||||
|
HTTP_PROXY="http://127.0.0.1:9", HTTPS_PROXY="http://127.0.0.1:9",
|
||||||
|
PYTHONIOENCODING="utf-8",
|
||||||
|
)
|
||||||
|
# Il sottoprocesso non deve leggere il .env di sviluppo ne' un DB reale.
|
||||||
|
env.pop("DB_URL", None)
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-W", "ignore", "-c", _WORKER, str(REPO), str(out)],
|
||||||
|
cwd=REPO, env=env, capture_output=True, text=True, timeout=180,
|
||||||
|
)
|
||||||
|
assert res.returncode == 0, f"sottoprocesso fallito:\n{res.stdout}\n{res.stderr}"
|
||||||
|
esito = json.loads(res.stdout.strip().splitlines()[-1])
|
||||||
|
assert esito["ok"] is True
|
||||||
|
assert esito["mathjax_locale"], "Kaleido userebbe MathJax dal CDN: niente rete = report lenti"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf(path: Path):
|
||||||
|
import pdfplumber
|
||||||
|
|
||||||
|
return pdfplumber.open(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_spc_ha_carta_di_controllo_e_istogramma(pdf_generati):
|
||||||
|
"""Il report SPC e' un PDF vero con una pagina almeno e i due grafici disegnati."""
|
||||||
|
path = pdf_generati / "spc.pdf"
|
||||||
|
assert path.read_bytes()[:5] == b"%PDF-"
|
||||||
|
with _pdf(path) as pdf:
|
||||||
|
assert len(pdf.pages) >= 1
|
||||||
|
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||||
|
# I titoli stanno dentro l'SVG di Plotly: se ci sono, Kaleido ha reso e WeasyPrint ha disegnato.
|
||||||
|
assert "Control Chart" in testo
|
||||||
|
assert "Histogram" in testo
|
||||||
|
# Grafico presente = segni vettoriali (tracce, assi, barre) oltre al testo.
|
||||||
|
segni = sum(len(p.curves) + len(p.lines) + len(p.rects) for p in pdf.pages)
|
||||||
|
assert segni > 40, f"troppo pochi segni vettoriali ({segni}): grafico assente?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_misure_e_pdf_vero(pdf_generati):
|
||||||
|
"""Anche il report a tabella (solo WeasyPrint, niente Kaleido) esce con le righe misurate."""
|
||||||
|
path = pdf_generati / "misure.pdf"
|
||||||
|
assert path.read_bytes()[:5] == b"%PDF-"
|
||||||
|
with _pdf(path) as pdf:
|
||||||
|
assert len(pdf.pages) >= 1
|
||||||
|
testo = "\n".join(p.extract_text() or "" for p in pdf.pages)
|
||||||
|
assert "10.3800" in testo # il valore anomalo e' in tabella
|
||||||
Reference in New Issue
Block a user