Metadata-Version: 2.5
Name: mussannoni
Version: 0.1.2
Summary: Render Tanzanian school examination result reports to pixel-measured PDFs from plain Python dicts
Project-URL: Homepage, https://github.com/reubenmisana-code/mussannoni
Project-URL: Repository, https://github.com/reubenmisana-code/mussannoni
Project-URL: Issues, https://github.com/reubenmisana-code/mussannoni/issues
Author: Reuben Misana
Author-email: Kiyabo Nhende <kiyaboapp@gmail.com>
Maintainer-email: Kiyabo Nhende <kiyaboapp@gmail.com>
License-Expression: MIT
License-File: LICENSE
License-File: templates/_shared/fonts/LIBERATION-LICENSE.txt
Keywords: education,jinja2,necta,pdf,reports,sars,tanzania,weasyprint
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Printing
Classifier: Topic :: Text Processing :: Markup :: HTML
Classifier: Typing :: Typed
Requires-Python: <3.14,>=3.11
Requires-Dist: jinja2==3.1.6
Requires-Dist: pymupdf==1.28.2
Requires-Dist: weasyprint>=70.0
Provides-Extra: workshop
Requires-Dist: httpx==0.28.1; extra == 'workshop'
Requires-Dist: lxml==6.1.3; extra == 'workshop'
Requires-Dist: pillow==12.3.0; extra == 'workshop'
Requires-Dist: pyyaml==6.0.3; extra == 'workshop'
Requires-Dist: scikit-image==0.26.0; extra == 'workshop'
Description-Content-Type: text/markdown

# MussaNnoni Report Conversion

Reproduce every SARS examination report as a measured HTML + CSS template, and render new
reports from application data onto the same grid.

