evals/evals.json
{
"schema_version": 1,
"skill_name": "documents",
"evals": [
{
"id": "pdf-report-generation",
"prompt": "Generate a one-page PDF report from this markdown: '# Q3 Revenue Summary', '## Highlights', '- Revenue grew 12% quarter over quarter', '- Two new enterprise customers closed', '## Outlook', 'Flat growth expected in Q4 due to seasonality.' The report needs a title, a highlights section, and an outlook section, and it must open reliably in any PDF reader.",
"expected_output": "A PDF file (report.pdf) produced from the markdown content model through the shared workflow: scope, content model, template, render, validate, deliver. The artifact starts with a %PDF- header, contains page objects, and ends with the %%EOF trailer, so it opens in any reader. Text remains selectable (a real text stream, not a rasterized image). Before delivery the agent runs scripts/validate-documents.py --render-check --json report.pdf, confirms exit 0 with status ok (or render check unavailable when no renderer is installed), and records the validation result with the artifact.",
"assertions": [
"The output is a real PDF artifact with a %PDF- header, page objects, and %%EOF trailer",
"The content model (title, highlights, outlook) maps one-to-one onto the rendered document",
"The text is selectable rather than rasterized",
"The validation script is run and its result (ok, or unavailable render check) is reported with the artifact",
"Delivery records the content source and template used so the report can be regenerated"
]
},
{
"id": "word-memo-generation",
"prompt": "Turn these meeting notes into a Word (.docx) memo with a title, two sections with real heading styles, and a short paragraph in each: Title: 'Product Sync 2026-07-14'. Section 'Decisions' with 'The team adopted the quarterly roadmap and moved the mobile launch to September.' Section 'Action items' with 'Anika to draft the pricing page copy by Friday. Dev to wire the new payment provider by end of month.'",
"expected_output": "A .docx file (memo.docx) that is a valid OOXML package: ZIP container with [Content_Types].xml and word/document.xml, and the XML well-formed. Headings use heading styles (w:pStyle referencing a style), not manually bolded runs, so the navigation pane and TOC work. Body text lives in w:t runs. The agent runs scripts/validate-documents.py --json memo.docx, confirms exit 0 and status ok, and reports the structure check before delivering.",
"assertions": [
"The memo is a .docx OOXML package with [Content_Types].xml and word/document.xml present",
"Headings use heading styles so the document is navigable, not ad-hoc formatting",
"Body text is stored in w:t runs",
"The validation script exits 0 with status ok on the produced file",
"The delivery notes the styles used and confirms no placeholder text remains"
]
},
{
"id": "excel-sheet-generation",
"prompt": "Build an Excel (.xlsx) workbook from this CSV: Quarter,Revenue\\nQ1,100\\nQ2,150\\nQ3,120. Add a header row, a Total row that sums the Revenue column, and number formatting for the revenue cells. Save as revenue.xlsx.",
"expected_output": "An .xlsx workbook (revenue.xlsx) with xl/workbook.xml and at least one xl/worksheets/sheet1.xml part, well-formed XML inside a valid ZIP container. The header row holds Quarter and Revenue labels, the three data rows hold the CSV values as real cell values, and the Total row uses a formula (SUM) that also carries a cached value so viewers that do not recalculate still show 370. Revenue cells use a number format. The agent validates with scripts/validate-documents.py --json revenue.xlsx (exit 0, status ok) and spot-checks a cell value against the source CSV before delivery.",
"assertions": [
"The workbook is a valid ZIP container with xl/workbook.xml and worksheet parts",
"The header row, data rows, and Total row are real cells with the CSV values",
"The Total row uses a SUM formula with a cached result (370)",
"Revenue cells carry an explicit number format",
"The validation script exits 0 with status ok and a cell value is spot-checked against the source"
]
},
{
"id": "powerpoint-deck-generation",
"prompt": "Create a 3-slide PowerPoint (.pptx) deck from this outline: Slide 1 title 'Q3 Review', subtitle 'Finance team'. Slide 2 'Revenue' with bullets 'Up 12% QoQ', 'Two new logos'. Slide 3 'Next quarter' with bullets 'Flat growth expected', 'Focus on retention'. Add speaker notes to each content slide and save as q3-review.pptx.",
"expected_output": "A .pptx deck (q3-review.pptx) with ppt/presentation.xml and three ppt/slides/slideN.xml parts inside a valid ZIP container, all XML well-formed. Every slide ID in the presentation's sldIdLst resolves through presentation.xml.rels to a real slide part, so the deck opens without repair prompts. Slide titles and bullets are text in a:p/a:r/a:t elements within each slide's shape tree, and speaker notes exist for the content slides. The agent validates with scripts/validate-documents.py --json q3-review.pptx (exit 0, status ok) and confirms the slide count matches the outline.",
"assertions": [
"The deck is a valid ZIP container with ppt/presentation.xml and three slide parts",
"Every slide ID in sldIdLst resolves to a real slide part through the relationships file",
"Titles and bullets are present as slide text elements and match the outline",
"Speaker notes are included for the content slides",
"The validation script exits 0 with status ok and the slide count matches the outline"
]
},
{
"id": "multi-format-validation-gate",
"prompt": "A colleague generated four files for a client delivery: report.pdf, brief.docx, data.xlsx, and deck.pptx. Before sending them out, verify every file is structurally sound and will render, and explain what the validation result means including what happens when no renderer is installed on the machine.",
"expected_output": "A validation pass over all four formats using scripts/validate-documents.py --render-check --json with the four files: report.pdf, brief.docx, data.xlsx, deck.pptx. The agent interprets the JSON report: status ok means every file passed structural checks (PDF header/EOF/page objects; OOXML ZIP container, content types, required parts, XML well-formedness); per-file render results are ok when a renderer produced output, and unavailable when no renderer is installed for that format, which is reported honestly rather than treated as a defect. Any file that fails structure or rendering is flagged with its failed checks and the agent fixes or rebuilds it before delivery. The explanation notes exit codes 0 (pass or render unavailable), 1 (failure), 2 (usage error).",
"assertions": [
"All four files (pdf, docx, xlsx, pptx) are validated in one pass with the shared script",
"The report distinguishes structural status (ok/fail) from render status (ok/unavailable)",
"A missing renderer is reported as unavailable, not as a document failure",
"Failed checks are identified per file and the artifact is fixed or rebuilt before delivery",
"Exit codes are explained: 0 pass, 1 failure, 2 usage error"
]
},
{
"id": "docx-text-extraction",
"prompt": "A teammate has a .docx file with a draft policy and wants the full text pulled out as clean plain text, plus a check that the document structure (headings and paragraphs) is intact. Extract the text and report on the document's structural health.",
"expected_output": "An extraction pass over the .docx that reads word/document.xml, pulls w:t text in document order (headings and paragraphs preserved in reading order), and returns clean plain text. The same pass validates the document structurally with scripts/validate-documents.py: the ZIP container is a real archive, [Content_Types].xml is present, word/document.xml is well-formed XML, and heading styles are present so the outline is intact. The report states the text extraction result and the structural health verdict (ok, or the specific failed checks if the file is corrupt).",
"assertions": [
"Text is extracted from word/document.xml in document order",
"Headings and paragraphs are preserved in the extracted plain text",
"The structural validation runs and reports the ZIP, content-types, and document.xml checks",
"The report separates extraction success from structural health",
"Corrupt files are diagnosed with their specific failed checks rather than a generic error"
]
}
]
}
fixtures/sample.docx
PK �]�f��� � [Content_Types].xml}P�n�0����(q衪�$�����Ib�MC��;ʡ�=μU�]�{�dc��n�����a����z��
.��I��E�9&$��@��JI�J����1a`d��C�3�*���i�`(U�=d�>� ;W�ˁ��"I�t&�Y�����PW�`~�T����'M6ђ R�L���.�w^&[��ry�,��Q&�ge��͍�q�ƫ~vK9j$�ɽ���~������PK �]:I�� + _rels/.rels��;�0ཧ��Ӵ��]RWT%n�<��GoO�m��,7����!jg�e�pR[��2�6{ 1q+��,2X0B��g��N���$#62�R�J����X:�6OFO��z.�\!�VՎ�OڕIz� ��2,���8j�G'nm�q�+�e&$��v�Y�mCW/��PK �]z>?3� H word/document.xmlm��N�0��{��w�����n�76�94f���Q����
�[���~���b�������i$�������� Te��
�����æ�:G�%bb%�T����9wZ���ֆ2&�}P���,'=Qq�Ј�ʂ�C�>�h}�A����[�Kؕ%x���j���瀠�^��%���X¬^0Sᛂ]�V������Y�obI���Y%���,i���_�����PK �]�f��� � � [Content_Types].xmlPK �]:I�� + � _rels/.relsPK �]z>?3� H �� word/document.xmlPK � � fixtures/sample.pptx
PK �]&��8� 6 [Content_Types].xml�Q;O�0��+,�U�!����89���/�ܪ��\�"*�0Yw�S�z{tV0� ��������C#_w�ō��w`��F���]ջSD,���1�x��P"zF��dӠ"�7P]Uյ��g��ȓ�l�{�ao�x8�z)�Вwq�j$�h��̸:��[JqN(Y9sh4��L��b�p�=�e��P�@�O���b�*&$����w�UC��]�{ǒ_�ҁ��?ʐ�%-���̮
�����PK �]��C�� . _rels/.rels���
�0�OQrw�<�Ⱥ]D�U��6��M��ON<xL��/�n�vf�4y'�*J`�ד3��y{ FI:-g�P��m��/8˔wh���80�����J*|@�'��V�\FÃT7i���r����d�;]뗀��~&�'��]�q�+�e
&!$"Rn��E��75_}�l^PK �]F~�� I ppt/presentation.xml��Ok1��~�0wMVt�.��R����Y7�dR�~��*E襷��y?�Ͱ�x�Θ�� �Y `t46�$?���r1��+��Ő���0Uj�UJ�>I�KI=�g�V1a��)f�J��ğs��-��x@� q���ר?}e�!��f��z"9s0oT~5�F�z���M�i��������E�K��Q����n���=��c\|PK �]O�~� " ppt/_rels/presentation.xml.rels�ϱ
�0�ݧ�۴"Ҵ�]�>@H�m0MB.�}{�8Xpp�������9[��H�;UQC��6np���0J�ii�C�ͦ���)wh2�XF �R
G�IM8K*|@�7���L9Ƒ�nrD�+�=��4+�uZ@�t�_�c�a0
O^�gt�� N�h̠�#&���VEր75_}�l^PK �]Fi�* R ppt/slides/slide1.xml���j�0��}
�����1B���m�=�k��_�&m�~r���0�E���������h6a��ٚ�gh'�k��?��q��Rhg�����l*_F-�X��S�%@�hD�:��b�.��F�#�$52�EqF(�b
������ٳ�c�o�~�����rf��wp��i��|+?�D�$�D�Wt��F�{��+~��k0�4�(_�$梍��62� �E�(�_J��lR� �i�֝0�N�ĢV!�$ȝWSA��6������" |l
����#��PK �]&��8� 6 � [Content_Types].xmlPK �]��C�� . �+ _rels/.relsPK �]F~�� I � ppt/presentation.xmlPK �]O�~� " � ppt/_rels/presentation.xml.relsPK �]Fi�* R �� ppt/slides/slide1.xmlPK L A fixtures/sample.xlsx
PK �]�-�� / [Content_Types].xml���N�0��}
��*v�!��~���<��l+���-��㤅*pᴲgf��]m'k�#i�j�%g�o��k��{,n8������ķͪ��aG5R
�R�� �e���B���� j��UY^K�]B��4��Mu��MbS�>�h����qf�B0ZAʺ<���8DN.t�u6py�0+?ι��2Q��^ �'��%'#�}��K.��]��^�m�
��1Y#�),h�����I.c��E�����w7�PK �]�d6۲ ) _rels/.rels�Ͽ�0�hn���1��bLX
>@-ǟPzM[�ގb/�����2k�D�G2�4�FQ;�^�����|����
X�CU&������,"�B�'νp�>%�&n:r�qt=�RM�G�ϲw���խ W�9�f���M]7*<�z�h��D���1X4���DSQ�e�7��PK �]Lr�� xl/workbook.xml�OAn�0���މU%႐8`�
��w�]S��k��{�Y�fv�����|�h`�.+0H�@������&G��L��6}��X.'��~�����Z&�NK^��2�D��)g����:!�8�uU}���+���d�8��<\#Rz��.��:�E�o��
��[=x��<p��P0҄Ld�k�}k�6�^��PK �]�@q"� * xl/_rels/workbook.xml.rels���
�0������y�u�����(]��[[�����x<��O~!E��'q'ϣ5�4AF�v4��Ks��ApP�U�5$a!��L�3M*�F�""�%!�"�fũudb�Y?�[ߣS��z�m���P�LQ�|�� ���?���Q����L&�8��<��*�S��1�K�F�,p�a�� PK �]&%�� � xl/worksheets/sheet1.xmlu��N�0E��
�{:y��튪b���dh,�qdOS�{��"@�ξ�{��Q�ߋcr��,��HMh�|}y���"�����P�OLrg6��{�Yd %-;�� 5z��a@ʓ���|�GHCD�^J���(n�[GҨKv�l���,b~HN��p_J�Z:��3ǜ�d�Ǔ��QS�Oe�Vy��+������՚�\2N�єE�`\��3�^�VK��{��>�M��PK �]�-�� / � [Content_Types].xmlPK �]�d6۲ ) �2 _rels/.relsPK �]Lr�� �
xl/workbook.xmlPK �]�@q"� * �� xl/_rels/workbook.xml.relsPK �]&%�� � �� xl/worksheets/sheet1.xmlPK E � README.md
# Documents — PDF, Word, Excel & PowerPoint Skill
One skill that lets your agent generate, inspect, validate, and fix PDF, Word (.docx), Excel (.xlsx), and PowerPoint (.pptx) files — with a shared workflow, per-format references, and a validation script that verifies output quality before anything ships.
## Why Install This Skill
Document output is one of the most common things people ask agents to produce, yet it is easy to get subtly wrong: files that open in one viewer but corrupt in another, spreadsheets with broken cell references, decks with missing slide relationships. This skill packages the full generation-to-delivery loop so your agent produces files that are structurally sound and actually render.
After installing, your agent can turn a markdown brief into a formatted PDF, build a Word report with proper headings and tables, generate a spreadsheet from CSV data, assemble a slide deck from an outline — and then run the included validation script on every artifact to prove it is well-formed before you ever open it. Because the four formats share one workflow, one skill covers them all; you do not need four overlapping skills with four sets of instructions to maintain.
## What You Get
| Directory | Purpose |
|-----------|---------|
| `SKILL.md` | Shared six-step workflow (scope → content model → template → render → validate → deliver) with per-format load-on-demand |
| `references/pdf.md` | PDF generation, tooling, and validation specifics |
| `references/word.md` | Word (.docx) package layout, generation, and validation specifics |
| `references/excel.md` | Excel (.xlsx) workbook structure, generation, and validation specifics |
| `references/powerpoint.md` | PowerPoint (.pptx) deck structure, generation, and validation specifics |
| `references/output-quality.md` | Cross-format output-quality checklist for all four formats |
| `scripts/validate-documents.py` | Stdlib-only validation script: structural sanity + optional render check, with `--json` output and graceful degradation when no renderer is installed |
| `templates/pdf-template.md` | Fillable generation template for PDF (print-ready HTML/CSS or LaTeX) |
| `templates/word-template.md` | Fillable generation template for Word documents |
| `templates/excel-template.md` | Fillable generation template for Excel workbooks |
| `templates/powerpoint-template.md` | Fillable generation template for PowerPoint decks |
| `fixtures/` | One small valid sample per format, used to smoke-test the validation script |
## Quick Start
```bash
# Validate a finished artifact (human report)
python3 scripts/validate-documents.py report.pdf
# Validate with a render check and machine-readable output
python3 scripts/validate-documents.py --render-check --json report.pdf data.xlsx deck.pptx
# Smoke-test the script against the bundled per-format fixtures
python3 scripts/validate-documents.py --json fixtures/sample.pdf fixtures/sample.docx fixtures/sample.xlsx fixtures/sample.pptx
```
The render check uses `pdftoppm` (poppler-utils) for PDF and LibreOffice for Office formats when they are installed. When neither is present, validation still performs full structural checks and reports the render check as `unavailable` instead of failing — no renderer required to use the skill.
## Triggers
Load this skill when the user mentions any of:
- **Generating documents**: "create a PDF report", "make a Word document", "turn this CSV into a spreadsheet", "build a slide deck"
- **Editing documents**: "update the docx", "change the Excel file", "fix this presentation"
- **Extracting from documents**: "read the text from this PDF", "pull the table out of this xlsx"
- **Converting**: "docx to PDF", "export this data as an Excel file"
- **Validating**: "check that this document is valid", "why won't this file open", "verify the output before sending"
Do not load for ebooks (use the `epub` skill), image/video/media production, or data pipeline work (use `data-engineering`).
## Requirements
- Python 3.8+ — the validation script uses only the standard library.
- Optional renderers (only for the render-check step): `pdftoppm`/`mutool`/`ghostscript` for PDF, `libreoffice`/`soffice` for Office formats. Generation libraries such as python-docx, openpyxl, or python-pptx are optional per format and documented in the references.
references/excel.md
# Excel (.xlsx) — Generation & Validation Reference
> **Last Updated:** 2026-08-03
Load this reference when the target format is **Excel** — generating an .xlsx
workbook, modifying one, or validating a spreadsheet artifact. It complements
the shared workflow in `SKILL.md`; this file is the Excel-specific detail for
steps 3-5 (template, render, validate).
## XLSX fundamentals
An .xlsx file is an **OPC ZIP archive**:
- **`[Content_Types].xml`** — content types for workbook and worksheet parts.
- **`_rels/.rels`** — package relationships; points at `xl/workbook.xml`.
- **`xl/workbook.xml`** — sheet list (`<sheets><sheet name=... sheetId=...
r:id=.../>`); the workbook-level relationships file
`xl/_rels/workbook.xml.rels` maps each `r:id` to a worksheet part.
- **`xl/worksheets/sheetN.xml`** — cell data: `<sheetData>` with `<row>` and
`<c r="A1">` cells. Cells hold values in `<v>` (numeric) or inline strings
via `<is><t>`; shared strings live in `xl/sharedStrings.xml` and are
referenced by index.
- **`xl/styles.xml`** — number formats, fonts, fills, column widths.
- **`xl/calcChain.xml`** and formula cells — `<f>` elements hold formulas;
the `<v>` element holds the **cached** result.
The validation script checks the ZIP container, `[Content_Types].xml`,
`xl/workbook.xml`, at least one `xl/worksheets/sheetN.xml`, and their XML
well-formedness.
## Generation paths
### openpyxl (recommended)
`pip install openpyxl`, then build from the data model:
```python
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Revenue"
ws.append(["Quarter", "Revenue"]) # row 1
ws.append(["Q1", 100]) # row 2
wb.save("data.xlsx")
```
Write headers first, then data rows; let the library handle shared strings and
styles. For large datasets, consider `write_only` mode to keep memory flat.
### Raw OPC construction (small, dependency-free artifacts)
For tiny workbooks, write the OOXML package directly with stdlib `zipfile` +
XML: `[Content_Types].xml`, `_rels/.rels`, `xl/workbook.xml`,
`xl/_rels/workbook.xml.rels`, and `xl/worksheets/sheet1.xml`. This is what the
bundled fixture `fixtures/sample.xlsx` does. Keep the
`xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"` namespace.
### CSV is not Excel
When the consumer only needs tabular data and never needs formatting,
formulas, or multiple sheets, a CSV is simpler and more robust than xlsx. Use
xlsx when the artifact itself is the deliverable.
## Validation specifics
```bash
python3 scripts/validate-documents.py --json data.xlsx
python3 scripts/validate-documents.py --render-check --json data.xlsx
```
Structural checks the script runs for XLSX:
- **ZIP container** — `PK..` magic; the file is a real archive.
- **Content types** — `[Content_Types].xml` present.
- **Workbook part** — `xl/workbook.xml` present and well-formed XML.
- **Worksheets** — at least one `xl/worksheets/sheetN.xml` present.
- **Text content** — informational check for cell markers.
The render check converts the workbook to PDF via LibreOffice and reports
`unavailable` when LibreOffice is not installed.
## Output-quality checklist for Excel
Before delivery, verify:
- **Headers on row 1** — a clear header row with column meaning, so the sheet
is self-describing.
- **Values, not just formulas** — every `<f>` formula cell has a cached `<v>`
result; viewers that do not recalculate show the cached value.
- **Number formats are right** — dates and currencies use the intended number
format instead of raw serial numbers where users will be confused.
- **No broken references** — no `#REF!`/`#VALUE!` errors in cached values.
- **Column widths readable** — data is not clipped in the default view.
- **Sheet names meaningful** — default `Sheet1` is a smell for a deliverable.
See [references/output-quality.md](output-quality.md) for the cross-format
version of this checklist.
references/output-quality.md
# Output-Quality Validation — All Four Formats
> **Last Updated:** 2026-08-03
Load this reference when you need to **verify a produced document before
delivery**, regardless of format. It is step 5 (validate) of the shared
workflow in `SKILL.md`, expanded into a checklist that applies to PDF, Word,
Excel, and PowerPoint alike, plus the exact behavior of the validation script.
## Why validate at all
The most common document defects are structural, not typographical: a file
that opens in one application and corrupts in another, a spreadsheet whose
cells are invisible to formulas, a deck whose slides do not render. Structural
validation catches these before delivery, cheaply and deterministically, and a
render check catches the next class (layout, fonts, overflow) whenever a
renderer is available.
## Two layers of checking
### 1. Structural sanity (always available, stdlib only)
The validation script checks properties that any reader depends on:
| Format | Checks |
|--------|--------|
| PDF | `%PDF-` header, `%%EOF` trailer, at least one page object |
| Word (.docx) | ZIP container, `[Content_Types].xml`, `word/document.xml` well-formed |
| Excel (.xlsx) | ZIP container, `[Content_Types].xml`, `xl/workbook.xml` + worksheet parts well-formed |
| PowerPoint (.pptx) | ZIP container, `[Content_Types].xml`, `ppt/presentation.xml` + slide parts well-formed |
These checks need no third-party packages, so validation always runs.
### 2. Render check (best effort, graceful)
When an external renderer is installed, the script additionally **renders the
file** — `pdftoppm`/`mutool`/`gs` rasterize PDF page 1; LibreOffice converts
Office formats to PDF. When no renderer is available for a format, the render
check reports `unavailable` in the JSON and exits 0: a missing renderer is an
environment fact, not a document defect. Never block delivery on a render
check you could not run; say so in the provenance instead.
## Running the validation script
```bash
python3 scripts/validate-documents.py report.pdf brief.docx data.xlsx deck.pptx
python3 scripts/validate-documents.py --render-check --json report.pdf data.xlsx
```
Exit codes:
- **0** — every file passes structural validation (and any attempted render
succeeded, or no renderer was available).
- **1** — at least one file fails structure or an attempted render failed.
- **2** — usage/I/O error (missing or unreadable path).
JSON report (`--json`): top-level `status` is `ok`, `fail`, `unavailable`, or
`error`; each file carries per-check results and a `render` object. The
`summary` block gives the pass/fail/skip/error counts.
## Content-completeness checks
Structure passing does not mean the content is right. Before delivery, confirm
the artifact matches the agreed scope:
- **Expected sections present** — the scope's headings/slides/sheets all exist.
- **No placeholder remnants** — no unresolved `[fill: ...]` markers, `TODO`
text, or lorem ipsum from the template.
- **Data intact** — for Excel, spot-check cell values against the source data;
for prose, check a few paragraphs verbatim.
- **Metadata set** — title/author where the reader displays it.
## Visual and layout quality
Renderer-dependent, so verify when a renderer is available or by opening the
artifact:
- **No overflow** — text fits boxes/cells/slides; no clipped content.
- **No missing glyphs** — fonts embedded (PDF) or available (Office); no
`tofu` boxes.
- **Consistent styling** — headings use styles; decks use one layout family.
- **Page/slide count matches the scope** — no blank trailing pages/slides.
## Accessibility basics
Accessibility is part of output quality, not a separate concern:
- **PDF** — tagged PDFs with real text (not scans) and a reading order.
- **Word** — heading styles (they become the navigation/outline), alt text on
images, real tables with header rows.
- **Excel** — header rows on row 1, no blank separator rows inside tables,
meaningful sheet names.
- **PowerPoint** — alt text on images, sensible shape reading order, notes.
## The deliverable gate
A document is ready to deliver when:
1. `scripts/validate-documents.py` exits 0 on it (structure passed).
2. The render check is `ok` (renders) **or** honestly reported as
`unavailable` (no renderer present).
3. Content-completeness and visual checks pass against the scope.
4. The provenance records the content model, template version, and validation
result, so the artifact can be regenerated and re-verified.
Anything less is a known deviation — record it rather than hiding it.
references/pdf.md
# PDF — Generation & Validation Reference
> **Last Updated:** 2026-08-03
Load this reference when the target format is **PDF** — generating a
fixed-layout document, converting content to PDF, or validating a PDF artifact.
It complements the shared workflow in `SKILL.md`; this file is the PDF-specific
detail for steps 3-5 (template, render, validate).
## PDF fundamentals
A PDF file is a linear byte stream, not a container:
- **Header** — `%PDF-1.x` near the start (x = 2..7 in practice).
- **Body** — numbered indirect objects (`N 0 obj ... endobj`): a catalog
(`/Type /Catalog`), page tree (`/Type /Pages` with `/Kids` and `/Count`),
page objects (`/Type /Page`), content streams, and font objects.
- **Cross-reference table (xref)** — byte offsets of every object, which lets
readers jump straight to an object; followed by the `trailer` with `/Root`.
- **`startxref`** — byte offset of the xref table; **`%%EOF`** terminates the
file.
The validation script checks exactly the properties that break in real life:
the `%PDF-` header, the `%%EOF` trailer, and the presence of page objects. A
file that opens in one viewer but not another is almost always a broken xref
or a stream whose `/Length` does not match its content — see
`references/output-quality.md` for the cross-format checklist.
## Generation paths
PDF is a **fixed-layout** format: the author, not the reader, decides where
every glyph lands. Choose the path by how much layout control you need.
### Print-ready HTML/CSS (recommended for reports and memos)
Author the document as HTML with print CSS (`@page` rules, page breaks,
`@media print`), then render to PDF with a print-capable engine:
- **WeasyPrint** (Python, pip installable) — excellent CSS paged-media support;
embed fonts via `@font-face`.
- **Headless Chromium** (`--headless --print-to-pdf`) — full CSS support, best
for complex layouts; pass `--no-pdf-header-footer` for clean output.
Keep the content in the content model (step 2 of the shared workflow), fill
[templates/pdf-template.md](../templates/pdf-template.md), and render. The
template is the layout contract; the HTML/CSS is where fonts, margins, and
page breaks live.
### LaTeX (best for technical and long-form documents)
Write LaTeX source from the content model and compile with a TeX toolchain
(`pdflatex`, `xelatex`). Gives precise typography, references, and TOC
control. Costs: a toolchain dependency and a longer render cycle.
### Direct PDF construction (small, dependency-free artifacts)
For tiny fixed artifacts (a one-page certificate, a label), a minimal PDF can
be written by hand with stdlib only: build the objects, compute the xref
offsets, and write the trailer. Keep streams short and compute `/Length`
exactly. This is what the bundled fixture `fixtures/sample.pdf` does.
### What not to do
- Do not fake a PDF by renaming a text file — every PDF must start with the
`%PDF-` header and end with `%%EOF`; readers will reject anything else.
- Do not generate a PDF that relies on fonts that will not be embedded;
unembedded fonts render as garbage or get substituted (see output quality).
- Do not rasterize text to images unless the document is genuinely a scan;
text should stay selectable.
## Text extraction (reading a PDF)
PDF is a rendering format, so "reading" it means extracting text:
- **pypdf** (`pip install pypdf`) — extract text per page: `PageObject.extract_text()`.
- **pdfminer.six** — more accurate layout-aware extraction for complex layouts.
- **pdftotext** (poppler-utils) — fast CLI extraction for simple documents.
Extraction quality varies with how the PDF was produced. Scanned PDFs contain
no text layer at all — they are images; extraction requires OCR, which is
outside this skill's scope.
## Validation specifics
```bash
python3 scripts/validate-documents.py --json report.pdf
python3 scripts/validate-documents.py --render-check --json report.pdf
```
Structural checks the script runs for PDF:
- **PDF header** — `%PDF-` signature near the start.
- **EOF marker** — `%%EOF` trailer near the end.
- **Page objects** — at least one `/Type /Page` object.
The render check renders page 1 to a raster via `pdftoppm` (or `mutool`/`gs`)
and reports `unavailable` when no renderer is installed.
## Output-quality checklist for PDF
Before delivery, verify:
- **Pages render** — the render check succeeds; no blank or corrupt pages.
- **Text is selectable** — a content-stream text marker (BT/ET) is present
unless the document is intentionally a scan.
- **Fonts are embedded** — no `missing glyph` boxes; embed via the generator's
font options.
- **Page count matches the scope** — no accidental blank trailing pages.
- **Links and bookmarks** — internal links and the outline are functional.
- **Metadata** — title/author set where the reader will show them.
See [references/output-quality.md](output-quality.md) for the cross-format
version of this checklist.
references/powerpoint.md
# PowerPoint (.pptx) — Generation & Validation Reference
> **Last Updated:** 2026-08-03
Load this reference when the target format is **PowerPoint** — generating a
.pptx deck, modifying one, or validating a presentation artifact. It
complements the shared workflow in `SKILL.md`; this file is the
PowerPoint-specific detail for steps 3-5 (template, render, validate).
## PPTX fundamentals
A .pptx file is an **OPC ZIP archive**:
- **`[Content_Types].xml`** — content types for presentation and slide parts.
- **`_rels/.rels`** — package relationships; points at `ppt/presentation.xml`.
- **`ppt/presentation.xml`** — the deck: `<p:sldIdLst>` lists slide IDs; the
relationships file `ppt/_rels/presentation.xml.rels` maps each `r:id` to a
slide part. Also carries slide size (`<p:sldSz cx cy/>`).
- **`ppt/slides/slideN.xml`** — each slide: `<p:cSld>` with `<p:spTree>`
(the shape tree). Text boxes are `<p:sp>` shapes whose `<p:txBody>` holds
`<a:p>` paragraphs with `<a:r>` runs and `<a:t>` text.
- **`ppt/notesSlides/notesSlideN.xml`** — speaker notes.
- **`ppt/media/`** — embedded images.
The validation script checks the ZIP container, `[Content_Types].xml`,
`ppt/presentation.xml`, at least one `ppt/slides/slideN.xml`, and their XML
well-formedness.
## Generation paths
### python-pptx (recommended)
`pip install python-pptx`, then build from the slide outline:
```python
from pptx import Presentation
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1]) # title + content
slide.shapes.title.text = "Q3 Results"
slide.placeholders[1].text = "Revenue up 12%\nMargins stable"
prs.save("deck.pptx")
```
Drive the content from the slide outline (title + bullets + notes per slide);
the layout choice is separate from the content.
### Raw OPC construction (small, dependency-free artifacts)
For tiny decks, write the OOXML package directly with stdlib `zipfile` + XML:
`[Content_Types].xml`, `_rels/.rels`, `ppt/presentation.xml`,
`ppt/_rels/presentation.xml.rels`, and `ppt/slides/slide1.xml`. This is what
the bundled fixture `fixtures/sample.pptx` does. Keep the
`xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"`
namespace and remember that every slide ID in `sldIdLst` needs a matching
relationship.
## Validation specifics
```bash
python3 scripts/validate-documents.py --json deck.pptx
python3 scripts/validate-documents.py --render-check --json deck.pptx
```
Structural checks the script runs for PPTX:
- **ZIP container** — `PK..` magic; the file is a real archive.
- **Content types** — `[Content_Types].xml` present.
- **Presentation part** — `ppt/presentation.xml` present and well-formed XML.
- **Slides** — at least one `ppt/slides/slideN.xml` present.
- **Text content** — informational check for `<a:t>` text markers.
The render check converts the deck to PDF via LibreOffice and reports
`unavailable` when LibreOffice is not installed.
## Output-quality checklist for PowerPoint
Before delivery, verify:
- **Slide IDs resolve** — every `<p:sldId>` in `sldIdLst` maps through
`presentation.xml.rels` to a real slide part.
- **Text fits** — no text boxes overflowing their shapes; keep bullets short.
- **Images are embedded** — media parts exist in `ppt/media/` and are
referenced by relationship.
- **Notes present where required** — speaker notes are part of the deck for
presentation use.
- **Slide size is intentional** — `sldSz` matches the intended aspect ratio
(16:9 vs 4:3).
- **Reading order** — shapes in `spTree` appear in a sensible order for screen
readers and tabbing.
See [references/output-quality.md](output-quality.md) for the cross-format
version of this checklist.
references/word.md
# Word (.docx) — Generation & Validation Reference
> **Last Updated:** 2026-08-03
Load this reference when the target format is **Word** — generating an
editable .docx, modifying one, or validating a Word artifact. It complements
the shared workflow in `SKILL.md`; this file is the Word-specific detail for
steps 3-5 (template, render, validate).
## DOCX fundamentals
A .docx file is an **OPC (Open Packaging Conventions) ZIP archive**:
- **`[Content_Types].xml`** — declares the content type of every part.
- **`_rels/.rels`** — package relationships; points at the main document part.
- **`word/document.xml`** — the document body: `w:p` (paragraphs) containing
`w:r` (runs) containing `w:t` (text); `w:tbl` for tables; `w:sectPr` for
section properties.
- **`word/styles.xml`** — named styles (`w:style` with `w:styleId`); the
document references them with `w:pStyle`/`w:rStyle`.
- **`word/media/`** — embedded images referenced via relationships in
`word/_rels/document.xml.rels`.
- **`word/header*.xml` / `word/footer*.xml`** — headers and footers.
- **`docProps/core.xml`** — metadata (title, author, dates).
Content lives in `word/document.xml`; everything else supports it. The
validation script checks the ZIP container, `[Content_Types].xml`, the
`word/document.xml` part, and its XML well-formedness.
## Generation paths
### python-docx (recommended for prose documents)
`pip install python-docx`, then build from the content model:
```python
from docx import Document
doc = Document()
doc.add_heading(title, level=0)
for heading, body in sections:
doc.add_heading(heading, level=1)
doc.add_paragraph(body)
doc.save("report.docx")
```
Use styles (`add_heading` applies built-in heading styles) rather than
manual formatting so the document stays navigable and re-styleable.
### Pandoc (markdown → docx)
`pandoc report.md -o report.docx` produces clean, style-based output and is
ideal when the content model is markdown. Use a reference doc (`--reference-doc`)
to control styles.
### Raw OPC construction (small, dependency-free artifacts)
For tiny or highly controlled documents, write the OOXML package directly with
stdlib `zipfile` + XML: `[Content_Types].xml`, `_rels/.rels`,
`word/document.xml`, and optional `word/styles.xml`. This is what the bundled
fixture `fixtures/sample.docx` does. Keep the XML namespaced correctly
(`xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"`).
## Validation specifics
```bash
python3 scripts/validate-documents.py --json brief.docx
python3 scripts/validate-documents.py --render-check --json brief.docx
```
Structural checks the script runs for DOCX:
- **ZIP container** — `PK..` magic; the file is a real archive.
- **Content types** — `[Content_Types].xml` present.
- **Main part** — `word/document.xml` present and well-formed XML.
- **Text content** — informational check for `w:t` text markers.
The render check converts the file to PDF via LibreOffice (`--headless
--convert-to pdf`) and reports `unavailable` when LibreOffice is not installed.
## Output-quality checklist for Word
Before delivery, verify:
- **Styles, not ad-hoc formatting** — headings use heading styles so the
navigation pane and TOC work.
- **Tables are real tables** — `w:tbl` structure, not tab-separated text.
- **Images are embedded** — media parts exist and are referenced, not hot-linked.
- **No unresolved fields** — update or remove stale TOC/field placeholders.
- **Spellable, openable text** — text is in `w:t` runs, not encoded oddly.
- **Header/footer present when required** — page numbers, document title.
See [references/output-quality.md](output-quality.md) for the cross-format
version of this checklist.
scripts/validate-documents.py
#!/usr/bin/env python3
"""Structural sanity and render validation for PDF, Word, Excel, and PowerPoint files.
Checks the four modern document formats the ``documents`` skill generates:
* PDF -- %PDF- header, %%EOF trailer, at least one page object
* .docx -- OOXML Word: valid ZIP container, [Content_Types].xml, word/document.xml
* .xlsx -- OOXML Excel: valid ZIP container, [Content_Types].xml, xl/workbook.xml,
at least one xl/worksheets/sheet*.xml
* .pptx -- OOXML PowerPoint: valid ZIP container, [Content_Types].xml,
ppt/presentation.xml, at least one ppt/slides/slide*.xml
Legacy binary Office formats (.doc/.xls/.ppt) are recognized via their OLE2 magic
bytes and reported as legacy containers with a reduced structural check.
Every check is static and uses only the Python standard library, so validation
never depends on third-party packages.
Render check (``--render-check``): when an external renderer is installed, the
script attempts to actually render the file -- pdftoppm/mutool/ghostscript for PDF,
LibreOffice for Office formats -- and reports whether rendering produced output.
When no renderer is available for a format the render check reports ``unavailable``
and exits 0: the missing renderer is not treated as a document defect (graceful
degradation).
Exit codes:
0 all files pass structural validation (and any attempted render succeeded,
or no renderer was available); unsupported extensions are skipped, not failed
1 at least one file fails structural validation or an attempted render failed
2 usage or I/O error (missing path, unreadable file)
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
VERSION = "1.0.0"
FORMAT_BY_EXT = {
".pdf": "pdf",
".docx": "docx",
".xlsx": "xlsx",
".pptx": "pptx",
".doc": "legacy-word",
".xls": "legacy-excel",
".ppt": "legacy-powerpoint",
}
# Required OOXML parts per format; worksheet/slide discovery is regex-based so a
# workbook can hold any number of sheets.
REQUIRED_PARTS = {
"docx": ["word/document.xml"],
"xlsx": ["xl/workbook.xml"],
"pptx": ["ppt/presentation.xml"],
}
SHEET_PATTERN = re.compile(r"^xl/worksheets/sheet\d+\.xml$")
SLIDE_PATTERN = re.compile(r"^ppt/slides/slide\d+\.xml$")
PDF_HEADER = re.compile(rb"%PDF-\d\.\d")
PAGE_OBJECT = re.compile(rb"/Type\s*/Page[^s]")
PAGES_TREE_COUNT = re.compile(rb"/Count\s+([1-9]\d*)")
OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
def check(name, ok, detail, fatal=True):
"""Build one check result entry. ``fatal`` marks checks that decide whether
the file passes; non-fatal checks are informational only."""
return {"name": name, "ok": bool(ok), "detail": detail, "fatal": fatal}
def detect_format(path):
"""Return the logical format for a path, or None when unsupported."""
return FORMAT_BY_EXT.get(path.suffix.lower())
def check_pdf(data):
"""Structural sanity checks for a PDF byte stream."""
results = []
results.append(
check("PDF header", bool(PDF_HEADER.search(data[:1024])), "expected a %PDF-x.y signature near the start")
)
results.append(
check("EOF marker", b"%%EOF" in data[-2048:], "expected a %%EOF trailer marker near the end")
)
# Page objects may be stored in compressed object streams (PDF 1.5+ ObjStm),
# which hides their "/Type /Page" bytes from a raw scan. The Pages tree's
# /Count entry stays legible in the common case, so accept either signal.
raw_pages = len(PAGE_OBJECT.findall(data))
count_match = PAGES_TREE_COUNT.search(data)
tree_pages = int(count_match.group(1)) if count_match else 0
total_pages = max(raw_pages, tree_pages)
results.append(
check("page objects", total_pages >= 1,
"found %d raw page object(s) and a /Count of %d in the page tree"
% (raw_pages, tree_pages))
)
if total_pages == 0:
results.append(
check("content stream", b"BT" in data and b"ET" in data,
"no page objects; checked for a text stream (BT/ET) as a fallback",
fatal=False)
)
return results
def parse_xml_bytes(raw):
"""Parse XML bytes; return (ok, message)."""
try:
ET.fromstring(raw)
return True, "well-formed XML"
except ET.ParseError as exc:
return False, "malformed XML: %s" % exc
def check_ooxml(data, fmt):
"""Structural sanity checks for an OOXML (docx/xlsx/pptx) byte stream."""
results = []
results.append(
check("ZIP container", data[:4] == b"PK\x03\x04", "expected a ZIP (PK..) container signature")
)
try:
# ZipFile needs a file-like object; BytesIO keeps the check in-memory.
import io
with zipfile.ZipFile(io.BytesIO(data)) as zf:
names = zf.namelist()
results.append(
check("content types", "[Content_Types].xml" in names,
"[Content_Types].xml present" if "[Content_Types].xml" in names
else "[Content_Types].xml missing from archive")
)
for part in REQUIRED_PARTS[fmt]:
present = part in names
results.append(check(part, present, "present" if present else "missing required part %s" % part))
if fmt == "xlsx":
sheets = [n for n in names if SHEET_PATTERN.match(n)]
results.append(check("worksheets", len(sheets) >= 1,
"%d worksheet(s) found" % len(sheets) if sheets else "no xl/worksheets/sheetN.xml found"))
if fmt == "pptx":
slides = [n for n in names if SLIDE_PATTERN.match(n)]
results.append(check("slides", len(slides) >= 1,
"%d slide(s) found" % len(slides) if slides else "no ppt/slides/slideN.xml found"))
# XML well-formedness of the key parts.
for part in REQUIRED_PARTS[fmt]:
if part in names:
ok, message = parse_xml_bytes(zf.read(part))
results.append(check("%s XML" % part, ok, message))
# Informational: does the content carry any text at all?
text_markers = {
"docx": b"<w:t",
"xlsx": b"<c ",
"pptx": b"<a:t",
}
marker = text_markers[fmt]
has_text = False
for name in names:
if name.endswith(".xml"):
try:
if marker in zf.read(name):
has_text = True
break
except (KeyError, RuntimeError):
continue
results.append(check("text content", has_text, "text markers found" if has_text else "no text content detected", fatal=False))
except zipfile.BadZipFile as exc:
results.append(check("ZIP readable", False, str(exc)))
except (OSError, RuntimeError) as exc:
results.append(check("ZIP readable", False, str(exc)))
return results
def check_legacy_ole(data):
"""Structural sanity for legacy binary Office files (.doc/.xls/.ppt)."""
results = [
check("OLE2 container", data[:8] == OLE2_MAGIC, "expected a Compound File (OLE2) magic signature"),
]
results.append(
check("size", len(data) > 512, "legacy OLE container is %d byte(s); the compound file header is 512 bytes"
% len(data))
)
return results
def check_file(path, fmt, data):
"""Run the structural checks for a detected format; returns (status, checks)."""
if fmt == "pdf":
checks = check_pdf(data)
elif fmt in ("docx", "xlsx", "pptx"):
checks = check_ooxml(data, fmt)
elif fmt in ("legacy-word", "legacy-excel", "legacy-powerpoint"):
checks = check_legacy_ole(data)
else:
return "skipped", []
failed = any(item["ok"] is False and item.get("fatal", True) for item in checks)
return ("fail" if failed else "pass"), checks
def find_pdf_renderer():
"""Locate an installed PDF renderer binary, or None."""
for name in ("pdftoppm", "mutool", "gs"):
found = shutil.which(name)
if found:
return found
return None
def find_office_renderer():
"""Locate an installed Office (OOXML) renderer binary, or None."""
for name in ("libreoffice", "soffice"):
found = shutil.which(name)
if found:
return found
return None
def render_pdf(path, tmpdir):
"""Render the first page of a PDF to a raster; returns a render result dict.
Each supported renderer has a different CLI: pdftoppm takes ``-png``,
``mutool`` needs ``draw -o``, and ghostscript needs ``-sDEVICE``. The
argument list is dispatched per renderer so a machine with any one of the
three can run the render check.
"""
renderer = find_pdf_renderer()
if not renderer:
return {
"status": "unavailable",
"renderer": None,
"reason": "no PDF renderer installed (pdftoppm, mutool, or ghostscript)",
}
name = Path(renderer).name
prefix = str(tmpdir / "page")
if name == "pdftoppm":
cmd = [renderer, "-png", "-r", "72", "-f", "1", "-l", "1", str(path), prefix]
elif name == "mutool":
cmd = [renderer, "draw", "-o", prefix + "-%d.png", "-r", "72", str(path), "1-1"]
elif name == "gs":
cmd = [
renderer,
"-dSAFER", "-dBATCH", "-dNOPAUSE",
"-sDEVICE=png16m", "-r72",
"-sOutputFile=" + prefix + "-%d.png",
str(path),
]
else:
return {
"status": "unavailable",
"renderer": name,
"reason": "unsupported PDF renderer %r (expected pdftoppm, mutool, or gs)" % name,
}
try:
proc = subprocess.run(cmd, capture_output=True, timeout=60)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"status": "failed", "renderer": name, "reason": "renderer error: %s" % exc}
pages = sorted(tmpdir.glob("page*.png"))
if proc.returncode != 0:
reason = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()[:300]
return {"status": "failed", "renderer": name, "reason": reason or "renderer exited nonzero"}
if not pages:
return {"status": "failed", "renderer": name, "reason": "renderer produced no output pages"}
return {"status": "ok", "renderer": name, "pages": len(pages)}
def render_ooxml(path, tmpdir):
"""Convert an Office file to PDF via LibreOffice; returns a render result dict."""
renderer = find_office_renderer()
if not renderer:
return {
"status": "unavailable",
"renderer": None,
"reason": "no Office renderer installed (libreoffice or soffice)",
}
try:
proc = subprocess.run(
[renderer, "--headless", "--convert-to", "pdf", "--outdir", str(tmpdir), str(path)],
capture_output=True,
timeout=180,
)
except (OSError, subprocess.TimeoutExpired) as exc:
return {"status": "failed", "renderer": Path(renderer).name, "reason": "renderer error: %s" % exc}
pdfs = sorted(tmpdir.glob("*.pdf"))
if proc.returncode != 0:
reason = (proc.stderr or proc.stdout or b"").decode("utf-8", "replace").strip()[:300]
return {"status": "failed", "renderer": Path(renderer).name, "reason": reason or "renderer exited nonzero"}
if not pdfs:
return {"status": "failed", "renderer": Path(renderer).name, "reason": "conversion produced no PDF output"}
return {"status": "ok", "renderer": Path(renderer).name, "pages": len(pdfs)}
def attempt_render(path, fmt, tmpdir):
"""Render one file with an installed renderer; graceful when none exists."""
if fmt == "pdf":
return render_pdf(path, tmpdir)
if fmt in ("docx", "xlsx", "pptx"):
return render_ooxml(path, tmpdir)
return {
"status": "unavailable",
"renderer": None,
"reason": "no renderer applies to format %r" % fmt,
}
def renderer_available_for(fmt):
"""True when an installed renderer can render the given format."""
if fmt == "pdf":
return find_pdf_renderer() is not None
if fmt in ("docx", "xlsx", "pptx"):
return find_office_renderer() is not None
return False
def validate_files(paths, render_check=False):
"""Validate every path; returns the report dict (see module docstring)."""
entries = []
io_error = False
for raw in paths:
path = Path(raw)
if not path.exists():
entries.append({
"path": raw,
"format": None,
"size": None,
"status": "error",
"checks": [check("exists", False, "path does not exist")],
"render": {"status": "not_requested"},
})
io_error = True
continue
if not path.is_file():
entries.append({
"path": raw,
"format": None,
"size": None,
"status": "error",
"checks": [check("file", False, "path is not a regular file")],
"render": {"status": "not_requested"},
})
io_error = True
continue
try:
data = path.read_bytes()
except OSError as exc:
entries.append({
"path": raw,
"format": None,
"size": None,
"status": "error",
"checks": [check("readable", False, "cannot read file: %s" % exc.strerror)],
"render": {"status": "not_requested"},
})
io_error = True
continue
fmt = detect_format(path)
if fmt is None:
entries.append({
"path": raw,
"format": None,
"size": len(data),
"status": "skipped",
"reason": "unsupported extension %r (expected .pdf, .docx, .xlsx, .pptx)" % path.suffix,
"checks": [],
"render": {"status": "not_requested"},
})
continue
status, checks = check_file(path, fmt, data)
render_result = {"status": "not_requested"}
if render_check:
with tempfile.TemporaryDirectory(prefix="documents-render-") as tmp:
render_result = attempt_render(path, fmt, Path(tmp))
entry = {
"path": raw,
"format": fmt,
"size": len(data),
"status": status,
"checks": checks,
"render": render_result,
}
entries.append(entry)
failed = [e for e in entries if e["status"] == "fail"]
errored = [e for e in entries if e["status"] == "error"]
skipped = [e for e in entries if e["status"] == "skipped"]
passed = [e for e in entries if e["status"] == "pass"]
status = "ok"
exit_code = 0
if errored and not failed:
# Missing/unreadable paths are usage-level problems, not document failures.
status = "error"
exit_code = 2
if failed:
status = "fail"
exit_code = 1
elif render_check and not errored:
# Render-check mode: when no renderer applies to any of the files, the
# render check is unavailable rather than a failure (graceful
# degradation). An unsupported file also yields "unavailable": there is
# nothing to render.
renderable = [e for e in entries if e["status"] == "pass" and e["format"]]
if not renderable:
status = "unavailable"
elif not any(renderer_available_for(e["format"]) for e in renderable):
status = "unavailable"
elif any(e.get("render", {}).get("status") == "failed" for e in entries):
status = "fail"
exit_code = 1
return {
"tool": "validate-documents.py",
"version": VERSION,
"render_check": render_check,
"status": status,
"ok": status == "ok",
"files": entries,
"summary": {
"files": len(entries),
"passed": len(passed),
"failed": len(failed),
"skipped": len(skipped),
"errors": len(errored),
},
"exit_code": exit_code,
}
def print_human(report):
"""Print a human-readable report to stdout."""
for entry in report["files"]:
fmt = entry["format"] or "unknown"
if entry["status"] == "pass":
verdict = "PASS"
elif entry["status"] == "fail":
verdict = "FAIL"
elif entry["status"] == "error":
verdict = "ERROR"
else:
verdict = "SKIPPED"
print("%s: %s - %s" % (entry["path"], fmt, verdict))
if entry.get("reason"):
print(" reason: %s" % entry["reason"])
for item in entry["checks"]:
mark = "ok" if item["ok"] else "FAIL"
print(" [%s] %s: %s" % (mark, item["name"], item["detail"]))
render = entry["render"]
if render.get("status") != "not_requested":
print(" render: %s (%s)" % (render.get("status"), render.get("reason") or render.get("renderer") or "n/a"))
summary = report["summary"]
print(
"%d file(s): %d passed, %d failed, %d skipped, %d error(s) - overall %s"
% (summary["files"], summary["passed"], summary["failed"], summary["skipped"], summary["errors"], report["status"])
)
def build_parser():
parser = argparse.ArgumentParser(
prog="validate-documents.py",
description=(
"Validate PDF, Word (.docx), Excel (.xlsx), and PowerPoint (.pptx) files: "
"structural sanity (container signatures, required parts, XML well-formedness) "
"plus an optional render check against an installed renderer. Emits a "
"machine-readable JSON report with --json. Exit 0 when all files pass, 1 when a "
"file fails validation or rendering, 2 on usage or I/O errors."
),
epilog=(
"Examples:\n"
" validate-documents.py report.pdf brief.docx\n"
" validate-documents.py --json report.pdf\n"
" validate-documents.py --render-check --json report.pdf data.xlsx"
),
)
parser.add_argument("files", nargs="+", metavar="FILE", help="document file(s) to validate")
parser.add_argument("--json", action="store_true", help="emit a machine-readable JSON report")
parser.add_argument(
"--render-check",
action="store_true",
help="attempt to render each file with an installed renderer "
"(pdftoppm/mutool/ghostscript for PDF, LibreOffice for Office formats); "
"reports 'unavailable' when no renderer is present",
)
parser.add_argument("--version", action="version", version="%(prog)s " + VERSION)
return parser
def main(argv=None):
parser = build_parser()
args = parser.parse_args(argv)
report = validate_files(args.files, render_check=args.render_check)
if args.json:
print(json.dumps(report, indent=2))
else:
print_human(report)
return report["exit_code"]
if __name__ == "__main__":
sys.exit(main())
SKILL.md
---
name: documents
description: >-
Generate, inspect, validate, and fix PDF, Word (.docx), Excel (.xlsx), and
PowerPoint (.pptx) documents: turn structured content into render-ready
artifacts, verify structural and output quality before delivery, and repair
broken files. Use when a task involves creating, editing, converting, or
validating office documents and PDFs. Do not use for ebook packaging (use
epub), for images, video, or other media production, for API or code
documentation, or for data pipelines (use data-engineering).
license: MIT
compatibility: >-
Python 3.8+ for scripts; validation uses only the standard library. Optional
renderers for the render check: poppler-utils (pdftoppm) for PDF and
LibreOffice for Office formats; both degrade gracefully when absent.
Portable across all AgentSkills-compatible harnesses.
metadata:
skills: documents, pdf, docx, xlsx, pptx, word, excel, powerpoint, office, report, generation
tags: documents, pdf, docx, xlsx, pptx, word, excel, powerpoint, office, generation, validation
---
# Documents — PDF, Word, Excel & PowerPoint Skill
One skill for the four most common document formats. All four share a single
agent workflow — structured content in, render-ready, validated artifact out —
so they live in ONE family skill with per-format references, following the
`epub` precedent. Load the shared workflow below, then pull the per-format
reference for the format you are actually touching.
| Format | Extension | Reference (load on demand) |
|--------|-----------|----------------------------|
| PDF | `.pdf` | [references/pdf.md](references/pdf.md) |
| Word | `.docx` | [references/word.md](references/word.md) |
| Excel | `.xlsx` | [references/excel.md](references/excel.md) |
| PowerPoint | `.pptx` | [references/powerpoint.md](references/powerpoint.md) |
| All formats | — | [references/output-quality.md](references/output-quality.md) |
Generation templates for each format live in [templates/](templates/), and the
validation script with per-format fixtures lives in [scripts/](scripts/).
## When to use
Load this skill when the task involves any of the four formats:
- **Generate**: build a report, memo, spreadsheet, or deck from structured
content (markdown, JSON, data tables, outlines).
- **Edit**: modify an existing document's content, layout, or metadata in place.
- **Extract**: pull text, tables, or structure out of an existing file.
- **Convert**: move content between formats or from a data source into a document.
- **Validate**: check that a produced artifact is structurally sound and will
render correctly before it is delivered.
## When not to use
- **Ebooks and EPUB** — use the `epub` skill; it owns the EPUB container,
reading order, and package validation.
- **Images, video, and other media** — this skill covers document formats only;
route media production to the appropriate media skills.
- **Code and API documentation sites** — use the technical-documentation and
documentation-site conventions, not office documents.
- **Data pipelines** — moving or transforming raw data belongs to
`data-engineering`; Excel here is a *deliverable format*, not a data store.
- **Office documents to Markdown** — converting an existing office document
(docx, xlsx, pptx, pdf, odt, rtf, epub, csv) to GitHub-Flavored Markdown
belongs to the `anydoc` skill; this skill owns generation, editing, and
validation, not document-to-markdown extraction.
## The Shared Workflow
Every document task follows the same six steps, regardless of format. Deep
format-specific detail is deferred to the per-format reference — read it at the
step where it matters.
### 1. Scope
Pin down what the document is for before touching a file:
- **Audience and purpose** — who reads it and what decision it supports.
- **Format** — PDF (fixed layout, print, archival), Word (editable prose,
review), Excel (data, calculations), PowerPoint (presentation).
- **Boundaries** — page/slide count, size limits, brand or style constraints.
- **Source of truth** — the structured content the document is generated from
(markdown, JSON, CSV, outline), so the artifact is reproducible.
### 2. Content model
Represent the document's content as structured data before rendering:
- A **title, sections/headings, body text, and metadata** for prose documents.
- A **table model** (headers, rows, column types) for spreadsheets.
- A **slide outline** (title + bullets per slide, speaker notes) for decks.
- Keep content and layout separate: content in the model, layout in the
template. This is what makes regeneration cheap.
### 3. Template
Choose the generation template for the target format from [templates/](templates/):
- [templates/pdf-template.md](templates/pdf-template.md) — fixed-layout
document skeleton (print-ready HTML/CSS or LaTeX source).
- [templates/word-template.md](templates/word-template.md) — Word processing
document structure (styles, headings, tables).
- [templates/excel-template.md](templates/excel-template.md) — workbook
structure (sheets, cells, shared strings, formulas).
- [templates/powerpoint-template.md](templates/powerpoint-template.md) — slide
deck structure (slides, layouts, notes).
Fill the `[fill: ...]` markers in the template with content from the content
model. Templates are the contract between content and layout — changing the
template is how you change appearance without touching content.
### 4. Render
Produce the artifact file:
- **PDF** — render the template to PDF (print CSS in a browser or engine, or a
LaTeX toolchain). See [references/pdf.md](references/pdf.md) for tooling.
- **Word / Excel / PowerPoint** — write the OOXML package directly (stdlib
`zipfile` + XML for small artifacts) or with the conventional library for the
format (python-docx, openpyxl, python-pptx). See the per-format reference for
the exact package layout to produce.
### 5. Validate
Never deliver unvalidated output. Run the validation script:
```bash
python3 scripts/validate-documents.py --render-check --json report.pdf brief.docx data.xlsx deck.pptx
```
The script performs **structural sanity** (container signatures, required
parts, XML well-formedness) and, when a renderer is installed, a **render
check** (actually renders the file). When no renderer is present it reports
`unavailable` instead of failing — validation never hard-requires a renderer.
See [references/output-quality.md](references/output-quality.md) for the full
output-quality checklist, and the fixture files in
[fixtures/](fixtures/) (one per format) to smoke-test the script itself:
```bash
python3 scripts/validate-documents.py --json fixtures/sample.pdf fixtures/sample.docx fixtures/sample.xlsx fixtures/sample.pptx
```
### 6. Deliver
Hand off the artifact with its provenance:
- The **source content model** (so it can be regenerated).
- The **template version** used.
- The **validation result** (structure passed; render checked or unavailable).
- Any **known deviations** (fonts substituted, images downscaled, layout drift).
## Exit conditions
The task is complete when the artifact exists, passes structural validation
(and the render check when a renderer is available), and the content matches
the agreed scope. Stop after delivering the validated artifact with its
provenance; do not keep iterating on layout without a new scope instruction.
## Scripts
All scripts live in [scripts/](scripts/) relative to this skill's directory and
follow cli-builder conventions: `--json` for machine output, non-interactive,
errors to stderr. Run with `--help` for full flag details.
### validate-documents.py — Structural Sanity + Render Check
```bash
python3 scripts/validate-documents.py report.pdf # human report
python3 scripts/validate-documents.py --json report.pdf # machine report
python3 scripts/validate-documents.py --render-check --json report.pdf data.xlsx deck.pptx
```
Behavior:
- **Structural sanity** per format: PDF header/EOF/page objects; OOXML ZIP
container, `[Content_Types].xml`, required parts, XML well-formedness.
Legacy `.doc/.xls/.ppt` files are recognized via OLE2 magic bytes.
- **Render check** (`--render-check`): renders PDF via `pdftoppm`/`mutool`/`gs`
and Office formats via LibreOffice. Reports `unavailable` — exit 0 — when no
renderer is installed (graceful degradation, never a crash).
- **Exit codes**: 0 all pass (or render check unavailable); 1 a file fails
structure or rendering; 2 usage/I/O error.
- **JSON output**: top-level `status` (`ok` / `fail` / `unavailable` / `error`)
with per-file checks and render results.
## Related skills
- [epub](../epub/SKILL.md) — ebook container skill; the sibling family-skill
precedent for this format family.
- [data-engineering](../data-engineering/SKILL.md) — data pipelines and
transformation; Excel is a deliverable format here, not a data store.
- [cli-builder](../cli-builder/SKILL.md) — the CLI conventions the validation
script follows (`--json`, non-interactive, exit codes).
templates/excel-template.md
# Excel (.xlsx) Generation Template
Fill every `[fill: ...]` marker with content from the data model, then
generate the .xlsx (openpyxl, or raw OPC for tiny workbooks). Delete this
instruction block after filling.
## Workbook metadata
- **Workbook title:** _[fill: workbook name]_
- **Owner / source:** _[fill: team and data source]_
- **Date generated:** _[fill: YYYY-MM-DD]_
- **Version:** _[fill: 1.0]_
## Scope contract
- **Purpose:** _[fill: what decisions this workbook supports]_
- **Sheet list:** _[fill: one line per sheet: name and content]_
- **Source of truth:** _[fill: path to the source data (CSV/JSON/etc.)]_
## Sheet layout — _[fill: sheet name]_
### Columns
| Column | Header text | Type (number/date/text/currency) | Notes |
|--------|-------------|----------------------------------|-------|
| A | _[fill: header]_ | _[fill: type]_ | _[fill: notes]_ |
| B | _[fill: header]_ | _[fill: type]_ | _[fill: notes]_ |
### Rows
- **Row 1 header row:** _[fill: yes]_
- **Data rows:** _[fill: source path or inline rows]_
- **Formulas:** _[fill: which cells hold formulas and what they compute; every
formula cell must carry a cached value]_
### Styling
- **Number formats:** _[fill: currency/date formats per column]_
- **Column widths:** _[fill: widths so data is not clipped]_
- **Freeze panes:** _[fill: header row frozen?]_
## Validation gate
```bash
python3 scripts/validate-documents.py --render-check --json output.xlsx
```
- **Structure passed:** _[fill: script exit code and status]_
- **Render check:** _[fill: ok or unavailable, with renderer used]_
- **Spot check:** _[fill: 2-3 cells compared against source data, confirmed]_
templates/pdf-template.md
# PDF Generation Template
Fill every `[fill: ...]` marker with content from the content model, then
render to PDF (print-ready HTML/CSS or LaTeX). Delete this instruction block
after filling.
## Document metadata
- **Title:** _[fill: document title]_
- **Author / owner:** _[fill: author or team]_
- **Audience:** _[fill: who reads this and what decision it supports]_
- **Date:** _[fill: YYYY-MM-DD]_
- **Version:** _[fill: 1.0]_
## Scope contract
- **Purpose:** _[fill: one sentence on what this PDF is for]_
- **Page budget:** _[fill: expected page count / upper bound]_
- **Layout engine:** _[fill: print HTML/CSS (WeasyPrint or headless Chromium), LaTeX, or direct PDF]_
- **Source of truth:** _[fill: path to the content model this is generated from]_
## Content outline
### Section 1 — _[fill: section title]_
- **Body:** _[fill: paragraph or bullet content]_
- **Layout notes:** _[fill: fonts, spacing, page-break constraints]_
### Section 2 — _[fill: section title]_
- **Body:** _[fill: paragraph or bullet content]_
- **Layout notes:** _[fill: fonts, spacing, page-break constraints]_
### Section N — _[fill: section title]_
- **Body:** _[fill: paragraph or bullet content]_
- **Layout notes:** _[fill: fonts, spacing, page-break constraints]_
## Assets
- **Images/figures:** _[fill: image paths and placement]_
- **Fonts:** _[fill: font names; confirm they will be embedded]_
## Validation gate
```bash
python3 scripts/validate-documents.py --render-check --json output.pdf
```
- **Structure passed:** _[fill: script exit code and status]_
- **Render check:** _[fill: ok or unavailable, with renderer used]_
- **Page count observed:** _[fill: matches scope?]_
templates/powerpoint-template.md
# PowerPoint (.pptx) Generation Template
Fill every `[fill: ...]` marker with content from the slide outline, then
generate the .pptx (python-pptx, or raw OPC for tiny decks). Delete this
instruction block after filling.
## Deck metadata
- **Deck title:** _[fill: presentation title]_
- **Presenter / owner:** _[fill: presenter or team]_
- **Audience:** _[fill: who sees this and the setting]_
- **Date:** _[fill: YYYY-MM-DD]_
- **Version:** _[fill: 1.0]_
## Scope contract
- **Purpose:** _[fill: what the talk decides or informs]_
- **Slide budget:** _[fill: expected slide count]_
- **Aspect ratio:** _[fill: 16:9 or 4:3]_
- **Layout family:** _[fill: which layout to reuse across slides]_
- **Source of truth:** _[fill: path to the outline content model]_
## Slide outline
### Slide 1 — Title
- **Title:** _[fill: deck title]_
- **Subtitle:** _[fill: presenter, date]_
### Slide 2 — _[fill: section title]_
- **Title:** _[fill: slide title]_
- **Bullets:** _[fill: one bullet per line; keep short enough to fit]_
- **Notes:** _[fill: speaker notes]_
### Slide N — _[fill: section title]_
- **Title:** _[fill: slide title]_
- **Bullets:** _[fill: one bullet per line; keep short enough to fit]_
- **Notes:** _[fill: speaker notes]_
## Assets
- **Images:** _[fill: image paths; confirm they will be embedded in ppt/media]_
- **Charts/tables:** _[fill: any data visuals and their source data]_
## Validation gate
```bash
python3 scripts/validate-documents.py --render-check --json output.pptx
```
- **Structure passed:** _[fill: script exit code and status]_
- **Render check:** _[fill: ok or unavailable, with renderer used]_
- **Slide count observed:** _[fill: matches scope?]_
- **Overflow check:** _[fill: longest bullet verified to fit, or flagged]_
templates/word-template.md
# Word (.docx) Generation Template
Fill every `[fill: ...]` marker with content from the content model, then
generate the .docx (python-docx, pandoc, or raw OPC). Delete this instruction
block after filling.
## Document metadata
- **Title:** _[fill: document title]_
- **Author / owner:** _[fill: author or team]_
- **Audience:** _[fill: who reads this]_
- **Date:** _[fill: YYYY-MM-DD]_
- **Version:** _[fill: 1.0]_
## Scope contract
- **Purpose:** _[fill: one sentence on what this document is for]_
- **Style base:** _[fill: template or style set to build on]_
- **Source of truth:** _[fill: path to the content model]_
## Content outline (styles, not ad-hoc formatting)
### Heading 1 — _[fill: section title]_
- **Paragraph(s):** _[fill: body text, one paragraph per bullet]_
- **Style to apply:** _[fill: Heading 2 / Normal / List Bullet]_
- **Table needed:** _[fill: yes/no; if yes, headers and row content]_
### Heading 2 — _[fill: section title]_
- **Paragraph(s):** _[fill: body text, one paragraph per bullet]_
- **Style to apply:** _[fill: Heading 2 / Normal / List Bullet]_
- **Table needed:** _[fill: yes/no; if yes, headers and row content]_
## Tables
| Table name | Header row | Rows |
|------------|------------|------|
| _[fill: name]_ | _[fill: column headers]_ | _[fill: row content or source path]_ |
## Assets
- **Images:** _[fill: image paths; confirm they will be embedded in word/media]_
- **Header/footer:** _[fill: page numbers, running title]_
- **Metadata fields:** _[fill: title/author for docProps]_
## Validation gate
```bash
python3 scripts/validate-documents.py --render-check --json output.docx
```
- **Structure passed:** _[fill: script exit code and status]_
- **Render check:** _[fill: ok or unavailable, with renderer used]_
- **Headings check:** _[fill: all headings use heading styles, confirmed]_
tests/test_validate_documents.py
#!/usr/bin/env python3
"""Tests for documents/scripts/validate-documents.py.
Runs standalone (``python3 documents/tests/test_validate_documents.py``) and is
discovered by scripts/check-artifacts.py's ``unittest`` discovery pass. Every
test is environment-independent: renderer availability is simulated by
monkeypatching the module's renderer-discovery functions, never by assuming a
renderer is or is not installed.
"""
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPTS_DIR = REPO_ROOT / "documents" / "scripts"
FIXTURES_DIR = REPO_ROOT / "documents" / "fixtures"
SPEC = importlib.util.spec_from_file_location(
"validate_documents", SCRIPTS_DIR / "validate-documents.py"
)
vd = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(vd)
FIXTURES = ["sample.pdf", "sample.docx", "sample.xlsx", "sample.pptx"]
class StructuralValidationTests(unittest.TestCase):
def test_all_fixtures_pass(self):
for name in FIXTURES:
with self.subTest(fixture=name):
report = vd.validate_files([str(FIXTURES_DIR / name)])
self.assertEqual(report["status"], "ok")
self.assertEqual(report["exit_code"], 0)
self.assertEqual(report["files"][0]["status"], "pass")
def test_each_format_detected(self):
expected = {
"sample.pdf": "pdf",
"sample.docx": "docx",
"sample.xlsx": "xlsx",
"sample.pptx": "pptx",
}
for name, fmt in expected.items():
with self.subTest(fixture=name):
report = vd.validate_files([str(FIXTURES_DIR / name)])
self.assertEqual(report["files"][0]["format"], fmt)
def test_json_output_has_status_and_ok(self):
report = vd.validate_files([str(FIXTURES_DIR / "sample.pdf")])
self.assertIn("status", report)
self.assertIn("ok", report)
self.assertTrue(report["ok"])
self.assertTrue(json.dumps(report)) # serializable
def test_broken_pdf_fails(self):
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(b"this is not a pdf at all")
path = tmp.name
try:
report = vd.validate_files([path])
self.assertEqual(report["status"], "fail")
self.assertEqual(report["exit_code"], 1)
self.assertEqual(report["files"][0]["status"], "fail")
finally:
os.unlink(path)
def test_corrupt_zip_fails(self):
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
tmp.write(b"PK\x03\x04 not really a zip")
path = tmp.name
try:
report = vd.validate_files([path])
self.assertEqual(report["status"], "fail")
self.assertEqual(report["exit_code"], 1)
finally:
os.unlink(path)
def test_missing_file_is_error(self):
report = vd.validate_files([str(REPO_ROOT / "documents" / "fixtures" / "nope.pdf")])
self.assertEqual(report["status"], "error")
self.assertEqual(report["exit_code"], 2)
def test_unsupported_extension_skipped(self):
report = vd.validate_files([str(REPO_ROOT / "documents" / "SKILL.md")])
self.assertEqual(report["files"][0]["status"], "skipped")
self.assertEqual(report["status"], "ok")
self.assertEqual(report["exit_code"], 0)
class RenderCheckTests(unittest.TestCase):
def setUp(self):
# Force "no renderer installed" for deterministic degradation tests.
self.orig_pdf = vd.find_pdf_renderer
self.orig_office = vd.find_office_renderer
vd.find_pdf_renderer = lambda: None
vd.find_office_renderer = lambda: None
def tearDown(self):
vd.find_pdf_renderer = self.orig_pdf
vd.find_office_renderer = self.orig_office
def test_render_check_unavailable_without_renderer(self):
for name in FIXTURES:
with self.subTest(fixture=name):
report = vd.validate_files([str(FIXTURES_DIR / name)], render_check=True)
self.assertEqual(report["status"], "unavailable")
self.assertEqual(report["exit_code"], 0)
self.assertEqual(report["files"][0]["render"]["status"], "unavailable")
def test_render_check_unavailable_for_unsupported_file(self):
report = vd.validate_files([str(REPO_ROOT / "documents" / "SKILL.md")], render_check=True)
self.assertEqual(report["status"], "unavailable")
self.assertEqual(report["exit_code"], 0)
def test_render_ok_with_pdf_renderer(self):
vd.find_pdf_renderer = lambda: "/usr/bin/env-test-pdftoppm"
vd.find_office_renderer = lambda: None
# Renderer discovery is stubbed; render_pdf is stubbed to success so no
# real binary is required.
original = vd.render_pdf
vd.render_pdf = lambda path, tmpdir: {"status": "ok", "renderer": "pdftoppm", "pages": 1}
try:
report = vd.validate_files([str(FIXTURES_DIR / "sample.pdf")], render_check=True)
self.assertEqual(report["status"], "ok")
self.assertEqual(report["exit_code"], 0)
self.assertEqual(report["files"][0]["render"]["status"], "ok")
finally:
vd.render_pdf = original
def test_render_failure_fails_report(self):
vd.find_pdf_renderer = lambda: "/usr/bin/env-test-pdftoppm"
original = vd.render_pdf
vd.render_pdf = lambda path, tmpdir: {"status": "failed", "renderer": "pdftoppm", "reason": "boom"}
try:
report = vd.validate_files([str(FIXTURES_DIR / "sample.pdf")], render_check=True)
self.assertEqual(report["status"], "fail")
self.assertEqual(report["exit_code"], 1)
finally:
vd.render_pdf = original
def test_render_pdf_dispatches_renderer_specific_args(self):
# Each supported PDF renderer has its own CLI; a machine with only
# mutool or ghostscript (no pdftoppm) must still render correctly.
captured = {}
def fake_run(cmd, capture_output, timeout):
captured["cmd"] = cmd
return subprocess.CompletedProcess(cmd, 0, b"", b"")
original_run = vd.subprocess.run
original_find = vd.find_pdf_renderer
vd.subprocess.run = fake_run
try:
with tempfile.TemporaryDirectory() as tmp:
vd.find_pdf_renderer = lambda: "/usr/bin/pdftoppm"
vd.render_pdf(FIXTURES_DIR / "sample.pdf", Path(tmp))
self.assertIn("-png", captured["cmd"])
self.assertNotIn("draw", captured["cmd"])
vd.find_pdf_renderer = lambda: "/usr/bin/mutool"
vd.render_pdf(FIXTURES_DIR / "sample.pdf", Path(tmp))
self.assertIn("draw", captured["cmd"])
self.assertIn("-o", captured["cmd"])
vd.find_pdf_renderer = lambda: "/usr/bin/gs"
vd.render_pdf(FIXTURES_DIR / "sample.pdf", Path(tmp))
self.assertIn("-sDEVICE=png16m", captured["cmd"])
self.assertTrue(any(arg.startswith("-sOutputFile=") for arg in captured["cmd"]))
finally:
vd.subprocess.run = original_run
vd.find_pdf_renderer = original_find
class CliTests(unittest.TestCase):
def run_cli(self, *args):
return subprocess.run(
[sys.executable, str(SCRIPTS_DIR / "validate-documents.py")] + list(args),
capture_output=True,
text=True,
)
def test_help_exits_zero_and_advertises_json(self):
proc = self.run_cli("--help")
self.assertEqual(proc.returncode, 0)
self.assertIn("--json", proc.stdout)
self.assertIn("--render-check", proc.stdout)
def test_cli_json_on_fixture(self):
proc = self.run_cli("--json", str(FIXTURES_DIR / "sample.xlsx"))
self.assertEqual(proc.returncode, 0)
report = json.loads(proc.stdout)
self.assertEqual(report["status"], "ok")
def test_cli_exit_1_on_broken(self):
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(b"junk")
path = tmp.name
try:
proc = self.run_cli("--json", path)
self.assertEqual(proc.returncode, 1)
finally:
os.unlink(path)
def test_cli_render_check_unavailable_when_no_renderer(self):
# The CLI uses the real renderer discovery; run it only when we can
# force the no-renderer path via the module-level stub in-process,
# which is covered by RenderCheckTests. Here we only assert the CLI
# accepts the flag and returns 0 or 1 without crashing.
proc = self.run_cli("--render-check", "--json", str(FIXTURES_DIR / "sample.pdf"))
self.assertIn(proc.returncode, (0, 1))
report = json.loads(proc.stdout)
self.assertIn(report["status"], ("ok", "unavailable", "fail"))
if __name__ == "__main__":
unittest.main()