Files
rengine/reports/render/env.py
T
Yogesh OjhaandClaude Fable 5.1 068f14bbfb feat(reports): the document says its answer before you open it
A design pass over the rendered documents, on the six themes, the print
stylesheet, the charts and the cover artwork. The brief was a baseline that
looked templated; the bar was a client deliverable someone would be proud to
hand over.

The structural failure was the severity chart. On a real scan, 589 informational
findings swamped 20 criticals into an invisible sliver, on the one page everyone
reads. Severity counts are now stat tiles, so the number is always legible, and
the ring and bar draw only the actionable set with the informational total
stated in the caption. That is the dataviz rule that a dominant neutral bucket
is a KPI, not a slice.

The executive page now opens with a verdict band: the gauge, the headline, the
severity tiles, then five surface figures in one row, and only then the prose at
a readable measure. Every cover carries a grade mark, so the report tells you
its answer on the front; on blueprint the reticle artwork is drawn around it.
Findings are a card with a hierarchy and a facts row where a table used to be,
control mapping is a line of text per framework rather than a wall of chips,
tables have a settled rhythm, and the section number sits in the accent.

The warning collector from the previous commit found every WeasyPrint defect
here. font-weight accepts only the hundreds, so two themes had been rendering
headings in a silent bold. content(text) drops the whitespace between an inline
span and its neighbour, so the running header read "1Executive summary" and the
bookmark with it; both now read a precomputed attribute. break-after: avoid on a
div is not honoured, so labels were orphaning from their content. A dark-paper
theme needs its background on @page as well as html, or the margins print white,
which is why noir had a white frame. word-break: break-word is invalid, and the
hostname column in the asset table was being crushed to a sliver by auto layout.

Two string edits in this pass silently missed because ruff had reformatted the
target; reading the function back after each edit is now written down.

Verified on the gov.cy scan: all 26 sections in all six themes, 57 to 60 pages
each, zero renderer warnings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017f2Mf2HGwFaTRnui4UxpRd
2026-09-06 13:59:44 +05:30

150 lines
4.3 KiB
Python

"""The Jinja environment: section templates plus the filters they all use."""
from __future__ import annotations
import re
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, select_autoescape
from markdown_it import MarkdownIt
from reports.charts import bars, cover_art, dial, donut, matrix, sparkline, stack_bar
from reports.charts.svg import Slice
from reports.render.media import image_data_uri
from shared.definitions.ports import SERVICE_CLASS_LABELS
from shared.definitions.vulnerabilities import SEVERITY_LABELS
TEMPLATES = Path(__file__).resolve().parent.parent / "templates"
SECTIONS = Path(__file__).resolve().parent.parent / "sections"
_MD = MarkdownIt("commonmark", {"html": False, "linkify": False, "typographer": True})
_MD.enable("table")
_MD.enable("strikethrough")
_REMOTE_IMG = re.compile(r"<img\b[^>]*?src=[\"\']((?!data:)[^\"\']*)[\"\'][^>]*>", re.I)
_MINUTE = 60
_HOUR = 3600
def markdown(value: str | None) -> str:
"""Authored text may embed an image, never link one, so nothing here can be fetched."""
return _REMOTE_IMG.sub("", _MD.render(value or "")).strip()
def number(value: float | int | None) -> str:
if value is None:
return "—"
return f"{int(value):,}" if float(value).is_integer() else f"{value:,.1f}"
def percent(value: float | None, *, digits: int = 0) -> str:
if value is None:
return "—"
return f"{value * 100:.{digits}f}%"
def share(part: float | None, whole: float | None, *, digits: int = 0) -> str:
if not whole:
return "—"
return percent((part or 0) / whole, digits=digits)
def date(value: datetime | None, fmt: str = "%d %B %Y") -> str:
return value.strftime(fmt) if value else "—"
def datetime_at(value: datetime | None) -> str:
return value.strftime("%d %b %Y, %H:%M UTC") if value else "—"
def duration(seconds: float | None) -> str:
if seconds is None:
return "—"
total = int(seconds)
if total < _MINUTE:
return f"{total}s"
if total < _HOUR:
return f"{total // 60}m {total % 60:02d}s"
return f"{total // 3600}h {(total % 3600) // 60:02d}m"
def clip(value: str | None, length: int = 80) -> str:
"""Trim, and drop the replacement character a bad byte upstream leaves behind."""
text = (value or "").replace("\ufffd", "")
return text if len(text) <= length else text[: length - 1] + "…"
def severity_label(value: str | None) -> str:
return SEVERITY_LABELS.get((value or "").lower(), "Unknown")
def service_label(value: str | None) -> str:
return SERVICE_CLASS_LABELS.get((value or "").lower(), "Other")
def plural(count: int, word: str, suffix: str = "s") -> str:
return word if count == 1 else f"{word}{suffix}"
def counted(count: int, word: str, suffix: str = "s") -> str:
return f"{number(count)} {plural(count, word, suffix)}"
def pairs(rows) -> list[tuple[str, float]]:
return [(getattr(r, "name", ""), getattr(r, "count", 0)) for r in rows]
def severity_fill(value: str | None) -> str:
return f"var(--r-sev-{(value or 'unknown').lower()})"
def chart_fill(index: int) -> str:
return f"var(--r-chart-{(index % 8) + 1})"
@lru_cache(maxsize=1)
def environment() -> Environment:
env = Environment(
loader=ChoiceLoader([FileSystemLoader(TEMPLATES), FileSystemLoader(SECTIONS)]),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters.update(
{
"md": markdown,
"num": number,
"pct": percent,
"share": share,
"date": date,
"at": datetime_at,
"dur": duration,
"clip": clip,
"sev": severity_label,
"svc": service_label,
"plural": plural,
"counted": counted,
"sevfill": severity_fill,
"pairs": pairs,
"shot": image_data_uri,
}
)
env.globals.update(
{
"Slice": Slice,
"donut": donut,
"bars": bars,
"stack_bar": stack_bar,
"dial": dial,
"sparkline": sparkline,
"matrix": matrix,
"cover_art": cover_art,
"chart_fill": chart_fill,
"severity_fill": severity_fill,
}
)
return env