Design credit: Mussa Nnoni — SARS ([sars.ac.tz](https://sars.ac.tz)). Created by Reuben Misana
and Kiyabo Nhende. See [CREDITS.md](CREDITS.md).

This repository is two things, and they are documented separately:

- **A package** — `pip install mussannoni`, pass a dict, get a PDF. Start below.
- **A fidelity workshop** — the machinery that measures the 46 reference PDFs and proves the
  templates reproduce them. That is the specification from [Goal](#goal) onward.

> **Working on aligning the reports with live application data?** Start at
> [docs/06-exametrics-alignment.md](docs/06-exametrics-alignment.md). It carries the per-report
> status of all 46, what each still needs, the verification procedure, and the approaches already
> measured as wrong.

## Install

```sh
pip install mussannoni
```

For glyph-exact output also install Arial and Times New Roman on the rendering host; without
them the layout still holds, because the bundled fallbacks are metric-compatible, but the glyph
outlines differ. `mussannoni doctor` reports what it can find.

## Use

Pass the data as a dict (or JSON) and get PDF bytes back:

```python
import mussannoni

pdf = mussannoni.render_report(
    "council_best_students",
    {
        "rows": [
            [1, "NYAMAGANA", "MWANZA SECONDARY", "GOVERNMENT", "S0333-0001", "JUMA ALI", "M"],
            [2, "ILEMELA", "BUGANDO SECONDARY", "PRIVATE", "S0334-0002", "ASHA HAMISI", "F"],
        ],
    },
    level="primary",
)

open("report.pdf", "wb").write(pdf)
```

Rows may be lists (positional, by column) or mappings keyed by column label or index. `None`
renders as an empty cell, not the string `"None"`. Rows are paginated automatically and the
measured header band repeats on every page. `render_report_to_file(...)` writes straight to a
path and creates parent directories.

`level` is needed only for the handful of report keys that exist at both levels — pass just the
key otherwise, and you get a clear error rather than the wrong report if it is ambiguous.

The full data contract:

```python
{
  "title":   str,                   # optional; defaults to the measured title
  "columns": [str, ...],            # optional; override the measured column labels
  "header":  {"0.3": str, ...},     # optional; override any header cell, addressed "row.column"
  "rows":    [[value, ...], ...],   # required; or [{"LABEL": value, ...}, ...]
}
```

### Discovering a report

```python
mussannoni.list_reports()                 # all 46, in processing order
mussannoni.list_reports("primary")        # 28
layout = mussannoni.report_layout("council_best_students", level="primary")
layout["header"]["labels"]                # what the columns are called
layout["rows_per_page"]                   # how many rows fit before it paginates
```

### From the shell

```sh
mussannoni reports                         # list what can be rendered
mussannoni layout council_best_students --level primary
mussannoni render council_best_students --level primary --data rows.json --out report.pdf
mussannoni doctor                          # which engines and fonts are usable here
```

### Reproducing a reference exactly

`render_report` places *your* data on the measured grid. To reproduce a reference PDF glyph for
glyph — every cluster at its recorded advance — build a geometry document and render that
instead:

```python
mussannoni.render_document(document, "primary", "council_best_students")
```

`mussannoni.document` documents that format in full. The difference, and why generated text
cannot carry the measured per-glyph corrections, is explained in
[docs/05-packaging.md](docs/05-packaging.md).

### Engines

The package defaults to **WeasyPrint**, which renders in-process and is the only engine `pip`
can install. The workshop defaults to **Chromium** via the `agent-browser` CLI, because every
committed calibration was measured against it. Select one with `engine=`, the
`MUSSANNONI_ENGINE` environment variable, or `--engine`. They are not interchangeable at
sub-point precision — see [Rendering engine](#rendering-engine).

---

## Goal

Reproduce every SARS report as an HTML + CSS template that renders to a PDF
**visually identical** to the original. The original PDFs are the reference; our
templates are the product. Success is measured by page-for-page comparison, not
by "looks close".

Two sources define the whole corpus — one secondary exam, one primary exam, both
2026 Mwanza / Mwanza CC:

| Level | Exam | URL |
|-------|------|-----|
| SECONDARY | Form Two Mock Result 2026 | `https://sars.ac.tz/results/exam/form-two-mock-result/2026/mwanza/mwanza-cc-1787231849` |
| PRIMARY | Matokeo Darasa la IV Mock Mkoa 2026 | `https://sars.ac.tz/results/exam/matokeo-darasa-la-iv-mock-mkoa/2026/mwanza/mwanza-cc-1787231849` |

Each page exposes three blocks: **School List**, **District Summaries**,
**Regional Summaries**. Every link is a `view-results?file=<key>.pdf&name=<label>`
redirect to a stored PDF — `results/pdfs/…` for schools, `summaries/…` for
summaries.

## The deduplication rule (this is the whole point)

The two pages carry **330 links** but only **46 distinct layouts** — 284
redundant PDFs of identical structure with different numbers in them. A layout is
identified by *what it is*, never by whose data was in the sample.

Link budget as published:

| Block | Secondary | Primary | Total |
|-------|-----------|---------|-------|
| School list | 64 | 170 | 234 |
| District summaries | 7 | 13 | 20 |
| Regional summaries | 48 | 28 | 76 |
| **Links** | **119** | **211** | **330** |
| **Distinct layouts** | **18** | **28** | **46** |

Collapse in this order:

1. **Schools — 234 links → 2 PDFs.** Every school results PDF on a level shares
   one layout. Take **one school per level** and stop. (Secondary: 64 schools.
   Primary: 170 schools.)
2. **Per-subject reports — 36 links across 22 subjects → 1 PDF.**
   `SCHOOL RANK-<SUBJECT>` is published once per subject offered — Mathematics,
   Biology, Kiswahili, Chemistry, EDK, HTM, Fine Art, Agriculture, BKeeping,
   Busines Studies, Bible Knowledge, Computer Science, Chines Language, English
   Language, Food, French Language, Geography, History, Music, Physics, Sport
   Studies, Theatre Arts — 22 distinct subjects, 14 of them listed twice. One
   subject's report *is* the template for all of them; the subject name is data,
   not layout. Take **Mathematics only**.
3. **Republished duplicates — drop the second copy.** Both pages list the same
   report twice under different object keys (the secondary regional block
   repeats most of its list; every primary regional entry appears exactly twice).
   Dedup by display name, keep the first key.
4. **Government / Private / Overall variants — download all three, then verify.**
   `SCHOOLS RANK OVERALL`, `SCHOOLS RANK FOR GOVERNMENTS`, `SCHOOL RANK FOR
   PRIVATES` are probably one layout with a different row filter. Confirm by
   diffing the converted HTML; if identical, keep one template and alias the
   other two to it. Do not assume before diffing.

Net corpus: **18 secondary + 28 primary = 46 PDFs.**

This count is not a coincidence. `app/services/exametrics/mussannoni/registry.py`
already declares 18 `SECONDARY_REPORTS` and 28 `PRIMARY_REPORTS`, and the keys
line up one-for-one with the live page. The registry is correct and complete —
this conversion fills in the templates behind it, it does not redesign it.

## Download manifest

Order matters: one school first, then council, then region. Prove the pipeline on
the hardest layout (school results — the widest grid) before scaling out.

### SECONDARY — 18

**Phase 1 · school (1)**

| # | `report_key` | Sample to take |
|---|--------------|----------------|
| 1 | `school_results` | `S0333 – MWANZA SECONDARY SCHOOL` |

**Phase 2 · council, scope `MWANZA CC` (7)**

| # | `report_key` | Page label |
|---|--------------|------------|
| 2 | `council_subjects_rank` | SUBJECTS RANK |
| 3 | `council_schools_rank` | SCHOOLS RANK |
| 4 | `council_wards_rank` | WARDS RANK |
| 5 | `council_top_schools` | 10 BEST SCHOOLS |
| 6 | `council_best_students` | 10 BEST STUDENTS |
| 7 | `council_best_students_subjectwise` | 10 BEST STUDENTS SUBJECTWISE |
| 8 | `council_schools_rank_subjectwise` | SCHOOLS RANK SUBJECTWISE |

**Phase 3 · subject scope (1) + region scope, `MWANZA` (9)**

| # | `report_key` | Page label | Note |
|---|--------------|------------|------|
| 9 | `subject_schools_rank` | SCHOOL RANK-MATHEMATICS | stands for all 22 subjects |
| 10 | `region_schools_rank_overall` | SCHOOLS RANK OVERALL | |
| 11 | `region_schools_rank_government` | SCHOOLS RANK FOR GOVERNMENTS | diff against #10 |
| 12 | `region_schools_rank_private` | SCHOOL RANK FOR PRIVATES | diff against #10 |
| 13 | `region_top_schools` | TOP 10 SCHOOLS | |
| 14 | `region_best_students_overall` | BEST STUDENTS-OVERALL | |
| 15 | `region_best_students_subjectwise` | BEST STUDENTS-SUBJECTWISE | |
| 16 | `region_council_performance` | F2 DISTRICT PERFORMANCE | |
| 17 | `region_subjects_performance` | OVERALL SUBJECTS PERFORMANCE | |
| 18 | `region_mobility` | F2 MOCK MOBILITY 2026 | |

### PRIMARY — 28

Swahili report names below are reproduced verbatim from the SARS site. They are
the originals and stay as-is; no new Swahili is introduced anywhere else.

**Phase 1 · school (1)**

| # | `report_key` | Sample to take |
|---|--------------|----------------|
| 19 | `school_results` | `PS1304014 – BUTIMBA PRIMARY SCHOOL` |

**Phase 2 · council, scope `MWANZA CC` (13)**

| # | `report_key` | Page label |
|---|--------------|------------|
| 20 | `council_kata_rank_alama` | KATA RANK ALAMA |
| 21 | `council_kata_rank_grading` | KATA RANK GRADING |
| 22 | `council_school_rank_binafsi` | SCHOOL RANK BINAFSI |
| 23 | `council_school_rank_serikali` | SCHOOL RANK SERIKALI |
| 24 | `council_school_rank_in_grade` | SCHOOL RANK IN GRADE |
| 25 | `council_school_rank_ufaulu_alama` | SCHOOL RANK UFAULU ALAMA |
| 26 | `council_best_students` | 10 BEST STUDENTS |
| 27 | `council_top_schools_alama` | 10 BEST SCHOOLS ALAMA |
| 28 | `council_top_schools_grading` | 10 BEST SCHOOLS GRADING |
| 29 | `council_top_schools_kimasomo_overall` | 10 BEST SCHOOLS KIMASOMO OVERALL |
| 30 | `council_top_schools_kimasomo_serikali` | 10 BEST SCHOOLS KIMASOMO SERIKALI |
| 31 | `council_subject_summary` | SUBJECT SUMMARY |
| 32 | `council_ufaulu_wa_masomo` | UFAULU WA MASOMO |

**Phase 3 · region, scope `MWANZA` (14)**

Every one of these is listed twice on the page under two object keys. Keep the
first, discard the second.

| # | `report_key` | Page label |
|---|--------------|------------|
| 33 | `region_kata_serikali` | MKOA KATA SHULE ZA SERIKALI STD4 2026 |
| 34 | `region_kata_binafsi` | MKOA KATA SHULE BINAFSI STD4 2026 |
| 35 | `region_kata_jumla` | KATA STD4 JUMLA 2026 |
| 36 | `region_shule_bora_jumla` | MKOA SHULE BORA STD4 JUMLA 2026 |
| 37 | `region_shule_bora_masomo_serikali` | MKOA SHULE BORA MASOMO SERIKALI STD4 2026 |
| 38 | `region_shule_bora_masomo_jumla` | MKOA SHULE BORA MASOMO STD4 JUMLA 2026 |
| 39 | `region_ufaulu_masomo` | MKOA UFAULU MASOMO STD4 2026 |
| 40 | `region_ufaulu_masomo_jumla` | MKOA UFAULU MASOMO STD4 JUMLA 2026 |
| 41 | `region_wanafunzi_bora` | MKOA WANAFUNZI BORA STD4 2026 |
| 42 | `region_shule_serikali` | SHULE SERIKALI STD4 2026 |
| 43 | `region_shule_binafsi` | SHULE BINAFSI STD4 2026 |
| 44 | `region_shule_nafasi_jumla` | SHULE NAFASI STD4 JUMLA 2026 |
| 45 | `region_halmashauri_masomo` | HALMASHAURI MASOMO STD4 2026 |
| 46 | `region_halmashauri_jumla` | HALMASHAURI STD4 JUMLA 2026 |

### Not in the corpus

**Zone scope.** `registry.applicable()` emits zone reports only when an exam
spans two or more regions. Mwanza is a single region, so no zone sample exists
and none can be converted from this corpus. That is a known, recorded gap — it
must not be faked by widening a region template.

## Repository structure

One report converts to one directory. Everything needed to prove that report is
correct lives in that directory, so a report can be reviewed, re-cut, or deleted
on its own.

```
mussannoni/
├── README.md                       what this is, how to run it, conversion status table
├── Makefile                        fetch / convert / render / verify / report
├── pyproject.toml                  pinned deps: httpx, pymupdf, lxml, jinja2, pillow, pytest
├── .gitignore                      work/, *.local.*  — never the corpus or the outputs
│
├── catalog/
│   ├── reports.yaml                the 46 entries: key, level, scope, title, source URL,
│   │                               object key, sample unit, status, aliases
│   ├── subjects.yaml               subjects per level — the dedup evidence for rule 2
│   └── dedup.md                    which links collapsed into which entry, and why
│
├── corpus/                         REFERENCE — read-only, never edited, committed
│   ├── secondary/
│   │   ├── school-results.pdf
│   │   ├── council-subjects-rank.pdf
│   │   ├── region-schools-rank-overall.pdf
│   │   └── …                       18 files, named by report_key
│   ├── primary/
│   │   └── …                       28 files
│   └── MANIFEST.json               sha256 + byte size + page count + fetch timestamp
│
├── extract/                        what the PDF actually contains — machine-read, not typed
│   ├── secondary/school-results/
│   │   ├── spans.json              every text span: text, x, y, font, size, weight, colour
│   │   ├── rules.json              every line and filled rect: x0,y0,x1,y1, width, colour
│   │   ├── geometry.json           page size, margins, column x-edges, row pitch, header bands
│   │   ├── fonts.json              embedded fonts → local face + fallback
│   │   └── page-01.png             300 dpi raster, the visual baseline
│   └── …                           one directory per report
│
├── templates/                      THE PRODUCT
│   ├── _shared/
│   │   ├── reset.css               page box, @page size/margin, zero-default table
│   │   ├── tokens.css              measured values only — font stack, sizes, rules, pitch
│   │   ├── grid.css               column-width primitives keyed to measured x-edges
│   │   ├── header.html             exam title / scope line / logo block
│   │   ├── footer.html             page x of y, generated-on, closing block
│   │   └── fonts/                  woff faces + LICENSE for each
│   ├── secondary/
│   │   ├── school-results/
│   │   │   ├── template.html       Jinja2 — structure and slots, no literal sample data
│   │   │   ├── report.css          only what this report adds over _shared
│   │   │   ├── bindings.yaml       column → canonical field, formats, alignment, widths
│   │   │   └── fixture.json        data scraped from the reference, for rendering a diffable proof
│   │   └── …
│   └── primary/ …
│
├── output/                         generated — committed so a diff is reviewable
│   ├── secondary/school-results/
│   │   ├── rendered.pdf            template + fixture, through the real engine
│   │   ├── rendered-page-01.png    300 dpi, same raster settings as the reference
│   │   ├── diff-page-01.png        red = our pixels, blue = reference pixels
│   │   └── report.json             per-page ssim, pixel delta %, text-position max drift
│   └── …
│
├── tools/
│   ├── fetch.py                    resolve view-results → object key → PDF, honour catalog dedup
│   ├── extract.py                  PyMuPDF → spans/rules/geometry/fonts/raster
│   ├── measure.py                  spans → column edges, row pitch, font sizes, margins
│   ├── scaffold.py                 measurements → first-cut template.html + report.css
│   ├── render.py                   template + fixture → PDF (same engine as production)
│   ├── compare.py                  reference vs rendered → ssim, delta, drift, diff image
│   └── status.py                   regenerate the README status table from output/*/report.json
│
├── docs/
│   ├── 00-approach.md              the convert-one-at-a-time loop, and why
│   ├── 01-fidelity.md              acceptance thresholds and how they are measured
│   ├── 02-geometry.md              how column edges and row pitch are derived from spans
│   ├── 03-fonts.md                 embedded-font identification and substitution decisions
│   └── 04-integration.md           promoting a finished template into backend-sis
│
└── tests/
    ├── test_catalog.py             330 links dedup to 46 entries; no key collisions
    ├── test_templates.py           every template parses; every binding field is canonical;
    │                               no literal sample value from the reference survives
    └── test_fidelity.py            every report marked done still meets threshold
```

### Why this shape

- **`corpus/` is read-only input, never source.** It is committed so conversions
  are reproducible, and it is never edited. Editing a reference to make a
  template pass is the one failure that invalidates the whole exercise.
- **`extract/` is machine-read, never hand-typed.** Column positions and font
  sizes come out of PyMuPDF. Eyeballing a PDF and typing `width: 72px` is how
  drift gets in.
- **`output/` is committed.** A pixel diff is only useful if a reviewer can see
  it in the pull request without running anything.
- **Templates carry no sample values.** `fixture.json` holds the reference's
  numbers for proof-rendering; `template.html` holds only slots. This mirrors the
  rule already enforced in `mussannoni/service.py`: a report with no binding is
  skipped, never rendered, because rendering it would print the reference's own
  figures as if they were live results.
- **Naming is by identity, not by sample.** `council-subjects-rank`, never
  `mwanza-cc-subjects-rank`. Mwanza was the source of the sample; it is not part
  of any report's identity.

## The conversion loop

One report at a time, fully finished before the next starts. A half-converted
report is worth nothing, and forty half-converted reports are worth less.

```
1  fetch      tools/fetch.py <key>       → corpus/<level>/<key>.pdf + MANIFEST entry
2  extract    tools/extract.py <key>     → extract/<level>/<key>/{spans,rules,geometry,fonts}.json
                                          + page-NN.png at 300 dpi
3  measure    tools/measure.py <key>     → column x-edges, row pitch, font sizes, margins,
                                          header/footer bands. Read from spans — never guessed.
4  scaffold   tools/scaffold.py <key>    → first-cut template.html + report.css from step 3
5  bind       edit bindings.yaml         → each column mapped to a canonical field,
                                          with format, alignment, width
6  fixture    edit/generate fixture.json → the reference's own rows, for proof-rendering only
7  render     tools/render.py <key>      → output/.../rendered.pdf via the production engine
8  compare    tools/compare.py <key>     → ssim, pixel delta, text drift, diff-page-NN.png
9  iterate    adjust report.css only     → repeat 7–8 until thresholds pass
10 record     tools/status.py            → flip catalog status to done, refresh README table
```

Step 9 adjusts **CSS**, not the reference and not the thresholds.

### Order

`school_results` converts first on each level. It is the widest grid, the most
page-break-sensitive, and the only report that repeats per school — so it
exercises pagination, column compression, and header repetition all at once.
Whatever `_shared/` primitives it needs, every later report reuses. Council
reports follow (narrower, single-table, footer totals), then region reports
(mostly council variants of the same shapes).

## Fidelity criteria

A report is `done` only when, on **every** page:

| Check | Threshold |
|-------|-----------|
| Page count | exactly equal to the reference |
| Page size and orientation | exactly equal |
| Structural similarity (SSIM) | ≥ 0.98 |
| Differing pixels at 300 dpi | ≤ 0.5% |
| Text baseline / left-edge drift | ≤ 1.0 pt for every span |
| Table column x-edges | ≤ 0.5 pt from reference |
| Row count per page and break positions | identical |
| Rule weights and colours | identical |

Anything short of that is `wip`, whatever it looks like at a glance.

Font substitution is the one permitted deviation, and only when the embedded
face cannot be licensed or extracted. It must be recorded in `docs/03-fonts.md`
with the measured drift it introduces — never silently swapped.

## Rendering engine

Render with the same engine production uses, or the comparison proves nothing.
`tools/render.py` builds the HTML once and dispatches to a selectable engine:

- **`chromium`** — headless Chromium via the `agent-browser` CLI. The reference
  renderer for CSS fidelity and the house default.
- **`weasyprint`** — in-process via the `weasyprint` library, much smaller files,
  but its CSS support differs.

Select the engine three ways, in this precedence order (highest first):

1. `--engine {chromium,weasyprint}` on `tools.render` and `tools.convert`
2. the `MUSSANNONI_ENGINE` environment variable
3. the default, `chromium`

```
uv run python -m tools.render primary council_best_students --engine weasyprint
MUSSANNONI_ENGINE=weasyprint uv run python -m tools.render primary council_best_students
make convert-one LEVEL=primary REPORT=council_best_students ENGINE=weasyprint
```

The Makefile exposes the same choice through the `ENGINE` variable; with no
`ENGINE` set, every target defaults to chromium. `tools/render.py` records the
engine that produced each render in `output/<level>/<key>/render-meta.json`, and
`tools/compare.py` reads it so `report.json`'s `engine` field always reflects
what actually rendered the PDF.

**Post-render size optimization.** Chromium emits every positioned span as an
uncompressed PDF object with no object streams, so a four-page render can exceed
1.5 MB — about 8× the reference. After any engine writes the PDF, `tools/render.py`
runs a structural recompression pass (`optimize_pdf`) that re-saves it through
PyMuPDF with object streams, deflate and garbage collection. Font subsetting and
`clean=True` are deliberately omitted — both measured worse, in time or in bytes;
see [docs/01-fidelity.md](docs/01-fidelity.md). This is content-preserving and
pixel-identical (page count, geometry and rasterised pixels are unchanged and
asserted so). Across the corpus it takes the committed renders from 181,693,210 bytes
to roughly 35.6 MB — **5.1×** smaller, within 2.6× of the references in aggregate;
council-best-students drops from ~1.57 MB to ~318 KB against a 185 KB original.
WeasyPrint output is natively small, so the pass is a near no-op there. The pass is
on by default; pass `--no-optimize` to inspect raw engine output.

Convert and verify against **chromium** first. If a template also needs to hold
up under weasyprint, that is a second, separately recorded verification — a pass
on one engine is not a pass on the other.

Note the snap-confinement trap: a snap Chromium build gets a private `/tmp`, so
writing a PDF there reports success while the host sees nothing. All Chromium
work happens in a working directory under the project tree.

## Integration back into backend-sis

This repo is a **conversion workshop**, not a runtime dependency. Nothing in
`backend-sis` imports it. A finished template is promoted by copying:

- `templates/<level>/<key>/template.html` → the path
  `registry.ReportDef.template` already resolves to
  (`<level>/<key-with-dashes>.html`)
- `templates/_shared/*.css` and `_shared/fonts/*` → the assets bundle served
  alongside generated reports
- `bindings.yaml` → the column binding the resolver consumes

`fixture.json`, `corpus/`, `extract/`, and `output/` never ship. They are
evidence, not product.

## Status tracking

`catalog/reports.yaml` carries one status per report and `tools/status.py`
renders it into the README:

| status | meaning |
|--------|---------|
| `todo` | not fetched |
| `fetched` | reference in corpus, not yet extracted |
| `extracted` | measurements available, no template |
| `wip` | template renders, thresholds not met |
| `done` | all fidelity checks pass, recorded in `output/<key>/report.json` |
| `blocked` | cannot proceed — no sample exists (zone scope), or font unobtainable |
| `alias` | identical layout to another key; points at it, carries no template |

Progress is counted in `done`, never in "templates written".

<!-- conversion-status:start -->

### Conversion status

46 reports in the catalog: **46** wip.

Committed renders total **33.9 MB** vs **13.1 MB** of corpus originals (2.59× the originals). Renders are structurally optimized post-render (object streams + deflate + garbage collection), which is content-preserving and pixel-identical.

`Pages` counts pages meeting every gate. Metrics are the worst value across the
report's pages, measured at 300 dpi by `tools/compare.py`. `Rendered` / `Original` are
the `output/` PDF versus the `corpus/` reference byte sizes. Progress is counted in
`done` only.

| # | Level | `report_key` | Pages | SSIM (worst) | Pixel delta (worst) | Text drift | Column drift | Rendered | Original | Status |
|---|-------|--------------|-------|--------------|---------------------|------------|--------------|----------|----------|--------|
| 1 | SECONDARY | `school_results` | 0/15 | 0.8976 | 11.84% | 0.49 pt | 0.24 pt | 2511 KB | 879 KB | `wip` |
| 2 | SECONDARY | `council_subjects_rank` | 0/2 | 0.9387 | 5.81% | 0.43 pt | 0.42 pt | 109 KB | 64 KB | `wip` |
| 3 | SECONDARY | `council_schools_rank` | 0/1 | 0.8998 | 12.77% | 0.6 pt | 0.12 pt | 381 KB | 257 KB | `wip` |
| 4 | SECONDARY | `council_wards_rank` | 0/1 | 0.9608 | 4.94% | 0.52 pt | 0.24 pt | 119 KB | 73 KB | `wip` |
| 5 | SECONDARY | `council_top_schools` | 0/3 | 0.9541 | 5.34% | 0.52 pt | 0.18 pt | 323 KB | 237 KB | `wip` |
| 6 | SECONDARY | `council_best_students` | 0/5 | 0.9342 | 8.30% | 0.502 pt | 0.24 pt | 483 KB | 127 KB | `wip` |
| 7 | SECONDARY | `council_best_students_subjectwise` | 10/30 | 0.9743 | 2.56% | 0.55 pt | 0.66 pt | 428 KB | 241 KB | `wip` |
| 8 | SECONDARY | `council_schools_rank_subjectwise` | 1/24 | 0.9286 | 8.70% | 0.806 pt | 0.54 pt | 1742 KB | 1083 KB | `wip` |
| 9 | SECONDARY | `subject_schools_rank` | 0/6 | 0.9281 | 8.95% | 0.57 pt | 0.48 pt | 983 KB | 279 KB | `wip` |
| 10 | SECONDARY | `region_schools_rank_overall` | 0/6 | 0.8821 | 13.84% | 0.66 pt | 0.18 pt | 1857 KB | 362 KB | `wip` |
| 11 | SECONDARY | `region_schools_rank_government` | 0/4 | 0.8759 | 14.42% | 0.66 pt | 0.18 pt | 1468 KB | 336 KB | `wip` |
| 12 | SECONDARY | `region_schools_rank_private` | 0/2 | 0.9004 | 11.31% | 0.66 pt | 0.18 pt | 490 KB | 307 KB | `wip` |
| 13 | SECONDARY | `region_top_schools` | 0/6 | 0.9815 | 2.10% | 0.51 pt | 0.18 pt | 326 KB | 285 KB | `wip` |
| 14 | SECONDARY | `region_best_students_overall` | 0/9 | 0.9356 | 4.66% | 0.72 pt | 0.18 pt | 502 KB | 321 KB | `wip` |
| 15 | SECONDARY | `region_best_students_subjectwise` | 0/23 | 0.9803 | 2.17% | 0.66 pt | 0.6 pt | 676 KB | 268 KB | `wip` |
| 16 | SECONDARY | `region_council_performance` | 0/5 | 0.9767 | 2.82% | 1.746 pt | 0.24 pt | 328 KB | 640 KB | `wip` |
| 17 | SECONDARY | `region_subjects_performance` | 0/2 | 0.9591 | 4.56% | 0.57 pt | 0.72 pt | 162 KB | 217 KB | `wip` |
| 18 | SECONDARY | `region_mobility` | 0/6 | 0.9299 | 8.44% | 0.45 pt | 0.48 pt | 976 KB | 396 KB | `wip` |
| 19 | PRIMARY | `school_results` | 0/5 | 0.9375 | 7.65% | 0.67 pt | 0.48 pt | 386 KB | 324 KB | `wip` |
| 20 | PRIMARY | `council_kata_rank_alama` | 0/2 | 0.9444 | 6.48% | 1.152 pt | 0.42 pt | 178 KB | 126 KB | `wip` |
| 21 | PRIMARY | `council_kata_rank_grading` | 0/1 | 0.9577 | 5.25% | 0.46 pt | 0.36 pt | 153 KB | 84 KB | `wip` |
| 22 | PRIMARY | `council_school_rank_binafsi` | 0/2 | 0.9459 | 8.27% | 0.636 pt | 0.36 pt | 440 KB | 275 KB | `wip` |
| 23 | PRIMARY | `council_school_rank_serikali` | 0/2 | 0.9370 | 8.38% | 0.52 pt | 0.36 pt | 427 KB | 282 KB | `wip` |
| 24 | PRIMARY | `council_school_rank_in_grade` | 0/3 | 0.9066 | 10.47% | 0.73 pt | 0.24 pt | 953 KB | 558 KB | `wip` |
| 25 | PRIMARY | `council_school_rank_ufaulu_alama` | 0/4 | 0.8840 | 12.82% | 0.666 pt | 0.36 pt | 889 KB | 494 KB | `wip` |
| 26 | PRIMARY | `council_best_students` | 0/4 | 0.9590 | 5.05% | 0.53 pt | 0.24 pt | 310 KB | 181 KB | `wip` |
| 27 | PRIMARY | `council_top_schools_alama` | 0/2 | 0.9456 | 5.74% | 0.53 pt | 0.6 pt | 197 KB | 117 KB | `wip` |
| 28 | PRIMARY | `council_top_schools_grading` | 0/3 | 0.9607 | 5.03% | 0.55 pt | 0.18 pt | 361 KB | 233 KB | `wip` |
| 29 | PRIMARY | `council_top_schools_kimasomo_overall` | 0/3 | 0.9600 | 5.02% | 0.56 pt | 0.42 pt | 332 KB | 259 KB | `wip` |
| 30 | PRIMARY | `council_top_schools_kimasomo_serikali` | 0/3 | 0.9458 | 5.50% | 0.55 pt | 0.42 pt | 323 KB | 255 KB | `wip` |
| 31 | PRIMARY | `council_subject_summary` | 0/1 | 0.9557 | 4.81% | 0.64 pt | 0.24 pt | 110 KB | 72 KB | `wip` |
| 32 | PRIMARY | `council_ufaulu_wa_masomo` | 0/1 | 0.9785 | 2.42% | 0.91 pt | 0.12 pt | 97 KB | 67 KB | `wip` |
| 33 | PRIMARY | `region_kata_serikali` | 0/4 | 0.9169 | 9.94% | 0.72 pt | 0.3 pt | 986 KB | 261 KB | `wip` |
| 34 | PRIMARY | `region_kata_binafsi` | 0/2 | 0.9287 | 8.90% | 0.63 pt | 0.3 pt | 331 KB | 204 KB | `wip` |
| 35 | PRIMARY | `region_kata_jumla` | 0/4 | 0.9070 | 11.41% | 0.57 pt | 0.3 pt | 1035 KB | 258 KB | `wip` |
| 36 | PRIMARY | `region_shule_bora_jumla` | 0/5 | 0.9624 | 4.57% | 1.479 pt | 0.54 pt | 488 KB | 218 KB | `wip` |
| 37 | PRIMARY | `region_shule_bora_masomo_serikali` | 0/3 | 0.9616 | 4.78% | 0.57 pt | 0.48 pt | 291 KB | 220 KB | `wip` |
| 38 | PRIMARY | `region_shule_bora_masomo_jumla` | 0/3 | 0.9600 | 4.49% | 0.39 pt | 0.48 pt | 286 KB | 220 KB | `wip` |
| 39 | PRIMARY | `region_ufaulu_masomo` | 0/3 | 0.9771 | 2.44% | 0.593 pt | 0.24 pt | 168 KB | 222 KB | `wip` |
| 40 | PRIMARY | `region_ufaulu_masomo_jumla` | 0/1 | 0.9771 | 2.05% | 0.45 pt | 0.12 pt | 85 KB | 204 KB | `wip` |
| 41 | PRIMARY | `region_wanafunzi_bora` | 0/6 | 0.9778 | 2.60% | 0.6 pt | 0.42 pt | 297 KB | 204 KB | `wip` |
| 42 | PRIMARY | `region_shule_serikali` | 0/15 | 0.9223 | 8.94% | 0.57 pt | 0.3 pt | 4093 KB | 448 KB | `wip` |
| 43 | PRIMARY | `region_shule_binafsi` | 0/4 | 0.9296 | 8.80% | 1.02 pt | 0.3 pt | 1008 KB | 248 KB | `wip` |
| 44 | PRIMARY | `region_shule_nafasi_jumla` | 0/16 | 0.8931 | 11.19% | 0.66 pt | 0.3 pt | 5949 KB | 564 KB | `wip` |
| 45 | PRIMARY | `region_halmashauri_masomo` | 0/6 | 0.9733 | 2.92% | 0.45 pt | 0.18 pt | 435 KB | 221 KB | `wip` |
| 46 | PRIMARY | `region_halmashauri_jumla` | 0/4 | 0.9733 | 3.13% | 0.54 pt | 0.18 pt | 277 KB | 203 KB | `wip` |

<!-- conversion-status:end -->
