diff --git a/strix/viewer/report_pdf.py b/strix/viewer/report_pdf.py
index 4d9fe3c8..fbf79710 100644
--- a/strix/viewer/report_pdf.py
+++ b/strix/viewer/report_pdf.py
@@ -14,6 +14,7 @@ the user's own hands. Strix cannot read the delivered report.
from __future__ import annotations
import html
+import re
import secrets
from datetime import datetime
from io import BytesIO
@@ -154,7 +155,15 @@ def _styles() -> dict[str, ParagraphStyle]:
spaceBefore=10, spaceAfter=2,
)
styles["body"] = ParagraphStyle(
- "Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT
+ "Body", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT, spaceAfter=8
+ )
+ styles["md_heading"] = ParagraphStyle(
+ "MdHeading", fontName=_SANS_BOLD, fontSize=11, leading=15, textColor=_INK,
+ spaceBefore=10, spaceAfter=4,
+ )
+ styles["bullet"] = ParagraphStyle(
+ "Bullet", fontName=_SANS, fontSize=10, leading=15, textColor=_TEXT,
+ leftIndent=16, firstLineIndent=-11, spaceAfter=3,
)
styles["meta_inline"] = ParagraphStyle(
"MetaInline", fontName=_SANS, fontSize=9, leading=13, textColor=_MUTED, spaceBefore=4
@@ -352,15 +361,115 @@ def _cover(
]
+def _inline_md(text: str) -> str:
+ """Convert inline markdown (bold, italic, `code`) to reportlab markup.
+
+ Code spans are stashed as placeholders before bold/italic run, so bold that
+ wraps a code span (``**`x`**``) works and code contents are never mangled.
+ """
+ codes: list[str] = []
+
+ def _stash(match: re.Match[str]) -> str:
+ codes.append(match.group(1))
+ return f"\x00{len(codes) - 1}\x00"
+
+ seg = html.escape(re.sub(r"`([^`]+)`", _stash, text))
+ seg = re.sub(r"\*\*(.+?)\*\*", r"\1", seg)
+ seg = re.sub(r"__(.+?)__", r"\1", seg)
+ seg = re.sub(r"\*(.+?)\*", r"\1", seg)
+
+ def _restore(match: re.Match[str]) -> str:
+ inner = html.escape(codes[int(match.group(1))])
+ return f'{inner}'
+
+ return re.sub(r"\x00(\d+)\x00", _restore, seg)
+
+
+def _strip_leading_heading(md: str) -> str:
+ """Drop a single leading markdown heading (each section adds its own title)."""
+ lines = md.lstrip("\n").split("\n")
+ if lines and re.match(r"^#{1,6}\s+", lines[0].strip()):
+ return "\n".join(lines[1:]).lstrip("\n")
+ return md
+
+
+def _markdown_flowables( # noqa: PLR0915 - cohesive block parser, splitting hurts clarity
+ md: str, styles: dict[str, ParagraphStyle]
+) -> list[Flowable]:
+ """Render a markdown block (headings, lists, fenced code, prose) to flowables."""
+ flow: list[Flowable] = []
+ para: list[str] = []
+ bullets: list[tuple[str, str]] = []
+
+ def flush_para() -> None:
+ if para:
+ flow.append(Paragraph(_inline_md(" ".join(para)), styles["body"]))
+ para.clear()
+
+ def flush_bullets() -> None:
+ for marker, item in bullets:
+ flow.append(Paragraph(f"{marker} {_inline_md(item)}", styles["bullet"]))
+ bullets.clear()
+
+ lines = md.replace("\r\n", "\n").split("\n")
+ i = 0
+ while i < len(lines):
+ stripped = lines[i].strip()
+ if stripped.startswith("```"):
+ flush_para()
+ flush_bullets()
+ i += 1
+ code: list[str] = []
+ while i < len(lines) and not lines[i].strip().startswith("```"):
+ code.append(lines[i])
+ i += 1
+ i += 1 # closing fence
+ flow.append(Paragraph(_esc("\n".join(code)) or " ", styles["code"]))
+ continue
+ if not stripped:
+ flush_para()
+ flush_bullets()
+ i += 1
+ continue
+ heading = re.match(r"^(#{1,6})\s+(.*)$", stripped)
+ if heading:
+ flush_para()
+ flush_bullets()
+ flow.append(Paragraph(_inline_md(heading.group(2)), styles["md_heading"]))
+ i += 1
+ continue
+ ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped)
+ unordered = re.match(r"^[-*+]\s+(.*)$", stripped)
+ if ordered:
+ flush_para()
+ bullets.append((f"{ordered.group(1)}.", ordered.group(2)))
+ i += 1
+ continue
+ if unordered:
+ flush_para()
+ bullets.append(("•", unordered.group(1)))
+ i += 1
+ continue
+ flush_bullets()
+ para.append(stripped)
+ i += 1
+
+ flush_para()
+ flush_bullets()
+ return flow
+
+
def _field_block(
styles: dict[str, ParagraphStyle], label: str, value: Any, *, code: bool = False
) -> list[Flowable]:
if value is None or (isinstance(value, str) and not value.strip()):
return []
- return [
- Paragraph(label.upper(), styles["field_label"]),
- Paragraph(_esc(value), styles["code"] if code else styles["body"]),
- ]
+ flow: list[Flowable] = [Paragraph(label.upper(), styles["field_label"])]
+ if code:
+ flow.append(Paragraph(_esc(value), styles["code"]))
+ else:
+ flow.extend(_markdown_flowables(str(value), styles))
+ return flow
def _finding_flowables(
@@ -403,6 +512,37 @@ def _finding_flowables(
return story
+def _overview_flowables(
+ styles: dict[str, ParagraphStyle], record: dict[str, Any], total: int, counts: dict[str, int]
+) -> list[Flowable]:
+ story: list[Flowable] = [
+ _section(styles, "Executive Summary"),
+ Spacer(1, 16),
+ _severity_grid(styles, counts),
+ Spacer(1, 10),
+ Paragraph(f"{total} total findings across this assessment.", styles["body"]),
+ ]
+ scan_results = record.get("scan_results")
+ if not isinstance(scan_results, dict):
+ return story
+ summary = scan_results.get("executive_summary")
+ if isinstance(summary, str) and summary.strip():
+ story.append(Spacer(1, 16))
+ story.extend(_markdown_flowables(_strip_leading_heading(summary), styles))
+ for label, key in (
+ ("Methodology", "methodology"),
+ ("Technical Analysis", "technical_analysis"),
+ ("Recommendations", "recommendations"),
+ ):
+ value = scan_results.get(key)
+ if isinstance(value, str) and value.strip():
+ story.append(Spacer(1, 20))
+ story.append(_section(styles, label))
+ story.append(Spacer(1, 12))
+ story.extend(_markdown_flowables(_strip_leading_heading(value), styles))
+ return story
+
+
def generate_report_pdf(run_dir: Path) -> bytes:
"""Render a branded, full-detail PDF report for the run at ``run_dir``."""
record = read_run_summary(run_dir)
@@ -425,35 +565,8 @@ def generate_report_pdf(run_dir: Path) -> bytes:
story: list[Flowable] = []
story.extend(_cover(styles, record, run_name))
+ story.extend(_overview_flowables(styles, record, len(vulns), counts))
- # Executive summary + severity grid.
- story.append(_section(styles, "Executive Summary"))
- story.append(Spacer(1, 16))
- story.append(_severity_grid(styles, counts))
- story.append(Spacer(1, 10))
- story.append(
- Paragraph(f"{len(vulns)} total findings across this assessment.", styles["body"])
- )
-
- scan_results = record.get("scan_results")
- if isinstance(scan_results, dict):
- summary = scan_results.get("executive_summary")
- if isinstance(summary, str) and summary.strip():
- story.append(Spacer(1, 16))
- story.append(Paragraph(_esc(summary), styles["body"]))
- for label, key in (
- ("Methodology", "methodology"),
- ("Technical Analysis", "technical_analysis"),
- ("Recommendations", "recommendations"),
- ):
- value = scan_results.get(key)
- if isinstance(value, str) and value.strip():
- story.append(Spacer(1, 20))
- story.append(_section(styles, label))
- story.append(Spacer(1, 12))
- story.append(Paragraph(_esc(value), styles["body"]))
-
- # Findings.
story.append(PageBreak())
story.append(_section(styles, "Findings"))
story.append(Spacer(1, 16))