
Brand Docx
- 24 installs
- 238 repo stars
- Updated July 10, 2026
- ferdinandobons/brand-docs
Extract a company's brand from a Word template into a reusable profile, then generate new on-brand .docx documents from that profile.
About
Extracts a brand profile from a .docx template and generates new on-brand Word documents from saved profiles plus variable content, via a deterministic Python engine. A developer uses it to produce branded reports that match a company template without hand-editing styles.
- Seven-verb contract: extract, comprehend, verify, generate, learn, propose-overrides, refine
- Preserves the template shell byte-for-byte and resolves content through profile.json
Brand Docx by the numbers
- 24 all-time installs (skills.sh)
- Ranked #426 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/ferdinandobons/brand-docs --skill brand-docxAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 238 |
| Last updated | July 10, 2026 |
| Repository | ferdinandobons/brand-docs ↗ |
What it does
Extract a company's brand from a Word template into a reusable profile, then generate new on-brand .docx documents from that profile.
Files
brand-docx
Use this skill when the user wants a reusable Word brand kit or wants to create a new on-brand .docx from a company template and variable content.
This is an AI-agent skill for Codex and Claude Code. The user should not need to write JSON or run shell commands. The agent converts the user's content into an IntermediateDocument, invokes the internal engine, verifies the output, and returns the generated .docx.
The seven verbs: three deterministic + four model-assisted
Every brand skill (brand-docx, brand-pptx, brand-xlsx) implements the same contract. The deterministic core is extract / verify / generate; on top of it sit the optional learning verbs comprehend / learn / propose-overrides / refine, each fail-closed (the engine validates every proposal and authors every value).
| Verb | Input | Output |
|---|---|---|
| extract | a company .docx template | a reusable Brand Profile |
| comprehend (optional, model-driven) | a saved profile + a model-authored comprehension.json | the profile with a validated, cached comprehension block |
| verify | a saved Brand Profile | QA findings + a verdict |
| generate | content (an IntermediateDocument) + a profile | a new on-brand .docx |
| learn (deterministic distillation) | the profile's cross-run generation history | recurring QA findings distilled into shell-frozen overrides, advisory until --accept |
| propose-overrides (model-driven) | the recurring remainder learn could not bind + a model-authored proposal | shell-backed corrections through the same fail-closed sink, advisory until --accept |
| refine | end-of-generation user feedback (text or a screenshot) as a refinement.json delta | the existing comprehension overlaid for FUTURE generations, advisory until --accept |
comprehend is optional: generate works on the deterministic profile alone. When a current comprehension is present, generate additionally reconciles the template's preserved cover/index structures with the new content. See reference/comprehension.md for the full step.
Hard Rules
- Treat
python scripts/cli.py ...as an internal engine command, not the user-facing workflow. scripts/cli.pyis a LAUNCHER that locates the engine root by itself: it works from this skill folder AND from the repo/plugin root (setBRAND_DOCS_ROOTto override). Never guess deeper paths likescripts/brandkit/....- Run the dependency preflight before starting extract / comprehend / verify / generate, and report missing or unusable dependencies before proceeding.
- Extract opens the source template read-only and saves
brand-kit/<name>/template/shell.docxbyte-for-byte. - Generate opens the saved shell and resolves every semantic block through
profile.json. - Do not put style names, colors, fonts, or brand identifiers in an IntermediateDocument.
- If the user did not provide a template or enough content, ask for the missing input.
- Return the generated file path plus a QA summary.
- Consult
profile.json.artifact_catalogbefore generation when the user asks to mimic a specific piece of the template.
Preflight (always first)
Before doing any work, run:
python scripts/cli.py doctorUse its output to decide the run mode:
- If a required Python dependency is missing, install/repair it before extraction
or generation; the core engine is not ready.
- If only visual renderers are missing or unusable (
sofficepluspdftoppmor
optional PyMuPDF/fitz), the core L0 workflow can still run, but a full visual audit cannot be claimed. Tell the user what is missing, include the install/repair hint printed by doctor, and either proceed with degraded QA or install the renderer first.
- If optional OCR (
tesseract) is missing, the visual audit can still run, but
rendered residual-text proof is incomplete. Report that limitation when judging stale placeholders or field caches.
- For
--qa deepor--qa strict, prefer repairing/installing renderers before
generation. If the environment cannot run them, deep generates a degraded manifest and strict fails with a visual proof blocker.
Agent Workflow
1. Run the dependency preflight above and report any degraded capability. 2. Determine the brand name and locate the user-provided .docx template. 3. If no matching brand-kit/<name> exists, extract one. 4. Comprehend the template (optional, model-driven; see below). Skip when a current comprehension is already cached or no model is available. 5. Convert the user's requested content into IntermediateDocument JSON. 6. Generate the .docx with the internal engine. 7. Run QA and report any warnings honestly. 8. Feedback (only after returning the file): invite a refinement of the understanding for future documents (see below).
Before generation, inspect profile.json.artifact_catalog when the user asks to mimic a specific template piece. It records OOXML parts, media parts, paragraph/table styles, style details, sections/margins, paragraph samples, and table counts.
Authoring the IntermediateDocument
The IDoc is where "correct document" becomes "great document". Author it role-first, against the profile, never style-first:
1. Read `brand-kit/<name>/PROFILE.md` before writing a block. It lists the role table (with scope, placement, required slots), the brand palette tokens, and the template's structural order. Choose every block by MEANING from that table; the engine resolves it to the template's own artifacts. 2. Respect the skeleton. Cover fields first (cover.title, cover.subtitle, extra cover fields), a toc block only where the template keeps one, then the freeform body. 3. Shape the body for the reader. One heading level 1 per major section and a real 1-2-3 hierarchy below it (the TOC regenerates from exactly these headings); paragraphs of 2-5 sentences; list blocks for enumerations (bullet for unordered, number for sequences); every table and image with a caption block so derived indexes regenerate from real content; callout sparingly, for genuinely load-bearing notes; quote only for actual quotations. 4. Color discipline. The named style already carries the brand: the default is NO run color. When emphasis is truly needed, reference a palette role (primary, text, ...) or a theme slot (accent1) from PROFILE.md, never a hex. 5. Reuse before re-deriving. When a comprehension is present, prefer its component / section fragments (comprehension.fragments in profile.json) with {{slot}} values over hand-building a recurring layout. 6. Never name a style, font, size, or hex. If a block needs something the role table cannot express, say so in the QA summary instead of inventing formatting: the resolver is the only author of values.
Feedback (end of generation)
Ask for feedback only after you have returned the generated .docx and its QA summary - never before or during generation. Invite the user to reply with text or a screenshot of the document, and name the roles, palette colors, and sections you actually used so the answer is concrete. A screenshot is your own multimodal read; the engine only ever ingests the structured JSON delta you distil from it.
Turn the answer into a small refinement delta of verbatim ids and merge it with the refine verb (see reference/comprehension.md):
python scripts/cli.py refine --name <brand> --input refinement.json --acceptA refinement improves FUTURE generations of this brand only - it mutates the saved profile, never the .docx you just produced. To apply it, generate again.
When the SAME QA finding recurs across runs, you can also propose a shell-bound correction with propose-overrides: the comprehend-input bundle surfaces the recurring generation_history, and you NAME a shell-backed re-point (a stub role to an existing healthy role, a number_format mask the shell uses, or a captured demo value) that the engine binds fail-closed (see reference/comprehension.md). It is advisory until --accept, improves FUTURE generations only, and every live correction surfaces as an INFO override_applied finding in QA.
Internal Extract
python scripts/cli.py extract --name <brand> --template <template.docx> --scope projectInternal Comprehend (optional, model-driven)
Read reference/comprehension.md for the full guidance, the six questions, and the anti-overfitting directive. In short:
python scripts/cli.py comprehend-input --name <brand> # prints {facts, excerpt} for the model
python scripts/cli.py comprehend --name <brand> --input comprehension.json # the ONLY writerSkip this verb when comprehension.status is present and its source_shell_sha256 equals the live provenance.shell.sha256 (a current comprehension is already cached). A re-extract resets it to absent; re-run comprehend only then. Never re-run it at generate time.
docx readiness. The Word extractor surfaces cover anchors, TOC/list
fields when present, and text regions. A current comprehension can therefore
steer cover fill, index regeneration, and demo-region clearing. If a document
genuinely has no TOC/list field, do not force one; a ref into an empty field
inventory is fail-closed and will be rejected.
Internal Verify
python scripts/cli.py verify --name <brand> --scope auto --qa auto--qa selects the QA depth (see reference/visual-audit.md):
fast: deterministic L0 only (schema, resolver targets, residual text, structural diffs).auto: L0 + L1 visual pixel proxies when renderers (sofficepluspdftoppmor optional PyMuPDF/fitz) are present; otherwise L0 plus a single INFOvisual.unavailable.deep: L0 + L1 + a `visual_manifest.json` and per-page PNGs; iftesseractis installed the manifest also includes OCR text/hits. The orchestrator must then run the L2 step (see below).strict: deep visual audit plus gate errors when full render proof is unavailable or L1/OCR evidence is not clean.
Verify has no output to render, so all modes behave as L0 at verify time; the visual stages run at generate time.
Internal Generate
python scripts/cli.py generate --name <brand> --input <intermediate-document.json> --output <output.docx> --scope auto --qa autoSee reference/comprehension.md, reference/profile-schema.md, reference/generation.md, reference/visual-audit.md, and examples/intermediate-document.example.json.
Visual audit (two-stage)
The engine renders the output and runs deterministic pixel proxies, but the qualitative visual judgement is yours (the orchestrator), never the engine's - the Python engine never calls a model. To run the full two-stage audit:
1. Generate with --qa deep. The engine renders each page to a PNG, runs the L1 proxies, and writes visual_manifest.json next to the output in an <output-file>.visual/ dir, such as report.docx.visual/ (a side artifact; the .docx bytes never change). 2. Read the manifest path from stdout (visual manifest: <path>). 3. Open the PNGs listed in pages[*].png. For every entry in checklist, judge PASS/FAIL against the rendered pages, taking l1_findings and ocr.hits into account. 4. If any checklist item FAILS (or an L1 visual.blank_page / visual.edge_bleed WARNING or visual.ocr_residual_text hit is confirmed as a real defect): repair the IntermediateDocument/content or the generated composition, regenerate, then re-run the audit. Loop until the checklist is clean, or until no further targeted repair can be justified without user input.
L1 findings are WARNING-only and never fail the gate by themselves; the real qualitative gate is your L2 judgement.
During repair, treat the template as a source of reusable structure, not a rule to preserve blindly. If inherited section breaks, front-matter scaffolding, field-result caches, or other template structures create blank pages, stale entries, overlaps, or visibly broken pagination, diagnose the structure as the cause and make the smallest targeted composition change. It is acceptable to collapse, move, or remove a template section break when preserving it damages the final generated document. After every repair, regenerate and rerun --qa deep or --qa strict.
Current Guarantees and Limits
Generation opens the saved .docx shell, clears detected demo text, and applies only styles resolved from profile.json. L0 QA catches schema problems, unresolved roles, markdown literals, and residual demo text.
When a current comprehension is present, generation also fills the cover slots in place (no duplicate title) and reconciles preserved indexes (a table of contents, a list of tables/figures) against the new content (regenerating, preserving, or purging stale entries) instead of carrying demo entries forward. Destructive reconciliation is bounded: a clear/remove is honored only when determinism corroborates and confidence clears a threshold, else the structure is kept with a warning.
Extraction also records a broad artifact_catalog: OOXML parts, media parts, paragraph/table styles, style details, sections/margins, paragraph samples, and table counts. Use it to understand and describe template conventions beyond the roles that are directly generatable today.
The two-stage visual audit closes the "L0-only" gap: L1 deterministic pixel proxies catch rendered-layout defects L0 cannot see (blank/broken pages, content bleeding past the printable margins), and the L2 manifest drives the orchestrator's qualitative judgement and repair loop. See reference/visual-audit.md.
DOCX visual overflow requires render-time QA with LibreOffice because Word layout is not deterministic from OOXML alone. When soffice and both PDF rasterizers (pdftoppm, optional PyMuPDF/fitz) are absent (e.g. CI), the visual audit degrades cleanly to L0 plus a single INFO visual.unavailable; exit codes are unchanged and the skill does not claim a full no-overflow visual proof.
{
"cover": {
"title": "Quarterly Business Review",
"subtitle": "Q2 2026 - Revenue, delivery and risk",
"fields": {
"doc_id": "RPT-2026-014",
"date": "2026-06-08",
"author": "Strategy Office",
"confidentiality": "Internal"
}
},
"blocks": [
{ "type": "toc", "title": "Contents", "max_level": 3 },
{ "type": "heading", "level": 1, "runs": [{ "t": "Executive summary" }] },
{
"type": "paragraph",
"runs": [
{ "t": "Net revenue grew " },
{ "t": "8% quarter over quarter", "b": true },
{ "t": ", led by " },
{ "t": "enterprise", "i": true },
{ "t": " expansion. Full detail in the " },
{ "t": "revenue model", "link": "https://example.com/model" },
{ "t": "." }
]
},
{
"type": "kpi",
"layout": "row",
"items": [
{ "label": "Net revenue", "value": "$4.2M", "delta": "+8%" },
{ "label": "Gross margin", "value": "71%", "delta": "+1.4pt" },
{ "label": "Logo churn", "value": "2.1%", "delta": "-0.3pt" }
]
},
{
"type": "callout",
"intent": "success",
"title": "On track",
"runs": [{ "t": "All three north-star metrics beat plan this quarter." }]
},
{ "type": "divider" },
{ "type": "heading", "level": 1, "runs": [{ "t": "Highlights" }] },
{
"type": "list",
"ordered": false,
"items": [
{
"runs": [{ "t": "Pipeline", "b": true }, { "t": " coverage reached 3.2x." }],
"items": [
{ "runs": [{ "t": "Enterprise segment up " }, { "t": "22%", "b": true }, { "t": "." }] },
{ "text": "Mid-market steady." }
]
},
{ "runs": [{ "t": "Delivery SLA held at " }, { "t": "99.9%", "b": true }, { "t": "." }] }
]
},
{
"type": "list",
"ordered": true,
"items": [
{ "text": "Close the EMEA expansion plan." },
{ "text": "Ship the billing migration." },
{ "text": "Hire two senior engineers." }
]
},
{ "type": "heading", "level": 2, "runs": [{ "t": "Revenue by quarter" }] },
{
"type": "table",
"role": "default",
"columns": ["Quarter", "Net revenue", "Growth"],
"rows": [
["Q1", "$3.9M", "+5%"],
["Q2", "$4.2M", { "runs": [{ "t": "+8%", "b": true }] }]
],
"caption": "Net revenue and quarter-over-quarter growth."
},
{ "type": "caption", "target": "figure", "runs": [{ "t": "Figure 1. Quarterly net revenue trend." }] },
{
"type": "image",
"asset": "revenue_chart",
"alt": "Quarterly net revenue trend",
"caption": [{ "t": "Source: internal finance model." }]
},
{
"type": "callout",
"intent": "warning",
"title": "Watch",
"runs": [{ "t": "Two enterprise renewals slip into Q3 if the security review is not closed." }]
},
{
"type": "quote",
"runs": [{ "t": "Brand consistency is what lets a small team look enterprise-grade." }],
"attribution": "Head of Brand"
},
{ "type": "pagebreak" },
{ "type": "heading", "level": 1, "runs": [{ "t": "Appendix" }] },
{
"type": "paragraph",
"runs": [
{ "t": "Definitions and methodology. Inline " },
{ "t": "code", "code": true },
{ "t": " and " },
{ "t": "emphasis", "i": true },
{ "t": " render with the brand body style; only the inline toggles are author intent." }
]
}
]
}
Block Catalog
The IntermediateDocument (IID) is an ordered flow of typed blocks that carry intent, never presentation. No block ever names a style, hex, font, or layout; the resolver maps each block to a concrete brand artifact in profile.json. The authoritative model lives in scripts/brandkit/ir/model.py (the BLOCK_TYPES registry is the closed set of type discriminators).
Inline text is a rich-run array (runs: [{t, b?, i?, u?, strike?, code?, sup?, sub?, link?, color?}]); a bare text: "..." is accepted as sugar and normalized to runs on parse. color is a brand palette token (a theme slot like accent1), never a literal hex.
DOCX generation now renders every block type natively - heading, paragraph, callout, list, table, quote, caption, divider, image, kpi, chart, smartart, toc, pagebreak - and component/section expand to their primitive sub-blocks before rendering. PPTX and XLSX share the engine and render their applicable types, degrading loudly (a visible block_degraded finding, never a silent drop) only where a format does not yet realize one.
Block types
The 16 block type values (the keys of BLOCK_TYPES):
- `heading` - a section heading. Carries
level(int) andruns. Resolves to
heading.{level}.
- `paragraph` - a body paragraph. Carries
runsand an optionalvariant.
Resolves to paragraph.{variant or default}.
- `list` - an ordered or unordered list. Carries
ordered(bool) and nested
items (each a ListItem with runs, level, and optional sub-items). Resolves to list.{bullet|number}.{level}.
- `table` - a table. Carries
columns(header-row rich cells),rows(lists
of TableCell with optional header/colspan/rowspan), an optional caption, and a role (default "default"). Resolves to table.{role} plus a header style.
- `callout` - a highlighted box. Carries a semantic
intent
(info | warning | danger | success | note), runs, and an optional title. The intent is semantic; the brand picks the color. Resolves to callout.{intent}.
- `kpi` - a KPI / metric card group. Carries
items(each aKpiItemwith
label, value, optional delta) and an optional layout. Resolves to component:kpi.{layout}.
- `chart` - a chart. Carries
chart_type(default"bar"),series
([{name, values}]), categories, and an optional title. Authored as a NATIVE chart on both docx and pptx (a real DrawingML c:chart: an inline w:drawing on docx, a graphicFrame on pptx) that inherits the document/deck theme's accent colors, so it is on-brand by construction. bar/column/barh/line/area/pie/doughnut map to the matching chart type; an unknown type falls back to a clustered column chart (surfaced as INFO, never silent), an empty/all-non-numeric chart degrades loudly, and a multi-series pie/doughnut surfaces a truncation WARNING (only its first series renders). The docx chart uses inline cached data (no embedded workbook), so generation stays byte-idempotent. On xlsx charts are native too but authored differently: the grid model is range-based, so a chart is a GridDocument.charts entry ({sheet?, type, title?, anchor, data, categories?, data_titles?}) that REFERENCES the workbook's own cell ranges via openpyxl - the data lives in the sheet, which is the spreadsheet's strength.
- `smartart` - a diagram. Carries
diagram(default"process") andnodes
([{text, children}]). Authored as a NATIVE, on-brand (theme-colored) diagram: on pptx as autoshapes (a chevron row for a process/flow, a stacked rounded-box list otherwise); on docx as a brand-styled table (a process is a single row, one cell per step; a list a single column, one row per node). A node's children are kept (sub-lines in the pptx shape, inlined in the docx cell), so nothing is lost; an empty diagram degrades loudly. The diagram is rendered, not editable SmartArt (the layout-engine parts are not authored).
- `component` - a reference to a reusable single fragment. Carries
ref(names
a components/<ref> profile entry) and slots (fills its render contract). Expanded into primitive sub-blocks before resolution.
- `section` - a reference to a multi-block reusable unit. Carries
ref(names a
sections/<ref> profile entry) and slots. Like component, expanded to primitives before resolution.
- `caption` - a figure/table caption line. Carries
runsand an optional
target ("figure" | "table"). Resolves to caption.
- `toc` - a table-of-contents marker. Carries an optional
titleand
max_level (default 3). On DOCX it renders natively: if the shell already carries an outline TOC it defers to it (refreshed in place, no duplicate); otherwise it authors a native, updateable outline TOC field at the block's position (its visible cache filled from the document's headings).
- `image` - an image reference. Carries
asset(a profile asset id) orsrc
(an external path) - exactly one - plus optional alt, caption, and width_emu/height_emu sizing hints. Resolves to an image placement.
- `quote` - a block quotation. Carries
runsand an optionalattribution.
Resolves to quote (falls back to body).
- `divider` - a horizontal rule / separator. No payload. Resolves to a brand
divider.
- `pagebreak` - an explicit page (docx) or slide (pptx) break. No payload.
Every block also accepts the base fields id (optional author-supplied identifier, stable across regenerations) and meta (free-form annotations the resolver ignores).
Cover (not a flow block)
The document's cover is not in BLOCK_TYPES; it sits in IntermediateDocument.cover as semantic slots only: title, subtitle, and a free-form fields map (e.g. {"doc_id": "RPT-2026-014", "date": "2026-06-04"}). The resolver maps these to whatever cover anchors the shell has. An absent cover (None) means "leave the shell cover as-is".
<!-- SPDX-License-Identifier: MIT --> <!-- SHARED BODY: skills/{brand-docx,brand-pptx,brand-xlsx}/reference/comprehension.md are BYTE-IDENTICAL. The comprehension guidance is format-neutral by design (a cover slot is a cover slot whether the file is a Word document, a PowerPoint deck, or an Excel workbook), so the same prompt drives all three formats and cannot drift. A CI guard (tests/test_reference_sync.py) asserts byte-identity. Edit all three together, or run the guard's --write helper. -->
Comprehension (optional, model-driven)
comprehend is the third of the four verbs (extract / comprehend / verify / generate). It is optional and model-driven: it is the one step where the model (you) reads a bounded bundle of deterministic facts about the template and writes down what each structure is for, so that generate can reconcile the template's preserved cover/index structures with the new content instead of leaving a cover unfilled, appending a duplicate title, or carrying stale index entries from removed demo content.
Everything load-bearing the model writes is a verbatim id copied from the facts bundle; the deterministic validator rejects anything else. The model proposes, the engine disposes. generate still works with no comprehension at all (the deterministic path), so skip this verb whenever a current comprehension already exists or no model is available.
When to run it
Run comprehend once per template shell, right after extract, before generate. Skip it when a current comprehension is already cached, that is, when the profile's comprehension.status is present and its source_shell_sha256 equals the live provenance.shell.sha256. A re-extract produces a new shell and resets comprehension.status to absent, so re-run comprehend only then. Never re-run it at generate time: the understanding is frozen into profile.json at merge and reused byte-for-byte.
The two CLI steps
comprehend is realized as two internal engine steps. Treat them as engine plumbing, not a user-facing workflow.
# 1. Print the bounded {facts, excerpt} bundle for the model to read.
python scripts/cli.py comprehend-input --name <brand>
# 2. Merge + validate + cache the model-authored comprehension.json (THE ONLY WRITER).
python scripts/cli.py comprehend --name <brand> --input comprehension.jsoncomprehend-input prints {facts, excerpt}: facts is the surfaced, format-uniform inventory the model reasons over and binds to (inventories: cover_anchors, fields, regions, roles, each entry an {"id": ...}), plus structure/anchors/styles slices; excerpt is an ordered, length-capped sample of text the extractor already collected. Read this bundle. Never parse the raw template or its OOXML.
comprehend is the single writer of the comprehension block. It re-runs the full schema validation and a fail-closed membership check, and only on a clean pass freezes the block into profile.json with status='present', stamping source_shell_sha256 from the live shell hash. On any finding it writes status='rejected' with the findings and exits non-zero; read the findings, fix the offending refs, and retry. A clean merge is idempotent: running comprehend twice yields a byte-identical profile.json.
The six questions (the SAME for every format)
Reasoning over the structure in the bundle, answer exactly these six questions. They are format-neutral: a cover slot is a cover slot, a derived index is a derived index, demo content is demo content, a brand color is a brand color, whether the file is a Word document, a PowerPoint deck, or an Excel workbook.
1. What is each element FOR? For each surfaced role and cover anchor, what is its purpose in this template? Annotate roles in role_annotations and name each cover slot's semantic_role/purpose. 2. What is demo vs real? Which surfaced regions hold placeholder/sample content the author is meant to replace, and which hold real, structural content to keep? Record each in demo_classification with a verdict. 3. Which artifacts feed which index? For each surfaced derived index (a table of contents, a list of tables/figures, an agenda/section list, anything the document regenerates from its own content), which role's items feed it, and should it be regenerated, preserved, or cleared? Record it in conventions.indexes. For a caption index (a list of tables or figures, identified by a non-null seq_id), also set caption_target (table or figure): it tells generate which captionable kind feeds the index, so it can emit the matching numbered SEQ field on each caption and regenerate the index from the new content. Without it a caption index can only be preserved or cleared (never repopulated), so its cache would keep the template's stale entries. 4. Which cover slot is which? For each surfaced cover anchor, which content slot fills it (binds_to), and should it be filled in place, cleared, or left alone (fill_rule)? Record it in cover_slots. 5. Which content shapes recur? Do the same few primitive blocks repeat as a unit across the template (a callout pattern, a titled card, a standard section opener)? fragment_candidates in the bundle hints at recurrences; trust your own reading of the excerpt too. For each genuine recurrence, propose a reusable fragment in fragments: a ref, a kind (component for a single inline fragment, section for a multi-block unit), a purpose, and a blocks template built ONLY from primitive block types. Put a {{slot}} token wherever the text varies per use; the author fills it via the referencing block's slots. Propose nothing when no shape genuinely recurs. 6. What is each brand color FOR / where does the template use it? The bundle's palette lists every brand color the extractor observed (a theme slot like accent1, or an off-theme hex:RRGGBB), each with its captured ref, provenance (where it was seen), and a coarse frequency. For each color, what role does it play in this template (a name, a purpose, a use_when, an optional semantic_role)? Record it in palette_annotations, keyed by the palette id. You name a color; you never author one - the deterministic capture owns the actual ref/hex, and a key into an empty/absent palette inventory is fail-closed. Annotate nothing when the template carried no color. An optional alias field on an annotation names a dotted-token alias for the captured color entry, minted at merge time with the captured ref byte-copied - so an off-theme hex:RRGGBB accent becomes addressable as a clean run-color token. The alias must be a syntactically-legal dotted token (the same lowercase family.qualifier grammar as a role id) that does not collide with an existing palette key or role id; you name it, the engine copies the captured ref.
The anti-overfitting directive (state and obey verbatim)
A title slot is a title slot whether its placeholder reads "Titolo", "Title",
or "Titre". Quote a literal only as evidence, never as a matching rule.
Reason over structure (builtin style ids, field codes, placeholder types, named
regions, geometry), not over the words a particular template happens to use.
Concretely, in the comprehension JSON:
- Every load-bearing reference - a
cover_slotskey (anchor_ref), an
index_ref, a region_ref, a feeds_from_role_id, a role_annotations key, a palette_annotations key (a palette id) - must be a verbatim id copied from the facts bundle. If an id is not in the surfaced inventory, do not invent it; the merge is fail-closed and will reject it (a ref into an empty inventory is itself an error).
- Eight fields are closed enums, and each maps to a real engine branch:
status (present|absent|rejected), fill_rule (in_place|clear|leave), reconcile (regenerate|preserve|clear), verdict (demo|real|mixed), a fragments entry's kind (component|section), a caption index's caption_target (table|figure), an audit row's verdict (PASS|FAIL|NA), and a triage entry's disposition (expected|defect). Use exactly those values.
- Every other field (
semantic_role, an index'skind,purpose,
generation_rules, evidence, region names) is an open advisory token. The generator never pattern-matches on it, so write it honestly for a human reader; never bend it to fit a fixed vocabulary.
Worked example (frozen role-ids + non-language placeholders only)
The example uses frozen role-ids (heading.1, the kind of id that appears in feeds_from_role_id and role_annotations) and non-language placeholder ids (<slot-1>, <index-A>, <region-1>, standing in for the surfaced cover-anchor / field / region ids) on purpose: it must carry zero plausible word from any human language, so it can never become a matching rule. Replace each <...> with a verbatim id from your own bundle.
{
"confidence": 0.86,
"cover_slots": {
"<slot-1>": { "semantic_role": "title", "purpose": "main cover title",
"binds_to": "title", "demo_value": "<captured placeholder text>",
"fill_rule": "in_place" }
},
"conventions": {
"indexes": [
{ "index_ref": "<index-A>", "kind": "outline", "seq_id": null,
"feeds_from_role_id": "heading.1", "reconcile": "regenerate" },
{ "index_ref": "<index-B>", "kind": "caption", "seq_id": "<seq>",
"feeds_from_role_id": "caption", "caption_target": "table",
"reconcile": "regenerate" }
],
"sections": [ { "region_ref": "<region-1>", "required": true, "repeatable": false } ]
},
"role_annotations": {
"heading.1": { "purpose": "top-level section heading",
"generation_rules": "one per major section" }
},
"demo_classification": {
"regions": [ { "region_ref": "<region-1>", "verdict": "real",
"evidence": "structural content region, not sample text" } ]
},
"fragments": [
{ "ref": "<fragment-1>", "kind": "component", "purpose": "recurring titled note",
"blocks": [ { "type": "callout", "intent": "note",
"runs": [ { "t": "{{body}}" } ] } ] }
],
"palette_annotations": {
"<color-1>": { "name": "primary brand color", "semantic_role": "accent",
"purpose": "headings and key emphasis",
"use_when": "drawing attention to a section title",
"alias": "accent.primary" }
}
}The <color-1> key is a verbatim palette id from the bundle's palette inventory (a theme slot like accent1, or hex:RRGGBB). The name / purpose / use_when / semantic_role are advisory free text you write for a human; you never author a color value - the deterministic capture owns the actual ref/hex, and the merge mirrors your names onto theme.palette without ever touching the captured color. The optional alias (here accent.primary) is a dotted-token alias for the captured color entry, minted at merge time with the captured ref byte-copied for reachability as a run-color token; it must be a syntactically-legal dotted token that collides with no existing palette key or role id, and again you only name it - the engine copies the captured ref.
Format readiness
Comprehension binds only to the ids a format's extractor actually surfaces. All three Office extractors expose cover anchors and regions when the template contains them. Derived-index fields are format-specific: DOCX can expose TOC/list fields, PPTX can expose an agenda/section-list field when present, and XLSX keeps fields intentionally empty because workbooks have no TOC-style field code. The model writes only the parts it has ids for; never force a cover, index, or region shape onto an empty inventory; a ref into an empty inventory is fail-closed and will be rejected.
Reusable fragments are format-neutral too: any format may carry a fragments proposal, and each lands in the profile's components / sections registry for generate to inline. A fragment's blocks must be primitive block types (never a style, color, or layout), so a proposed fragment resolves through the same brand chokepoint as inline content and cannot be off-brand. Propose fragments only when a shape genuinely recurs; an empty fragments list is the norm.
Persisting the visual-audit verdict (the audit map)
After a generate --qa deep|strict run, the engine writes a visual_manifest.json side artifact listing the rendered PNG pages, the exact-artifact shell_sha256 / content_sha256, and a profile-derived checklist (each item has an id). You (the model) open the pages and judge each checklist item. You MAY persist that judgement back into the comprehension as an audit map, keyed by the manifest's checklist[*].id, then merge it via comprehend:
"audit": {
"<checklist-id>": {
"verdict": "PASS", // PASS|FAIL|NA (closed enum)
"evidence": "cover title is bound, no residual demo prompt",
"shell_sha256": "<manifest.shell_sha256>",
"content_sha256": "<manifest.content_sha256>"
}
}The audit key MUST be a verbatim checklist[*].id from the manifest; a key that is not a current checklist id is fail-closed and rejected at merge (same rule as every other load-bearing ref). verdict is the closed PASS|FAIL|NA; evidence is advisory free text for a human; the two shas are copied verbatim from the manifest so the verdict is scoped to that exact artifact. You never write a brand value here, only a disposition against a structural checklist id the engine derived.
This makes the next generate cheaper: a same-shell / same-content generate short-circuits the L2 render+manifest round when every current checklist item PASSes at the matching (shell_sha256, content_sha256). Any FAIL, any NA, a newly-derived checklist id with no row, or a sha mismatch forces a full L2 round, so the short-circuit can never hide a regression. It is disabled under --qa strict (which always re-renders) and never fires at verify. An empty audit map is the norm until you have judged a render.
Triaging an ambiguous QA warning (the triage list)
Some QA checks emit a WARNING because the signal is genuinely ambiguous: a full-bleed cover that the edge-bleed proxy flags, a deliberately blank section page, or a native component family the new content legitimately has fewer of. When you (the model) have judged that such a WARNING is an EXPECTED property of this template - not a defect - you MAY persist that judgement as a triage list, merged via comprehend:
"triage": [
{
"check": "visual.edge_bleed", // expected|defect maps the disposition
"location": "page:1:bottom", // the finding's exact location (or null)
"disposition": "expected", // expected -> demote that WARNING to INFO
"evidence": "the cover is intentionally full-bleed in this brand"
}
]Only three checks are triage-eligible, and they are ALL WARNING-only: visual.blank_page, visual.edge_bleed, and component_survival. A triage entry naming any other check is fail-closed and rejected at merge, and two entries for the same (check, location) pair are rejected as ambiguous. disposition is the closed expected|defect; location is copied verbatim from the QA finding (it may be null); evidence is advisory free text for a human.
The effect is deliberately narrow and one-directional: an expected entry demotes exactly the matched WARNING to INFO (the message keeps a (triaged EXPECTED: ...) note); a defect entry, or any entry that does not match a finding, leaves the gate verbatim. A triage entry can NEVER turn an ERROR into a warning, nor raise a severity - the eligible checks are WARNING-only and the demotion only ever lowers a WARNING to INFO, so a real failure can never be silenced. An empty triage list is the norm.
Promoting a faked heading onto a real heading role (the promote_appearance list)
Some templates fake a heading: a line that LOOKS like a heading - visibly larger and/or in a brand color - is authored with the body paragraph style (no heading role, no named heading style), so the deterministic engine treats it as body and the brand heading look is lost. The extractor's pure-deterministic detector surfaces each such body-style run as a pseudo_heading fact in the bundle when its captured size or color is a clear outlier vs the dominant body appearance. Each fact carries a stable structural ref, the run's OWN captured outlier size_hp (half-points) and/or color, and coarse, brand-text-free evidence (e.g. "size 44hp vs dominant body 24hp"). The list is absent when the detector found no outlier.
When you judge that a candidate is a real heading the template faked, you MAY persist a promote_appearance list, merged via comprehend:
"promote_appearance": [
{
"pseudo_heading_ref": "body_run_3", // a verbatim ref from facts.pseudo_headings
"target_role_id": "heading.1" // a declared heading.* role to promote onto
}
]You name only the two ids; you never author a size or color. On a clean merge the engine COPIES the captured outlier size_hp/color from the detector fact onto roles[target_role_id].appearance, so the generated heading carries the size/color the template proved it uses. The pseudo_heading_ref MUST be a ref the detector surfaced (an empty/absent pseudo_headings inventory fails closed), target_role_id MUST be a declared heading.* role (a non-heading target is rejected), and a (ref, target) pair must be unique. The promoted size/color is re-validated shell-backed at QA exactly like any applied appearance: a value the shell does not carry is an ERROR, so the engine can never inject a size/color the template lacks. An empty promote_appearance list is the norm.
Refining the understanding from user feedback (the refine verb)
After a generation, the user may give qualitative feedback - in text or as a screenshot of the produced file. You turn that answer into a small, structured refinement delta of verbatim ids and merge it with the refine verb:
# Overlay the delta onto the EXISTING comprehension, then re-validate the whole block.
python scripts/cli.py refine --name <brand> --input refinement.json
# Add --accept to persist the refined comprehension (else the diff is previewed).
python scripts/cli.py refine --name <brand> --input refinement.json --acceptA refinement delta touches ONLY the existing qualitative-understanding sinks - role_annotations, palette_annotations, demo_classification, cover_slots, and conventions (indexes / sections). It is not a schema change and it never writes a brand value: a palette annotation carries only name / purpose / use_when / semantic_role (the ref / hex stay the deterministic capture's), and every ref is still a verbatim id from the surfaced inventories.
"palette_annotations": {
"<color-1>": { "name": "primary brand", "use_when": "section headings" }
},
"role_annotations": {
"heading.1": { "purpose": "section titles", "generation_rules": "keep terse" }
}The verb overlays the delta onto the present block (replacing or adding the named sink entries; list sinks merge by their ref, never naive concat), then routes the WHOLE combined block back through comprehend's single fail-closed writer: the full schema + membership validation re-runs and every ref re-binds to surface_inventories, so a delta naming an id that is not surfaced (or one in an empty inventory) is rejected and nothing is written. Without --accept the post-overlay diff is previewed and the prior block stays authoritative; --accept persists it and re-stamps source_shell_sha256.
A screenshot is YOUR multimodal read of the produced file; the engine only ever ingests the resulting structured JSON delta of verbatim ids - it never sees the image. The feedback ask happens only after the file and its QA summary are returned, never before or during generation, and a refinement improves FUTURE generations - it never re-emits or edits the file you just produced.
Proposing overrides corrections from recurring findings (the propose-overrides verb)
refine sharpens the qualitative understanding; propose-overrides is its peer for shell-bound corrections - the model-assisted sibling of the deterministic learn verb. When the same QA finding keeps recurring across runs, the engine's deterministic learn distils the ones it can bind to a brand-safe target on its own (a stub role with a healthy same-family sibling, a captured demo string). The ambiguous remainder - a stub role with no sibling, a finding whose right re-point needs judgement - is surfaced to you in the comprehend-input bundle under facts.generation_history: a bounded, message-free list of {check, location, severity, recurred_runs} (the universal (check, location) identity only, never the finding's message text). You reason over it and author a small overrides proposal, then merge it:
# Overlay the proposal onto any existing lesson, then re-validate the whole block.
python scripts/cli.py propose-overrides --name <brand> --input overrides.json
# Add --accept to make the correction LIVE (else it is written advisory-'absent').
python scripts/cli.py propose-overrides --name <brand> --input overrides.json --acceptA proposal may ONLY NAME a shell-backed pointer - never author a style, font, or color:
// reroute a role whose resolver is a dead stub to an EXISTING healthy role:
"reroute_roles": { "heading.9": "heading.1" },
// swap a number_format MASK the shell already uses (xlsx):
"number_format_swaps": { "<role-1>": "#,##0" },
// register a CAPTURED demo string for clearing:
"demo_clears": ["<captured-demo-value>"]The verb overlays the proposal onto any present lesson (additive: a deterministic learn lesson and your proposal coexist, your entry winning a key collision) and routes the WHOLE combined block through the single merge_overrides writer: the target role must be a declared, shell-backed role, the mask must be one the shell uses, and the demo value must have been captured - any unbound pointer rejects the WHOLE proposal (all-or-nothing), so an off-brand correction is impossible by construction. Without --accept the correction is written but kept OUT of the live resolver (status='absent', byte-identical generation); --accept makes it LIVE and re-stamps source_shell_sha256, so a re-extract (new shell) invalidates it.
Like the feedback ask, this happens only after generation and improves FUTURE generations only. Every LIVE correction is auditable: the gate emits an INFO override_applied finding for each one (in generate and verify alike), so a learned re-point is never silent.
DOCX Extraction
Extraction copies the source .docx as template/shell.docx, parses theme colors when available, records paragraph/table styles, discovers a simple cover title placeholder, detects demo instruction text, and infers the core DOCX roles.
Ordered document structure (schema 1.1.0+)
Extraction also detects the template's ordered top-level skeleton - the sequence of regions a generated document of this brand must respect - and records it in profile.structure:
"structure": {
"ordered": true, // top-level region order must be respected
"skeleton": [
{"region": "cover", "order": 0, "role": "section.cover",
"required": true, "repeatable": false, "evidence": "..."},
{"region": "toc", "order": 1, "role": "section.toc",
"required": true, "repeatable": false, "evidence": "..."},
{"region": "body", "order": 2, "role": "section.body",
"required": true, "repeatable": true, "freeform": true, "evidence": "..."}
]
}Only regions actually present in the template are included. The body region is freeform: element order inside it is not prescribed.
How regions are detected (brand-agnostic, multilingual)
Detection runs on the lxml body element (doc.element.body), never on python-docx paragraph indices, because the cover title and the TOC commonly live inside block-level w:sdt elements that python-docx does not expose.
- Cover region - body-level content before the first TOC region or first
Heading-1 paragraph. Cover anchors (SDTs / placeholders / logos) come from cover.discover_cover().
- TOC region - any of:
- a block-level
w:sdtwhosew:docPartGallery/@w:valisTable of Contents, - a paragraph using a TOC / TOCHeading style (style id/name contains
toc/sommario/indice/inhalt/contenido),
- a
w:instrTextwhose text starts withTOC, or - a heading whose text is a known contents word in EN/IT/FR/DE/ES
(Contents, Sommario, Indice, Inhalt, Table des matières, Índice, Contenido, …).
- Body region - everything after the TOC (or after the cover when there is no
TOC) up to the final body-level w:sectPr.
The detector is brand-agnostic by design: instead of hardcoding one template's TOCHeading/Sommario literals, it matches any *toc*-named style token and the multilingual contents words above, so it works on any company template in any language.
anchors.toc.present is a real detection result (no longer hardcoded false).
Per-artifact usage
Every role entry in roles (and every component, when present) carries a usage object recording where and how the artifact is used. See profile-schema.md for the vocabulary. In short: cover.* roles are structural/required at the front of the skeleton, toc is structural/required after the cover, and body roles (heading, paragraph, list, callout, table, quote, caption) are freeform and used on demand. The usage is derived from the role family (already inferred from style placement / OOXML), never from a brand-specific name.
PROFILE.md prints a ## Structure section (the ordered skeleton) and, next to each role, its usage (scope · placement · required), so a human or agent reading it sees exactly which parts to respect in order versus use on demand.
DOCX Generation
Generation parses an IntermediateDocument, opens the saved shell, clears template demo content, fills the cover title when provided, writes blocks in order, and applies only styles resolved from the Brand Profile.
Run color palette token
An inline run may carry an optional color field: a palette token, not a literal color. It is a verbatim key of theme.palette - a theme slot like accent1 (or a dotted role/region-style palette id) - the same id the bundle's palette inventory surfaces and comprehension.palette_annotations names. The resolver maps the token to the captured color ref and applies it as the run's color (a run with no color inherits the body/role default). The token is never a literal: a hex-shaped or #-bearing value is structurally dropped before it can reach the writer, so a literal color can never enter through a run. An unknown token leaves the run inherited and records a graceful color_token_unresolved INFO finding.
An off-theme hex:RRGGBB palette entry can be NAMED by the model (in palette_annotations) but is not yet addressable as a run `color` token: the :-bearing key is rejected by the same run-token validator that blocks bare hex, so such a token is dropped and the run stays inherited. Only theme-slot / dotted palette ids are applyable per run today; reference an off-theme color via a theme slot when the template carries one.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "IntermediateDocument",
"type": "object",
"required": ["blocks"],
"properties": {
"cover": { "type": "object" },
"meta": { "type": "object" },
"blocks": {
"type": "array",
"items": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "type": "string" }
}
}
}
}
}
Brand Profile Schema
The schema discriminator is kind. A docx profile writes kind: "docx" with a surface.docx block and a role registry named roles.
Concrete style ids and names live only in profile.json. IntermediateDocument content uses semantic role intent such as heading, paragraph, callout, and table.
schema_version is `1.2.0` (SCHEMA_VERSION). The minor bumps are additive, so every profile written by an older extractor stays valid - validate() never rejects a profile for a section it lacks:
- 1.1.0 introduced the optional
structuresection and the optional per-role
usage object. Profiles written by 1.0.0 extractors (lacking both) remain valid.
- 1.2.0 introduced the optional top-level
comprehensionblock - the single
canonical sink for the model's understanding of the template (cover slots, derived-index/section conventions, role annotations, demo-vs-real classification). Every extractor now stamps an empty comprehension block with status: "absent" by default, so the deterministic path is the ground truth until comprehend runs. 1.2.0 also relaxes region names (structure.skeleton[].region and the comprehension refs) from the frozen cover/toc/body trio to open tokens validated for syntax only - the generator branches on the boolean region attributes, never on the name.
The structure section
"structure": {
"ordered": true,
"skeleton": [
{"region": "cover", "order": 0, "role": "section.cover",
"required": true, "repeatable": false, "evidence": "..."},
{"region": "toc", "order": 1, "role": "section.toc",
"required": true, "repeatable": false, "evidence": "..."},
{"region": "body", "order": 2, "role": "section.body",
"required": true, "repeatable": true, "freeform": true, "evidence": "..."}
]
}ordered(bool) - whentrue, the top-level region order must be respected on
generation.
skeleton- an ordered list of the regions actually present in the
template. Each region carries:
region- an open token (since 1.2.0) validated for syntax only (dotted
lowercase, like a role id). docx conventionally uses the cover | toc | body trio shown here; pptx/xlsx may use their own honest names (agenda, appendix, sheet, …). The generator branches on the boolean attributes below, never on the name.
order(int) - the region's position in the skeleton.role- the section role id (section.cover|section.toc|
section.body).
required(bool) - must appear in every document of this brand.repeatable(bool) - the region may occur more than once (true forbody).freeform(bool, body only) - element order inside the region is not
prescribed.
evidence- the human-readable signal the region was detected from.
An empty/absent structure (or skeleton: []) means no ordered skeleton was detected; generation falls back to the legacy "append into body" behaviour.
Per-artifact usage
Every role entry in roles (and every component, when present) may carry a usage object:
"usage": {
"scope": "cover" | "toc" | "body" | "anywhere",
"placement": "structural" | "freeform",
"required": true | false,
"order": <int or null>
}scope- which region the artifact belongs to.placement-structural= part of the ordered skeleton (must appear in its
slot); freeform = used on demand inside the freeform body region.
required- must appear in every document of this brand.order- the skeleton position whenstructural, elsenull.
Derivation (from the role family, never a brand-specific name):
| role family | scope | placement | required | order |
|---|---|---|---|---|
cover.* | cover | structural | true | 0 |
toc | toc | structural | true | 1 |
heading / paragraph / list / callout / table / quote / caption … | body | freeform | false | null |
validate() accepts a profile with or without usage on its roles; when present, it checks the scope/placement enums and that order is an int or null.
QA
M1 implements deterministic L0 checks:
- profile schema validity
- every indexed role has a resolver
- no literal markdown leaks into generated text
- no detected demo instruction text remains in generated output
On top of L0, a two-stage visual audit runs when renderers are present and the QA mode asks for it:
- L1: deterministic pixel proxies on the rendered PNGs (
visual.blank_page,
visual.edge_bleed, visual.no_pages), each a WARNING that never fails the gate by itself.
- L2: a
visual_manifest.json(PNG paths + a profile-derived checklist) the
orchestrator reads to make the qualitative judgement and drive a repair loop. The engine never calls a model.
It is gated by --qa (fast = L0 only; auto = L0 + L1; deep = L0 + L1 + manifest; strict = deep plus visual gate errors) and degrades gracefully when soffice/pdftoppm are absent in auto/deep (L0 plus INFO/WARNING findings). strict fails when full render proof is unavailable. See visual-audit.md.
<!-- SPDX-License-Identifier: MIT -->
Visual audit (two-stage)
The visual audit sits on top of the L0 deterministic gate (schema, resolver targets, residual text, structural diffs). L0 stays unchanged and authoritative. The audit adds two stages that see what L0 cannot, the rendered layout:
- L1: deterministic pixel proxies (engine). The engine renders the output to
per-page PNGs and runs pixel checks that flag rendered-layout defects. During --qa deep, optional Tesseract OCR also scans the rendered pages for captured template/demo text. Each defect is a Finding(check="visual.<name>"), severity WARNING (never ERROR, so the audit never fails a gate that L0 passes).
- L2: qualitative judgement (orchestrator, i.e. you). The engine emits a
structured visual_manifest.json (PNG paths + a profile-derived checklist + the L1 findings). You open the PNGs, judge each checklist item PASS/FAIL, and drive a repair loop. The engine never calls a model.
The PNGs and the manifest are side artifacts written to an <output-file>.visual/ dir next to the output, such as report.docx.visual/. The bytes of the generated document never change because of the audit.
When it runs (--qa)
--qa | renderers present (soffice + pdftoppm, or optional PyMuPDF/fitz fallback) | renderers absent (e.g. CI) |
|---|---|---|
fast | L0 only | L0 only (identical) |
auto | L0 + L1 | L0 + one INFO visual.unavailable |
deep | L0 + L1 + manifest (INFO visual.manifest with the path) -> triggers your L2 step | L0 + INFO visual.unavailable + a degraded manifest with the checklist; on macOS a first-page Quick Look fallback may be included |
strict | L0 + L1 + OCR/manifest + ERROR visual.strict if render/L1/OCR evidence is unclean | L0 + degraded manifest + ERROR visual.strict_unavailable |
Notes:
- At verify time there is no output to render, so every mode behaves as L0.
- Renderer absence degrades cleanly: INFO/WARNING findings only, no ERROR, **exit
code unchanged**. auto may create no .visual dir; deep writes a degraded .visual/visual_manifest.json so the orchestrator can still inspect what was skipped and which checklist items remain unproven.
strictis the release-quality mode: it writes the manifest likedeep, but
fails when full render proof is unavailable or when L1/OCR findings need repair.
- The renderer is env-detected via
doctor.probe()["visual_qa"], which
smoke-tests the DOCX, PPTX, and XLSX render paths end to end; run python scripts/cli.py doctor before starting the workflow and report missing/unusable dependencies before claiming a full visual audit.
pdftoppmremains the baseline PDF rasterizer. If it is missing or fails after
LibreOffice produced a PDF, optional PyMuPDF (fitz) can rasterize the PDF as a degraded fallback; the manifest records this in environment.optional_python.
- Optional OCR uses the external
tesseractbinary. Missing OCR does not block
the visual audit; it only means rendered residual-text proof is incomplete. The manifest records OCR availability in environment.ocr and results in ocr.
L1 proxies
| check id | catches (that L0 cannot see) | severity |
|---|---|---|
visual.blank_page | a blank/near-blank page: broken page, content not rendered, or overflow that pushed everything off the page | WARNING |
visual.edge_bleed | content touching/exceeding the printable margins (text/image clipping or overflow); the defect OverflowCapability.RENDER exists to intercept | WARNING |
visual.no_pages | an existing output that rendered zero pages | WARNING |
visual.ocr_residual_text | optional OCR saw captured template/demo text still visible in the render | WARNING |
The proxies are deterministic (aggregate luma/ink fractions, no randomness, no single-pixel font-hinting sensitivity) and conservative (tuned to render data: content pages mean-luma ~240-252, a blank page ~255) to minimize false positives. They accept either a PIL image or a PNG path and never raise on an unreadable image (they return no findings instead).
A WARNING is a signal for your L2 judgement, not a standalone failure: a legitimately near-empty page (a minimal cover/separator) or a deliberate full-bleed background exists, and only you can tell those apart from a real defect.
The manifest (visual_manifest.json)
Top-level fields:
{
"schema_version": "visual-manifest-1",
"kind": "docx",
"profile_name": "<identity.name>",
"document": "out.docx",
"renderers_available": true,
"qa_mode": "deep",
"dpi": 100,
"pages": [
{"index": 1, "png": "page-1.png", "width": 850, "height": 1100, "orientation": "portrait"}
],
"l1_findings": [
{"check": "visual.blank_page", "severity": "WARNING", "message": "...", "location": "page:2"}
],
"ocr": {
"engine": "tesseract",
"available": true,
"status": "ok",
"terms_checked": ["<captured template/demo text>"],
"pages": [{"index": 1, "text": "...", "text_truncated": false}],
"hits": [],
"errors": []
},
"environment": {
"platform": "macOS-...",
"python": "3.x",
"visual_qa": true,
"degraded": false,
"renderers": {
"soffice": {"available": true, "path": "/path/to/soffice"},
"pdftoppm": {"available": true, "path": "/path/to/pdftoppm"}
},
"optional_python": {
"fitz": {"available": false, "purpose": "PyMuPDF PDF raster fallback"}
},
"ocr": {
"tesseract": {"available": true, "path": "/path/to/tesseract", "purpose": "optional OCR visible-text audit"}
},
"install_hints": []
},
"checklist": [
{"id": "regions_present", "what": "...", "derived_from": "structure.skeleton[*].region + order", "severity_hint": "WARNING"}
],
"instructions": "Open each PNG. For each checklist item, judge PASS/FAIL ..."
}pages may be populated even when renderers_available is false: this means a degraded fallback image (for example a macOS Quick Look thumbnail) is available for inspection, not that full page-by-page render proof succeeded. Treat those PNGs as advisory evidence only and do not claim a clean visual audit from them.
png paths are relative to the `.visual` dir. When renderers are absent the manifest carries "degraded": true; pages is populated only if an advisory fallback image exists, l1_findings still records visual failure/degradation signals, and the checklist stays populated so you know what would have been inspected with full render proof.
Checklist items (derived from the profile, model-free)
Each item is included only when its backing data exists in the profile, so the checklist is tailored to the template:
| item id | inspect | derived from |
|---|---|---|
regions_present | expected regions appear in order | structure.skeleton[*].region + order |
cover_correct | bound title, no duplicate, no demo prompt | anchors.cover + comprehension.cover_slots |
no_residual_placeholder | no template demo text visible | surface.<kind>.demo_region + cover demo values + optional ocr.hits |
palette_on_brand | on-screen colors are brand colors | theme.colors + theme.palette_roles |
roles_styled | semantic blocks carry brand styles, not "Normal" | roles._index |
no_overlap | no overlapping/clipped text or shapes | constant; reinforced by visual.edge_bleed |
no_blank_pages | no unexpected blank/broken pages | constant; reinforced by visual.blank_page |
charts_rendered | charts drawn correctly, not empty boxes | present only when a chart role/component exists |
overflow_clean | nothing past the printable margins | qa.overflow_capability ∈ {render, estimator, cellfit} |
L2 repair loop (your protocol)
1. Generate with --qa deep. 2. Read the manifest path from stdout (visual manifest: <path>). 3. Open every pages[*].png. For each checklist item, judge PASS/FAIL against the rendered pages, taking l1_findings and ocr.hits into account (an L1 WARNING is a pointer to a page/side worth looking at, not a verdict). 4. If any item FAILS (or an L1/OCR WARNING is a real defect on inspection): repair the IntermediateDocument/content, regenerate, and re-run the audit. 5. Repeat until the checklist is clean (max 3 iterations by default), then return the file with an honest QA summary. 6. For release-quality validation, rerun with --qa strict; any visual.strict ERROR identifies the exact visual finding that still needs targeted repair.
The engine produces the evidence; the qualitative judgement and the decision to regenerate are yours.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import os
import sys
from pathlib import Path
def _root(start: Path) -> Path:
env_root = os.environ.get("BRAND_DOCS_ROOT")
if env_root:
root = Path(env_root).expanduser().resolve()
if (root / "scripts" / "brandkit").is_dir():
return root
for parent in [start] + list(start.parents):
if (parent / ".claude-plugin").is_dir():
return parent
return start.parents[3]
ROOT = _root(Path(__file__).resolve())
sys.path.insert(0, str(ROOT / "scripts"))
from brandkit.cli import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())