"""Validated, dependency-free PDF rendering for roll calculation reports.""" from __future__ import annotations from dataclasses import dataclass from datetime import datetime import math import re from typing import Any A4_WIDTH = 595.28 A4_HEIGHT = 841.89 class ReportValidationError(ValueError): """Raised when report input is incomplete or unsafe to render.""" @dataclass(frozen=True) class DiameterValue: value: float display: str def _text(value: Any, field: str, *, required: bool = True, limit: int = 240) -> str: if value is None: value = "" if not isinstance(value, str): raise ReportValidationError(f"{field} must be text") value = " ".join(value.split()).strip() if required and not value: raise ReportValidationError(f"{field} is required") if len(value) > limit: raise ReportValidationError(f"{field} is too long") return value def _positive_number(value: Any, field: str) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)): raise ReportValidationError(f"{field} must be a number") value = float(value) if not math.isfinite(value) or value <= 0: raise ReportValidationError(f"{field} must be greater than zero") return value def _measurement_display( value: float, display: str, unit: str, field: str, *, decimals: int = 1 ) -> str: match = re.fullmatch( rf"([0-9]+\.[0-9]{{{decimals}}}) {re.escape(unit)}", display ) tolerance = (0.5 * (10 ** -decimals)) + 0.000001 if not match or abs(float(match.group(1)) - value) > tolerance: raise ReportValidationError(f"{field} does not match its numeric value") return display def validate_report_payload(payload: Any) -> dict[str, Any]: """Validate the internal PDF layout model produced from a calculation result.""" if not isinstance(payload, dict): raise ReportValidationError("JSON object required") if payload.get("report_type") != "roll_diameter": raise ReportValidationError("unsupported or incomplete calculation result") raw_diameters = payload.get("diameters") if not isinstance(raw_diameters, dict): raise ReportValidationError("diameters are required") diameters: dict[str, DiameterValue] = {} for key in ("minimum", "average", "maximum"): raw = raw_diameters.get(key) if not isinstance(raw, dict): raise ReportValidationError(f"diameters.{key} is required") value = _positive_number(raw.get("value"), f"diameters.{key}.value") display_field = f"diameters.{key}.display" display = _text(raw.get("display"), display_field, limit=40) display = _measurement_display(value, display, "mm", display_field) diameters[key] = DiameterValue(value=value, display=display) if not ( diameters["minimum"].value <= diameters["average"].value <= diameters["maximum"].value ): raise ReportValidationError("diameter values must be ordered minimum to maximum") raw_length = payload.get("roll_length") if not isinstance(raw_length, dict): raise ReportValidationError("roll_length is required") length_value = _positive_number(raw_length.get("value"), "roll_length.value") length_display = _text( raw_length.get("display"), "roll_length.display", limit=40 ) roll_length = { "value": length_value, "display": _measurement_display( length_value, length_display, "m", "roll_length.display", decimals=2, ), } raw_inputs = payload.get("inputs") if not isinstance(raw_inputs, list) or not 3 <= len(raw_inputs) <= 16: raise ReportValidationError("between 3 and 16 calculation inputs are required") inputs = [] for index, item in enumerate(raw_inputs): if not isinstance(item, dict): raise ReportValidationError(f"inputs[{index}] must be an object") inputs.append( { "label": _text(item.get("label"), f"inputs[{index}].label", limit=60), "value": _text(item.get("value"), f"inputs[{index}].value", limit=100), } ) article = None raw_article = payload.get("article") if raw_article is not None: if not isinstance(raw_article, dict): raise ReportValidationError("article must be an object") number = _text(raw_article.get("number"), "article.number", required=False, limit=60) name = _text(raw_article.get("name"), "article.name", required=False, limit=240) if number or name: article = {"number": number, "name": name} roll_weight = None raw_weight = payload.get("roll_weight") if raw_weight is not None: if not isinstance(raw_weight, dict): raise ReportValidationError("roll_weight must be an object") weight_value = _positive_number(raw_weight.get("value"), "roll_weight.value") weight_display = _text( raw_weight.get("display"), "roll_weight.display", limit=40 ) roll_weight = { "value": weight_value, "display": _measurement_display( weight_value, weight_display, "kg", "roll_weight.display" ), } warnings = [] raw_warnings = payload.get("warnings", []) if not isinstance(raw_warnings, list) or len(raw_warnings) > 12: raise ReportValidationError("warnings must be a list of at most 12 items") for index, item in enumerate(raw_warnings): if not isinstance(item, dict): raise ReportValidationError(f"warnings[{index}] must be an object") warnings.append( { "title": _text(item.get("title"), f"warnings[{index}].title", limit=120), "message": _text(item.get("message"), f"warnings[{index}].message", limit=500), "severity": ( item.get("severity") if item.get("severity") in {"warning", "critical"} else "warning" ), } ) notes = [] raw_notes = payload.get("notes", []) if not isinstance(raw_notes, list) or len(raw_notes) > 12: raise ReportValidationError("notes must be a list of at most 12 items") for index, item in enumerate(raw_notes): notes.append(_text(item, f"notes[{index}]", limit=500)) report_text_size = sum( len(item["title"]) + len(item["message"]) for item in warnings ) + sum(len(note) for note in notes) if report_text_size > 1800: raise ReportValidationError("warnings and notes are too long for a one-page report") return { "article": article, "inputs": inputs, "diameters": diameters, "roll_length": roll_length, "roll_weight": roll_weight, "warnings": warnings, "notes": notes, } def safe_report_filename(article_number: str | None, generated_at: datetime) -> str: reference = re.sub(r"[^A-Za-z0-9._-]+", "-", article_number or "calculation") reference = reference.strip("-._")[:60] or "calculation" return f"roll-calculation_{reference}_{generated_at:%Y-%m-%d_%H%M%S}.pdf" def report_from_calculation_result(result: Any) -> dict[str, Any]: """Adapt an authoritative calculation result to the existing PDF model.""" if not isinstance(result, dict) or result.get("status") != "success": raise ReportValidationError("a successful calculation result is required") calculation = result.get("calculation") effective = result.get("effective_inputs") request = result.get("request") provenance = result.get("provenance") if not all(isinstance(item, dict) for item in (calculation, effective, request, provenance)): raise ReportValidationError("incomplete authoritative calculation result") if provenance.get("calculator") != "roll_calculation.calculate_roll": raise ReportValidationError("unknown calculation provenance") def effective_value(field: str) -> Any: item = effective.get(field) if not isinstance(item, dict) or "value" not in item: raise ReportValidationError(f"effective input {field} is required") return item["value"] length = effective_value("roll_length_m") core_diameter = effective_value("core_diameter_mm") core_type = effective_value("core_type") thickness = effective_value("thickness_mm") thickness_stddev = effective_value("thickness_stddev_mm") inputs = [ {"label": "Core diameter", "value": f"{core_diameter:.1f} mm"}, {"label": "Core type", "value": str(core_type)}, {"label": "Product thickness", "value": f"{thickness:.3f} mm"}, {"label": "Thickness stddev", "value": f"{thickness_stddev:.3f} mm"}, {"label": "Product length", "value": f"{length:.2f} m"}, ] if request.get("category"): inputs.append({"label": "Product category", "value": request["category"]}) if request.get("production_site"): inputs.append({"label": "Production site", "value": request["production_site"]}) weight = calculation.get("roll_weight_kg") if weight is not None: width = effective_value("width_m") area_weight = effective_value("area_weight_g_m2") inputs.extend( ( {"label": "Roll width", "value": f"{width:.2f} m"}, {"label": "Area weight", "value": f"{area_weight:.1f} g/m²"}, ) ) article = result.get("resolved_article") raw_report = { "report_type": "roll_diameter", "article": ( {"number": article.get("number", ""), "name": article.get("name", "")} if isinstance(article, dict) else None ), "inputs": inputs, "diameters": { "minimum": { "value": calculation.get("minimum_diameter_mm"), "display": f"{calculation.get('minimum_diameter_mm'):.1f} mm", }, "average": { "value": calculation.get("average_diameter_mm"), "display": f"{calculation.get('average_diameter_mm'):.1f} mm", }, "maximum": { "value": calculation.get("maximum_diameter_mm"), "display": f"{calculation.get('maximum_diameter_mm'):.1f} mm", }, }, "roll_length": {"value": length, "display": f"{length:.2f} m"}, "roll_weight": ( {"value": weight, "display": f"{weight:.1f} kg"} if weight is not None else None ), "warnings": result.get("warnings", []), "notes": result.get("notes", []), } return validate_report_payload(raw_report) def _pdf_escape(value: str) -> bytes: value = value.translate( str.maketrans( { "≥": ">=", "≤": "<=", "−": "-", "σ": "sigma", "π": "pi", "✓": "OK", "✗": "X", } ) ) encoded = value.encode("cp1252", errors="replace") return encoded.replace(b"\\", b"\\\\").replace(b"(", b"\\(").replace(b")", b"\\)") def _wrap(value: str, width: float, font_size: float) -> list[str]: max_chars = max(12, int(width / (font_size * 0.52))) words = value.split() lines: list[str] = [] current = "" for word in words: while len(word) > max_chars: if current: lines.append(current) current = "" lines.append(word[: max_chars - 1] + "-") word = word[max_chars - 1 :] candidate = f"{current} {word}".strip() if len(candidate) <= max_chars: current = candidate else: if current: lines.append(current) current = word if current or not lines: lines.append(current) return lines class _Canvas: def __init__(self) -> None: self.commands: list[bytes] = [] def raw(self, command: str) -> None: self.commands.append(command.encode("ascii")) def text( self, x: float, y: float, value: str, *, size: float = 9, bold: bool = False, color: tuple[float, float, float] = (0.16, 0.20, 0.18), ) -> None: font = "F2" if bold else "F1" prefix = ( f"BT /{font} {size:.2f} Tf " f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} rg " f"1 0 0 1 {x:.2f} {y:.2f} Tm (" ).encode("ascii") self.commands.append(prefix + _pdf_escape(value) + b") Tj ET") def line( self, x1: float, y1: float, x2: float, y2: float, *, color=(0.8, 0.8, 0.8), width=0.7, ) -> None: self.raw( f"{color[0]:.3f} {color[1]:.3f} {color[2]:.3f} RG {width:.2f} w " f"{x1:.2f} {y1:.2f} m {x2:.2f} {y2:.2f} l S" ) def rect( self, x: float, y: float, width: float, height: float, *, fill, stroke=None ) -> None: command = f"{fill[0]:.3f} {fill[1]:.3f} {fill[2]:.3f} rg " if stroke: command += f"{stroke[0]:.3f} {stroke[1]:.3f} {stroke[2]:.3f} RG 0.8 w " command += f"{x:.2f} {y:.2f} {width:.2f} {height:.2f} re {'B' if stroke else 'f'}" self.raw(command) def stream(self) -> bytes: return b"\n".join(self.commands) + b"\n" def _make_pdf(content: bytes) -> bytes: objects = [ b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", ( b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] " b"/Resources << /Font << /F1 4 0 R /F2 5 0 R >> /ExtGState << /GS1 6 0 R >> >> " b"/Contents 7 0 R >>" ), b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>", b"<< /Type /ExtGState /ca 0.085 /CA 0.085 >>", ( b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"endstream" ), ] pdf = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") offsets = [0] for number, obj in enumerate(objects, start=1): offsets.append(len(pdf)) pdf.extend(f"{number} 0 obj\n".encode("ascii")) pdf.extend(obj) pdf.extend(b"\nendobj\n") xref = len(pdf) pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii")) pdf.extend(b"0000000000 65535 f \n") for offset in offsets[1:]: pdf.extend(f"{offset:010d} 00000 n \n".encode("ascii")) pdf.extend( ( f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" f"startxref\n{xref}\n%%EOF\n" ).encode("ascii") ) return bytes(pdf) def render_roll_report( report: dict[str, Any], *, generated_at: datetime, build_info: dict[str, str], ) -> bytes: """Render a validated report model as a deliberately compact one-page PDF.""" canvas = _Canvas() green = (0.10, 0.43, 0.28) pale_green = (0.91, 0.97, 0.94) dark = (0.12, 0.18, 0.15) muted = (0.38, 0.43, 0.40) margin = 40 usable_width = A4_WIDTH - (2 * margin) canvas.rect(0, A4_HEIGHT - 92, A4_WIDTH, 92, fill=(0.08, 0.29, 0.20)) canvas.text(margin, 805, "ROLL CALCULATOR", size=20, bold=True, color=(1, 1, 1)) canvas.text(margin, 787, "Calculation report", size=10, color=(0.82, 0.93, 0.87)) generated_text = generated_at.astimezone().strftime("Generated %Y-%m-%d %H:%M %Z") canvas.text(370, 805, generated_text, size=8, color=(0.88, 0.95, 0.91)) canvas.text( 370, 790, ( f"Version {build_info.get('version', 'unknown')} | " f"Build {build_info.get('commit', 'unknown')}" ), size=8, color=(0.88, 0.95, 0.91), ) article = report["article"] y = 726 if article: article_heading = " | ".join( part for part in (article["number"], article["name"]) if part ) canvas.text(margin, y + 13, "ARTICLE", size=7.5, bold=True, color=green) article_lines = _wrap(article_heading, usable_width, 11) if len(article_lines) > 2: article_lines[1] = article_lines[1].rstrip("- ") + "…" for line in article_lines[:2]: canvas.text(margin, y, line, size=11, bold=True, color=dark) y -= 14 y -= 5 canvas.text(margin, y, "CALCULATION RESULTS", size=8, bold=True, color=green) y -= 67 card_gap = 10 card_width = (usable_width - 3 * card_gap) / 4 result_cards = ( (("MINIMUM", "DIAMETER"), report["diameters"]["minimum"].display), (("AVERAGE", "DIAMETER"), report["diameters"]["average"].display), (("MAXIMUM", "DIAMETER"), report["diameters"]["maximum"].display), (("ROLL", "LENGTH"), report["roll_length"]["display"]), ) for index, (label_lines, display) in enumerate(result_cards): x = margin + index * (card_width + card_gap) canvas.rect(x, y, card_width, 54, fill=pale_green, stroke=(0.55, 0.77, 0.65)) canvas.text(x + 10, y + 39, label_lines[0], size=6.5, bold=True, color=green) canvas.text(x + 10, y + 31, label_lines[1], size=6.5, bold=True, color=green) canvas.text( x + 10, y + 10, display, size=13.5, bold=True, color=dark, ) y -= 26 if report["roll_weight"]: canvas.text(margin, y, "CALCULATED ROLL WEIGHT", size=7.5, bold=True, color=green) canvas.text( margin + 146, y - 2, report["roll_weight"]["display"], size=12, bold=True, color=dark, ) y -= 28 canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74)) y -= 19 canvas.text(margin, y, "INPUT DATA", size=8, bold=True, color=green) y -= 18 column_width = (usable_width - 24) / 2 row_height = 23 inputs = report["inputs"] rows = math.ceil(len(inputs) / 2) for index, item in enumerate(inputs): column = index % 2 row = index // 2 x = margin + column * (column_width + 24) item_y = y - row * row_height canvas.text(x, item_y, item["label"].upper(), size=6.8, bold=True, color=muted) value = item["value"] if len(value) > 48: value = value[:47] + "…" canvas.text(x, item_y - 11, value, size=9.2, color=dark) y -= rows * row_height + 9 entries = [ (item["title"], item["message"], item["severity"]) for item in report["warnings"] ] entries.extend(("Note", note, "note") for note in report["notes"]) if entries: canvas.line(margin, y, A4_WIDTH - margin, y, color=(0.72, 0.78, 0.74)) y -= 19 canvas.text(margin, y, "WARNINGS & NOTES", size=8, bold=True, color=green) y -= 17 available = max(72, y - 63) font_size = 8.2 prepared = [] while True: prepared = [] total_lines = 0 for title, message, severity in entries: title_lines = _wrap(title, usable_width - 20, font_size) message_lines = _wrap(message, usable_width - 20, font_size) prepared.append((title_lines, message_lines, severity)) total_lines += len(title_lines) + len(message_lines) line_height = font_size + 2.2 required = total_lines * line_height + len(entries) * 5 if required <= available or font_size <= 5.6: break font_size -= 0.4 if required > available: raise ReportValidationError( "warnings and notes do not fit on a one-page report" ) line_height = font_size + 2.2 for title_lines, message_lines, severity in prepared: if severity == "critical": color = (0.69, 0.21, 0.17) elif severity == "warning": color = (0.61, 0.40, 0.08) else: color = green for line in title_lines: canvas.text(margin + 8, y, line, size=font_size, bold=True, color=color) y -= line_height for line in message_lines: canvas.text(margin + 8, y, line, size=font_size, color=dark) y -= line_height y -= 5 footer_y = 34 canvas.line(margin, footer_y + 21, A4_WIDTH - margin, footer_y + 21, color=(0.72, 0.78, 0.74)) canvas.text( margin, footer_y + 7, ( "Internal advisory calculation. Verify all inputs and outputs for " "plausibility before use." ), size=7.2, color=muted, ) canvas.text( A4_WIDTH - 160, footer_y + 7, f"Version {build_info.get('version', 'unknown')}", size=7.2, color=muted, ) # Repeated marks are painted into the page content stream, not attached as annotations/layers. canvas.raw("q /GS1 gs 0.65 0.70 0.67 rg") angle = math.radians(35) a = math.cos(angle) b = math.sin(angle) for x, mark_y in ((-40, 155), (120, 300), (-20, 445), (145, 590), (-15, 735)): canvas.raw( f"BT /F2 29 Tf {a:.5f} {b:.5f} {-b:.5f} {a:.5f} {x:.2f} {mark_y:.2f} Tm " "(INTERNAL USE ONLY) Tj ET" ) canvas.raw("Q") return _make_pdf(canvas.stream())