feat: strengthen artifact contracts and coverage

This commit is contained in:
Tw93
2026-08-02 00:46:56 +08:00
parent e4a297390e
commit fbdb54f59b
35 changed files with 3429 additions and 300 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "kami",
"version": "1.11.0",
"version": "1.12.0",
"description": "Typeset professional documents and landing pages with the Kami design system.",
"category": "documents",
"source": "./plugins/kami",
+2 -2
View File
@@ -6,8 +6,8 @@
"type": "skill-md",
"description": "Use when a user asks for a finished document whose appearance matters: a resume, one-pager, letter, portfolio, long report, slide deck, equity report, changelog, or a landing page. Kami fills a constrained parchment template and exports HTML to PDF, PNG, or editable PPTX, then verifies the result with deterministic and perceptual checks. Skip it when the user only wants the text.",
"url": "/SKILL.md",
"digest": "sha256:33e1bd61e07a991a4b6a423b6c6639d0063a4f658b21708c0b2f947ddf029105",
"version": "1.11.0",
"digest": "sha256:4c703e6a5454e60c6f63d085b2d6d76be2b95a8e5ff62befe6418083142c417a",
"version": "1.12.0",
"license": "MIT",
"homepage": "https://kami.tw93.fun",
"repository": "https://github.com/tw93/kami",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "kami",
"title": "Kami",
"description": "Render and verify Kami documents locally: list templates and content schemas, render filled HTML to PDF, run the deterministic checks, and rasterize pages for a perceptual review pass.",
"version": "1.11.0",
"version": "1.12.0",
"protocolVersion": "2025-06-18",
"license": "MIT",
"homepage": "https://kami.tw93.fun",
@@ -16,7 +16,7 @@
{
"registryType": "github",
"identifier": "tw93/kami",
"version": "1.11.0",
"version": "1.12.0",
"transport": {
"type": "stdio"
},
+3 -2
View File
@@ -29,8 +29,9 @@ Only the entries whose role is not obvious from the filename:
fields carry the per-field quality bar distilled from `writing.md`, so schema edits
and `writing.md` edits move together.
- `scripts/shared.py` - the canonical registries (`HTML_TEMPLATES`,
`SCREEN_TEMPLATES`, `PPTX_TEMPLATES`, `DIAGRAM_TEMPLATES`) and each template's
`build_max_pages`. `build.py` derives its targets from them; add or remove a
`SCREEN_TEMPLATES`, `PPTX_TEMPLATES`, `MARP_TEMPLATES`, `DIAGRAM_TEMPLATES`) and each template's
`build_max_pages`. `build.py` derives its internal build targets from them;
Marp stays discovery-only because it uses an external CLI. Add or remove a
template or diagram here, never in a per-script dict.
- `scripts/render.py` - the single render entry (`render_pdf`, `build_slides`, PDF
metadata stamping). `build.py`, `verify.py`, and `mcp_server.py` all call it; never
+9 -9
View File
@@ -31,15 +31,15 @@ Skip and fall back to the brand profile defaults if the referenced path does not
## Step 1 · Decide the language
**Match the user's language.** Chinese -> `*.html` / `slides-weasy.html`. English -> `*-en.html` / `slides-weasy-en.html`. Japanese -> CJK path (`.html` / `slides-weasy.html`) as best-effort, JP Mincho first, visual QA before shipping. Korean -> dedicated `*-ko.html` / `slides-weasy-ko.html` family as best-effort, visual QA before shipping. Reference docs are shared English specs.
**Match the user's language.** Chinese -> `*.html` / `slides-weasy.html`. English -> `*-en.html` / `slides-weasy-en.html`. Japanese -> CJK path (`.html` / `slides-weasy.html` / `slides-marp.md`) as best-effort, JP Mincho first, visual QA before shipping. Korean -> dedicated `*-ko.html` / `slides-weasy-ko.html` family, with `slides-marp.md` as the best-effort Markdown path, visual QA before shipping. Reference docs are shared English specs.
| User language | HTML templates | Slides (PDF default) | Slides (PPTX fallback) |
|---|---|---|---|
| Chinese (primary) | `*.html` | `slides-weasy.html` | `slides.py` |
| English | `*-en.html` | `slides-weasy-en.html` | `slides-en.py` |
| Japanese (best-effort) | `*.html` | `slides-weasy.html` | `slides.py` |
| Korean (best-effort) | `*-ko.html` | `slides-weasy-ko.html` | n/a (use `slides-en.py` only if PPTX is required) |
| Other languages (best-effort) | choose CJK or EN path by script coverage, then verify manually | choose `slides-weasy.html` or `slides-weasy-en.html`, then verify manually | use `slides.py` / `slides-en.py` only if PPTX is required |
| User language | HTML templates | Slides (PDF default) | Slides (PPTX fallback) | Slides (Marp) |
|---|---|---|---|---|
| Chinese (primary) | `*.html` | `slides-weasy.html` | `slides.py` | `slides-marp.md` |
| English | `*-en.html` | `slides-weasy-en.html` | `slides-en.py` | `slides-marp-en.md` |
| Japanese (best-effort) | `*.html` | `slides-weasy.html` | `slides.py` | `slides-marp.md` |
| Korean (best-effort) | `*-ko.html` | `slides-weasy-ko.html` | n/a (use `slides-en.py` only if PPTX is required) | `slides-marp.md` |
| Other languages (best-effort) | choose CJK or EN path by script coverage, then verify manually | choose `slides-weasy.html` or `slides-weasy-en.html`, then verify manually | use `slides.py` / `slides-en.py` only if PPTX is required | choose `slides-marp.md` or `slides-marp-en.md`, then verify manually |
> Default to the WeasyPrint HTML path; fall back to PPTX (`slides*.py`) only when the user explicitly needs an editable deck.
@@ -228,7 +228,7 @@ When building a new document (not a text tweak on an existing one), write the di
}
```
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy and does not participate in content-to-HTML coverage. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy. Only `brief.required_assets` joins the content-to-HTML coverage gate. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
The top-level envelope is strict: it contains only `type`, `lang`, `brief`, and `content`.
Use a language tag such as `cn`, `en`, `ko`, or `zh-TW`; misspelled tags and extra
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
+3 -3
View File
@@ -1,5 +1,5 @@
/* @theme kami */
/* Kami Marp theme (CN). Mirrors slides-weasy.html tokens. */
/* Kami Marp theme (CJK). Mirrors slides-weasy.html tokens. */
@font-face {
font-family: "TsangerJinKai02";
@@ -31,9 +31,9 @@
--border: #e8e6dc;
--border-soft:#e5e3d8;
--serif: "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif CN", "Noto Serif CJK SC", "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif;
--serif: "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif K", "Source Han Serif KR", "Source Han Serif CN", "Noto Serif CJK SC", "Noto Serif CJK KR", "Noto Serif SC", "Noto Serif KR", "Songti SC", "STSong", AppleMyungjo, Batang, "SimSun", Georgia, serif;
--sans: var(--serif);
--mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", monospace;
--mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif K", "Source Han Serif KR", monospace;
--rhythm-module: 14pt;
--rhythm-section: 18pt;
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="How agents and developers integrate Kami: the local MCP server, content schemas, deterministic checks, and every machine-readable file this site publishes.">
<link rel="icon" href="assets/images/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="./styles.css?v=1.11.0">
<link rel="stylesheet" href="./styles.css?v=1.12.0">
<link rel="canonical" href="https://kami.tw93.fun/developers">
<link rel="alternate" type="text/markdown" href="https://kami.tw93.fun/developers.md">
<meta property="og:type" content="website">
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -163,7 +163,7 @@
<!-- ============ HERO ============ -->
<header class="hero">
<div class="eyebrow">
<span>Design System · v1.11.0<span class="eyebrow-date"> · 2026.07</span></span>
<span>Design System · v1.12.0<span class="eyebrow-date"> · 2026.08</span></span>
<span class="hero-links">
<nav class="lang-switch" aria-label="Language" data-lang-switch>
<button class="lang-trigger" type="button" aria-expanded="false" data-lang-trigger>日本語</button>
@@ -392,7 +392,7 @@ codex plugin add kami@kami</pre>
</div>
<div class="type-row">
<div class="type-label">Label</div>
<div class="type-sample label">Design System · v1.11.0</div>
<div class="type-sample label">Design System · v1.12.0</div>
<div class="type-specs"><b>7.5-8 pt</b><br>weight 600<br>line 1.35</div>
</div>
</div>
+2 -2
View File
@@ -175,7 +175,7 @@
<!-- ============ HERO ============ -->
<header class="hero">
<div class="eyebrow">
<span>Design System · v1.11.0<span class="eyebrow-date"> · 2026.07</span></span>
<span>Design System · v1.12.0<span class="eyebrow-date"> · 2026.08</span></span>
<span class="hero-links">
<nav class="lang-switch" aria-label="Language" data-lang-switch>
<button class="lang-trigger" type="button" aria-expanded="false" data-lang-trigger>한국어</button>
@@ -403,7 +403,7 @@ codex plugin add kami@kami</pre>
</div>
<div class="type-row">
<div class="type-label">Label</div>
<div class="type-sample label">Design System · v1.11.0</div>
<div class="type-sample label">Design System · v1.12.0</div>
<div class="type-specs"><b>7.5-8 pt</b><br>weight 600<br>line 1.35</div>
</div>
</div>
+2 -2
View File
@@ -163,7 +163,7 @@
<!-- ============ HERO ============ -->
<header class="hero">
<div class="eyebrow">
<span>Design System · v1.11.0<span class="eyebrow-date"> · 2026.07</span></span>
<span>Design System · v1.12.0<span class="eyebrow-date"> · 2026.08</span></span>
<span class="hero-links">
<nav class="lang-switch" aria-label="Language" data-lang-switch>
<button class="lang-trigger" type="button" aria-expanded="false" data-lang-trigger>繁體中文</button>
@@ -402,7 +402,7 @@ codex plugin add kami@kami</pre>
</div>
<div class="type-row">
<div class="type-label">Label</div>
<div class="type-sample label">Design System · v1.11.0</div>
<div class="type-sample label">Design System · v1.12.0</div>
<div class="type-specs"><b>7.5-8 pt</b><br>weight 600<br>line 1.35</div>
</div>
+2 -2
View File
@@ -163,7 +163,7 @@
<!-- ============ HERO ============ -->
<header class="hero">
<div class="eyebrow">
<span>Design System · v1.11.0<span class="eyebrow-date"> · 2026.07</span></span>
<span>Design System · v1.12.0<span class="eyebrow-date"> · 2026.08</span></span>
<span class="hero-links">
<nav class="lang-switch" aria-label="Language" data-lang-switch>
<button class="lang-trigger" type="button" aria-expanded="false" data-lang-trigger>简体中文</button>
@@ -402,7 +402,7 @@ codex plugin add kami@kami</pre>
</div>
<div class="type-row">
<div class="type-label">Label</div>
<div class="type-sample label">Design System · v1.11.0</div>
<div class="type-sample label">Design System · v1.12.0</div>
<div class="type-specs"><b>7.58 pt</b><br>weight 600<br>line 1.35</div>
</div>
+2 -2
View File
@@ -186,7 +186,7 @@
<!-- ============ HERO ============ -->
<header class="hero">
<div class="eyebrow">
<span>Design System · v1.11.0<span class="eyebrow-date"> · 2026.07</span></span>
<span>Design System · v1.12.0<span class="eyebrow-date"> · 2026.08</span></span>
<span class="hero-links">
<nav class="lang-switch" aria-label="Language" data-lang-switch>
<button class="lang-trigger" type="button" aria-expanded="false" data-lang-trigger>English</button>
@@ -414,7 +414,7 @@ codex plugin add kami@kami</pre>
</div>
<div class="type-row">
<div class="type-label">Label</div>
<div class="type-sample label">Design System · v1.11.0</div>
<div class="type-sample label">Design System · v1.12.0</div>
<div class="type-specs"><b>7.5-8 pt</b><br>weight 600<br>line 1.35</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kami",
"version": "1.11.0",
"version": "1.12.0",
"description": "Typeset professional documents and landing pages with the Kami design system.",
"author": {
"name": "Tw93",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kami",
"version": "1.11.0",
"version": "1.12.0",
"description": "Professional document and landing-page typesetting skill for Codex: resumes, one-pagers, reports, letters, portfolios, slides, and more.",
"author": {
"name": "Tw93",
+9 -9
View File
@@ -31,15 +31,15 @@ Skip and fall back to the brand profile defaults if the referenced path does not
## Step 1 · Decide the language
**Match the user's language.** Chinese -> `*.html` / `slides-weasy.html`. English -> `*-en.html` / `slides-weasy-en.html`. Japanese -> CJK path (`.html` / `slides-weasy.html`) as best-effort, JP Mincho first, visual QA before shipping. Korean -> dedicated `*-ko.html` / `slides-weasy-ko.html` family as best-effort, visual QA before shipping. Reference docs are shared English specs.
**Match the user's language.** Chinese -> `*.html` / `slides-weasy.html`. English -> `*-en.html` / `slides-weasy-en.html`. Japanese -> CJK path (`.html` / `slides-weasy.html` / `slides-marp.md`) as best-effort, JP Mincho first, visual QA before shipping. Korean -> dedicated `*-ko.html` / `slides-weasy-ko.html` family, with `slides-marp.md` as the best-effort Markdown path, visual QA before shipping. Reference docs are shared English specs.
| User language | HTML templates | Slides (PDF default) | Slides (PPTX fallback) |
|---|---|---|---|
| Chinese (primary) | `*.html` | `slides-weasy.html` | `slides.py` |
| English | `*-en.html` | `slides-weasy-en.html` | `slides-en.py` |
| Japanese (best-effort) | `*.html` | `slides-weasy.html` | `slides.py` |
| Korean (best-effort) | `*-ko.html` | `slides-weasy-ko.html` | n/a (use `slides-en.py` only if PPTX is required) |
| Other languages (best-effort) | choose CJK or EN path by script coverage, then verify manually | choose `slides-weasy.html` or `slides-weasy-en.html`, then verify manually | use `slides.py` / `slides-en.py` only if PPTX is required |
| User language | HTML templates | Slides (PDF default) | Slides (PPTX fallback) | Slides (Marp) |
|---|---|---|---|---|
| Chinese (primary) | `*.html` | `slides-weasy.html` | `slides.py` | `slides-marp.md` |
| English | `*-en.html` | `slides-weasy-en.html` | `slides-en.py` | `slides-marp-en.md` |
| Japanese (best-effort) | `*.html` | `slides-weasy.html` | `slides.py` | `slides-marp.md` |
| Korean (best-effort) | `*-ko.html` | `slides-weasy-ko.html` | n/a (use `slides-en.py` only if PPTX is required) | `slides-marp.md` |
| Other languages (best-effort) | choose CJK or EN path by script coverage, then verify manually | choose `slides-weasy.html` or `slides-weasy-en.html`, then verify manually | use `slides.py` / `slides-en.py` only if PPTX is required | choose `slides-marp.md` or `slides-marp-en.md`, then verify manually |
> Default to the WeasyPrint HTML path; fall back to PPTX (`slides*.py`) only when the user explicitly needs an editable deck.
@@ -228,7 +228,7 @@ When building a new document (not a text tweak on an existing one), write the di
}
```
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy and does not participate in content-to-HTML coverage. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
`type` is one of the schema names in `references/schemas/` (one-pager, letter, resume, long-doc, portfolio, slides, equity-report, changelog, landing-page). `brief` records why this artifact exists and how it will be judged; it is not audience copy. Only `brief.required_assets` joins the content-to-HTML coverage gate. For a visual repair, add `target`, `evidence`, and `preserve` so the fix cannot silently grow beyond the reported surface. Older IR files without `brief` remain valid, but every new document should write it. Read the matching content schema before writing: its `$comment` notes carry the per-field quality bar. Then validate before any layout work:
The top-level envelope is strict: it contains only `type`, `lang`, `brief`, and `content`.
Use a language tag such as `cn`, `en`, `ko`, or `zh-TW`; misspelled tags and extra
+1 -1
View File
@@ -1 +1 @@
1.11.0
1.12.0
@@ -1,5 +1,5 @@
/* @theme kami */
/* Kami Marp theme (CN). Mirrors slides-weasy.html tokens. */
/* Kami Marp theme (CJK). Mirrors slides-weasy.html tokens. */
@font-face {
font-family: "TsangerJinKai02";
@@ -31,9 +31,9 @@
--border: #e8e6dc;
--border-soft:#e5e3d8;
--serif: "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif CN", "Noto Serif CJK SC", "Noto Serif SC", "Songti SC", "STSong", "SimSun", Georgia, serif;
--serif: "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif K", "Source Han Serif KR", "Source Han Serif CN", "Noto Serif CJK SC", "Noto Serif CJK KR", "Noto Serif SC", "Noto Serif KR", "Songti SC", "STSong", AppleMyungjo, Batang, "SimSun", Georgia, serif;
--sans: var(--serif);
--mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", monospace;
--mono: "JetBrains Mono", "SF Mono", "Fira Code", Consolas, Monaco, "TsangerJinKai02", "Source Han Serif SC", "Source Han Serif K", "Source Han Serif KR", monospace;
--rhythm-module: 14pt;
--rhythm-section: 18pt;
@@ -12,7 +12,7 @@ Default to the WeasyPrint HTML path. Switch to pptx only if the user explicitly
|---|---|---|
| WeasyPrint HTML → PDF (default) | `slides-weasy.html` / `slides-weasy-en.html` / `slides-weasy-ko.html` | All cases unless PPTX or Marp is required |
| python-pptx → PPTX (fallback) | `slides.py` / `slides-en.py` | User explicitly requires editable PPTX |
| Marp Markdown (variant) | `assets/templates/marp/slides-marp.md` (+ `slides-marp.css`) / `slides-marp-en.md` (+ `slides-marp-en.css`) | User explicitly asks for Marp, "markdown slides", or a `.md` deck. Shipped `.md` is a working demo of Kami Marp itself; copy it, swap content, keep the structure. Renders via local `marp` CLI; not bundled. |
| Marp Markdown (variant) | `assets/templates/marp/slides-marp.md` (+ `slides-marp.css`) / `slides-marp-en.md` (+ `slides-marp-en.css`) | User explicitly asks for Marp, "markdown slides", or a `.md` deck. Use the CJK variant for Chinese and as the best-effort Japanese/Korean path; use `-en` for English. Copy the shipped working deck, swap content, keep the structure, and render via the local `marp` CLI (not bundled). |
## Page size
@@ -1000,9 +1000,9 @@ Files:
| Asset | Path |
|---|---|
| CN theme CSS | `assets/templates/marp/slides-marp.css` |
| CJK theme CSS (CN, JP/KO best-effort) | `assets/templates/marp/slides-marp.css` |
| EN theme CSS | `assets/templates/marp/slides-marp-en.css` |
| CN sample deck | `assets/templates/marp/slides-marp.md` |
| CJK sample deck | `assets/templates/marp/slides-marp.md` |
| EN sample deck | `assets/templates/marp/slides-marp-en.md` |
Shared with WeasyPrint slides: every design token (`--parchment`, `--brand`, `--serif`, `--mono`), the Kami class scale (`.eyebrow`, `.lead`, `.mt`, `.ml`, `.mb`, `.mc`, `.co`, `.c2`, `table.t2x2`, `table.data`, `section.cover`), and the 280×158mm page size. The Marp theme is a port, not a redesign.
@@ -352,9 +352,9 @@ Use the `npx @marp-team/marp-cli@latest ...` form below for zero-install. For re
| Asset | Path |
|---|---|
| CN theme | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| CJK theme (CN, JP/KO best-effort) | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) |
| CN sample deck | `assets/templates/marp/slides-marp.md` |
| CJK sample deck | `assets/templates/marp/slides-marp.md` |
| EN sample deck | `assets/templates/marp/slides-marp-en.md` |
### Render commands
+803 -40
View File
@@ -14,6 +14,7 @@ Thresholds and DPI live in `references/checks_thresholds.json`.
from __future__ import annotations
import re
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
@@ -72,88 +73,850 @@ def check_placeholders(paths: list[str]) -> int:
# ---------- markdown residue check ----------
def css_hidden_selectors(raw: str) -> tuple[set[str], set[str]]:
"""Return simple class and id selectors hidden by inline stylesheet rules.
def _decode_css_escapes(text: str) -> str:
"""Decode the CSS escapes needed to recognize property names and values."""
def replace_hex(match: re.Match) -> str:
try:
return chr(int(match.group(1), 16))
except (ValueError, OverflowError):
return ""
This intentionally recognizes only unambiguous single-element selectors;
a full CSS cascade belongs to the renderer, not this zero-dependency check.
text = re.sub(r"\\([0-9a-fA-F]{1,6})\s?", replace_hex, text)
return re.sub(r"\\([^\r\n])", r"\1", text)
def _css_numeric_value(value: str) -> tuple[float, str] | None:
"""Parse a small, deterministic subset of CSS numeric expressions."""
value = re.sub(r"\s+", "", value.lower())
direct = re.fullmatch(r"([-+]?(?:\d+(?:\.\d*)?|\.\d+))(%|[a-z]+)?", value)
if direct:
return float(direct.group(1)), direct.group(2) or ""
function = re.fullmatch(r"(calc|min|max|clamp)\((.*)\)", value)
if not function:
return None
name, body = function.groups()
if name == "calc":
return _css_numeric_value(body)
parts = [_css_numeric_value(part) for part in body.split(",")]
if not parts or any(part is None for part in parts):
return None
parsed = [part for part in parts if part is not None]
units = {unit for number, unit in parsed if number != 0 and unit}
if len(units) > 1:
return None
unit = next(iter(units), next((u for _, u in parsed if u), ""))
values = [number for number, _ in parsed]
if name == "min":
return min(values), unit
if name == "max":
return max(values), unit
if len(values) == 3:
lower, preferred, upper = values
return max(lower, min(preferred, upper)), unit
return None
def _is_zero_css_value(value: str) -> bool:
parsed = _css_numeric_value(value)
return parsed is not None and parsed[0] == 0
def _is_extreme_css_offset(value: str) -> bool:
parsed = _css_numeric_value(value)
if parsed is None:
return False
number, unit = parsed
threshold = 100 if unit in {"%", "em", "rem"} else 2000
return abs(number) >= threshold
def _style_state(
style: str,
*,
fail_closed: bool,
replaced_element: bool = False,
) -> tuple[bool, bool]:
"""Return ``(hidden, ambiguous)`` for a declaration block.
CSS comments are removed first because browsers accept declarations such
as ``display/**/: none``. The coverage gate must not treat those elements
as visible merely because the declaration was split by a comment.
"""
clean = re.sub(r"/\*.*?\*/", "", style, flags=re.S)
clean = _decode_css_escapes(clean)
properties: dict[str, tuple[str, bool]] = {}
for declaration in clean.split(";"):
name, separator, value = declaration.partition(":")
if not separator:
continue
name = re.sub(r"\s+", "", name.lower())
important = re.search(r"!\s*important\s*$", value, flags=re.I) is not None
value = re.sub(r"!\s*important\s*$", "", value, flags=re.I)
value = re.sub(r"\s+", "", value.lower())
previous = properties.get(name)
if previous is None or important or not previous[1]:
properties[name] = (value, important)
def resolved(name: str, depth: int = 0) -> tuple[str, bool]:
if name not in properties:
return "", False
if depth > 8:
return "", True
value = properties[name][0]
match = re.fullmatch(r"var\((--[\w-]+)(?:,(.*))?\)", value)
if not match:
return value, False
custom_name, fallback = match.groups()
if custom_name in properties:
return resolved(custom_name, depth + 1)
if fallback is None:
return "", True
temporary_name = f"--kami-fallback-{depth}"
properties[temporary_name] = (fallback, False)
try:
return resolved(temporary_name, depth + 1)
finally:
properties.pop(temporary_name, None)
display, display_ambiguous = resolved("display")
visibility, visibility_ambiguous = resolved("visibility")
if fail_closed and (
(display_ambiguous and "display" in properties)
or (visibility_ambiguous and "visibility" in properties)
):
return False, True
if display == "none" or display.startswith("var("):
return True, False
if visibility in {"hidden", "collapse"} or visibility.startswith("var("):
return True, False
opacity, opacity_ambiguous = resolved("opacity")
if opacity_ambiguous and "opacity" in properties and fail_closed:
return False, True
if _is_zero_css_value(opacity):
return True, False
font_size, font_size_ambiguous = resolved("font-size")
if font_size_ambiguous and "font-size" in properties and fail_closed:
return False, True
if _is_zero_css_value(font_size):
return True, False
color, color_ambiguous = resolved("color")
if color_ambiguous and "color" in properties and fail_closed:
return False, True
if color == "transparent":
return True, False
transform, transform_ambiguous = resolved("transform")
if transform_ambiguous and "transform" in properties and fail_closed:
return False, True
if (
re.search(r"scale(?:x|y)?\([-+]?(?:0+(?:\.0*)?|\.0+)\)", transform)
or re.search(
r"scale\([-+]?(?:0+(?:\.0*)?|\.0+),"
r"[-+]?(?:0+(?:\.0*)?|\.0+)\)",
transform,
)
):
return True, False
matrix = re.search(r"matrix\(([^)]*)\)", transform)
if matrix:
try:
values = [float(value) for value in matrix.group(1).split(",")]
except ValueError:
values = []
if len(values) == 6 and all(value == 0 for value in values[:4]):
return True, False
if re.search(r"translate(?:x|y)?\([^)]*(?:-[2-9]\d{3,}|-[1-9]\d{4,})", transform):
return True, False
if fail_closed and "transform" in properties and "var(" in transform:
return False, True
for property_name in ("scale", "zoom"):
value, ambiguous = resolved(property_name)
if ambiguous and property_name in properties and fail_closed:
return False, True
if _is_zero_css_value(value):
return True, False
content_visibility, content_visibility_ambiguous = resolved("content-visibility")
if content_visibility_ambiguous and "content-visibility" in properties and fail_closed:
return False, True
if content_visibility == "hidden":
return True, False
filter_value, filter_ambiguous = resolved("filter")
if filter_ambiguous and "filter" in properties and fail_closed:
return False, True
if re.search(r"opacity\((?:0+(?:\.0*)?|\.0+)(?:%)?\)", filter_value):
return True, False
if fail_closed and "filter" in properties and "url(" in filter_value:
return False, True
if fail_closed:
for property_name in ("mask", "mask-image", "-webkit-mask", "-webkit-mask-image"):
value, ambiguous = resolved(property_name)
if property_name in properties and (ambiguous or value not in {"", "none"}):
return False, True
if fail_closed:
for property_name in (
"top", "right", "bottom", "left", "inset", "inset-inline",
"inset-block", "margin-top", "margin-right", "margin-bottom",
"margin-left",
):
value, ambiguous = resolved(property_name)
if property_name not in properties:
continue
if ambiguous:
return False, True
if _is_extreme_css_offset(value):
return True, False
text_indent, text_indent_ambiguous = resolved("text-indent")
if text_indent_ambiguous and "text-indent" in properties and fail_closed:
return False, True
if _is_extreme_css_offset(text_indent):
return True, False
clip, clip_ambiguous = resolved("clip")
clip_path, clip_path_ambiguous = resolved("clip-path")
if fail_closed and (
(clip_ambiguous and "clip" in properties)
or (clip_path_ambiguous and "clip-path" in properties)
):
return False, True
if clip in {"rect(0,0,0,0)", "rect(0px,0px,0px,0px)"}:
return True, False
if clip_path in {"inset(50%)", "inset(100%)", "circle(0)", "circle(0px)"}:
return True, False
if fail_closed and "clip" in properties and clip not in {"", "auto"}:
return False, True
if fail_closed and "clip-path" in properties and clip_path not in {"", "none"}:
return False, True
width, width_ambiguous = resolved("width")
max_width, max_width_ambiguous = resolved("max-width")
height, height_ambiguous = resolved("height")
max_height, max_height_ambiguous = resolved("max-height")
overflow, overflow_ambiguous = resolved("overflow")
if fail_closed and (
(width_ambiguous and "width" in properties)
or (max_width_ambiguous and "max-width" in properties)
or (height_ambiguous and "height" in properties)
or (max_height_ambiguous and "max-height" in properties)
):
return False, True
if overflow_ambiguous and "overflow" in properties and fail_closed:
return False, True
zero_width = _is_zero_css_value(width) or _is_zero_css_value(max_width)
zero_height = _is_zero_css_value(height) or _is_zero_css_value(max_height)
if (zero_width or zero_height) and (
replaced_element or overflow in {"hidden", "clip"}
):
return True, False
if fail_closed:
for property_name, value, ambiguous in (
("opacity", opacity, opacity_ambiguous),
("font-size", font_size, font_size_ambiguous),
("width", width, width_ambiguous),
("max-width", max_width, max_width_ambiguous),
("height", height, height_ambiguous),
("max-height", max_height, max_height_ambiguous),
):
if property_name in properties and (
ambiguous or ("(" in value and _css_numeric_value(value) is None)
):
return False, True
return False, False
def _style_hides(
style: str,
*,
fail_closed: bool,
replaced_element: bool = False,
) -> bool:
return _style_state(
style,
fail_closed=fail_closed,
replaced_element=replaced_element,
)[0]
def _css_hidden_filters(
raw: str,
*,
fail_closed: bool,
) -> tuple[
set[str], set[str], set[str], set[tuple[str, str | None]],
set[str], set[str], set[str], set[tuple[str, str | None]], bool,
]:
"""Return conservative class, id, and tag filters for hidden CSS rules.
The stdlib parser does not implement the CSS cascade. For compound
selectors, it marks the target class/id when available; for a bare target
such as ``.concealed img``, it marks the nearest ancestor class/id. This
can reject an ambiguous document, but it cannot turn hidden evidence into
a coverage pass. Pseudo-elements are ignored because they do not hide the
underlying element.
"""
hidden_classes: set[str] = set()
hidden_ids: set[str] = set()
hidden_tags: set[str] = set()
hidden_attrs: set[tuple[str, str | None]] = set()
ambiguous_classes: set[str] = set()
ambiguous_ids: set[str] = set()
ambiguous_tags: set[str] = set()
ambiguous_attrs: set[tuple[str, str | None]] = set()
globally_ambiguous = False
decoded_markup = unescape(raw)
if fail_closed and re.search(
r"<link\b[^>]*\bstylesheet\b[^>]*>",
decoded_markup,
flags=re.I,
):
ambiguous_tags.add("*")
globally_ambiguous = True
style_blocks = re.findall(r"<style\b[^>]*>(.*?)</style\s*>", raw, flags=re.I | re.S)
for block in style_blocks:
clean = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
clean = _decode_css_escapes(clean)
if fail_closed and re.search(r"@import\b", clean, flags=re.I):
ambiguous_tags.add("*")
globally_ambiguous = True
for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", clean, flags=re.S):
compact = re.sub(r"\s+", "", body.lower())
if "display:none" not in compact and "visibility:hidden" not in compact:
hides, body_ambiguous = _style_state(body, fail_closed=fail_closed)
if not hides and not body_ambiguous:
continue
class_store = ambiguous_classes if body_ambiguous else hidden_classes
id_store = ambiguous_ids if body_ambiguous else hidden_ids
tag_store = ambiguous_tags if body_ambiguous else hidden_tags
attr_store = ambiguous_attrs if body_ambiguous else hidden_attrs
selectors = _decode_css_escapes(selectors)
if (
re.search(r":[\w-]+\s*\(", selectors)
or re.search(r"[+~]", selectors)
or re.search(r"\[[^\]]*(?:[~|^$*]=)", selectors)
or re.search(r"\[[^\]]*,[^\]]*\]", selectors)
):
if fail_closed:
ambiguous_tags.add("*")
globally_ambiguous = True
continue
for selector in selectors.split(","):
selector = selector.strip()
class_match = re.fullmatch(r"(?:[a-zA-Z][\w-]*)?\.([\w-]+)", selector)
id_match = re.fullmatch(r"(?:[a-zA-Z][\w-]*)?#([\w-]+)", selector)
if class_match:
hidden_classes.add(class_match.group(1))
elif id_match:
hidden_ids.add(id_match.group(1))
if not selector or "::" in selector:
continue
compounds = [
part for part in re.split(r"\s+|[>+~]", selector) if part
]
if not compounds:
continue
target = compounds[-1]
target_classes = set(re.findall(r"\.([\w-]+)", target))
target_ids = set(re.findall(r"#([\w-]+)", target))
target_attrs = {
(
match.group(1).lower(),
next(
(value for value in match.groups()[1:] if value is not None),
None,
),
)
for match in re.finditer(
r"\[\s*([\w-]+)(?:\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\]\s]+)))?\s*\]",
target,
)
}
if ":not(" not in target and (target_classes or target_ids):
class_store.update(target_classes)
id_store.update(target_ids)
continue
if target_attrs:
attr_store.update(target_attrs)
continue
if target.startswith(":root"):
tag_store.add("html")
continue
ancestors = " ".join(compounds[:-1])
ancestor_classes = set(re.findall(r"\.([\w-]+)", ancestors))
ancestor_ids = set(re.findall(r"#([\w-]+)", ancestors))
if ancestor_classes or ancestor_ids:
class_store.update(ancestor_classes)
id_store.update(ancestor_ids)
continue
tag_match = re.match(r"(?:\*|[a-zA-Z][\w-]*)", target)
if tag_match:
tag_store.add(tag_match.group(0).lower())
else:
ambiguous_tags.add("*")
globally_ambiguous = True
return (
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
globally_ambiguous,
)
def css_hidden_selectors(raw: str) -> tuple[set[str], set[str]]:
"""Return class and id filters hidden by inline stylesheet rules."""
hidden_classes, hidden_ids, _, _, _, _, _, _, _ = _css_hidden_filters(
raw,
fail_closed=False,
)
return hidden_classes, hidden_ids
class _VisibleTextParser(HTMLParser):
"""Extract visible text from filled HTML while skipping code-like blocks."""
class _HtmlVisibilityParser(HTMLParser):
"""Shared fail-closed visibility state for text and resource parsers."""
_SKIP_TAGS = {"code", "head", "noscript", "pre", "script", "style", "template"}
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
_P_IMPLICIT_CLOSE_STARTS = {
"address", "article", "aside", "blockquote", "details", "div",
"dl", "fieldset", "figcaption", "figure", "footer", "form",
"h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr",
"main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul",
}
_OPTIONAL_REPEAT_TAGS = {
"dd", "dt", "li", "option", "tbody", "td", "tfoot", "th", "thead", "tr",
}
_REPLACED_TAGS = {"audio", "embed", "iframe", "image", "img", "object", "svg", "video"}
_PRESENTATION_STYLE_ATTRS = {
"clip", "clip-path", "color", "display", "filter", "font-size", "height",
"mask", "mask-image", "max-height", "max-width", "opacity", "overflow",
"transform", "visibility", "width",
}
_SVG_POSITIONED_TAGS = {
"circle", "ellipse", "foreignobject", "image", "rect", "svg", "text", "tspan", "use",
}
def __init__(self, hidden_classes: set[str], hidden_ids: set[str]) -> None:
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
*,
skip_tags: set[str],
fail_closed: bool,
) -> None:
super().__init__(convert_charrefs=True)
self._hidden_classes = hidden_classes
self._hidden_ids = hidden_ids
self._hidden_tags = hidden_tags
self._hidden_attrs = hidden_attrs
self._ambiguous_classes = ambiguous_classes
self._ambiguous_ids = ambiguous_ids
self._ambiguous_tags = ambiguous_tags
self._ambiguous_attrs = ambiguous_attrs
self._skip_tags = skip_tags
self._fail_closed = fail_closed
self._visibility_ambiguous = visibility_ambiguous
self._skip_depth = 0
self._skip_stack: list[bool] = []
self.parts: list[str] = []
self._ambiguous_depth = 0
self._skip_stack: list[tuple[str, bool, bool]] = []
self._svg_viewports: list[tuple[float, float, float, float]] = []
self._ambiguous_markup = False
def _mark_ambiguous(self) -> None:
self._ambiguous_markup = True
self._skip_stack.clear()
self._svg_viewports.clear()
self._skip_depth = 1 if self._fail_closed else 0
self._ambiguous_depth = 1 if self._fail_closed else 0
def _pop_top(self) -> None:
tag, hidden, ambiguous = self._skip_stack.pop()
if tag == "svg" and self._svg_viewports:
self._svg_viewports.pop()
if hidden:
self._skip_depth = max(0, self._skip_depth - 1)
if ambiguous:
self._ambiguous_depth = max(0, self._ambiguous_depth - 1)
@staticmethod
def _svg_viewport(attrs_map: dict[str, str]) -> tuple[float, float, float, float] | None:
raw = attrs_map.get("viewbox", "")
if raw:
try:
values = [float(value) for value in re.split(r"[\s,]+", raw.strip())]
except ValueError:
values = []
if len(values) == 4 and values[2] > 0 and values[3] > 0:
return values[0], values[1], values[2], values[3]
width = _css_numeric_value(attrs_map.get("width", ""))
height = _css_numeric_value(attrs_map.get("height", ""))
if width and height and width[0] > 0 and height[0] > 0:
return 0.0, 0.0, width[0], height[0]
return None
def _svg_position_state(
self,
tag: str,
attrs_map: dict[str, str],
) -> tuple[bool, bool]:
if tag not in self._SVG_POSITIONED_TAGS:
return False, False
viewport = self._svg_viewports[-1] if self._svg_viewports else None
if viewport is not None and tag in {"foreignobject", "text", "tspan"}:
# A coordinate point does not prove that the complete glyph or
# foreign-object box intersects the viewport. Renderer box data is
# required, so SVG text cannot be deterministic atomic evidence.
return False, True
parsed_positions: dict[str, float] = {}
for name in ("x", "y", "dx", "dy"):
if name not in attrs_map:
continue
parsed = _css_numeric_value(attrs_map[name])
if parsed is None or parsed[1] not in {"", "px", "pt"}:
return False, True
parsed_positions[name] = parsed[0]
if tag == "svg" and viewport is not None and parsed_positions:
# Nested SVG establishes another viewport. Proving its transformed
# overlap requires the renderer, so do not accept facts from it as
# deterministic static evidence.
return False, True
if any(parsed_positions.get(name, 0) != 0 for name in ("dx", "dy")):
return False, True
if viewport is None:
if any(abs(value) >= 10000 for value in parsed_positions.values()):
return True, False
return False, False
min_x, min_y, view_width, view_height = viewport
x = parsed_positions.get("x", min_x)
y = parsed_positions.get("y", min_y)
max_x = min_x + view_width
max_y = min_y + view_height
if tag == "image":
image_width = _css_numeric_value(attrs_map.get("width", ""))
image_height = _css_numeric_value(attrs_map.get("height", ""))
if (
image_width is None
or image_height is None
or image_width[1] not in {"", "px", "pt"}
or image_height[1] not in {"", "px", "pt"}
or image_width[0] <= 0
or image_height[0] <= 0
):
return False, True
right = x + image_width[0]
bottom = y + image_height[0]
if right <= min_x or x >= max_x or bottom <= min_y or y >= max_y:
return True, False
if x < min_x or right > max_x or y < min_y or bottom > max_y:
return False, True
elif x < min_x or x > max_x or y < min_y or y > max_y:
return False, True
return False, False
@staticmethod
def _implicitly_closed_by_start(open_tag: str, new_tag: str) -> bool:
if open_tag == "p" and new_tag in _HtmlVisibilityParser._P_IMPLICIT_CLOSE_STARTS:
return True
if open_tag == "li" and new_tag == "li":
return True
if open_tag in {"dd", "dt"} and new_tag in {"dd", "dt"}:
return True
if open_tag in {"rp", "rt"} and new_tag in {"rp", "rt"}:
return True
if open_tag == "option" and new_tag in {"option", "optgroup"}:
return True
if open_tag == "optgroup" and new_tag == "optgroup":
return True
if open_tag in {"td", "th"} and new_tag in {
"td", "th", "tr", "tbody", "thead", "tfoot",
}:
return True
if open_tag == "tr" and new_tag in {"tr", "tbody", "thead", "tfoot"}:
return True
if open_tag in {"tbody", "thead", "tfoot"} and new_tag in {
"tbody", "thead", "tfoot",
}:
return True
return False
@staticmethod
def _implicitly_closed_by_end(open_tag: str, end_tag: str) -> bool:
return (
(open_tag == "p" and end_tag in {"address", "article", "aside", "blockquote", "body", "div", "footer", "form", "header", "main", "nav", "section"})
or (open_tag == "li" and end_tag in {"menu", "ol", "ul"})
or (open_tag in {"dd", "dt"} and end_tag == "dl")
or (open_tag in {"rp", "rt"} and end_tag == "ruby")
or (open_tag == "option" and end_tag in {"optgroup", "select"})
or (open_tag == "optgroup" and end_tag == "select")
or (open_tag in {"td", "th"} and end_tag in {"tr", "tbody", "thead", "tfoot", "table"})
or (open_tag == "tr" and end_tag in {"tbody", "thead", "tfoot", "table"})
or (open_tag in {"tbody", "thead", "tfoot"} and end_tag == "table")
)
@staticmethod
def _implicit_start_boundaries(new_tag: str) -> set[str]:
if new_tag == "li":
return {"menu", "ol", "ul"}
if new_tag in {"dd", "dt"}:
return {"dl"}
if new_tag in {"option", "optgroup"}:
return {"datalist", "select"}
if new_tag in {"rp", "rt"}:
return {"ruby"}
if new_tag in {"td", "th"}:
return {"table", "tr"}
if new_tag in {"tr", "tbody", "thead", "tfoot"}:
return {"table"}
return set()
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if self._ambiguous_markup:
return
boundaries = self._implicit_start_boundaries(tag)
boundary = max(
(
index
for index, (open_tag, _, _) in enumerate(self._skip_stack)
if open_tag in boundaries
),
default=-1,
)
implicit_match = next(
(
index
for index in range(len(self._skip_stack) - 1, boundary, -1)
if self._implicitly_closed_by_start(self._skip_stack[index][0], tag)
),
None,
)
if implicit_match is not None:
while len(self._skip_stack) > implicit_match:
self._pop_top()
attrs_map = {name.lower(): (value or "") for name, value in attrs}
style = re.sub(r"\s+", "", attrs_map.get("style", "").lower())
classes = set(attrs_map.get("class", "").split())
hidden = (
tag in self._SKIP_TAGS
presentation_style = ";".join(
f"{name}:{value}"
for name, value in attrs_map.items()
if name in self._PRESENTATION_STYLE_ATTRS
)
inline_hidden, inline_ambiguous = _style_state(
attrs_map.get("style", ""),
fail_closed=self._fail_closed,
replaced_element=tag in self._REPLACED_TAGS,
)
presentation_hidden, presentation_ambiguous = _style_state(
presentation_style,
fail_closed=self._fail_closed,
replaced_element=tag in self._REPLACED_TAGS,
)
svg_position_hidden, svg_position_ambiguous = self._svg_position_state(
tag,
attrs_map,
)
known_hidden = (
tag in self._skip_tags
or tag in self._hidden_tags
or "*" in self._hidden_tags
or any(
name in attrs_map
and (expected is None or attrs_map[name] == expected)
for name, expected in self._hidden_attrs
)
or "hidden" in attrs_map
or attrs_map.get("aria-hidden", "").lower() == "true"
or bool(classes & self._hidden_classes)
or attrs_map.get("id", "") in self._hidden_ids
or "display:none" in style
or "visibility:hidden" in style
or inline_hidden
or (self._fail_closed and presentation_hidden)
or (self._fail_closed and svg_position_hidden)
or (
self._fail_closed
and attrs_map.get("fill", "").strip().lower() == "none"
)
or (
self._fail_closed
and _is_zero_css_value(attrs_map.get("fill-opacity", ""))
)
)
if tag not in self._VOID_TAGS:
self._skip_stack.append(hidden)
if hidden:
self._skip_depth += 1
if tag in self._VOID_TAGS:
self._skip_depth -= 1
css_ambiguous = (
tag in self._ambiguous_tags
or "*" in self._ambiguous_tags
or any(
name in attrs_map
and (expected is None or attrs_map[name] == expected)
for name, expected in self._ambiguous_attrs
)
or bool(classes & self._ambiguous_classes)
or attrs_map.get("id", "") in self._ambiguous_ids
)
if known_hidden or (self._skip_depth and not self._ambiguous_depth):
inline_ambiguous = False
presentation_ambiguous = False
svg_position_ambiguous = False
css_ambiguous = False
current_ambiguous = self._fail_closed and (
css_ambiguous
or inline_ambiguous
or presentation_ambiguous
or svg_position_ambiguous
)
ambiguous_hidden = (
current_ambiguous and not known_hidden and self._skip_depth == 0
)
if not known_hidden and (self._ambiguous_depth or ambiguous_hidden):
self._handle_ambiguous_starttag(tag, attrs)
hidden = known_hidden or current_ambiguous
is_void = tag in self._VOID_TAGS
if not is_void:
self._skip_stack.append((tag, hidden, ambiguous_hidden))
if tag == "svg":
self._svg_viewports.append(
self._svg_viewport(attrs_map) or (0.0, 0.0, 4096.0, 4096.0)
)
if hidden:
self._skip_depth += 1
if ambiguous_hidden:
self._ambiguous_depth += 1
if hidden or self._skip_depth:
return
self._handle_visible_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
hidden = self._skip_stack.pop() if self._skip_stack else False
if hidden and self._skip_depth > 0:
self._skip_depth -= 1
tag = tag.lower()
if self._ambiguous_markup:
return
if tag in self._VOID_TAGS:
return
match = next(
(index for index in range(len(self._skip_stack) - 1, -1, -1)
if self._skip_stack[index][0] == tag),
None,
)
if match is None:
return
if match != len(self._skip_stack) - 1:
intervening = self._skip_stack[match + 1:]
if not intervening or not self._implicitly_closed_by_end(
intervening[0][0],
tag,
):
self._mark_ambiguous()
return
closing = self._skip_stack[match:]
del self._skip_stack[match:]
for open_tag, _, _ in reversed(closing):
if open_tag == "svg" and self._svg_viewports:
self._svg_viewports.pop()
self._skip_depth = max(
0,
self._skip_depth - sum(1 for _, hidden, _ in closing if hidden),
)
self._ambiguous_depth = max(
0,
self._ambiguous_depth
- sum(1 for _, _, ambiguous in closing if ambiguous),
)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# Self-closing hidden/input nodes contain no visible text and must not
# leave the parser in a skipped state for the following sibling.
return
# In HTML, a trailing slash is ignored on non-void elements. Treating
# <div hidden/> as closed would expose everything that follows even
# though browsers and WeasyPrint keep it inside the hidden div.
foreign_svg = tag.lower() == "svg" or bool(self._svg_viewports)
self.handle_starttag(tag, attrs)
if foreign_svg and tag.lower() not in self._VOID_TAGS:
self.handle_endtag(tag)
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self.parts.append(data)
if self._ambiguous_markup:
if not self._fail_closed:
self._handle_visible_data(data)
elif self._ambiguous_depth:
self._handle_ambiguous_data(data)
elif self._skip_depth == 0:
self._handle_visible_data(data)
def _handle_visible_starttag(
self, _tag: str, _attrs: list[tuple[str, str | None]],
) -> None:
return
def _handle_ambiguous_starttag(
self,
_tag: str,
_attrs: list[tuple[str, str | None]],
) -> None:
return
def _handle_ambiguous_data(self, _data: str) -> None:
return
def _handle_visible_data(self, _data: str) -> None:
return
def visible_html_text(raw: str) -> str:
parser = _VisibleTextParser(*css_hidden_selectors(raw))
class _VisibleTextParser(_HtmlVisibilityParser):
"""Extract visible text from filled HTML while skipping code-like blocks."""
_SKIP_TAGS = {
"clippath", "code", "datalist", "defs", "desc", "head", "mask",
"metadata", "noembed", "noframes", "noscript", "pattern", "pre", "rp",
"script", "style", "symbol", "template", "title",
}
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
*,
fail_closed: bool,
) -> None:
super().__init__(
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
visibility_ambiguous,
skip_tags=self._SKIP_TAGS,
fail_closed=fail_closed,
)
self.parts: list[str] = []
def _handle_visible_data(self, data: str) -> None:
self.parts.append(data)
def _handle_ambiguous_data(self, data: str) -> None:
if data.strip():
self._visibility_ambiguous = True
def visible_html_evidence(
raw: str,
*,
fail_closed: bool = False,
) -> tuple[str, bool]:
parser = _VisibleTextParser(
*_css_hidden_filters(raw, fail_closed=fail_closed),
fail_closed=fail_closed,
)
parser.feed(raw)
return "\n".join(parser.parts)
if fail_closed and parser._ambiguous_markup:
return "", True
return "\n".join(parser.parts), parser._visibility_ambiguous
def visible_html_text(raw: str, *, fail_closed: bool = False) -> str:
return visible_html_evidence(raw, fail_closed=fail_closed)[0]
def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]:
+198 -67
View File
@@ -17,14 +17,20 @@ editorial, not verbatim.
from __future__ import annotations
import json
import posixpath
import re
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.parse import unquote, urljoin, urlsplit
from checks import css_hidden_selectors, visible_html_text
from checks import (
_HtmlVisibilityParser,
_css_hidden_filters,
visible_html_evidence,
visible_html_text,
)
from shared import (
HTML_TEMPLATES,
MARP_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCHEMAS_DIR,
@@ -63,7 +69,10 @@ BRIEF_SCHEMA = {
"template": {"type": "string", "minLength": 1, "maxLength": 80},
"formats": {
"type": "array", "minItems": 1, "maxItems": 4,
"items": {"type": "string", "enum": ["html", "pdf", "pptx", "png"]},
"items": {
"type": "string",
"enum": ["html", "md", "pdf", "pptx", "png"],
},
},
"page_target": {"type": "integer", "minimum": 1, "maximum": 200},
"length_target": {"type": "string", "minLength": 1, "maxLength": 120},
@@ -106,7 +115,7 @@ BRIEF_SCHEMA = {
}
class _HtmlAttributeParser(HTMLParser):
class _HtmlAttributeParser(_HtmlVisibilityParser):
"""Collect resource-bearing HTML attributes for asset coverage checks."""
_RESOURCE_ATTRS = {
@@ -117,59 +126,98 @@ class _HtmlAttributeParser(HTMLParser):
"use": {"href"},
"video": {"poster", "src"},
}
_SKIP_TAGS = {"head", "noscript", "script", "style", "template"}
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
_SKIP_TAGS = {
"clippath", "defs", "head", "mask", "metadata", "noembed", "noframes",
"noscript", "pattern", "rp", "script", "style", "symbol", "template",
}
def __init__(self, hidden_classes: set[str], hidden_ids: set[str]) -> None:
super().__init__(convert_charrefs=True)
self._hidden_classes = hidden_classes
self._hidden_ids = hidden_ids
self._skip_depth = 0
self._skip_stack: list[bool] = []
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
) -> None:
super().__init__(
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
visibility_ambiguous,
skip_tags=self._SKIP_TAGS,
fail_closed=True,
)
self.values: set[str] = set()
self.base_href: str | None = None
self.ambiguous_resources = False
self._picture_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
attrs_map = {name.lower(): (value or "") for name, value in attrs}
style = re.sub(r"\s+", "", attrs_map.get("style", "").lower())
hidden = (
tag in self._SKIP_TAGS
or "hidden" in attrs_map
or attrs_map.get("aria-hidden", "").lower() == "true"
or bool(set(attrs_map.get("class", "").split()) & self._hidden_classes)
or attrs_map.get("id", "") in self._hidden_ids
or "display:none" in style
or "visibility:hidden" in style
)
if tag not in self._VOID_TAGS:
self._skip_stack.append(hidden)
if hidden:
self._skip_depth += 1
if tag in self._VOID_TAGS:
self._skip_depth -= 1
if self._skip_depth:
if tag == "picture":
self._picture_depth += 1
if tag == "base" and self.base_href is None:
self.base_href = next(
(value.strip() for name, value in attrs
if name.lower() == "href" and value and value.strip()),
None,
)
super().handle_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
super().handle_endtag(tag)
if tag.lower() == "picture" and self._picture_depth:
self._picture_depth -= 1
def _handle_visible_starttag(
self, tag: str, attrs: list[tuple[str, str | None]],
) -> None:
attrs_map = {name.lower(): (value or "") for name, value in attrs}
if tag in {"picture", "source"} or attrs_map.get("srcset"):
self.ambiguous_resources = True
if self._picture_depth or tag == "source":
return
if tag == "source" and (attrs_map.get("media") or attrs_map.get("type")):
return
if tag in {"img", "source"} and attrs_map.get("srcset"):
candidates = [
part.strip().split()
for part in attrs_map["srcset"].split(",")
if part.strip()
]
if len(candidates) == 1 and len(candidates[0]) == 1:
self.values.add(candidates[0][0])
return
allowed = self._RESOURCE_ATTRS.get(tag, set())
for name, value in attrs:
if name.lower() not in allowed or not value:
continue
if name.lower() == "srcset":
self.values.update(part.strip().split()[0] for part in value.split(",") if part.strip())
continue
else:
self.values.add(value.strip())
def handle_endtag(self, _tag: str) -> None:
hidden = self._skip_stack.pop() if self._skip_stack else False
if hidden and self._skip_depth > 0:
self._skip_depth -= 1
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
if tag.lower() not in self._VOID_TAGS:
self.handle_endtag(tag)
def _handle_ambiguous_starttag(
self,
tag: str,
attrs: list[tuple[str, str | None]],
) -> None:
attrs_map = {name.lower(): (value or "") for name, value in attrs}
allowed = self._RESOURCE_ATTRS.get(tag, set())
if any(name in attrs_map and attrs_map[name] for name in allowed):
self._visibility_ambiguous = True
if tag in {"picture", "source"} or attrs_map.get("srcset"):
self.ambiguous_resources = True
def load_schema(doc_type: str) -> dict:
@@ -256,7 +304,15 @@ def _brief_contract_issues(
) -> list[str]:
"""Cross-check the artifact brief against the selected document contract."""
issues: list[str] = []
template_names = {*HTML_TEMPLATES, *PPTX_TEMPLATES, *SCREEN_TEMPLATES}
if "page_target" not in brief and "length_target" not in brief:
issues.append("brief: requires page_target or length_target")
template_names = {
*HTML_TEMPLATES,
*MARP_TEMPLATES,
*PPTX_TEMPLATES,
*SCREEN_TEMPLATES,
}
template = brief.get("template")
if not isinstance(template, str):
return issues
@@ -276,7 +332,9 @@ def _brief_contract_issues(
formats = brief.get("formats")
if isinstance(formats, list):
if doc_type == "slides":
if template in MARP_TEMPLATES:
supported_formats = {"html", "md", "pdf", "pptx"}
elif doc_type == "slides":
# A slide deliverable combines the WeasyPrint source/PDF with the
# editable python-pptx fallback, even though `template` names the
# primary authoring path.
@@ -321,10 +379,15 @@ def _brief_contract_issues(
and isinstance(formats, list)
and "pptx" in formats
)
korean_marp_fallback = (
template == "slides-marp"
and requested_family == "ko"
)
if (
requested_family is not None
and requested_family != template_family
and not korean_pptx_fallback
and not korean_marp_fallback
):
issues.append(
f"brief.template: {template!r} is the {template_family} variant "
@@ -414,26 +477,49 @@ def _contains_atomic(haystack: str, needle: str) -> bool:
return re.search(left + re.escape(needle) + right, haystack) is not None
def html_resource_attributes(raw: str) -> set[str]:
parser = _HtmlAttributeParser(*css_hidden_selectors(raw))
def html_resource_evidence(raw: str) -> tuple[set[str], bool]:
parser = _HtmlAttributeParser(*_css_hidden_filters(raw, fail_closed=True))
parser.feed(raw)
return parser.values
if parser._ambiguous_markup:
return set(), True
if parser.base_href:
values = {urljoin(parser.base_href, value) for value in parser.values}
else:
values = parser.values
return values, (
parser._visibility_ambiguous or parser.ambiguous_resources
)
def html_resource_attributes(raw: str) -> set[str]:
return html_resource_evidence(raw)[0]
def _asset_present(needle: str, attributes: set[str]) -> bool:
expected_url = urlsplit(needle)
expected = unquote(expected_url.path).lstrip("./")
expected_path = posixpath.normpath(unquote(expected_url.path))
expected_is_url = bool(expected_url.scheme or expected_url.netloc)
expected_is_absolute = expected_path.startswith("/")
for raw in attributes:
actual_url = urlsplit(raw)
actual = unquote(actual_url.path).lstrip("./")
if expected_url.scheme or expected_url.netloc:
actual_path = posixpath.normpath(unquote(actual_url.path))
actual_is_url = bool(actual_url.scheme or actual_url.netloc)
if expected_is_url:
if (
actual_url.scheme.casefold() == expected_url.scheme.casefold()
and actual_url.netloc.casefold() == expected_url.netloc.casefold()
and actual == expected
and actual_path == expected_path
):
return True
elif actual == expected or actual.endswith(f"/{expected}"):
elif actual_is_url:
continue
elif expected_is_absolute:
if actual_path == expected_path:
return True
elif (
actual_path == expected_path
or actual_path.endswith(f"/{expected_path}")
):
return True
return False
@@ -515,18 +601,27 @@ def coverage_issues(
return issues, checked, skipped
def check_content(paths: list[str]) -> int:
def check_content(
paths: list[str],
*,
phase_codes: dict[str, int] | None = None,
) -> int:
"""CLI: --check-content content.json [filled.html]
Validates the content IR against its schema; with a filled HTML file,
also verifies every short atomic value made it into the visible text.
"""
def finish(phase: str, code: int) -> int:
if phase_codes is not None:
phase_codes[phase] = code
return code
args = [p for p in paths if not p.startswith("-")]
if not args or len(args) > 2:
known = ", ".join(content_schema_types()) or "none"
print("ERROR: usage: --check-content content.json [filled.html]")
print(f" known types: {known}")
return 2
return finish("contract", 2)
content_path = Path(args[0])
if not content_path.is_absolute():
@@ -534,20 +629,21 @@ def check_content(paths: list[str]) -> int:
rel = rel_to_root(content_path)
if not content_path.exists():
print(f"ERROR: {args[0]}: file not found")
return 2
return finish("contract", 2)
try:
data = json.loads(content_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"ERROR: {rel}: invalid JSON: {exc}")
return 1
return finish("contract", 1)
doc_type, issues = validate_content_file(data)
if issues:
print(f"ERROR: {rel}: {len(issues)} schema issue(s)")
for issue in issues:
print(f" {issue}")
return 1
return finish("contract", 1)
print(f"OK: {rel}: valid {doc_type} content")
finish("contract", 0)
if len(args) == 1:
return 0
@@ -558,29 +654,64 @@ def check_content(paths: list[str]) -> int:
html_rel = rel_to_root(html_path)
if not html_path.exists():
print(f"ERROR: {args[1]}: file not found")
return 2
return finish("coverage", 2)
html_raw = html_path.read_text(encoding="utf-8", errors="replace")
html_text = visible_html_text(html_raw)
html_attributes = html_resource_attributes(html_raw)
missing, checked, skipped = coverage_issues(
html_text, text_ambiguous = visible_html_evidence(html_raw, fail_closed=True)
html_attributes, resource_ambiguous = html_resource_evidence(html_raw)
content_missing, checked, skipped = coverage_issues(
data["content"], html_text, html_attributes
)
text_missing = [
issue for issue in content_missing
if "asset not found in document attributes" not in issue
]
asset_missing = [
issue for issue in content_missing
if "asset not found in document attributes" in issue
]
required_assets = (data.get("brief") or {}).get("required_assets", [])
if required_assets:
asset_missing, asset_checked, _ = coverage_issues(
required_asset_missing, asset_checked, _ = coverage_issues(
required_assets,
html_text,
html_attributes,
root_path="brief.required_assets",
force_assets=True,
)
missing.extend(asset_missing)
asset_missing.extend(required_asset_missing)
checked += asset_checked
missing = [*text_missing, *asset_missing]
if missing:
print(f"ERROR: {html_rel}: {len(missing)} content value(s) missing from document")
for issue in missing:
definite_missing = [
*(text_missing if not text_ambiguous else []),
*(asset_missing if not resource_ambiguous else []),
]
indeterminate = [
*(text_missing if text_ambiguous else []),
*(asset_missing if resource_ambiguous else []),
]
if not definite_missing:
print(
f"ERROR: {html_rel}: content coverage is indeterminate because "
"visibility or resource selection could not be proven statically"
)
for issue in missing:
print(f" {issue}")
return finish("coverage", 2)
print(
f"ERROR: {html_rel}: {len(definite_missing)} content value(s) "
"definitely missing from document"
)
for issue in definite_missing:
print(f" {issue}")
return 1
if indeterminate:
print(
f" NOTE: {len(indeterminate)} additional value(s) could not be "
"proven because visibility or resource selection is indeterminate"
)
for issue in indeterminate:
print(f" {issue}")
return finish("coverage", 1)
note = f" ({skipped} prose field(s) not held verbatim)" if skipped else ""
print(f"OK: {html_rel}: all {checked} atomic content values present{note}")
return 0
return finish("coverage", 0)
+35 -8
View File
@@ -43,6 +43,8 @@ from render import render_pdf
from shared import (
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
MARP_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCREEN_TEMPLATES,
content_schema_types,
@@ -54,7 +56,7 @@ from verify import check_fonts
PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"}
CHECK_RULESET_VERSION = 1
CHECK_RULESET_VERSION = 2
CHECK_REGISTRY = {
"html.placeholders": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
@@ -176,6 +178,8 @@ def tool_templates(_args: dict) -> dict:
"version": kami_version(),
"document_templates": {name: spec.source for name, spec in HTML_TEMPLATES.items()},
"screen_templates": dict(SCREEN_TEMPLATES),
"pptx_templates": dict(PPTX_TEMPLATES),
"marp_templates": dict(MARP_TEMPLATES),
"diagram_templates": dict(DIAGRAM_TEMPLATES),
"content_schema_types": content_schema_types(),
"templates_dir": str(ROOT / "assets" / "templates"),
@@ -220,10 +224,10 @@ def tool_render(args: dict) -> dict:
return {"pdf": str(out), "pages": pages}
def _run_check(fn, argv: list[str]) -> tuple[int, str]:
def _run_check(fn, argv: list[str], **kwargs) -> tuple[int, str]:
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
code = fn(argv)
code = fn(argv, **kwargs)
return code, buffer.getvalue().rstrip()
@@ -235,9 +239,11 @@ def _check_plan(path: Path, content: str | None) -> list[tuple[str, object, list
("html.markdown-residue", check_markdown_residue, [str(path)]),
]
if content:
content_path = str(_resolve(content))
checks.append((
"content.coverage", check_content,
[str(_resolve(content)), str(path)],
"content.contract",
check_content,
[content_path, str(path)],
))
return checks
if suffix == ".pdf":
@@ -260,15 +266,36 @@ def tool_check(args: dict) -> dict:
coverage: list[dict] = []
findings: list[dict] = []
worst = 0
for rule_id, fn, argv in _check_plan(path, args.get("content")):
code, report = _run_check(fn, argv)
def record(rule_id: str, code: int, report: str) -> None:
nonlocal worst
worst = max(worst, code)
reports.append(report)
status = "passed" if code == 0 else ("failed" if code == 1 else "degraded")
rule = {"id": rule_id, **CHECK_REGISTRY[rule_id]}
coverage.append({**rule, "status": status, "exit_code": code})
if status != "passed":
findings.append({**rule, "status": status, "evidence": report})
for rule_id, fn, argv in _check_plan(path, args.get("content")):
if rule_id == "content.contract" and len(argv) == 2:
phase_codes: dict[str, int] = {}
_, report = _run_check(fn, argv, phase_codes=phase_codes)
reports.append(report)
contract_code = phase_codes.get("contract", 2)
record("content.contract", contract_code, report)
if contract_code == 0 and "coverage" in phase_codes:
record("content.coverage", phase_codes["coverage"], report)
else:
rule = {"id": "content.coverage", **CHECK_REGISTRY["content.coverage"]}
coverage.append({
**rule,
"status": "not_run",
"exit_code": None,
"blocked_by": "content.contract",
})
continue
code, report = _run_check(fn, argv)
reports.append(report)
record(rule_id, code, report)
return {
"ruleset_version": CHECK_RULESET_VERSION,
"exit_code": worst,
@@ -148,6 +148,13 @@ PPTX_TEMPLATES: dict[str, str] = {
"slides-en": "slides-en.py",
}
# Markdown-first Marp slide variants. The Markdown files are the authoring
# entry points; their sibling CSS themes stay self-contained for the Marp CLI.
MARP_TEMPLATES: dict[str, str] = {
"slides-marp": "marp/slides-marp.md",
"slides-marp-en": "marp/slides-marp-en.md",
}
# Diagram HTMLs live in assets/diagrams and have no page-count contract.
# Registered here (not in build.py) so all template registries share one home.
# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py.
@@ -275,6 +282,11 @@ def pptx_targets() -> dict[str, str]:
return dict(PPTX_TEMPLATES)
def marp_targets() -> dict[str, str]:
"""Return target -> source mapping for Markdown-first Marp decks."""
return dict(MARP_TEMPLATES)
def diagram_targets() -> dict[str, str]:
"""Return target -> source mapping for assets/diagrams HTML templates."""
return dict(DIAGRAM_TEMPLATES)
@@ -70,6 +70,7 @@ from optional_deps import MissingDepError, require_pymupdf # noqa: E402
from shared import ( # noqa: E402
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
MARP_TEMPLATES,
PARCHMENT_RGB,
ROOT as REPO_ROOT,
SCREEN_TEMPLATES,
@@ -77,6 +78,7 @@ from shared import ( # noqa: E402
build_targets,
diagram_targets,
load_checks_thresholds,
marp_targets,
pptx_targets,
screen_targets,
)
@@ -366,6 +368,14 @@ def test_registry_consistency() -> None:
f"got {len(PPTX_TARGETS)}")
check("PPTX_TARGETS in build.py matches shared.pptx_targets()",
dict(PPTX_TARGETS) == pptx_targets())
check("Marp registry maps authoring entries with matching CSS",
marp_targets() == MARP_TEMPLATES
and all(
(TEMPLATES / source).exists()
and (TEMPLATES / source).with_suffix(".css").exists()
for source in MARP_TEMPLATES.values()
),
str(MARP_TEMPLATES))
check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED))
@@ -1246,14 +1256,23 @@ def test_check_markdown_residue_skips_html_code_blocks() -> None:
"<html><body><p>Visible text</p><pre><code>**example** `cmd`</code></pre></body></html>",
suffix=".html",
)
ambiguous_css = write_temp_html(
'<html><head><link rel="stylesheet" href="theme.css"></head>'
'<body><p>Visible **raw despite CSS uncertainty**</p></body></html>',
suffix=".html",
)
try:
rc = silently(check_markdown_residue, [str(dirty)])
check("check_markdown_residue fails visible raw markdown", rc == 1, f"rc={rc}")
rc = silently(check_markdown_residue, [str(clean_code)])
check("check_markdown_residue skips code/pre blocks", rc == 0, f"rc={rc}")
rc = silently(check_markdown_residue, [str(ambiguous_css)])
check("markdown residue includes text under ambiguous CSS",
rc == 1, f"rc={rc}")
finally:
dirty.unlink(missing_ok=True)
clean_code.unlink(missing_ok=True)
ambiguous_css.unlink(missing_ok=True)
# --------------------------- cross-template consistency ---------------------------
@@ -1940,11 +1959,87 @@ def test_check_content_cli_validates_and_covers() -> None:
rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage passes when atomic values present", rc == 0)
ambiguous_html = write_temp_html(
html.read_text(encoding="utf-8").replace(
"<p>", '<p style="font-size:calc(1px - 1px)">',
)
)
try:
ambiguous_rc = silently(
check_content,
[str(content_path), str(ambiguous_html)],
)
check("check_content degrades indeterminate visibility evidence",
ambiguous_rc == 2)
finally:
ambiguous_html.unlink()
missing_text_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", '<img srcset="a.png 1x,b.png 2x"></body>')
)
try:
missing_text_rc = silently(
check_content,
[str(content_path), str(missing_text_html)],
)
check("resource ambiguity cannot downgrade definite missing text",
missing_text_rc == 1)
finally:
missing_text_html.unlink()
for style in (
"clip-path:circle(50%)",
"mask-image:url(#rounded)",
"filter:url(#soften)",
):
decorated_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace(
"</body>",
f'<img style="{style}" src="decorative.svg"></body>',
)
)
try:
decorated_rc = silently(
check_content,
[str(content_path), str(decorated_html)],
)
check("decorative resource ambiguity cannot downgrade missing text",
decorated_rc == 1, style)
finally:
decorated_html.unlink()
for decoration in (
'<svg><path filter="url(#shadow)"></path></svg>',
'<svg><circle mask="url(#fade)"></circle></svg>',
'<svg><g clip-path="url(#round)"></g></svg>',
'<style>.decor { clip-path:circle(50%) }</style>'
'<svg><path class="decor"></path></svg>',
):
decorated_svg_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", f"{decoration}</body>")
)
try:
decorated_svg_rc = silently(
check_content,
[str(content_path), str(decorated_svg_html)],
)
check("decorative SVG ambiguity cannot downgrade missing text",
decorated_svg_rc == 1, decoration)
finally:
decorated_svg_html.unlink()
payload["brief"] = {
"audience": "Technical collaborator",
"job": "Secure review",
"template": "letter-en",
"formats": ["html", "pdf"],
"page_target": 1,
"required_assets": ["must-appear-logo.svg"],
"acceptance_checks": ["required logo is embedded"],
}
@@ -1952,6 +2047,27 @@ def test_check_content_cli_validates_and_covers() -> None:
missing_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage rejects a missing required brief asset",
missing_asset_rc == 1)
mixed_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", '<img srcset="a.png 1x,b.png 2x"></body>')
)
try:
mixed_report = io.StringIO()
with contextlib.redirect_stdout(mixed_report):
mixed_rc = check_content(
[str(content_path), str(mixed_html)]
)
report = mixed_report.getvalue()
check("mixed coverage reports definite and indeterminate findings separately",
mixed_rc == 1
and "definitely missing" in report
and "NOTE:" in report
and report.index("content.date") < report.index("NOTE:")
and report.index("brief.required_assets") > report.index("NOTE:"),
report)
finally:
mixed_html.unlink()
html.write_text(
html.read_text(encoding="utf-8").replace(
"</body>", '<img src="must-appear-logo.svg" alt=""></body>'
@@ -2003,7 +2119,12 @@ def test_content_ir_rejects_invalid_envelope() -> None:
def test_content_ir_validates_optional_artifact_brief() -> None:
from content import _brief_contract_issues, validate_content_file
from content import (
BRIEF_SCHEMA,
_brief_contract_issues,
validate_content_file,
validate_node,
)
body = {
"sender": "Ada Lovelace, London",
@@ -2095,12 +2216,15 @@ def test_content_ir_validates_optional_artifact_brief() -> None:
"ko",
)
korean_pptx_fallback_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf", "pptx"]},
{
"template": "slides-en", "formats": ["html", "pdf", "pptx"],
"page_target": 12,
},
"slides",
"ko",
)
korean_pdf_wrong_variant_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf"]},
{"template": "slides-en", "formats": ["html", "pdf"], "page_target": 12},
"slides",
"ko",
)
@@ -2121,6 +2245,39 @@ def test_content_ir_validates_optional_artifact_brief() -> None:
for issue in korean_pdf_wrong_variant_issues),
str(korean_pdf_wrong_variant_issues))
marp_brief = dict(
brief,
template="slides-marp-en",
formats=["md", "pdf"],
page_target=12,
)
marp_schema_issues = validate_node(marp_brief, BRIEF_SCHEMA, "brief")
marp_contract_issues = _brief_contract_issues(marp_brief, "slides", "en")
korean_marp_issues = _brief_contract_issues(
dict(marp_brief, template="slides-marp"),
"slides",
"ko",
)
check("brief contract accepts the shipped Marp authoring path",
marp_schema_issues == []
and marp_contract_issues == []
and korean_marp_issues == [],
f"schema={marp_schema_issues} contract={marp_contract_issues} "
f"korean={korean_marp_issues}")
no_length_contract = dict(brief)
no_length_contract.pop("page_target")
target_issues = _brief_contract_issues(no_length_contract, "letter", "en")
length_only_issues = _brief_contract_issues(
dict(no_length_contract, length_target="600 to 800 words"),
"letter",
"en",
)
check("artifact brief requires a page or length target",
any("page_target or length_target" in issue for issue in target_issues)
and length_only_issues == [],
f"missing={target_issues} length_only={length_only_issues}")
_, legacy_issues = validate_content_file({
"type": "letter", "lang": "en", "content": body,
})
@@ -2199,7 +2356,7 @@ def test_coverage_survives_split_markup_values() -> None:
def test_coverage_rejects_substrings_and_hidden_text() -> None:
"""Changed facts and hidden-only copies must not satisfy coverage."""
from content import coverage_issues
from checks import visible_html_text
from checks import visible_html_evidence, visible_html_text
cases = [
({"metric": "62%"}, "Revenue reached 162%"),
@@ -2223,13 +2380,233 @@ def test_coverage_rejects_substrings_and_hidden_text() -> None:
check("coverage ignores head, template, hidden, and display-none text",
len(missing) == 1, f"text={text!r} issues={missing}")
malformed_hidden = (
'<div hidden><img></img><p>SECRET-FACT</p></div>'
'<p>VISIBLE-FACT</p>'
)
malformed_text = visible_html_text(malformed_hidden)
malformed_missing, _, _ = coverage_issues(
{"hidden": "SECRET-FACT", "visible": "VISIBLE-FACT"},
malformed_text,
)
check("visible-text parser keeps hidden scope across a void closing tag",
len(malformed_missing) == 1
and "SECRET-FACT" in malformed_missing[0]
and "VISIBLE-FACT" in malformed_text,
f"text={malformed_text!r} issues={malformed_missing}")
self_closing_hidden = (
'<div hidden/><p>SELF-CLOSING-SECRET</p></div>'
'<p>SELF-CLOSING-VISIBLE</p>'
)
self_closing_text = visible_html_text(self_closing_hidden)
self_closing_missing, _, _ = coverage_issues(
{
"hidden": "SELF-CLOSING-SECRET",
"visible": "SELF-CLOSING-VISIBLE",
},
self_closing_text,
)
check("visible-text parser follows HTML semantics for non-void self-closing tags",
len(self_closing_missing) == 1
and "SELF-CLOSING-SECRET" in self_closing_missing[0]
and "SELF-CLOSING-VISIBLE" in self_closing_text,
f"text={self_closing_text!r} issues={self_closing_missing}")
benign_selector_text = visible_html_text(
'<style>[hidden] { display: none } '
'[aria-hidden="true"] { visibility: hidden } '
':root { --fallback-display: none }</style>'
'<p hidden>HIDDEN-BY-ATTRIBUTE</p>'
'<p aria-hidden="false">VISIBLE-ARIA-FALSE</p>'
'<p>VISIBLE-AFTER-SELECTOR</p>'
)
check("visibility parser scopes attribute selectors and custom properties",
"HIDDEN-BY-ATTRIBUTE" not in benign_selector_text
and "VISIBLE-ARIA-FALSE" in benign_selector_text
and "VISIBLE-AFTER-SELECTOR" in benign_selector_text,
repr(benign_selector_text))
important_hidden_text = visible_html_text(
'<style>.important-secret {'
'display: none !important; display: block'
'}</style>'
'<p class="important-secret">IMPORTANT-SECRET</p>',
fail_closed=True,
)
inline_important_text = visible_html_text(
'<p style="display:none!important;display:block">INLINE-IMPORTANT-SECRET</p>',
fail_closed=True,
)
visible_var_fallback = visible_html_text(
'<p style="display:var(--missing, block)">VISIBLE-VAR-FALLBACK</p>',
fail_closed=True,
)
check("visibility parser honors important cascade and visible var fallback",
"IMPORTANT-SECRET" not in important_hidden_text
and "INLINE-IMPORTANT-SECRET" not in inline_important_text
and "VISIBLE-VAR-FALLBACK" in visible_var_fallback,
f"style={important_hidden_text!r} inline={inline_important_text!r} "
f"fallback={visible_var_fallback!r}")
hidden_equivalents = [
'font-size:0%',
'font-size:calc(0px)',
'font-size:min(0px, 1px)',
'transform:scale(0, 0)',
'transform:matrix(0,0,0,0,0,0)',
'width:0%;height:0%;overflow:hidden',
'max-width:0;max-height:0;overflow:hidden',
'position:absolute;left:-99999px',
'text-indent:-99999px;overflow:hidden;white-space:nowrap',
'clip:rect(0, 0, 0, 0)',
]
equivalent_results = [
visible_html_text(
f'<p style="{style}">EQUIVALENT-HIDDEN</p>',
fail_closed=True,
)
for style in hidden_equivalents
]
check("visibility parser recognizes equivalent hidden CSS values",
all("EQUIVALENT-HIDDEN" not in text for text in equivalent_results),
str(equivalent_results))
ordinary_layout_text = visible_html_text(
'<p style="position:absolute;top:20px;left:20px">ABSOLUTE-VISIBLE</p>'
'<p style="transform:translateX(10px)">TRANSLATED-VISIBLE</p>'
'<p style="scale:.95">SCALED-VISIBLE</p>'
'<p style="backdrop-filter:blur(8px)">FILTERED-VISIBLE</p>'
'<p style="margin-top:-1px">MARGIN-VISIBLE</p>'
'<p style="text-indent:1em">INDENTED-VISIBLE</p>',
fail_closed=True,
)
check("ordinary positioned and transformed layouts remain visible",
all(marker in ordinary_layout_text for marker in (
"ABSOLUTE-VISIBLE", "TRANSLATED-VISIBLE", "SCALED-VISIBLE",
"FILTERED-VISIBLE", "MARGIN-VISIBLE", "INDENTED-VISIBLE",
)),
repr(ordinary_layout_text))
ambiguous_text, ambiguous_state = visible_html_evidence(
'<p style="font-size:calc(1px - 1px)">AMBIGUOUS-VISIBILITY</p>',
fail_closed=True,
)
check("unresolved functional visibility is excluded and marked ambiguous",
ambiguous_state and "AMBIGUOUS-VISIBILITY" not in ambiguous_text,
f"ambiguous={ambiguous_state} text={ambiguous_text!r}")
non_rendered_html = visible_html_text(
'<svg><title>SVG-TITLE</title><desc>SVG-DESC</desc></svg>'
'<svg><defs><text>SVG-DEFS</text></defs>'
'<symbol><text>SVG-SYMBOL</text></symbol>'
'<metadata>SVG-METADATA</metadata>'
'<clipPath><text>SVG-CLIP</text></clipPath>'
'<mask><text>SVG-MASK</text></mask>'
'<pattern><text>SVG-PATTERN</text></pattern>'
'<text>SVG-RENDERED</text></svg>'
'<noembed>NOEMBED-TEXT</noembed>'
'<noframes>NOFRAMES-TEXT</noframes>'
'<datalist><option>DATALIST-OPTION</option></datalist>'
'<ruby>base<rp>RUBY-FALLBACK</rp><rt>annotation</rt></ruby>'
'<p>RENDERED-TEXT</p>',
fail_closed=True,
)
check("visible text skips non-rendered metadata and fallback containers",
all(marker not in non_rendered_html for marker in (
"SVG-TITLE", "SVG-DESC", "SVG-DEFS", "SVG-SYMBOL",
"SVG-METADATA", "SVG-CLIP", "SVG-MASK", "SVG-PATTERN",
"NOEMBED-TEXT", "NOFRAMES-TEXT", "DATALIST-OPTION",
"RUBY-FALLBACK",
))
and "SVG-RENDERED" not in non_rendered_html
and "RENDERED-TEXT" in non_rendered_html,
repr(non_rendered_html))
hidden_svg_text = visible_html_text(
'<svg><text display="none">DISPLAY-SECRET</text>'
'<text visibility="hidden">VISIBILITY-SECRET</text>'
'<text opacity="0">OPACITY-SECRET</text>'
'<text>SVG-VISIBLE</text></svg>',
fail_closed=True,
)
check("visible text respects SVG presentation attributes",
all(marker not in hidden_svg_text for marker in (
"DISPLAY-SECRET", "VISIBILITY-SECRET", "OPACITY-SECRET",
))
and "SVG-VISIBLE" not in hidden_svg_text,
repr(hidden_svg_text))
off_viewport_svg_text = visible_html_text(
'<svg viewBox="0 0 100 100">'
'<text x="-99999" y="20">OFF-LEFT</text>'
'<text x="10000" y="20">OFF-RIGHT</text>'
'<text x="101" y="20">JUST-OFF-RIGHT</text>'
'<text x="-99" y="20">JUST-OFF-LEFT</text>'
'<text x="101%" y="20">PERCENT-OFF-RIGHT</text>'
'<text x="99" y="20" dx="10">DELTA-OFF-RIGHT</text>'
'<svg x="101" viewBox="0 0 10 10"><text x="0" y="5">NESTED-OFF</text></svg>'
'<text x="20" y="20">SVG-IN-VIEW</text></svg>',
fail_closed=True,
)
check("visible text rejects SVG coordinates outside the viewport",
"OFF-LEFT" not in off_viewport_svg_text
and "OFF-RIGHT" not in off_viewport_svg_text
and "JUST-OFF-RIGHT" not in off_viewport_svg_text
and "JUST-OFF-LEFT" not in off_viewport_svg_text
and "PERCENT-OFF-RIGHT" not in off_viewport_svg_text
and "DELTA-OFF-RIGHT" not in off_viewport_svg_text
and "NESTED-OFF" not in off_viewport_svg_text
and "SVG-IN-VIEW" not in off_viewport_svg_text,
repr(off_viewport_svg_text))
malformed_table = (
'<div hidden><table></div>MALFORMED-SECRET</table></div>'
'<p>MALFORMED-VISIBLE</p>'
)
malformed_coverage_text = visible_html_text(malformed_table, fail_closed=True)
malformed_residue_text = visible_html_text(malformed_table)
check("crossed HTML closes split coverage and residue conservatively",
"MALFORMED-SECRET" not in malformed_coverage_text
and "MALFORMED-SECRET" in malformed_residue_text,
f"coverage={malformed_coverage_text!r} residue={malformed_residue_text!r}")
optional_end_html = visible_html_text(
'<p>PARAGRAPH-FIRST<div>PARAGRAPH-SECOND</div><p>PARAGRAPH-THIRD'
'<ul><li>LIST-FIRST<li>LIST-SECOND</ul>'
'<table><tr><td>CELL-FIRST<td>CELL-SECOND</tr></table>',
fail_closed=True,
)
check("standard optional HTML end tags preserve visible content",
all(marker in optional_end_html for marker in (
"PARAGRAPH-FIRST", "PARAGRAPH-SECOND", "PARAGRAPH-THIRD",
"LIST-FIRST", "LIST-SECOND", "CELL-FIRST", "CELL-SECOND",
)),
repr(optional_end_html))
self_closing_svg_text, self_closing_svg_ambiguous = visible_html_evidence(
'<svg viewBox="0 0 100 100"><path d="M0 0L10 10" /></svg>'
'<p>VISIBLE-AFTER-SVG</p>',
fail_closed=True,
)
check("SVG foreign-content self-closing tags close without tainting HTML",
self_closing_svg_text.strip() == "VISIBLE-AFTER-SVG"
and not self_closing_svg_ambiguous,
f"text={self_closing_svg_text!r} "
f"ambiguous={self_closing_svg_ambiguous}")
def test_coverage_checks_asset_attributes() -> None:
from content import coverage_issues, html_resource_attributes
from checks import visible_html_text
from content import (
coverage_issues,
html_resource_attributes,
html_resource_evidence,
)
raw = (
'<img src="./images/product-shot.png" alt="Product">'
'<source srcset="images/product-shot@2x.webp 2x, images/product-shot.webp 1x">'
'<img src="images/product-shot@2x.webp" alt="Product at high density">'
'<template><img src="hidden-shot.png"></template>'
'<a href="linked-only.png">not embedded</a>'
)
@@ -2240,6 +2617,66 @@ def test_coverage_checks_asset_attributes() -> None:
missing, _, _ = coverage_issues({"image": "missing-shot.png"}, "", attrs)
check("coverage accepts image paths present in src and srcset",
present == [] and checked == 2, f"issues={present} attrs={attrs}")
hidden_svg_attrs = html_resource_attributes(
'<svg><defs><image href="hidden-def.png"></image></defs>'
'<symbol><image href="hidden-symbol.png"></image></symbol>'
'<image x="0" y="0" width="10" height="10" '
'href="visible-svg.png"></image></svg>'
)
check("asset coverage skips SVG definition resources",
hidden_svg_attrs == {"visible-svg.png"}, repr(hidden_svg_attrs))
hidden_asset_cases = [
'<svg><image display="none" href="required.svg"></image></svg>',
'<svg><image visibility="hidden" href="required.svg"></image></svg>',
'<svg><image opacity="0" href="required.svg"></image></svg>',
'<img style="width:0;height:0" src="required.svg">',
'<img width="0" height="0" src="required.svg">',
'<svg width="0" height="0"><image href="required.svg"></image></svg>',
'<img style="position:absolute;left:-99999px" src="required.svg">',
'<img style="transform:matrix(0,0,0,0,0,0)" src="required.svg">',
'<img style="width:calc(0px);height:calc(0px)" src="required.svg">',
'<svg viewBox="0 0 100 100"><image x="-99999" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="10000" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="101" y="0" width="10" height="10" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="101%" y="0" width="10" height="10" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><svg x="101"><image x="0" y="0" width="10" height="10" href="required.svg"></image></svg></svg>',
'<svg viewBox="0 0 100 100"><defs><clipPath id="empty"></clipPath></defs>'
'<image x="0" y="0" width="20" height="20" '
'clip-path="url(#empty)" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><defs><mask id="empty"></mask></defs>'
'<image x="0" y="0" width="20" height="20" '
'mask="url(#empty)" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><defs><filter id="empty"></filter></defs>'
'<image x="0" y="0" width="20" height="20" '
'filter="url(#empty)" href="required.svg"></image></svg>',
]
check("asset coverage rejects non-rendered CSS and presentation forms",
all(not html_resource_attributes(case) for case in hidden_asset_cases),
str([html_resource_attributes(case) for case in hidden_asset_cases]))
deterministic_attrs, responsive_ambiguous = html_resource_evidence(
'<img src="required.svg">'
'<img srcset="a.png 1x,b.png 2x">'
)
check("responsive ambiguity preserves unrelated deterministic resources",
deterministic_attrs == {"required.svg"} and responsive_ambiguous,
f"attrs={deterministic_attrs} ambiguous={responsive_ambiguous}")
hidden_responsive_attrs, hidden_responsive_ambiguous = html_resource_evidence(
'<div hidden><img srcset="a.png 1x,b.png 2x"></div>'
)
check("hidden responsive resources do not degrade asset evidence",
hidden_responsive_attrs == set() and not hidden_responsive_ambiguous,
f"attrs={hidden_responsive_attrs} "
f"ambiguous={hidden_responsive_ambiguous}")
svg_then_asset, svg_then_asset_ambiguous = html_resource_evidence(
'<svg viewBox="0 0 100 100"><path d="M0 0L10 10" /></svg>'
'<img src="required.svg">'
)
check("self-closing SVG graphics preserve following resource evidence",
svg_then_asset == {"required.svg"} and not svg_then_asset_ambiguous,
f"attrs={svg_then_asset} ambiguous={svg_then_asset_ambiguous}")
check("coverage rejects omitted image assets",
len(missing) == 1 and "missing-shot.png" in missing[0], str(missing))
hidden, _, _ = coverage_issues(
@@ -2266,6 +2703,147 @@ def test_coverage_checks_asset_attributes() -> None:
check("required absolute assets tolerate cache-query changes on the same origin and path",
same_origin == [], str(same_origin))
local_absolute_remote_copy, _, _ = coverage_issues(
["/approved/brand/logo.svg"],
"",
html_resource_attributes(
'<img src="https://attacker.example/approved/brand/logo.svg">'
),
root_path="brief.required_assets",
force_assets=True,
)
local_absolute_exact, _, _ = coverage_issues(
["/approved/brand/logo.svg"],
"",
html_resource_attributes('<img src="/approved/brand/logo.svg">'),
root_path="brief.required_assets",
force_assets=True,
)
check("required local absolute assets reject remote path impersonation",
len(local_absolute_remote_copy) == 1 and local_absolute_exact == [],
f"remote={local_absolute_remote_copy} exact={local_absolute_exact}")
malformed_hidden_attrs = html_resource_attributes(
'<div hidden><img></img><img src="hidden-after-void.svg"></div>'
'<img hidden src="hidden-void.svg">'
'<img src="visible.svg">'
)
check("resource parser keeps hidden scope across a void closing tag",
malformed_hidden_attrs == {"visible.svg"},
str(sorted(malformed_hidden_attrs)))
self_closing_hidden_attrs = html_resource_attributes(
'<div hidden/><img src="hidden-self-closing.svg"></div>'
'<img src="visible-after-self-closing.svg">'
)
check("resource parser follows HTML semantics for non-void self-closing tags",
self_closing_hidden_attrs == {"visible-after-self-closing.svg"},
str(sorted(self_closing_hidden_attrs)))
css_hidden_attrs = html_resource_attributes(
'<style>.concealed img { display: none } '
'.escaped { d\\69splay: n\\6f ne } '
'[hidden] { display: none } '
':root { --fallback-display: none }</style>'
'<div class="concealed"><img src="hidden-by-selector.svg"></div>'
'<img style="display/**/: none" src="hidden-by-inline-comment.svg">'
'<img class="escaped" src="hidden-by-css-escape.svg">'
'<img hidden src="hidden-by-attribute.svg">'
'<img src="visible-after-css.svg">'
)
check("resource parser fails closed on compound and comment-split hidden CSS",
css_hidden_attrs == {"visible-after-css.svg"},
str(sorted(css_hidden_attrs)))
ambiguous_css_cases = [
(
'<style>div:not(.show) img { display: none }</style>'
'<div><img src="hidden-by-not.svg"></div>',
"hidden-by-not.svg",
),
(
'<style>[data-state^="hid"] { display: none }</style>'
'<p data-state="hidden">HIDDEN-BY-ATTR-OPERATOR</p>',
"HIDDEN-BY-ATTR-OPERATOR",
),
(
'<style>.marker + p { display: none }</style>'
'<span class="marker"></span><p>HIDDEN-BY-SIBLING</p>',
"HIDDEN-BY-SIBLING",
),
(
'<style>.hidden-by-var { --hide: none; display: var(--hide) }</style>'
'<img class="hidden-by-var" src="hidden-by-var.svg">',
"hidden-by-var.svg",
),
(
'<style>.h\\69 dden { visibility: collapse }</style>'
'<img class="hidden" src="hidden-by-selector-escape.svg">',
"hidden-by-selector-escape.svg",
),
(
'<link rel="style&#115;heet" '
'href="data:text/css,.x%7Bdisplay%3Anone%7D">'
'<img class="x" src="hidden-by-encoded-stylesheet.svg">',
"hidden-by-encoded-stylesheet.svg",
),
(
'<style>@\\69mport url("data:text/css,.x%7Bdisplay%3Anone%7D")</style>'
'<img class="x" src="hidden-by-escaped-import.svg">',
"hidden-by-escaped-import.svg",
),
]
ambiguous_results = [
(
marker,
visible_html_text(raw, fail_closed=True),
html_resource_attributes(raw),
)
for raw, marker in ambiguous_css_cases
]
check("unsupported hiding CSS fails closed instead of partially matching",
all(
marker not in text and marker not in attrs
for marker, text, attrs in ambiguous_results
),
str(ambiguous_results))
responsive_attrs = html_resource_attributes(
'<picture>'
'<source media="(min-width:99999px)" srcset="never-selected.svg">'
'<img src="actual.svg" srcset="candidate-1.svg 1x, candidate-2.svg 2x">'
'</picture>'
)
check("resource parser excludes unresolved responsive candidates",
responsive_attrs == set(),
str(sorted(responsive_attrs)))
picture_fallback_attrs = html_resource_attributes(
'<picture>'
'<source media="(min-width:1px)" srcset="actual.svg">'
'<img src="required-fallback.svg">'
'</picture>'
)
bare_source_attrs = html_resource_attributes(
'<source srcset="bare-unrendered.svg">'
)
check("picture fallbacks and bare sources cannot prove required assets",
picture_fallback_attrs == set() and bare_source_attrs == set(),
f"picture={sorted(picture_fallback_attrs)} bare={sorted(bare_source_attrs)}")
remote_base, _, _ = coverage_issues(
["approved/logo.svg"],
"",
html_resource_attributes(
'<base href="https://attacker.example/">'
'<img src="approved/logo.svg">'
),
root_path="brief.required_assets",
force_assets=True,
)
check("required local assets reject a remote base URL",
len(remote_base) == 1,
str(remote_base))
def test_coverage_caps_adversarial_reports() -> None:
from content import MAX_COVERAGE_ISSUES, MAX_COVERAGE_VALUES, coverage_issues
@@ -2319,6 +2897,8 @@ def test_mcp_server_stdio_protocol() -> None:
payload = json.loads(body)
check("mcp kami_templates returns registries and schema types",
set(payload.get("document_templates", {})) == set(HTML_TEMPLATES)
and set(payload.get("pptx_templates", {})) == {"slides", "slides-en"}
and set(payload.get("marp_templates", {})) == set(MARP_TEMPLATES)
and payload.get("content_schema_types"),
body[:200])
doctor_body = replies.get(4, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
@@ -2342,11 +2922,41 @@ def test_mcp_check_returns_stable_findings_and_coverage() -> None:
broken = Path(d) / "broken.html"
clean.write_text("<html><body><p>Ready</p></body></html>", encoding="utf-8")
broken.write_text("<html><body><p>{{ missing }}</p></body></html>", encoding="utf-8")
invalid_content = Path(d) / "invalid-content.json"
invalid_content.write_text(
json.dumps({"type": "letter", "lang": "en", "content": {}}),
encoding="utf-8",
)
valid_content = Path(d) / "valid-content.json"
valid_content.write_text(json.dumps({
"type": "letter",
"lang": "en",
"content": {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
},
}), encoding="utf-8")
clean_result = tool_check({"path": str(clean)})
broken_result = tool_check({"path": str(broken)})
invalid_content_result = tool_check({
"path": str(clean), "content": str(invalid_content),
})
missing_coverage_result = tool_check({
"path": str(clean), "content": str(valid_content),
})
check("MCP check registry carries unique stable rule IDs",
len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
CHECK_REGISTRY and clean_result["ruleset_version"] == 2
and len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
and all({"scope", "severity", "required_engine", "explanation"} <= set(rule)
for rule in CHECK_REGISTRY.values()),
str(CHECK_REGISTRY))
@@ -2364,6 +2974,27 @@ def test_mcp_check_returns_stable_findings_and_coverage() -> None:
and broken_result["findings"][0]["status"] == "failed"
and "placeholder" in broken_result["report"].lower(),
json.dumps(broken_result)[:500])
invalid_ids = [item["id"] for item in invalid_content_result["findings"]]
invalid_coverage = {
item["id"]: item for item in invalid_content_result["coverage"]
}
check("MCP labels invalid content IR as contract failure before coverage",
"content.contract" in invalid_ids
and "content.coverage" not in invalid_ids
and invalid_coverage["content.coverage"]["status"] == "not_run"
and invalid_coverage["content.coverage"]["blocked_by"]
== "content.contract",
json.dumps(invalid_content_result)[:800])
missing_ids = [item["id"] for item in missing_coverage_result["findings"]]
missing_statuses = {
item["id"]: item["status"]
for item in missing_coverage_result["coverage"]
}
check("MCP runs coverage once after a valid content contract",
missing_ids == ["content.coverage"]
and missing_statuses["content.contract"] == "passed"
and missing_statuses["content.coverage"] == "failed",
json.dumps(missing_coverage_result)[:800])
def test_mcp_server_rejects_bad_frames_without_exiting() -> None:
+1 -1
View File
@@ -12,7 +12,7 @@ Default to the WeasyPrint HTML path. Switch to pptx only if the user explicitly
|---|---|---|
| WeasyPrint HTML → PDF (default) | `slides-weasy.html` / `slides-weasy-en.html` / `slides-weasy-ko.html` | All cases unless PPTX or Marp is required |
| python-pptx → PPTX (fallback) | `slides.py` / `slides-en.py` | User explicitly requires editable PPTX |
| Marp Markdown (variant) | `assets/templates/marp/slides-marp.md` (+ `slides-marp.css`) / `slides-marp-en.md` (+ `slides-marp-en.css`) | User explicitly asks for Marp, "markdown slides", or a `.md` deck. Shipped `.md` is a working demo of Kami Marp itself; copy it, swap content, keep the structure. Renders via local `marp` CLI; not bundled. |
| Marp Markdown (variant) | `assets/templates/marp/slides-marp.md` (+ `slides-marp.css`) / `slides-marp-en.md` (+ `slides-marp-en.css`) | User explicitly asks for Marp, "markdown slides", or a `.md` deck. Use the CJK variant for Chinese and as the best-effort Japanese/Korean path; use `-en` for English. Copy the shipped working deck, swap content, keep the structure, and render via the local `marp` CLI (not bundled). |
## Page size
+2 -2
View File
@@ -1000,9 +1000,9 @@ Files:
| Asset | Path |
|---|---|
| CN theme CSS | `assets/templates/marp/slides-marp.css` |
| CJK theme CSS (CN, JP/KO best-effort) | `assets/templates/marp/slides-marp.css` |
| EN theme CSS | `assets/templates/marp/slides-marp-en.css` |
| CN sample deck | `assets/templates/marp/slides-marp.md` |
| CJK sample deck | `assets/templates/marp/slides-marp.md` |
| EN sample deck | `assets/templates/marp/slides-marp-en.md` |
Shared with WeasyPrint slides: every design token (`--parchment`, `--brand`, `--serif`, `--mono`), the Kami class scale (`.eyebrow`, `.lead`, `.mt`, `.ml`, `.mb`, `.mc`, `.co`, `.c2`, `table.t2x2`, `table.data`, `section.cover`), and the 280×158mm page size. The Marp theme is a port, not a redesign.
+2 -2
View File
@@ -352,9 +352,9 @@ Use the `npx @marp-team/marp-cli@latest ...` form below for zero-install. For re
| Asset | Path |
|---|---|
| CN theme | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| CJK theme (CN, JP/KO best-effort) | `assets/templates/marp/slides-marp.css` (theme name: `kami`) |
| EN theme | `assets/templates/marp/slides-marp-en.css` (theme name: `kami-en`) |
| CN sample deck | `assets/templates/marp/slides-marp.md` |
| CJK sample deck | `assets/templates/marp/slides-marp.md` |
| EN sample deck | `assets/templates/marp/slides-marp-en.md` |
### Render commands
+803 -40
View File
@@ -14,6 +14,7 @@ Thresholds and DPI live in `references/checks_thresholds.json`.
from __future__ import annotations
import re
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
@@ -72,88 +73,850 @@ def check_placeholders(paths: list[str]) -> int:
# ---------- markdown residue check ----------
def css_hidden_selectors(raw: str) -> tuple[set[str], set[str]]:
"""Return simple class and id selectors hidden by inline stylesheet rules.
def _decode_css_escapes(text: str) -> str:
"""Decode the CSS escapes needed to recognize property names and values."""
def replace_hex(match: re.Match) -> str:
try:
return chr(int(match.group(1), 16))
except (ValueError, OverflowError):
return ""
This intentionally recognizes only unambiguous single-element selectors;
a full CSS cascade belongs to the renderer, not this zero-dependency check.
text = re.sub(r"\\([0-9a-fA-F]{1,6})\s?", replace_hex, text)
return re.sub(r"\\([^\r\n])", r"\1", text)
def _css_numeric_value(value: str) -> tuple[float, str] | None:
"""Parse a small, deterministic subset of CSS numeric expressions."""
value = re.sub(r"\s+", "", value.lower())
direct = re.fullmatch(r"([-+]?(?:\d+(?:\.\d*)?|\.\d+))(%|[a-z]+)?", value)
if direct:
return float(direct.group(1)), direct.group(2) or ""
function = re.fullmatch(r"(calc|min|max|clamp)\((.*)\)", value)
if not function:
return None
name, body = function.groups()
if name == "calc":
return _css_numeric_value(body)
parts = [_css_numeric_value(part) for part in body.split(",")]
if not parts or any(part is None for part in parts):
return None
parsed = [part for part in parts if part is not None]
units = {unit for number, unit in parsed if number != 0 and unit}
if len(units) > 1:
return None
unit = next(iter(units), next((u for _, u in parsed if u), ""))
values = [number for number, _ in parsed]
if name == "min":
return min(values), unit
if name == "max":
return max(values), unit
if len(values) == 3:
lower, preferred, upper = values
return max(lower, min(preferred, upper)), unit
return None
def _is_zero_css_value(value: str) -> bool:
parsed = _css_numeric_value(value)
return parsed is not None and parsed[0] == 0
def _is_extreme_css_offset(value: str) -> bool:
parsed = _css_numeric_value(value)
if parsed is None:
return False
number, unit = parsed
threshold = 100 if unit in {"%", "em", "rem"} else 2000
return abs(number) >= threshold
def _style_state(
style: str,
*,
fail_closed: bool,
replaced_element: bool = False,
) -> tuple[bool, bool]:
"""Return ``(hidden, ambiguous)`` for a declaration block.
CSS comments are removed first because browsers accept declarations such
as ``display/**/: none``. The coverage gate must not treat those elements
as visible merely because the declaration was split by a comment.
"""
clean = re.sub(r"/\*.*?\*/", "", style, flags=re.S)
clean = _decode_css_escapes(clean)
properties: dict[str, tuple[str, bool]] = {}
for declaration in clean.split(";"):
name, separator, value = declaration.partition(":")
if not separator:
continue
name = re.sub(r"\s+", "", name.lower())
important = re.search(r"!\s*important\s*$", value, flags=re.I) is not None
value = re.sub(r"!\s*important\s*$", "", value, flags=re.I)
value = re.sub(r"\s+", "", value.lower())
previous = properties.get(name)
if previous is None or important or not previous[1]:
properties[name] = (value, important)
def resolved(name: str, depth: int = 0) -> tuple[str, bool]:
if name not in properties:
return "", False
if depth > 8:
return "", True
value = properties[name][0]
match = re.fullmatch(r"var\((--[\w-]+)(?:,(.*))?\)", value)
if not match:
return value, False
custom_name, fallback = match.groups()
if custom_name in properties:
return resolved(custom_name, depth + 1)
if fallback is None:
return "", True
temporary_name = f"--kami-fallback-{depth}"
properties[temporary_name] = (fallback, False)
try:
return resolved(temporary_name, depth + 1)
finally:
properties.pop(temporary_name, None)
display, display_ambiguous = resolved("display")
visibility, visibility_ambiguous = resolved("visibility")
if fail_closed and (
(display_ambiguous and "display" in properties)
or (visibility_ambiguous and "visibility" in properties)
):
return False, True
if display == "none" or display.startswith("var("):
return True, False
if visibility in {"hidden", "collapse"} or visibility.startswith("var("):
return True, False
opacity, opacity_ambiguous = resolved("opacity")
if opacity_ambiguous and "opacity" in properties and fail_closed:
return False, True
if _is_zero_css_value(opacity):
return True, False
font_size, font_size_ambiguous = resolved("font-size")
if font_size_ambiguous and "font-size" in properties and fail_closed:
return False, True
if _is_zero_css_value(font_size):
return True, False
color, color_ambiguous = resolved("color")
if color_ambiguous and "color" in properties and fail_closed:
return False, True
if color == "transparent":
return True, False
transform, transform_ambiguous = resolved("transform")
if transform_ambiguous and "transform" in properties and fail_closed:
return False, True
if (
re.search(r"scale(?:x|y)?\([-+]?(?:0+(?:\.0*)?|\.0+)\)", transform)
or re.search(
r"scale\([-+]?(?:0+(?:\.0*)?|\.0+),"
r"[-+]?(?:0+(?:\.0*)?|\.0+)\)",
transform,
)
):
return True, False
matrix = re.search(r"matrix\(([^)]*)\)", transform)
if matrix:
try:
values = [float(value) for value in matrix.group(1).split(",")]
except ValueError:
values = []
if len(values) == 6 and all(value == 0 for value in values[:4]):
return True, False
if re.search(r"translate(?:x|y)?\([^)]*(?:-[2-9]\d{3,}|-[1-9]\d{4,})", transform):
return True, False
if fail_closed and "transform" in properties and "var(" in transform:
return False, True
for property_name in ("scale", "zoom"):
value, ambiguous = resolved(property_name)
if ambiguous and property_name in properties and fail_closed:
return False, True
if _is_zero_css_value(value):
return True, False
content_visibility, content_visibility_ambiguous = resolved("content-visibility")
if content_visibility_ambiguous and "content-visibility" in properties and fail_closed:
return False, True
if content_visibility == "hidden":
return True, False
filter_value, filter_ambiguous = resolved("filter")
if filter_ambiguous and "filter" in properties and fail_closed:
return False, True
if re.search(r"opacity\((?:0+(?:\.0*)?|\.0+)(?:%)?\)", filter_value):
return True, False
if fail_closed and "filter" in properties and "url(" in filter_value:
return False, True
if fail_closed:
for property_name in ("mask", "mask-image", "-webkit-mask", "-webkit-mask-image"):
value, ambiguous = resolved(property_name)
if property_name in properties and (ambiguous or value not in {"", "none"}):
return False, True
if fail_closed:
for property_name in (
"top", "right", "bottom", "left", "inset", "inset-inline",
"inset-block", "margin-top", "margin-right", "margin-bottom",
"margin-left",
):
value, ambiguous = resolved(property_name)
if property_name not in properties:
continue
if ambiguous:
return False, True
if _is_extreme_css_offset(value):
return True, False
text_indent, text_indent_ambiguous = resolved("text-indent")
if text_indent_ambiguous and "text-indent" in properties and fail_closed:
return False, True
if _is_extreme_css_offset(text_indent):
return True, False
clip, clip_ambiguous = resolved("clip")
clip_path, clip_path_ambiguous = resolved("clip-path")
if fail_closed and (
(clip_ambiguous and "clip" in properties)
or (clip_path_ambiguous and "clip-path" in properties)
):
return False, True
if clip in {"rect(0,0,0,0)", "rect(0px,0px,0px,0px)"}:
return True, False
if clip_path in {"inset(50%)", "inset(100%)", "circle(0)", "circle(0px)"}:
return True, False
if fail_closed and "clip" in properties and clip not in {"", "auto"}:
return False, True
if fail_closed and "clip-path" in properties and clip_path not in {"", "none"}:
return False, True
width, width_ambiguous = resolved("width")
max_width, max_width_ambiguous = resolved("max-width")
height, height_ambiguous = resolved("height")
max_height, max_height_ambiguous = resolved("max-height")
overflow, overflow_ambiguous = resolved("overflow")
if fail_closed and (
(width_ambiguous and "width" in properties)
or (max_width_ambiguous and "max-width" in properties)
or (height_ambiguous and "height" in properties)
or (max_height_ambiguous and "max-height" in properties)
):
return False, True
if overflow_ambiguous and "overflow" in properties and fail_closed:
return False, True
zero_width = _is_zero_css_value(width) or _is_zero_css_value(max_width)
zero_height = _is_zero_css_value(height) or _is_zero_css_value(max_height)
if (zero_width or zero_height) and (
replaced_element or overflow in {"hidden", "clip"}
):
return True, False
if fail_closed:
for property_name, value, ambiguous in (
("opacity", opacity, opacity_ambiguous),
("font-size", font_size, font_size_ambiguous),
("width", width, width_ambiguous),
("max-width", max_width, max_width_ambiguous),
("height", height, height_ambiguous),
("max-height", max_height, max_height_ambiguous),
):
if property_name in properties and (
ambiguous or ("(" in value and _css_numeric_value(value) is None)
):
return False, True
return False, False
def _style_hides(
style: str,
*,
fail_closed: bool,
replaced_element: bool = False,
) -> bool:
return _style_state(
style,
fail_closed=fail_closed,
replaced_element=replaced_element,
)[0]
def _css_hidden_filters(
raw: str,
*,
fail_closed: bool,
) -> tuple[
set[str], set[str], set[str], set[tuple[str, str | None]],
set[str], set[str], set[str], set[tuple[str, str | None]], bool,
]:
"""Return conservative class, id, and tag filters for hidden CSS rules.
The stdlib parser does not implement the CSS cascade. For compound
selectors, it marks the target class/id when available; for a bare target
such as ``.concealed img``, it marks the nearest ancestor class/id. This
can reject an ambiguous document, but it cannot turn hidden evidence into
a coverage pass. Pseudo-elements are ignored because they do not hide the
underlying element.
"""
hidden_classes: set[str] = set()
hidden_ids: set[str] = set()
hidden_tags: set[str] = set()
hidden_attrs: set[tuple[str, str | None]] = set()
ambiguous_classes: set[str] = set()
ambiguous_ids: set[str] = set()
ambiguous_tags: set[str] = set()
ambiguous_attrs: set[tuple[str, str | None]] = set()
globally_ambiguous = False
decoded_markup = unescape(raw)
if fail_closed and re.search(
r"<link\b[^>]*\bstylesheet\b[^>]*>",
decoded_markup,
flags=re.I,
):
ambiguous_tags.add("*")
globally_ambiguous = True
style_blocks = re.findall(r"<style\b[^>]*>(.*?)</style\s*>", raw, flags=re.I | re.S)
for block in style_blocks:
clean = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
clean = _decode_css_escapes(clean)
if fail_closed and re.search(r"@import\b", clean, flags=re.I):
ambiguous_tags.add("*")
globally_ambiguous = True
for selectors, body in re.findall(r"([^{}]+)\{([^{}]*)\}", clean, flags=re.S):
compact = re.sub(r"\s+", "", body.lower())
if "display:none" not in compact and "visibility:hidden" not in compact:
hides, body_ambiguous = _style_state(body, fail_closed=fail_closed)
if not hides and not body_ambiguous:
continue
class_store = ambiguous_classes if body_ambiguous else hidden_classes
id_store = ambiguous_ids if body_ambiguous else hidden_ids
tag_store = ambiguous_tags if body_ambiguous else hidden_tags
attr_store = ambiguous_attrs if body_ambiguous else hidden_attrs
selectors = _decode_css_escapes(selectors)
if (
re.search(r":[\w-]+\s*\(", selectors)
or re.search(r"[+~]", selectors)
or re.search(r"\[[^\]]*(?:[~|^$*]=)", selectors)
or re.search(r"\[[^\]]*,[^\]]*\]", selectors)
):
if fail_closed:
ambiguous_tags.add("*")
globally_ambiguous = True
continue
for selector in selectors.split(","):
selector = selector.strip()
class_match = re.fullmatch(r"(?:[a-zA-Z][\w-]*)?\.([\w-]+)", selector)
id_match = re.fullmatch(r"(?:[a-zA-Z][\w-]*)?#([\w-]+)", selector)
if class_match:
hidden_classes.add(class_match.group(1))
elif id_match:
hidden_ids.add(id_match.group(1))
if not selector or "::" in selector:
continue
compounds = [
part for part in re.split(r"\s+|[>+~]", selector) if part
]
if not compounds:
continue
target = compounds[-1]
target_classes = set(re.findall(r"\.([\w-]+)", target))
target_ids = set(re.findall(r"#([\w-]+)", target))
target_attrs = {
(
match.group(1).lower(),
next(
(value for value in match.groups()[1:] if value is not None),
None,
),
)
for match in re.finditer(
r"\[\s*([\w-]+)(?:\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\]\s]+)))?\s*\]",
target,
)
}
if ":not(" not in target and (target_classes or target_ids):
class_store.update(target_classes)
id_store.update(target_ids)
continue
if target_attrs:
attr_store.update(target_attrs)
continue
if target.startswith(":root"):
tag_store.add("html")
continue
ancestors = " ".join(compounds[:-1])
ancestor_classes = set(re.findall(r"\.([\w-]+)", ancestors))
ancestor_ids = set(re.findall(r"#([\w-]+)", ancestors))
if ancestor_classes or ancestor_ids:
class_store.update(ancestor_classes)
id_store.update(ancestor_ids)
continue
tag_match = re.match(r"(?:\*|[a-zA-Z][\w-]*)", target)
if tag_match:
tag_store.add(tag_match.group(0).lower())
else:
ambiguous_tags.add("*")
globally_ambiguous = True
return (
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
globally_ambiguous,
)
def css_hidden_selectors(raw: str) -> tuple[set[str], set[str]]:
"""Return class and id filters hidden by inline stylesheet rules."""
hidden_classes, hidden_ids, _, _, _, _, _, _, _ = _css_hidden_filters(
raw,
fail_closed=False,
)
return hidden_classes, hidden_ids
class _VisibleTextParser(HTMLParser):
"""Extract visible text from filled HTML while skipping code-like blocks."""
class _HtmlVisibilityParser(HTMLParser):
"""Shared fail-closed visibility state for text and resource parsers."""
_SKIP_TAGS = {"code", "head", "noscript", "pre", "script", "style", "template"}
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
}
_P_IMPLICIT_CLOSE_STARTS = {
"address", "article", "aside", "blockquote", "details", "div",
"dl", "fieldset", "figcaption", "figure", "footer", "form",
"h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr",
"main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul",
}
_OPTIONAL_REPEAT_TAGS = {
"dd", "dt", "li", "option", "tbody", "td", "tfoot", "th", "thead", "tr",
}
_REPLACED_TAGS = {"audio", "embed", "iframe", "image", "img", "object", "svg", "video"}
_PRESENTATION_STYLE_ATTRS = {
"clip", "clip-path", "color", "display", "filter", "font-size", "height",
"mask", "mask-image", "max-height", "max-width", "opacity", "overflow",
"transform", "visibility", "width",
}
_SVG_POSITIONED_TAGS = {
"circle", "ellipse", "foreignobject", "image", "rect", "svg", "text", "tspan", "use",
}
def __init__(self, hidden_classes: set[str], hidden_ids: set[str]) -> None:
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
*,
skip_tags: set[str],
fail_closed: bool,
) -> None:
super().__init__(convert_charrefs=True)
self._hidden_classes = hidden_classes
self._hidden_ids = hidden_ids
self._hidden_tags = hidden_tags
self._hidden_attrs = hidden_attrs
self._ambiguous_classes = ambiguous_classes
self._ambiguous_ids = ambiguous_ids
self._ambiguous_tags = ambiguous_tags
self._ambiguous_attrs = ambiguous_attrs
self._skip_tags = skip_tags
self._fail_closed = fail_closed
self._visibility_ambiguous = visibility_ambiguous
self._skip_depth = 0
self._skip_stack: list[bool] = []
self.parts: list[str] = []
self._ambiguous_depth = 0
self._skip_stack: list[tuple[str, bool, bool]] = []
self._svg_viewports: list[tuple[float, float, float, float]] = []
self._ambiguous_markup = False
def _mark_ambiguous(self) -> None:
self._ambiguous_markup = True
self._skip_stack.clear()
self._svg_viewports.clear()
self._skip_depth = 1 if self._fail_closed else 0
self._ambiguous_depth = 1 if self._fail_closed else 0
def _pop_top(self) -> None:
tag, hidden, ambiguous = self._skip_stack.pop()
if tag == "svg" and self._svg_viewports:
self._svg_viewports.pop()
if hidden:
self._skip_depth = max(0, self._skip_depth - 1)
if ambiguous:
self._ambiguous_depth = max(0, self._ambiguous_depth - 1)
@staticmethod
def _svg_viewport(attrs_map: dict[str, str]) -> tuple[float, float, float, float] | None:
raw = attrs_map.get("viewbox", "")
if raw:
try:
values = [float(value) for value in re.split(r"[\s,]+", raw.strip())]
except ValueError:
values = []
if len(values) == 4 and values[2] > 0 and values[3] > 0:
return values[0], values[1], values[2], values[3]
width = _css_numeric_value(attrs_map.get("width", ""))
height = _css_numeric_value(attrs_map.get("height", ""))
if width and height and width[0] > 0 and height[0] > 0:
return 0.0, 0.0, width[0], height[0]
return None
def _svg_position_state(
self,
tag: str,
attrs_map: dict[str, str],
) -> tuple[bool, bool]:
if tag not in self._SVG_POSITIONED_TAGS:
return False, False
viewport = self._svg_viewports[-1] if self._svg_viewports else None
if viewport is not None and tag in {"foreignobject", "text", "tspan"}:
# A coordinate point does not prove that the complete glyph or
# foreign-object box intersects the viewport. Renderer box data is
# required, so SVG text cannot be deterministic atomic evidence.
return False, True
parsed_positions: dict[str, float] = {}
for name in ("x", "y", "dx", "dy"):
if name not in attrs_map:
continue
parsed = _css_numeric_value(attrs_map[name])
if parsed is None or parsed[1] not in {"", "px", "pt"}:
return False, True
parsed_positions[name] = parsed[0]
if tag == "svg" and viewport is not None and parsed_positions:
# Nested SVG establishes another viewport. Proving its transformed
# overlap requires the renderer, so do not accept facts from it as
# deterministic static evidence.
return False, True
if any(parsed_positions.get(name, 0) != 0 for name in ("dx", "dy")):
return False, True
if viewport is None:
if any(abs(value) >= 10000 for value in parsed_positions.values()):
return True, False
return False, False
min_x, min_y, view_width, view_height = viewport
x = parsed_positions.get("x", min_x)
y = parsed_positions.get("y", min_y)
max_x = min_x + view_width
max_y = min_y + view_height
if tag == "image":
image_width = _css_numeric_value(attrs_map.get("width", ""))
image_height = _css_numeric_value(attrs_map.get("height", ""))
if (
image_width is None
or image_height is None
or image_width[1] not in {"", "px", "pt"}
or image_height[1] not in {"", "px", "pt"}
or image_width[0] <= 0
or image_height[0] <= 0
):
return False, True
right = x + image_width[0]
bottom = y + image_height[0]
if right <= min_x or x >= max_x or bottom <= min_y or y >= max_y:
return True, False
if x < min_x or right > max_x or y < min_y or bottom > max_y:
return False, True
elif x < min_x or x > max_x or y < min_y or y > max_y:
return False, True
return False, False
@staticmethod
def _implicitly_closed_by_start(open_tag: str, new_tag: str) -> bool:
if open_tag == "p" and new_tag in _HtmlVisibilityParser._P_IMPLICIT_CLOSE_STARTS:
return True
if open_tag == "li" and new_tag == "li":
return True
if open_tag in {"dd", "dt"} and new_tag in {"dd", "dt"}:
return True
if open_tag in {"rp", "rt"} and new_tag in {"rp", "rt"}:
return True
if open_tag == "option" and new_tag in {"option", "optgroup"}:
return True
if open_tag == "optgroup" and new_tag == "optgroup":
return True
if open_tag in {"td", "th"} and new_tag in {
"td", "th", "tr", "tbody", "thead", "tfoot",
}:
return True
if open_tag == "tr" and new_tag in {"tr", "tbody", "thead", "tfoot"}:
return True
if open_tag in {"tbody", "thead", "tfoot"} and new_tag in {
"tbody", "thead", "tfoot",
}:
return True
return False
@staticmethod
def _implicitly_closed_by_end(open_tag: str, end_tag: str) -> bool:
return (
(open_tag == "p" and end_tag in {"address", "article", "aside", "blockquote", "body", "div", "footer", "form", "header", "main", "nav", "section"})
or (open_tag == "li" and end_tag in {"menu", "ol", "ul"})
or (open_tag in {"dd", "dt"} and end_tag == "dl")
or (open_tag in {"rp", "rt"} and end_tag == "ruby")
or (open_tag == "option" and end_tag in {"optgroup", "select"})
or (open_tag == "optgroup" and end_tag == "select")
or (open_tag in {"td", "th"} and end_tag in {"tr", "tbody", "thead", "tfoot", "table"})
or (open_tag == "tr" and end_tag in {"tbody", "thead", "tfoot", "table"})
or (open_tag in {"tbody", "thead", "tfoot"} and end_tag == "table")
)
@staticmethod
def _implicit_start_boundaries(new_tag: str) -> set[str]:
if new_tag == "li":
return {"menu", "ol", "ul"}
if new_tag in {"dd", "dt"}:
return {"dl"}
if new_tag in {"option", "optgroup"}:
return {"datalist", "select"}
if new_tag in {"rp", "rt"}:
return {"ruby"}
if new_tag in {"td", "th"}:
return {"table", "tr"}
if new_tag in {"tr", "tbody", "thead", "tfoot"}:
return {"table"}
return set()
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if self._ambiguous_markup:
return
boundaries = self._implicit_start_boundaries(tag)
boundary = max(
(
index
for index, (open_tag, _, _) in enumerate(self._skip_stack)
if open_tag in boundaries
),
default=-1,
)
implicit_match = next(
(
index
for index in range(len(self._skip_stack) - 1, boundary, -1)
if self._implicitly_closed_by_start(self._skip_stack[index][0], tag)
),
None,
)
if implicit_match is not None:
while len(self._skip_stack) > implicit_match:
self._pop_top()
attrs_map = {name.lower(): (value or "") for name, value in attrs}
style = re.sub(r"\s+", "", attrs_map.get("style", "").lower())
classes = set(attrs_map.get("class", "").split())
hidden = (
tag in self._SKIP_TAGS
presentation_style = ";".join(
f"{name}:{value}"
for name, value in attrs_map.items()
if name in self._PRESENTATION_STYLE_ATTRS
)
inline_hidden, inline_ambiguous = _style_state(
attrs_map.get("style", ""),
fail_closed=self._fail_closed,
replaced_element=tag in self._REPLACED_TAGS,
)
presentation_hidden, presentation_ambiguous = _style_state(
presentation_style,
fail_closed=self._fail_closed,
replaced_element=tag in self._REPLACED_TAGS,
)
svg_position_hidden, svg_position_ambiguous = self._svg_position_state(
tag,
attrs_map,
)
known_hidden = (
tag in self._skip_tags
or tag in self._hidden_tags
or "*" in self._hidden_tags
or any(
name in attrs_map
and (expected is None or attrs_map[name] == expected)
for name, expected in self._hidden_attrs
)
or "hidden" in attrs_map
or attrs_map.get("aria-hidden", "").lower() == "true"
or bool(classes & self._hidden_classes)
or attrs_map.get("id", "") in self._hidden_ids
or "display:none" in style
or "visibility:hidden" in style
or inline_hidden
or (self._fail_closed and presentation_hidden)
or (self._fail_closed and svg_position_hidden)
or (
self._fail_closed
and attrs_map.get("fill", "").strip().lower() == "none"
)
or (
self._fail_closed
and _is_zero_css_value(attrs_map.get("fill-opacity", ""))
)
)
if tag not in self._VOID_TAGS:
self._skip_stack.append(hidden)
if hidden:
self._skip_depth += 1
if tag in self._VOID_TAGS:
self._skip_depth -= 1
css_ambiguous = (
tag in self._ambiguous_tags
or "*" in self._ambiguous_tags
or any(
name in attrs_map
and (expected is None or attrs_map[name] == expected)
for name, expected in self._ambiguous_attrs
)
or bool(classes & self._ambiguous_classes)
or attrs_map.get("id", "") in self._ambiguous_ids
)
if known_hidden or (self._skip_depth and not self._ambiguous_depth):
inline_ambiguous = False
presentation_ambiguous = False
svg_position_ambiguous = False
css_ambiguous = False
current_ambiguous = self._fail_closed and (
css_ambiguous
or inline_ambiguous
or presentation_ambiguous
or svg_position_ambiguous
)
ambiguous_hidden = (
current_ambiguous and not known_hidden and self._skip_depth == 0
)
if not known_hidden and (self._ambiguous_depth or ambiguous_hidden):
self._handle_ambiguous_starttag(tag, attrs)
hidden = known_hidden or current_ambiguous
is_void = tag in self._VOID_TAGS
if not is_void:
self._skip_stack.append((tag, hidden, ambiguous_hidden))
if tag == "svg":
self._svg_viewports.append(
self._svg_viewport(attrs_map) or (0.0, 0.0, 4096.0, 4096.0)
)
if hidden:
self._skip_depth += 1
if ambiguous_hidden:
self._ambiguous_depth += 1
if hidden or self._skip_depth:
return
self._handle_visible_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
hidden = self._skip_stack.pop() if self._skip_stack else False
if hidden and self._skip_depth > 0:
self._skip_depth -= 1
tag = tag.lower()
if self._ambiguous_markup:
return
if tag in self._VOID_TAGS:
return
match = next(
(index for index in range(len(self._skip_stack) - 1, -1, -1)
if self._skip_stack[index][0] == tag),
None,
)
if match is None:
return
if match != len(self._skip_stack) - 1:
intervening = self._skip_stack[match + 1:]
if not intervening or not self._implicitly_closed_by_end(
intervening[0][0],
tag,
):
self._mark_ambiguous()
return
closing = self._skip_stack[match:]
del self._skip_stack[match:]
for open_tag, _, _ in reversed(closing):
if open_tag == "svg" and self._svg_viewports:
self._svg_viewports.pop()
self._skip_depth = max(
0,
self._skip_depth - sum(1 for _, hidden, _ in closing if hidden),
)
self._ambiguous_depth = max(
0,
self._ambiguous_depth
- sum(1 for _, _, ambiguous in closing if ambiguous),
)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# Self-closing hidden/input nodes contain no visible text and must not
# leave the parser in a skipped state for the following sibling.
return
# In HTML, a trailing slash is ignored on non-void elements. Treating
# <div hidden/> as closed would expose everything that follows even
# though browsers and WeasyPrint keep it inside the hidden div.
foreign_svg = tag.lower() == "svg" or bool(self._svg_viewports)
self.handle_starttag(tag, attrs)
if foreign_svg and tag.lower() not in self._VOID_TAGS:
self.handle_endtag(tag)
def handle_data(self, data: str) -> None:
if self._skip_depth == 0:
self.parts.append(data)
if self._ambiguous_markup:
if not self._fail_closed:
self._handle_visible_data(data)
elif self._ambiguous_depth:
self._handle_ambiguous_data(data)
elif self._skip_depth == 0:
self._handle_visible_data(data)
def _handle_visible_starttag(
self, _tag: str, _attrs: list[tuple[str, str | None]],
) -> None:
return
def _handle_ambiguous_starttag(
self,
_tag: str,
_attrs: list[tuple[str, str | None]],
) -> None:
return
def _handle_ambiguous_data(self, _data: str) -> None:
return
def _handle_visible_data(self, _data: str) -> None:
return
def visible_html_text(raw: str) -> str:
parser = _VisibleTextParser(*css_hidden_selectors(raw))
class _VisibleTextParser(_HtmlVisibilityParser):
"""Extract visible text from filled HTML while skipping code-like blocks."""
_SKIP_TAGS = {
"clippath", "code", "datalist", "defs", "desc", "head", "mask",
"metadata", "noembed", "noframes", "noscript", "pattern", "pre", "rp",
"script", "style", "symbol", "template", "title",
}
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
*,
fail_closed: bool,
) -> None:
super().__init__(
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
visibility_ambiguous,
skip_tags=self._SKIP_TAGS,
fail_closed=fail_closed,
)
self.parts: list[str] = []
def _handle_visible_data(self, data: str) -> None:
self.parts.append(data)
def _handle_ambiguous_data(self, data: str) -> None:
if data.strip():
self._visibility_ambiguous = True
def visible_html_evidence(
raw: str,
*,
fail_closed: bool = False,
) -> tuple[str, bool]:
parser = _VisibleTextParser(
*_css_hidden_filters(raw, fail_closed=fail_closed),
fail_closed=fail_closed,
)
parser.feed(raw)
return "\n".join(parser.parts)
if fail_closed and parser._ambiguous_markup:
return "", True
return "\n".join(parser.parts), parser._visibility_ambiguous
def visible_html_text(raw: str, *, fail_closed: bool = False) -> str:
return visible_html_evidence(raw, fail_closed=fail_closed)[0]
def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]:
+198 -67
View File
@@ -17,14 +17,20 @@ editorial, not verbatim.
from __future__ import annotations
import json
import posixpath
import re
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlsplit
from urllib.parse import unquote, urljoin, urlsplit
from checks import css_hidden_selectors, visible_html_text
from checks import (
_HtmlVisibilityParser,
_css_hidden_filters,
visible_html_evidence,
visible_html_text,
)
from shared import (
HTML_TEMPLATES,
MARP_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCHEMAS_DIR,
@@ -63,7 +69,10 @@ BRIEF_SCHEMA = {
"template": {"type": "string", "minLength": 1, "maxLength": 80},
"formats": {
"type": "array", "minItems": 1, "maxItems": 4,
"items": {"type": "string", "enum": ["html", "pdf", "pptx", "png"]},
"items": {
"type": "string",
"enum": ["html", "md", "pdf", "pptx", "png"],
},
},
"page_target": {"type": "integer", "minimum": 1, "maximum": 200},
"length_target": {"type": "string", "minLength": 1, "maxLength": 120},
@@ -106,7 +115,7 @@ BRIEF_SCHEMA = {
}
class _HtmlAttributeParser(HTMLParser):
class _HtmlAttributeParser(_HtmlVisibilityParser):
"""Collect resource-bearing HTML attributes for asset coverage checks."""
_RESOURCE_ATTRS = {
@@ -117,59 +126,98 @@ class _HtmlAttributeParser(HTMLParser):
"use": {"href"},
"video": {"poster", "src"},
}
_SKIP_TAGS = {"head", "noscript", "script", "style", "template"}
_VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
_SKIP_TAGS = {
"clippath", "defs", "head", "mask", "metadata", "noembed", "noframes",
"noscript", "pattern", "rp", "script", "style", "symbol", "template",
}
def __init__(self, hidden_classes: set[str], hidden_ids: set[str]) -> None:
super().__init__(convert_charrefs=True)
self._hidden_classes = hidden_classes
self._hidden_ids = hidden_ids
self._skip_depth = 0
self._skip_stack: list[bool] = []
def __init__(
self,
hidden_classes: set[str],
hidden_ids: set[str],
hidden_tags: set[str],
hidden_attrs: set[tuple[str, str | None]],
ambiguous_classes: set[str],
ambiguous_ids: set[str],
ambiguous_tags: set[str],
ambiguous_attrs: set[tuple[str, str | None]],
visibility_ambiguous: bool,
) -> None:
super().__init__(
hidden_classes,
hidden_ids,
hidden_tags,
hidden_attrs,
ambiguous_classes,
ambiguous_ids,
ambiguous_tags,
ambiguous_attrs,
visibility_ambiguous,
skip_tags=self._SKIP_TAGS,
fail_closed=True,
)
self.values: set[str] = set()
self.base_href: str | None = None
self.ambiguous_resources = False
self._picture_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
attrs_map = {name.lower(): (value or "") for name, value in attrs}
style = re.sub(r"\s+", "", attrs_map.get("style", "").lower())
hidden = (
tag in self._SKIP_TAGS
or "hidden" in attrs_map
or attrs_map.get("aria-hidden", "").lower() == "true"
or bool(set(attrs_map.get("class", "").split()) & self._hidden_classes)
or attrs_map.get("id", "") in self._hidden_ids
or "display:none" in style
or "visibility:hidden" in style
)
if tag not in self._VOID_TAGS:
self._skip_stack.append(hidden)
if hidden:
self._skip_depth += 1
if tag in self._VOID_TAGS:
self._skip_depth -= 1
if self._skip_depth:
if tag == "picture":
self._picture_depth += 1
if tag == "base" and self.base_href is None:
self.base_href = next(
(value.strip() for name, value in attrs
if name.lower() == "href" and value and value.strip()),
None,
)
super().handle_starttag(tag, attrs)
def handle_endtag(self, tag: str) -> None:
super().handle_endtag(tag)
if tag.lower() == "picture" and self._picture_depth:
self._picture_depth -= 1
def _handle_visible_starttag(
self, tag: str, attrs: list[tuple[str, str | None]],
) -> None:
attrs_map = {name.lower(): (value or "") for name, value in attrs}
if tag in {"picture", "source"} or attrs_map.get("srcset"):
self.ambiguous_resources = True
if self._picture_depth or tag == "source":
return
if tag == "source" and (attrs_map.get("media") or attrs_map.get("type")):
return
if tag in {"img", "source"} and attrs_map.get("srcset"):
candidates = [
part.strip().split()
for part in attrs_map["srcset"].split(",")
if part.strip()
]
if len(candidates) == 1 and len(candidates[0]) == 1:
self.values.add(candidates[0][0])
return
allowed = self._RESOURCE_ATTRS.get(tag, set())
for name, value in attrs:
if name.lower() not in allowed or not value:
continue
if name.lower() == "srcset":
self.values.update(part.strip().split()[0] for part in value.split(",") if part.strip())
continue
else:
self.values.add(value.strip())
def handle_endtag(self, _tag: str) -> None:
hidden = self._skip_stack.pop() if self._skip_stack else False
if hidden and self._skip_depth > 0:
self._skip_depth -= 1
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self.handle_starttag(tag, attrs)
if tag.lower() not in self._VOID_TAGS:
self.handle_endtag(tag)
def _handle_ambiguous_starttag(
self,
tag: str,
attrs: list[tuple[str, str | None]],
) -> None:
attrs_map = {name.lower(): (value or "") for name, value in attrs}
allowed = self._RESOURCE_ATTRS.get(tag, set())
if any(name in attrs_map and attrs_map[name] for name in allowed):
self._visibility_ambiguous = True
if tag in {"picture", "source"} or attrs_map.get("srcset"):
self.ambiguous_resources = True
def load_schema(doc_type: str) -> dict:
@@ -256,7 +304,15 @@ def _brief_contract_issues(
) -> list[str]:
"""Cross-check the artifact brief against the selected document contract."""
issues: list[str] = []
template_names = {*HTML_TEMPLATES, *PPTX_TEMPLATES, *SCREEN_TEMPLATES}
if "page_target" not in brief and "length_target" not in brief:
issues.append("brief: requires page_target or length_target")
template_names = {
*HTML_TEMPLATES,
*MARP_TEMPLATES,
*PPTX_TEMPLATES,
*SCREEN_TEMPLATES,
}
template = brief.get("template")
if not isinstance(template, str):
return issues
@@ -276,7 +332,9 @@ def _brief_contract_issues(
formats = brief.get("formats")
if isinstance(formats, list):
if doc_type == "slides":
if template in MARP_TEMPLATES:
supported_formats = {"html", "md", "pdf", "pptx"}
elif doc_type == "slides":
# A slide deliverable combines the WeasyPrint source/PDF with the
# editable python-pptx fallback, even though `template` names the
# primary authoring path.
@@ -321,10 +379,15 @@ def _brief_contract_issues(
and isinstance(formats, list)
and "pptx" in formats
)
korean_marp_fallback = (
template == "slides-marp"
and requested_family == "ko"
)
if (
requested_family is not None
and requested_family != template_family
and not korean_pptx_fallback
and not korean_marp_fallback
):
issues.append(
f"brief.template: {template!r} is the {template_family} variant "
@@ -414,26 +477,49 @@ def _contains_atomic(haystack: str, needle: str) -> bool:
return re.search(left + re.escape(needle) + right, haystack) is not None
def html_resource_attributes(raw: str) -> set[str]:
parser = _HtmlAttributeParser(*css_hidden_selectors(raw))
def html_resource_evidence(raw: str) -> tuple[set[str], bool]:
parser = _HtmlAttributeParser(*_css_hidden_filters(raw, fail_closed=True))
parser.feed(raw)
return parser.values
if parser._ambiguous_markup:
return set(), True
if parser.base_href:
values = {urljoin(parser.base_href, value) for value in parser.values}
else:
values = parser.values
return values, (
parser._visibility_ambiguous or parser.ambiguous_resources
)
def html_resource_attributes(raw: str) -> set[str]:
return html_resource_evidence(raw)[0]
def _asset_present(needle: str, attributes: set[str]) -> bool:
expected_url = urlsplit(needle)
expected = unquote(expected_url.path).lstrip("./")
expected_path = posixpath.normpath(unquote(expected_url.path))
expected_is_url = bool(expected_url.scheme or expected_url.netloc)
expected_is_absolute = expected_path.startswith("/")
for raw in attributes:
actual_url = urlsplit(raw)
actual = unquote(actual_url.path).lstrip("./")
if expected_url.scheme or expected_url.netloc:
actual_path = posixpath.normpath(unquote(actual_url.path))
actual_is_url = bool(actual_url.scheme or actual_url.netloc)
if expected_is_url:
if (
actual_url.scheme.casefold() == expected_url.scheme.casefold()
and actual_url.netloc.casefold() == expected_url.netloc.casefold()
and actual == expected
and actual_path == expected_path
):
return True
elif actual == expected or actual.endswith(f"/{expected}"):
elif actual_is_url:
continue
elif expected_is_absolute:
if actual_path == expected_path:
return True
elif (
actual_path == expected_path
or actual_path.endswith(f"/{expected_path}")
):
return True
return False
@@ -515,18 +601,27 @@ def coverage_issues(
return issues, checked, skipped
def check_content(paths: list[str]) -> int:
def check_content(
paths: list[str],
*,
phase_codes: dict[str, int] | None = None,
) -> int:
"""CLI: --check-content content.json [filled.html]
Validates the content IR against its schema; with a filled HTML file,
also verifies every short atomic value made it into the visible text.
"""
def finish(phase: str, code: int) -> int:
if phase_codes is not None:
phase_codes[phase] = code
return code
args = [p for p in paths if not p.startswith("-")]
if not args or len(args) > 2:
known = ", ".join(content_schema_types()) or "none"
print("ERROR: usage: --check-content content.json [filled.html]")
print(f" known types: {known}")
return 2
return finish("contract", 2)
content_path = Path(args[0])
if not content_path.is_absolute():
@@ -534,20 +629,21 @@ def check_content(paths: list[str]) -> int:
rel = rel_to_root(content_path)
if not content_path.exists():
print(f"ERROR: {args[0]}: file not found")
return 2
return finish("contract", 2)
try:
data = json.loads(content_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"ERROR: {rel}: invalid JSON: {exc}")
return 1
return finish("contract", 1)
doc_type, issues = validate_content_file(data)
if issues:
print(f"ERROR: {rel}: {len(issues)} schema issue(s)")
for issue in issues:
print(f" {issue}")
return 1
return finish("contract", 1)
print(f"OK: {rel}: valid {doc_type} content")
finish("contract", 0)
if len(args) == 1:
return 0
@@ -558,29 +654,64 @@ def check_content(paths: list[str]) -> int:
html_rel = rel_to_root(html_path)
if not html_path.exists():
print(f"ERROR: {args[1]}: file not found")
return 2
return finish("coverage", 2)
html_raw = html_path.read_text(encoding="utf-8", errors="replace")
html_text = visible_html_text(html_raw)
html_attributes = html_resource_attributes(html_raw)
missing, checked, skipped = coverage_issues(
html_text, text_ambiguous = visible_html_evidence(html_raw, fail_closed=True)
html_attributes, resource_ambiguous = html_resource_evidence(html_raw)
content_missing, checked, skipped = coverage_issues(
data["content"], html_text, html_attributes
)
text_missing = [
issue for issue in content_missing
if "asset not found in document attributes" not in issue
]
asset_missing = [
issue for issue in content_missing
if "asset not found in document attributes" in issue
]
required_assets = (data.get("brief") or {}).get("required_assets", [])
if required_assets:
asset_missing, asset_checked, _ = coverage_issues(
required_asset_missing, asset_checked, _ = coverage_issues(
required_assets,
html_text,
html_attributes,
root_path="brief.required_assets",
force_assets=True,
)
missing.extend(asset_missing)
asset_missing.extend(required_asset_missing)
checked += asset_checked
missing = [*text_missing, *asset_missing]
if missing:
print(f"ERROR: {html_rel}: {len(missing)} content value(s) missing from document")
for issue in missing:
definite_missing = [
*(text_missing if not text_ambiguous else []),
*(asset_missing if not resource_ambiguous else []),
]
indeterminate = [
*(text_missing if text_ambiguous else []),
*(asset_missing if resource_ambiguous else []),
]
if not definite_missing:
print(
f"ERROR: {html_rel}: content coverage is indeterminate because "
"visibility or resource selection could not be proven statically"
)
for issue in missing:
print(f" {issue}")
return finish("coverage", 2)
print(
f"ERROR: {html_rel}: {len(definite_missing)} content value(s) "
"definitely missing from document"
)
for issue in definite_missing:
print(f" {issue}")
return 1
if indeterminate:
print(
f" NOTE: {len(indeterminate)} additional value(s) could not be "
"proven because visibility or resource selection is indeterminate"
)
for issue in indeterminate:
print(f" {issue}")
return finish("coverage", 1)
note = f" ({skipped} prose field(s) not held verbatim)" if skipped else ""
print(f"OK: {html_rel}: all {checked} atomic content values present{note}")
return 0
return finish("coverage", 0)
+35 -8
View File
@@ -43,6 +43,8 @@ from render import render_pdf
from shared import (
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
MARP_TEMPLATES,
PPTX_TEMPLATES,
ROOT,
SCREEN_TEMPLATES,
content_schema_types,
@@ -54,7 +56,7 @@ from verify import check_fonts
PROTOCOL_VERSION = "2025-06-18"
SUPPORTED_PROTOCOL_VERSIONS = {"2024-11-05", "2025-03-26", "2025-06-18"}
CHECK_RULESET_VERSION = 1
CHECK_RULESET_VERSION = 2
CHECK_REGISTRY = {
"html.placeholders": {
"scope": "html", "severity": "error", "required_engine": "stdlib",
@@ -176,6 +178,8 @@ def tool_templates(_args: dict) -> dict:
"version": kami_version(),
"document_templates": {name: spec.source for name, spec in HTML_TEMPLATES.items()},
"screen_templates": dict(SCREEN_TEMPLATES),
"pptx_templates": dict(PPTX_TEMPLATES),
"marp_templates": dict(MARP_TEMPLATES),
"diagram_templates": dict(DIAGRAM_TEMPLATES),
"content_schema_types": content_schema_types(),
"templates_dir": str(ROOT / "assets" / "templates"),
@@ -220,10 +224,10 @@ def tool_render(args: dict) -> dict:
return {"pdf": str(out), "pages": pages}
def _run_check(fn, argv: list[str]) -> tuple[int, str]:
def _run_check(fn, argv: list[str], **kwargs) -> tuple[int, str]:
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
code = fn(argv)
code = fn(argv, **kwargs)
return code, buffer.getvalue().rstrip()
@@ -235,9 +239,11 @@ def _check_plan(path: Path, content: str | None) -> list[tuple[str, object, list
("html.markdown-residue", check_markdown_residue, [str(path)]),
]
if content:
content_path = str(_resolve(content))
checks.append((
"content.coverage", check_content,
[str(_resolve(content)), str(path)],
"content.contract",
check_content,
[content_path, str(path)],
))
return checks
if suffix == ".pdf":
@@ -260,15 +266,36 @@ def tool_check(args: dict) -> dict:
coverage: list[dict] = []
findings: list[dict] = []
worst = 0
for rule_id, fn, argv in _check_plan(path, args.get("content")):
code, report = _run_check(fn, argv)
def record(rule_id: str, code: int, report: str) -> None:
nonlocal worst
worst = max(worst, code)
reports.append(report)
status = "passed" if code == 0 else ("failed" if code == 1 else "degraded")
rule = {"id": rule_id, **CHECK_REGISTRY[rule_id]}
coverage.append({**rule, "status": status, "exit_code": code})
if status != "passed":
findings.append({**rule, "status": status, "evidence": report})
for rule_id, fn, argv in _check_plan(path, args.get("content")):
if rule_id == "content.contract" and len(argv) == 2:
phase_codes: dict[str, int] = {}
_, report = _run_check(fn, argv, phase_codes=phase_codes)
reports.append(report)
contract_code = phase_codes.get("contract", 2)
record("content.contract", contract_code, report)
if contract_code == 0 and "coverage" in phase_codes:
record("content.coverage", phase_codes["coverage"], report)
else:
rule = {"id": "content.coverage", **CHECK_REGISTRY["content.coverage"]}
coverage.append({
**rule,
"status": "not_run",
"exit_code": None,
"blocked_by": "content.contract",
})
continue
code, report = _run_check(fn, argv)
reports.append(report)
record(rule_id, code, report)
return {
"ruleset_version": CHECK_RULESET_VERSION,
"exit_code": worst,
+12
View File
@@ -148,6 +148,13 @@ PPTX_TEMPLATES: dict[str, str] = {
"slides-en": "slides-en.py",
}
# Markdown-first Marp slide variants. The Markdown files are the authoring
# entry points; their sibling CSS themes stay self-contained for the Marp CLI.
MARP_TEMPLATES: dict[str, str] = {
"slides-marp": "marp/slides-marp.md",
"slides-marp-en": "marp/slides-marp-en.md",
}
# Diagram HTMLs live in assets/diagrams and have no page-count contract.
# Registered here (not in build.py) so all template registries share one home.
# The Mermaid-sourced ones are produced via scripts/mermaid_normalize.py.
@@ -275,6 +282,11 @@ def pptx_targets() -> dict[str, str]:
return dict(PPTX_TEMPLATES)
def marp_targets() -> dict[str, str]:
"""Return target -> source mapping for Markdown-first Marp decks."""
return dict(MARP_TEMPLATES)
def diagram_targets() -> dict[str, str]:
"""Return target -> source mapping for assets/diagrams HTML templates."""
return dict(DIAGRAM_TEMPLATES)
+638 -7
View File
@@ -70,6 +70,7 @@ from optional_deps import MissingDepError, require_pymupdf # noqa: E402
from shared import ( # noqa: E402
DIAGRAM_TEMPLATES,
HTML_TEMPLATES,
MARP_TEMPLATES,
PARCHMENT_RGB,
ROOT as REPO_ROOT,
SCREEN_TEMPLATES,
@@ -77,6 +78,7 @@ from shared import ( # noqa: E402
build_targets,
diagram_targets,
load_checks_thresholds,
marp_targets,
pptx_targets,
screen_targets,
)
@@ -366,6 +368,14 @@ def test_registry_consistency() -> None:
f"got {len(PPTX_TARGETS)}")
check("PPTX_TARGETS in build.py matches shared.pptx_targets()",
dict(PPTX_TARGETS) == pptx_targets())
check("Marp registry maps authoring entries with matching CSS",
marp_targets() == MARP_TEMPLATES
and all(
(TEMPLATES / source).exists()
and (TEMPLATES / source).with_suffix(".css").exists()
for source in MARP_TEMPLATES.values()
),
str(MARP_TEMPLATES))
check("PARCHMENT_RGB is canonical", PARCHMENT_RGB == (0xF5, 0xF4, 0xED))
@@ -1246,14 +1256,23 @@ def test_check_markdown_residue_skips_html_code_blocks() -> None:
"<html><body><p>Visible text</p><pre><code>**example** `cmd`</code></pre></body></html>",
suffix=".html",
)
ambiguous_css = write_temp_html(
'<html><head><link rel="stylesheet" href="theme.css"></head>'
'<body><p>Visible **raw despite CSS uncertainty**</p></body></html>',
suffix=".html",
)
try:
rc = silently(check_markdown_residue, [str(dirty)])
check("check_markdown_residue fails visible raw markdown", rc == 1, f"rc={rc}")
rc = silently(check_markdown_residue, [str(clean_code)])
check("check_markdown_residue skips code/pre blocks", rc == 0, f"rc={rc}")
rc = silently(check_markdown_residue, [str(ambiguous_css)])
check("markdown residue includes text under ambiguous CSS",
rc == 1, f"rc={rc}")
finally:
dirty.unlink(missing_ok=True)
clean_code.unlink(missing_ok=True)
ambiguous_css.unlink(missing_ok=True)
# --------------------------- cross-template consistency ---------------------------
@@ -1940,11 +1959,87 @@ def test_check_content_cli_validates_and_covers() -> None:
rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage passes when atomic values present", rc == 0)
ambiguous_html = write_temp_html(
html.read_text(encoding="utf-8").replace(
"<p>", '<p style="font-size:calc(1px - 1px)">',
)
)
try:
ambiguous_rc = silently(
check_content,
[str(content_path), str(ambiguous_html)],
)
check("check_content degrades indeterminate visibility evidence",
ambiguous_rc == 2)
finally:
ambiguous_html.unlink()
missing_text_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", '<img srcset="a.png 1x,b.png 2x"></body>')
)
try:
missing_text_rc = silently(
check_content,
[str(content_path), str(missing_text_html)],
)
check("resource ambiguity cannot downgrade definite missing text",
missing_text_rc == 1)
finally:
missing_text_html.unlink()
for style in (
"clip-path:circle(50%)",
"mask-image:url(#rounded)",
"filter:url(#soften)",
):
decorated_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace(
"</body>",
f'<img style="{style}" src="decorative.svg"></body>',
)
)
try:
decorated_rc = silently(
check_content,
[str(content_path), str(decorated_html)],
)
check("decorative resource ambiguity cannot downgrade missing text",
decorated_rc == 1, style)
finally:
decorated_html.unlink()
for decoration in (
'<svg><path filter="url(#shadow)"></path></svg>',
'<svg><circle mask="url(#fade)"></circle></svg>',
'<svg><g clip-path="url(#round)"></g></svg>',
'<style>.decor { clip-path:circle(50%) }</style>'
'<svg><path class="decor"></path></svg>',
):
decorated_svg_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", f"{decoration}</body>")
)
try:
decorated_svg_rc = silently(
check_content,
[str(content_path), str(decorated_svg_html)],
)
check("decorative SVG ambiguity cannot downgrade missing text",
decorated_svg_rc == 1, decoration)
finally:
decorated_svg_html.unlink()
payload["brief"] = {
"audience": "Technical collaborator",
"job": "Secure review",
"template": "letter-en",
"formats": ["html", "pdf"],
"page_target": 1,
"required_assets": ["must-appear-logo.svg"],
"acceptance_checks": ["required logo is embedded"],
}
@@ -1952,6 +2047,27 @@ def test_check_content_cli_validates_and_covers() -> None:
missing_asset_rc = silently(check_content, [str(content_path), str(html)])
check("check_content coverage rejects a missing required brief asset",
missing_asset_rc == 1)
mixed_html = write_temp_html(
html.read_text(encoding="utf-8")
.replace("2026-07-13", "2026-07-14")
.replace("</body>", '<img srcset="a.png 1x,b.png 2x"></body>')
)
try:
mixed_report = io.StringIO()
with contextlib.redirect_stdout(mixed_report):
mixed_rc = check_content(
[str(content_path), str(mixed_html)]
)
report = mixed_report.getvalue()
check("mixed coverage reports definite and indeterminate findings separately",
mixed_rc == 1
and "definitely missing" in report
and "NOTE:" in report
and report.index("content.date") < report.index("NOTE:")
and report.index("brief.required_assets") > report.index("NOTE:"),
report)
finally:
mixed_html.unlink()
html.write_text(
html.read_text(encoding="utf-8").replace(
"</body>", '<img src="must-appear-logo.svg" alt=""></body>'
@@ -2003,7 +2119,12 @@ def test_content_ir_rejects_invalid_envelope() -> None:
def test_content_ir_validates_optional_artifact_brief() -> None:
from content import _brief_contract_issues, validate_content_file
from content import (
BRIEF_SCHEMA,
_brief_contract_issues,
validate_content_file,
validate_node,
)
body = {
"sender": "Ada Lovelace, London",
@@ -2095,12 +2216,15 @@ def test_content_ir_validates_optional_artifact_brief() -> None:
"ko",
)
korean_pptx_fallback_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf", "pptx"]},
{
"template": "slides-en", "formats": ["html", "pdf", "pptx"],
"page_target": 12,
},
"slides",
"ko",
)
korean_pdf_wrong_variant_issues = _brief_contract_issues(
{"template": "slides-en", "formats": ["html", "pdf"]},
{"template": "slides-en", "formats": ["html", "pdf"], "page_target": 12},
"slides",
"ko",
)
@@ -2121,6 +2245,39 @@ def test_content_ir_validates_optional_artifact_brief() -> None:
for issue in korean_pdf_wrong_variant_issues),
str(korean_pdf_wrong_variant_issues))
marp_brief = dict(
brief,
template="slides-marp-en",
formats=["md", "pdf"],
page_target=12,
)
marp_schema_issues = validate_node(marp_brief, BRIEF_SCHEMA, "brief")
marp_contract_issues = _brief_contract_issues(marp_brief, "slides", "en")
korean_marp_issues = _brief_contract_issues(
dict(marp_brief, template="slides-marp"),
"slides",
"ko",
)
check("brief contract accepts the shipped Marp authoring path",
marp_schema_issues == []
and marp_contract_issues == []
and korean_marp_issues == [],
f"schema={marp_schema_issues} contract={marp_contract_issues} "
f"korean={korean_marp_issues}")
no_length_contract = dict(brief)
no_length_contract.pop("page_target")
target_issues = _brief_contract_issues(no_length_contract, "letter", "en")
length_only_issues = _brief_contract_issues(
dict(no_length_contract, length_target="600 to 800 words"),
"letter",
"en",
)
check("artifact brief requires a page or length target",
any("page_target or length_target" in issue for issue in target_issues)
and length_only_issues == [],
f"missing={target_issues} length_only={length_only_issues}")
_, legacy_issues = validate_content_file({
"type": "letter", "lang": "en", "content": body,
})
@@ -2199,7 +2356,7 @@ def test_coverage_survives_split_markup_values() -> None:
def test_coverage_rejects_substrings_and_hidden_text() -> None:
"""Changed facts and hidden-only copies must not satisfy coverage."""
from content import coverage_issues
from checks import visible_html_text
from checks import visible_html_evidence, visible_html_text
cases = [
({"metric": "62%"}, "Revenue reached 162%"),
@@ -2223,13 +2380,233 @@ def test_coverage_rejects_substrings_and_hidden_text() -> None:
check("coverage ignores head, template, hidden, and display-none text",
len(missing) == 1, f"text={text!r} issues={missing}")
malformed_hidden = (
'<div hidden><img></img><p>SECRET-FACT</p></div>'
'<p>VISIBLE-FACT</p>'
)
malformed_text = visible_html_text(malformed_hidden)
malformed_missing, _, _ = coverage_issues(
{"hidden": "SECRET-FACT", "visible": "VISIBLE-FACT"},
malformed_text,
)
check("visible-text parser keeps hidden scope across a void closing tag",
len(malformed_missing) == 1
and "SECRET-FACT" in malformed_missing[0]
and "VISIBLE-FACT" in malformed_text,
f"text={malformed_text!r} issues={malformed_missing}")
self_closing_hidden = (
'<div hidden/><p>SELF-CLOSING-SECRET</p></div>'
'<p>SELF-CLOSING-VISIBLE</p>'
)
self_closing_text = visible_html_text(self_closing_hidden)
self_closing_missing, _, _ = coverage_issues(
{
"hidden": "SELF-CLOSING-SECRET",
"visible": "SELF-CLOSING-VISIBLE",
},
self_closing_text,
)
check("visible-text parser follows HTML semantics for non-void self-closing tags",
len(self_closing_missing) == 1
and "SELF-CLOSING-SECRET" in self_closing_missing[0]
and "SELF-CLOSING-VISIBLE" in self_closing_text,
f"text={self_closing_text!r} issues={self_closing_missing}")
benign_selector_text = visible_html_text(
'<style>[hidden] { display: none } '
'[aria-hidden="true"] { visibility: hidden } '
':root { --fallback-display: none }</style>'
'<p hidden>HIDDEN-BY-ATTRIBUTE</p>'
'<p aria-hidden="false">VISIBLE-ARIA-FALSE</p>'
'<p>VISIBLE-AFTER-SELECTOR</p>'
)
check("visibility parser scopes attribute selectors and custom properties",
"HIDDEN-BY-ATTRIBUTE" not in benign_selector_text
and "VISIBLE-ARIA-FALSE" in benign_selector_text
and "VISIBLE-AFTER-SELECTOR" in benign_selector_text,
repr(benign_selector_text))
important_hidden_text = visible_html_text(
'<style>.important-secret {'
'display: none !important; display: block'
'}</style>'
'<p class="important-secret">IMPORTANT-SECRET</p>',
fail_closed=True,
)
inline_important_text = visible_html_text(
'<p style="display:none!important;display:block">INLINE-IMPORTANT-SECRET</p>',
fail_closed=True,
)
visible_var_fallback = visible_html_text(
'<p style="display:var(--missing, block)">VISIBLE-VAR-FALLBACK</p>',
fail_closed=True,
)
check("visibility parser honors important cascade and visible var fallback",
"IMPORTANT-SECRET" not in important_hidden_text
and "INLINE-IMPORTANT-SECRET" not in inline_important_text
and "VISIBLE-VAR-FALLBACK" in visible_var_fallback,
f"style={important_hidden_text!r} inline={inline_important_text!r} "
f"fallback={visible_var_fallback!r}")
hidden_equivalents = [
'font-size:0%',
'font-size:calc(0px)',
'font-size:min(0px, 1px)',
'transform:scale(0, 0)',
'transform:matrix(0,0,0,0,0,0)',
'width:0%;height:0%;overflow:hidden',
'max-width:0;max-height:0;overflow:hidden',
'position:absolute;left:-99999px',
'text-indent:-99999px;overflow:hidden;white-space:nowrap',
'clip:rect(0, 0, 0, 0)',
]
equivalent_results = [
visible_html_text(
f'<p style="{style}">EQUIVALENT-HIDDEN</p>',
fail_closed=True,
)
for style in hidden_equivalents
]
check("visibility parser recognizes equivalent hidden CSS values",
all("EQUIVALENT-HIDDEN" not in text for text in equivalent_results),
str(equivalent_results))
ordinary_layout_text = visible_html_text(
'<p style="position:absolute;top:20px;left:20px">ABSOLUTE-VISIBLE</p>'
'<p style="transform:translateX(10px)">TRANSLATED-VISIBLE</p>'
'<p style="scale:.95">SCALED-VISIBLE</p>'
'<p style="backdrop-filter:blur(8px)">FILTERED-VISIBLE</p>'
'<p style="margin-top:-1px">MARGIN-VISIBLE</p>'
'<p style="text-indent:1em">INDENTED-VISIBLE</p>',
fail_closed=True,
)
check("ordinary positioned and transformed layouts remain visible",
all(marker in ordinary_layout_text for marker in (
"ABSOLUTE-VISIBLE", "TRANSLATED-VISIBLE", "SCALED-VISIBLE",
"FILTERED-VISIBLE", "MARGIN-VISIBLE", "INDENTED-VISIBLE",
)),
repr(ordinary_layout_text))
ambiguous_text, ambiguous_state = visible_html_evidence(
'<p style="font-size:calc(1px - 1px)">AMBIGUOUS-VISIBILITY</p>',
fail_closed=True,
)
check("unresolved functional visibility is excluded and marked ambiguous",
ambiguous_state and "AMBIGUOUS-VISIBILITY" not in ambiguous_text,
f"ambiguous={ambiguous_state} text={ambiguous_text!r}")
non_rendered_html = visible_html_text(
'<svg><title>SVG-TITLE</title><desc>SVG-DESC</desc></svg>'
'<svg><defs><text>SVG-DEFS</text></defs>'
'<symbol><text>SVG-SYMBOL</text></symbol>'
'<metadata>SVG-METADATA</metadata>'
'<clipPath><text>SVG-CLIP</text></clipPath>'
'<mask><text>SVG-MASK</text></mask>'
'<pattern><text>SVG-PATTERN</text></pattern>'
'<text>SVG-RENDERED</text></svg>'
'<noembed>NOEMBED-TEXT</noembed>'
'<noframes>NOFRAMES-TEXT</noframes>'
'<datalist><option>DATALIST-OPTION</option></datalist>'
'<ruby>base<rp>RUBY-FALLBACK</rp><rt>annotation</rt></ruby>'
'<p>RENDERED-TEXT</p>',
fail_closed=True,
)
check("visible text skips non-rendered metadata and fallback containers",
all(marker not in non_rendered_html for marker in (
"SVG-TITLE", "SVG-DESC", "SVG-DEFS", "SVG-SYMBOL",
"SVG-METADATA", "SVG-CLIP", "SVG-MASK", "SVG-PATTERN",
"NOEMBED-TEXT", "NOFRAMES-TEXT", "DATALIST-OPTION",
"RUBY-FALLBACK",
))
and "SVG-RENDERED" not in non_rendered_html
and "RENDERED-TEXT" in non_rendered_html,
repr(non_rendered_html))
hidden_svg_text = visible_html_text(
'<svg><text display="none">DISPLAY-SECRET</text>'
'<text visibility="hidden">VISIBILITY-SECRET</text>'
'<text opacity="0">OPACITY-SECRET</text>'
'<text>SVG-VISIBLE</text></svg>',
fail_closed=True,
)
check("visible text respects SVG presentation attributes",
all(marker not in hidden_svg_text for marker in (
"DISPLAY-SECRET", "VISIBILITY-SECRET", "OPACITY-SECRET",
))
and "SVG-VISIBLE" not in hidden_svg_text,
repr(hidden_svg_text))
off_viewport_svg_text = visible_html_text(
'<svg viewBox="0 0 100 100">'
'<text x="-99999" y="20">OFF-LEFT</text>'
'<text x="10000" y="20">OFF-RIGHT</text>'
'<text x="101" y="20">JUST-OFF-RIGHT</text>'
'<text x="-99" y="20">JUST-OFF-LEFT</text>'
'<text x="101%" y="20">PERCENT-OFF-RIGHT</text>'
'<text x="99" y="20" dx="10">DELTA-OFF-RIGHT</text>'
'<svg x="101" viewBox="0 0 10 10"><text x="0" y="5">NESTED-OFF</text></svg>'
'<text x="20" y="20">SVG-IN-VIEW</text></svg>',
fail_closed=True,
)
check("visible text rejects SVG coordinates outside the viewport",
"OFF-LEFT" not in off_viewport_svg_text
and "OFF-RIGHT" not in off_viewport_svg_text
and "JUST-OFF-RIGHT" not in off_viewport_svg_text
and "JUST-OFF-LEFT" not in off_viewport_svg_text
and "PERCENT-OFF-RIGHT" not in off_viewport_svg_text
and "DELTA-OFF-RIGHT" not in off_viewport_svg_text
and "NESTED-OFF" not in off_viewport_svg_text
and "SVG-IN-VIEW" not in off_viewport_svg_text,
repr(off_viewport_svg_text))
malformed_table = (
'<div hidden><table></div>MALFORMED-SECRET</table></div>'
'<p>MALFORMED-VISIBLE</p>'
)
malformed_coverage_text = visible_html_text(malformed_table, fail_closed=True)
malformed_residue_text = visible_html_text(malformed_table)
check("crossed HTML closes split coverage and residue conservatively",
"MALFORMED-SECRET" not in malformed_coverage_text
and "MALFORMED-SECRET" in malformed_residue_text,
f"coverage={malformed_coverage_text!r} residue={malformed_residue_text!r}")
optional_end_html = visible_html_text(
'<p>PARAGRAPH-FIRST<div>PARAGRAPH-SECOND</div><p>PARAGRAPH-THIRD'
'<ul><li>LIST-FIRST<li>LIST-SECOND</ul>'
'<table><tr><td>CELL-FIRST<td>CELL-SECOND</tr></table>',
fail_closed=True,
)
check("standard optional HTML end tags preserve visible content",
all(marker in optional_end_html for marker in (
"PARAGRAPH-FIRST", "PARAGRAPH-SECOND", "PARAGRAPH-THIRD",
"LIST-FIRST", "LIST-SECOND", "CELL-FIRST", "CELL-SECOND",
)),
repr(optional_end_html))
self_closing_svg_text, self_closing_svg_ambiguous = visible_html_evidence(
'<svg viewBox="0 0 100 100"><path d="M0 0L10 10" /></svg>'
'<p>VISIBLE-AFTER-SVG</p>',
fail_closed=True,
)
check("SVG foreign-content self-closing tags close without tainting HTML",
self_closing_svg_text.strip() == "VISIBLE-AFTER-SVG"
and not self_closing_svg_ambiguous,
f"text={self_closing_svg_text!r} "
f"ambiguous={self_closing_svg_ambiguous}")
def test_coverage_checks_asset_attributes() -> None:
from content import coverage_issues, html_resource_attributes
from checks import visible_html_text
from content import (
coverage_issues,
html_resource_attributes,
html_resource_evidence,
)
raw = (
'<img src="./images/product-shot.png" alt="Product">'
'<source srcset="images/product-shot@2x.webp 2x, images/product-shot.webp 1x">'
'<img src="images/product-shot@2x.webp" alt="Product at high density">'
'<template><img src="hidden-shot.png"></template>'
'<a href="linked-only.png">not embedded</a>'
)
@@ -2240,6 +2617,66 @@ def test_coverage_checks_asset_attributes() -> None:
missing, _, _ = coverage_issues({"image": "missing-shot.png"}, "", attrs)
check("coverage accepts image paths present in src and srcset",
present == [] and checked == 2, f"issues={present} attrs={attrs}")
hidden_svg_attrs = html_resource_attributes(
'<svg><defs><image href="hidden-def.png"></image></defs>'
'<symbol><image href="hidden-symbol.png"></image></symbol>'
'<image x="0" y="0" width="10" height="10" '
'href="visible-svg.png"></image></svg>'
)
check("asset coverage skips SVG definition resources",
hidden_svg_attrs == {"visible-svg.png"}, repr(hidden_svg_attrs))
hidden_asset_cases = [
'<svg><image display="none" href="required.svg"></image></svg>',
'<svg><image visibility="hidden" href="required.svg"></image></svg>',
'<svg><image opacity="0" href="required.svg"></image></svg>',
'<img style="width:0;height:0" src="required.svg">',
'<img width="0" height="0" src="required.svg">',
'<svg width="0" height="0"><image href="required.svg"></image></svg>',
'<img style="position:absolute;left:-99999px" src="required.svg">',
'<img style="transform:matrix(0,0,0,0,0,0)" src="required.svg">',
'<img style="width:calc(0px);height:calc(0px)" src="required.svg">',
'<svg viewBox="0 0 100 100"><image x="-99999" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="10000" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="101" y="0" width="10" height="10" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><image x="101%" y="0" width="10" height="10" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><svg x="101"><image x="0" y="0" width="10" height="10" href="required.svg"></image></svg></svg>',
'<svg viewBox="0 0 100 100"><defs><clipPath id="empty"></clipPath></defs>'
'<image x="0" y="0" width="20" height="20" '
'clip-path="url(#empty)" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><defs><mask id="empty"></mask></defs>'
'<image x="0" y="0" width="20" height="20" '
'mask="url(#empty)" href="required.svg"></image></svg>',
'<svg viewBox="0 0 100 100"><defs><filter id="empty"></filter></defs>'
'<image x="0" y="0" width="20" height="20" '
'filter="url(#empty)" href="required.svg"></image></svg>',
]
check("asset coverage rejects non-rendered CSS and presentation forms",
all(not html_resource_attributes(case) for case in hidden_asset_cases),
str([html_resource_attributes(case) for case in hidden_asset_cases]))
deterministic_attrs, responsive_ambiguous = html_resource_evidence(
'<img src="required.svg">'
'<img srcset="a.png 1x,b.png 2x">'
)
check("responsive ambiguity preserves unrelated deterministic resources",
deterministic_attrs == {"required.svg"} and responsive_ambiguous,
f"attrs={deterministic_attrs} ambiguous={responsive_ambiguous}")
hidden_responsive_attrs, hidden_responsive_ambiguous = html_resource_evidence(
'<div hidden><img srcset="a.png 1x,b.png 2x"></div>'
)
check("hidden responsive resources do not degrade asset evidence",
hidden_responsive_attrs == set() and not hidden_responsive_ambiguous,
f"attrs={hidden_responsive_attrs} "
f"ambiguous={hidden_responsive_ambiguous}")
svg_then_asset, svg_then_asset_ambiguous = html_resource_evidence(
'<svg viewBox="0 0 100 100"><path d="M0 0L10 10" /></svg>'
'<img src="required.svg">'
)
check("self-closing SVG graphics preserve following resource evidence",
svg_then_asset == {"required.svg"} and not svg_then_asset_ambiguous,
f"attrs={svg_then_asset} ambiguous={svg_then_asset_ambiguous}")
check("coverage rejects omitted image assets",
len(missing) == 1 and "missing-shot.png" in missing[0], str(missing))
hidden, _, _ = coverage_issues(
@@ -2266,6 +2703,147 @@ def test_coverage_checks_asset_attributes() -> None:
check("required absolute assets tolerate cache-query changes on the same origin and path",
same_origin == [], str(same_origin))
local_absolute_remote_copy, _, _ = coverage_issues(
["/approved/brand/logo.svg"],
"",
html_resource_attributes(
'<img src="https://attacker.example/approved/brand/logo.svg">'
),
root_path="brief.required_assets",
force_assets=True,
)
local_absolute_exact, _, _ = coverage_issues(
["/approved/brand/logo.svg"],
"",
html_resource_attributes('<img src="/approved/brand/logo.svg">'),
root_path="brief.required_assets",
force_assets=True,
)
check("required local absolute assets reject remote path impersonation",
len(local_absolute_remote_copy) == 1 and local_absolute_exact == [],
f"remote={local_absolute_remote_copy} exact={local_absolute_exact}")
malformed_hidden_attrs = html_resource_attributes(
'<div hidden><img></img><img src="hidden-after-void.svg"></div>'
'<img hidden src="hidden-void.svg">'
'<img src="visible.svg">'
)
check("resource parser keeps hidden scope across a void closing tag",
malformed_hidden_attrs == {"visible.svg"},
str(sorted(malformed_hidden_attrs)))
self_closing_hidden_attrs = html_resource_attributes(
'<div hidden/><img src="hidden-self-closing.svg"></div>'
'<img src="visible-after-self-closing.svg">'
)
check("resource parser follows HTML semantics for non-void self-closing tags",
self_closing_hidden_attrs == {"visible-after-self-closing.svg"},
str(sorted(self_closing_hidden_attrs)))
css_hidden_attrs = html_resource_attributes(
'<style>.concealed img { display: none } '
'.escaped { d\\69splay: n\\6f ne } '
'[hidden] { display: none } '
':root { --fallback-display: none }</style>'
'<div class="concealed"><img src="hidden-by-selector.svg"></div>'
'<img style="display/**/: none" src="hidden-by-inline-comment.svg">'
'<img class="escaped" src="hidden-by-css-escape.svg">'
'<img hidden src="hidden-by-attribute.svg">'
'<img src="visible-after-css.svg">'
)
check("resource parser fails closed on compound and comment-split hidden CSS",
css_hidden_attrs == {"visible-after-css.svg"},
str(sorted(css_hidden_attrs)))
ambiguous_css_cases = [
(
'<style>div:not(.show) img { display: none }</style>'
'<div><img src="hidden-by-not.svg"></div>',
"hidden-by-not.svg",
),
(
'<style>[data-state^="hid"] { display: none }</style>'
'<p data-state="hidden">HIDDEN-BY-ATTR-OPERATOR</p>',
"HIDDEN-BY-ATTR-OPERATOR",
),
(
'<style>.marker + p { display: none }</style>'
'<span class="marker"></span><p>HIDDEN-BY-SIBLING</p>',
"HIDDEN-BY-SIBLING",
),
(
'<style>.hidden-by-var { --hide: none; display: var(--hide) }</style>'
'<img class="hidden-by-var" src="hidden-by-var.svg">',
"hidden-by-var.svg",
),
(
'<style>.h\\69 dden { visibility: collapse }</style>'
'<img class="hidden" src="hidden-by-selector-escape.svg">',
"hidden-by-selector-escape.svg",
),
(
'<link rel="style&#115;heet" '
'href="data:text/css,.x%7Bdisplay%3Anone%7D">'
'<img class="x" src="hidden-by-encoded-stylesheet.svg">',
"hidden-by-encoded-stylesheet.svg",
),
(
'<style>@\\69mport url("data:text/css,.x%7Bdisplay%3Anone%7D")</style>'
'<img class="x" src="hidden-by-escaped-import.svg">',
"hidden-by-escaped-import.svg",
),
]
ambiguous_results = [
(
marker,
visible_html_text(raw, fail_closed=True),
html_resource_attributes(raw),
)
for raw, marker in ambiguous_css_cases
]
check("unsupported hiding CSS fails closed instead of partially matching",
all(
marker not in text and marker not in attrs
for marker, text, attrs in ambiguous_results
),
str(ambiguous_results))
responsive_attrs = html_resource_attributes(
'<picture>'
'<source media="(min-width:99999px)" srcset="never-selected.svg">'
'<img src="actual.svg" srcset="candidate-1.svg 1x, candidate-2.svg 2x">'
'</picture>'
)
check("resource parser excludes unresolved responsive candidates",
responsive_attrs == set(),
str(sorted(responsive_attrs)))
picture_fallback_attrs = html_resource_attributes(
'<picture>'
'<source media="(min-width:1px)" srcset="actual.svg">'
'<img src="required-fallback.svg">'
'</picture>'
)
bare_source_attrs = html_resource_attributes(
'<source srcset="bare-unrendered.svg">'
)
check("picture fallbacks and bare sources cannot prove required assets",
picture_fallback_attrs == set() and bare_source_attrs == set(),
f"picture={sorted(picture_fallback_attrs)} bare={sorted(bare_source_attrs)}")
remote_base, _, _ = coverage_issues(
["approved/logo.svg"],
"",
html_resource_attributes(
'<base href="https://attacker.example/">'
'<img src="approved/logo.svg">'
),
root_path="brief.required_assets",
force_assets=True,
)
check("required local assets reject a remote base URL",
len(remote_base) == 1,
str(remote_base))
def test_coverage_caps_adversarial_reports() -> None:
from content import MAX_COVERAGE_ISSUES, MAX_COVERAGE_VALUES, coverage_issues
@@ -2319,6 +2897,8 @@ def test_mcp_server_stdio_protocol() -> None:
payload = json.loads(body)
check("mcp kami_templates returns registries and schema types",
set(payload.get("document_templates", {})) == set(HTML_TEMPLATES)
and set(payload.get("pptx_templates", {})) == {"slides", "slides-en"}
and set(payload.get("marp_templates", {})) == set(MARP_TEMPLATES)
and payload.get("content_schema_types"),
body[:200])
doctor_body = replies.get(4, {}).get("result", {}).get("content", [{}])[0].get("text", "{}")
@@ -2342,11 +2922,41 @@ def test_mcp_check_returns_stable_findings_and_coverage() -> None:
broken = Path(d) / "broken.html"
clean.write_text("<html><body><p>Ready</p></body></html>", encoding="utf-8")
broken.write_text("<html><body><p>{{ missing }}</p></body></html>", encoding="utf-8")
invalid_content = Path(d) / "invalid-content.json"
invalid_content.write_text(
json.dumps({"type": "letter", "lang": "en", "content": {}}),
encoding="utf-8",
)
valid_content = Path(d) / "valid-content.json"
valid_content.write_text(json.dumps({
"type": "letter",
"lang": "en",
"content": {
"sender": "Ada Lovelace, London",
"date": "2026-07-13",
"recipient": "Charles Babbage",
"salutation": "Dear Charles,",
"paragraphs": [
"I write to state my purpose in one sentence: the engine deserves a program of its own.",
"The evidence sits in the notes: fifty operations, one loop, and a table the machine can follow.",
"My ask is specific: review the table this month so we can test it on the mill.",
],
"signoff": "Sincerely,",
"signature": "Ada",
},
}), encoding="utf-8")
clean_result = tool_check({"path": str(clean)})
broken_result = tool_check({"path": str(broken)})
invalid_content_result = tool_check({
"path": str(clean), "content": str(invalid_content),
})
missing_coverage_result = tool_check({
"path": str(clean), "content": str(valid_content),
})
check("MCP check registry carries unique stable rule IDs",
len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
CHECK_REGISTRY and clean_result["ruleset_version"] == 2
and len(CHECK_REGISTRY) == len(set(CHECK_REGISTRY))
and all({"scope", "severity", "required_engine", "explanation"} <= set(rule)
for rule in CHECK_REGISTRY.values()),
str(CHECK_REGISTRY))
@@ -2364,6 +2974,27 @@ def test_mcp_check_returns_stable_findings_and_coverage() -> None:
and broken_result["findings"][0]["status"] == "failed"
and "placeholder" in broken_result["report"].lower(),
json.dumps(broken_result)[:500])
invalid_ids = [item["id"] for item in invalid_content_result["findings"]]
invalid_coverage = {
item["id"]: item for item in invalid_content_result["coverage"]
}
check("MCP labels invalid content IR as contract failure before coverage",
"content.contract" in invalid_ids
and "content.coverage" not in invalid_ids
and invalid_coverage["content.coverage"]["status"] == "not_run"
and invalid_coverage["content.coverage"]["blocked_by"]
== "content.contract",
json.dumps(invalid_content_result)[:800])
missing_ids = [item["id"] for item in missing_coverage_result["findings"]]
missing_statuses = {
item["id"]: item["status"]
for item in missing_coverage_result["coverage"]
}
check("MCP runs coverage once after a valid content contract",
missing_ids == ["content.coverage"]
and missing_statuses["content.contract"] == "passed"
and missing_statuses["content.coverage"] == "failed",
json.dumps(missing_coverage_result)[:800])
def test_mcp_server_rejects_bad_frames_without_exiting() -> None: