Files
pdf-reporting/report_generator/layout.py
T
alexanderandClaude Opus 5.5 864e2ff824 Add a summary report set from Markdown
from-markdown takes a hand-written summary such as SUMMARY.md and sets it
in the full report's dress: the same cover, header, footers, table of
contents, headings and grids. The shared macros move to macros.html.j2 so
both templates draw from them, and layout.render_pdf takes the template and
document title to use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:22:45 +02:00

93 lines
3.5 KiB
Python

"""Renders the SAST/SCA report template.
The template and its stylesheet are the design; this module is only what turns them into a
document. It takes a context that is already shaped for the page -- every number formatted, every
bar already a percentage -- so that the template places strings and never computes.
Nothing here reads an artifact. Filling a context from a scan is :mod:`context`, which works from
what :mod:`sources` collected; :func:`sample.sample_context` builds one by hand, which is what the
template tests render.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
TEMPLATE_DIR = Path(__file__).resolve().parent / "template"
TEMPLATE_NAME = "report.html.j2"
STYLESHEET = TEMPLATE_DIR / "report.css"
# What the document calls itself, in the PDF's own metadata rather than only on its first page.
TITLE = "Complete Security Findings Report"
AUTHOR = "CipherCell"
# The shape :func:`render_html` expects. Written down because the template guards on it: a key that
# is absent leaves its section out, which is how a scan that ran half the pipeline produces a
# shorter report rather than one full of empty tables.
CONTEXT = {
"cover": "scan_id, footer_date, logo, title, identity[], severity_ledger{title, rows[]}",
"project": "title, overview[], languages{title, rows[]}",
"summary": "title, overview[], distributions[{title, rows[], legend}]",
"critical": "title, table{columns[], rows[]}",
"sections": "[{title, new_page?, groups[{title, note?, cards[], table?}]}]",
}
def render_html(context: dict[str, Any], template: str = TEMPLATE_NAME) -> str:
"""Render the report as an HTML document.
Args:
context: The page-ready context; see :data:`CONTEXT`.
template: Which template in :data:`TEMPLATE_DIR` to draw it with.
Returns:
The rendered HTML.
"""
environment = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=select_autoescape(default_for_string=True, default=True),
trim_blocks=True,
lstrip_blocks=True,
)
return environment.get_template(template).render(**context)
def render_pdf(context: dict[str, Any], template: str = TEMPLATE_NAME, title: str = TITLE) -> bytes:
"""Render the report as a PDF.
Args:
context: The page-ready context; see :data:`CONTEXT`.
template: Which template in :data:`TEMPLATE_DIR` to draw it with.
title: What the document calls itself in its metadata.
Returns:
The PDF.
"""
# WeasyPrint pulls in native cairo and pango bindings. Imported here so that importing this
# module -- to render the HTML, or merely to read CONTEXT -- does not pay for them.
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
# Without this the @font-face rules are ignored and Space Grotesk is silently replaced by
# whatever the host happens to have, which is how the design sample came to be set in Carlito.
fonts = FontConfiguration()
# Relative URLs in the template and the stylesheet -- the mark, the font files -- resolve
# against the template directory.
document = HTML(string=render_html(context, template), base_url=str(TEMPLATE_DIR)).render(
stylesheets=[CSS(filename=str(STYLESHEET), font_config=fonts)],
font_config=fonts,
)
document.metadata.title = f"{title} - {context.get('cover', {}).get('subject', '')}".strip(" -")
document.metadata.authors = [AUTHOR]
return bytes(document.write_pdf())