diff --git a/src/odr/internal/csv/PLAN.md b/src/odr/internal/csv/PLAN.md
new file mode 100644
index 000000000..b84aeb1d4
--- /dev/null
+++ b/src/odr/internal/csv/PLAN.md
@@ -0,0 +1,284 @@
+# CSV plan
+
+Where the csv module is going, and in what order. Written before stage 1; keep
+it honest as stages land.
+
+## Today
+
+`CsvFile` is detection only — 60 lines, `is_decodable() == false`, rendered by
+`html::create_text_service` as a line list. `check_csv_file` reads the **whole**
+file, hard-codes `,` and `"`, and demands a globally uniform field count > 1.
+`text::TextFile`'s constructor runs `guess_charset` over the **whole** stream,
+and `open_strategy` does both speculatively while listing file types.
+
+The detected charset is currently used nowhere: `html/text_file.cpp` pipes raw
+bytes into a document declaring ``, so any non-UTF-8 text
+file already renders as mojibake (`// TODO charset`, `html/text_file.cpp:71`).
+
+## Target
+
+A csv opens as a spreadsheet document — one sheet, cells reachable through
+`SheetAdapter` — so the generic HTML renderer and every binding get table
+rendering without format-specific code. Separator, charset, quoting and header
+are autodetected from a bounded probe, overridable by the caller, and the
+resolved values are readable back.
+
+## Decisions taken up front
+
+**Cell strings are UTF-8.** `Text::content()` returns `std::string` and every
+binding treats it as UTF-8 (JNI `NewStringUTF`, embind, pybind11, `NSString`).
+Passing legacy bytes through and letting a browser sort it out only works for
+the html path, not the api path, so it is not an option. This is what makes an
+encoding we cannot decode fatal to the *sheet* path specifically, while the
+text path stays open to it.
+
+**No element per cell.** `ElementIdentifier` is a `std::uint64_t`
+(`definitions.hpp:7`): encode `kind | row | column` into it and decode in the
+adapter. A flat `ElementRegistry` over cells does not survive a large file, and
+the virtual-id seam is what keeps every later scaling decision an
+implementation detail. This is a deliberate deviation from the pattern in the
+root `AGENTS.md`; it belongs in a `csv/AGENTS.md` once stage 2 lands.
+
+**Detection rejects; the parser does not.** These are two jobs and
+`check_csv_file` currently conflates them — `open_strategy.cpp:281` even
+constructs a `CsvFile` *as* the probe, which is why detection pays for a full
+scan. Split into a probe returning resolved options plus a verdict, and a parser
+taking resolved options and judging nothing.
+
+Detection heuristics (sniffing an unknown text file): score candidate separators
+over the probe, require ≥ 2 columns, reject an unterminated quote at EOF. Both
+rules are discriminators — one column is every line of prose ever written, and a
+dangling quote is good evidence of not-csv — not statements about what is valid.
+
+The parser, given a separator, is **total**: any bytes plus a transcodable
+charset yield some sheet. A one-column csv is a legitimate csv; a truncated
+quoted field closes at EOF; an empty file is an empty sheet; ragged rows pad
+short and widen long. Nothing here is unrepresentable, so nothing throws.
+
+What is left to fail is an incoherent options struct (separator equal to the
+quote char, a separator of `"` or `\n`) — an argument error — and a charset we
+cannot transcode. `NoCsvFile` therefore becomes purely a detection exception,
+which is what its name always claimed.
+
+**Quoting is not type information.** RFC 4180 quoting is lexical — Excel quotes
+any field holding a delimiter, quote or newline, and many writers quote
+everything. Infer nothing from it.
+
+**Encoding is a public enum with a table behind it.** Encodings carry aliases
+the way mime types do (`UTF-8`/`utf8`, `windows-1252`/`cp1252`,
+`ISO-8859-1`/`latin1`) and uchardet hands back a name that has to map home, so
+unlike `FileType` this needs both directions. Mirror `file_type_table.cpp`:
+canonical name first, aliases after, one row per encoding, and a test that
+fails when an alias is claimed twice.
+
+```cpp
+enum class TextEncoding { unknown, utf8, utf16le, utf16be, utf32le, utf32be,
+ windows_1252, iso_8859_1, iso_8859_15, shift_jis, … };
+
+std::vector all_text_encodings();
+std::string text_encoding_to_string(TextEncoding);
+TextEncoding text_encoding_by_name(std::string_view) noexcept; // unknown if none
+std::span text_encoding_names(TextEncoding) noexcept;
+bool text_encoding_is_decodable(TextEncoding) noexcept;
+```
+
+Named `TextEncoding`/`encoding()` rather than `Charset`/`charset()` so it does
+not collide with the accessor it replaces. `TextFile::charset()` stays,
+implemented as `text_encoding_to_string(encoding())` and marked both
+`/// @deprecated` (the existing convention, `html.hpp:49`) and `[[deprecated]]`.
+There is no `-Werror` (`CMakeLists.txt:38`), so the attribute is warnings only;
+the three binding call sites (`jni_file.cpp:288`, `bind_file.cpp:218`,
+`ODRFile.mm:516`) move to `text_encoding_to_string(encoding())` and keep their
+own public surface unchanged.
+
+**Reuse from `pdf` means extraction first.** Nothing reaches across into
+`internal/pdf`. Anything shared moves into `internal/encoding` with pdf as one
+of its users. Stage 1 as designed needs no runtime reuse at all, so this binds
+only on the multibyte tier below.
+
+**Encoding tiers**, expressed as that last predicate.
+
+- **UTF-8/16/32 (+BOM)** — `utfcpp`, already a dep.
+- **Single-byte legacy** (windows-1252, ISO-8859-1/-15) — a generated 256-entry
+ byte→codepoint table per encoding. Generate from Python's built-in codecs
+ (`bytes([i]).decode('cp1252')`), which are the standard mappings; only the
+ *shape* comes from `tools/pdf/generate_encoding_data.py` → `pdf_encoding_data.cpp`
+ (auto-generated header, `clang-format off`, licence note). Do **not** reuse
+ WinAnsiEncoding as cp1252: they are close, not equal, and ISO-8859-x is not
+ in the pdf tables at all — the header on `pdf_doc_encoding_to_unicode`
+ documents exactly this class of near-miss.
+- **Multibyte legacy** (Shift-JIS/cp932, GBK, Big5, EUC-KR) — *deferred, not
+ declined*, with two viable routes and no need to pick until someone wants CJK
+ csv. Measured mapping counts: cp932 22 736, gbk 21 791, shift_jis 18 912,
+ big5 13 710, euc_kr 8 225.
+ - *Generate our own*, again from Python's codecs: exact, no refactor, but
+ ~85 k pairs ≈ 340 KB of static data as sorted `(uint16, char16)`, less with
+ the run encoding `pdf_cid_data` uses.
+ - *Hoist and share*: `pdf_cid.hpp:19` documents `translate_predefined_cmap`
+ as decoding the legacy CJK CMaps, "whose codes are the legacy encoding's
+ bytes", to UTF-8, and `pdf_cid_data.cpp` carries 17 RKSJ CMaps —
+ `90ms-RKSJ-H` being the Microsoft variant, i.e. cp932. That data is already
+ compiled into every binary, so sharing it costs no size. The price is the
+ extraction into `internal/encoding` and the lossiness of routing through a
+ character collection's UCS2 map.
+
+Until then a non-decodable encoding is *known but not decoded*: reported by
+name, rendered as text with `text_encoding_to_string(...)` in the html header so
+the browser decodes it. Only the spreadsheet path, which must hand UTF-8 to the
+bindings, is closed to it.
+
+---
+
+## Stage 1 — encoding, in `internal/encoding`
+
+No csv in this stage at all. It is the substrate everything else stands on, it
+ships alone, and it closes a live bug: the detected charset is used nowhere
+today, so every non-UTF-8 text file renders as mojibake. A package of its own
+rather than a corner of `internal/text`, because it is what `pdf` would later
+share.
+
+- public `TextEncoding` enum + `text_encoding_table.cpp` (aliases, both
+ directions, decodable predicate) as above; `TextFile::encoding()` added,
+ `charset()` deprecated, bindings moved off it.
+- `to_utf8(std::string_view, TextEncoding)`. BOM sniffing is deterministic and
+ runs before uchardet. Single-byte tables generated from Python's codecs, in
+ the `tools/pdf` output shape. Invalid bytes become U+FFFD, never a throw
+ mid-render — the override is the answer to a bad guess.
+- `guess_charset(std::istream &, std::size_t max_bytes)` — honour the existing
+ TODO in `text_util.hpp`. 64 KiB is enough for uchardet. Accept the
+ consequence: a file that is ASCII for 64 KiB with a `é` at 2 MB detects as
+ UTF-8 and meets an invalid byte later. That is what U+FFFD and the override
+ are for.
+- a binary test (NUL byte / share of C0 controls) as the actual "is this text"
+ oracle. uchardet names a charset, it does not gate.
+- `html/text_file.cpp` transcodes, or declares the charset for a non-
+ transcodable one, and drops its `// TODO charset`.
+
+## Stage 2 — the csv probe
+
+Splits detection from parsing and stops detection paying for a full scan.
+
+- `probe` reads one bounded prefix, shared by charset detection and separator
+ scoring, and returns resolved options plus a verdict. Drop the trailing
+ partial record from the sample.
+- separator scoring over `{',', ';', '\t', '|'}`: quote-aware split, modal field
+ count per candidate, pick by share-of-lines-matching then field count; a
+ winner yielding one column loses. Honour Excel's `sep=;` first line.
+- `open_strategy` keeps constructing a `CsvFile` to probe, because that
+ constructor *is* the probe now and is bounded. Detection reads its own 64 KiB
+ rather than sharing `text::TextFile`'s: caching the probe would make every
+ text file carry 64 KiB for the life of the object, and re-reading it costs
+ nothing.
+- the existing tests in `csv_file_test.cpp` move to the probe: ragged rows and
+ dangling quotes stay rejections *there*, and stop being rejections in the
+ parser. The comment on `a_quoted_field_must_be_terminated` records a real
+ misclassification — the rule survives, on the detection side only.
+
+## Stage 3 — options, shaped like decryption
+
+`nullopt` means autodetect; the resolved values are readable back so a caller
+can show "detected `;`, UTF-16LE" and offer an override. The realistic flow is
+open → wrong → adjust → reopen.
+
+```cpp
+struct CsvOptions {
+ std::optional charset;
+ std::optional separator;
+ std::optional quote;
+ std::optional header_row;
+ std::size_t probe_bytes{1 << 16};
+};
+```
+
+Follow `decrypt`: an immutable handle deriving another handle, narrowed on the
+concrete type (`file.cpp:332`, `file.cpp:345`). `TextFile::charset()` is already
+half of this pattern — stage 3 finishes it.
+
+```cpp
+CsvOptions options() const;
+CsvFile with_options(const CsvOptions &) const;
+```
+
+This keeps `odr::open` untouched — no new overload across six `open` signatures
+and six `DecodedFile` constructors — and the bindings already have the call
+pattern from decrypt (`wasm/src/wasm_file.cpp:86`, `jni/src/jni_file.cpp:162`).
+Do **not** copy `decrypt`'s state machine: `EncryptionState` gates rendering
+because an encrypted file cannot be rendered at all (`file.cpp:167`), whereas a
+csv without options always has a guess. An `options_required()` state would
+invent a gate the format does not have. Nor does the failure map: a wrong
+password leaves nothing to hand back, whereas the parser is total, so
+`with_options` fails only on an incoherent struct or an untranscodable charset.
+
+Options are per-format, so they need a concrete handle, but stage 2 routes csv
+through `as_document_file()` which has nowhere to put them. Add a public
+`CsvFile` handle alongside `TextFile`/`PdfFile` — `as_csv_file()`, `options()`,
+`with_options()`, `document()`. `PdfFile` is the precedent for a format-specific
+handle narrowing an inherited operation. The same shape gives `TextFile` a
+`with_charset(std::string)` next to its existing `charset()`.
+
+`header_row` is not in the struct: it would be inert until a sheet exists to
+mark a header on. It arrives with stage 4, or not at all.
+
+**Bindings come after stage 4, not here.** `CsvFile` is the handle stage 4 hangs
+`document()` off, and stage 4 also decides the file category — binding it now
+means binding it twice. One binding pass, once the shape has settled.
+
+## Stage 4 — the sheet document
+
+The visible win. Whole file in memory; the renderer caps at
+`spreadsheet_limit{10000, 500}` (`html.hpp:124`) so that is sufficient for
+output at any file size worth caring about today.
+
+- `CsvDocument` : `internal::Document`, `DocumentType::spreadsheet`, one sheet.
+- virtual element ids as decided above; `cell`/`dimensions` are the only way in
+ to the data, so stage 6 can change what is behind them.
+- the sheet is rectangular even where the file is not — a short row pads, a
+ long one widens. The counterpart to detection no longer rejecting ragged
+ files.
+- csv **stays** `FileCategory::text` (`file_type_table.cpp:416`), carrying
+ `DocumentType::spreadsheet`. Moving it to `document` was the plan and is not
+ what landed: a csv is still text, `TextFile::text()` still reads it, and
+ `document()` is the second view rather than a replacement. So `is_text_file()`
+ keeps answering true and `html::translate` orders its csv branch first. The
+ capabilities test enforces whatever the row claims.
+- `html_output_test` skips csv (`// TODO enable zip, csv, json`). Csv now
+ produces real output, so enabling it needs reference output committed to the
+ output repo and the pointer advanced — a separate repo, a separate change.
+
+## Stage 5 — value types
+
+`ValueType` is only `{unknown, string, float_number}`
+(`document_element.hpp:135`) and drives one CSS class — right alignment — so it
+stays small. Decided per *column*: a reader compares down a column, so one
+number in a column of prose is not a quantity. The first row is left out of the
+sample, because a header names its column rather than holding a value.
+
+The grammar is strict on purpose. A leading zero is what tells `007` from a
+quantity, and a thousands separator does not say which side of the Atlantic
+wrote it — `1,234` is a thousand or one point two three four depending. Dates
+are left alone entirely: `03/04/2026` is two different days and guessing gets
+it wrong confidently. The raw string is always what is displayed.
+
+## Stage 6 — large files
+
+Deferred: with the 10 000-row render cap this buys nothing for html output. It
+matters for open cost and for api consumers walking the sheet. Build it when
+something needs it; `CsvDocument::cell`/`dimensions` are the seam.
+
+- Row-start offsets are the only index needed — a row boundary is the one place
+ where "outside quotes" is unambiguous.
+- Checkpoint every ~1024 rows (offset + row index), binary search then scan
+ forward. 10 M rows at 8 bytes each is 80 MB and unshippable on a phone;
+ checkpoints are ~80 KB per GB.
+- `sheet_dimensions()` needs an exact count, i.e. one full quote-aware scan.
+ Make it lazy — first dimension query, not open — and build the checkpoints in
+ that pass.
+- Window staging: unescape **into** the staged buffer. `""` means a cell's value
+ is not a contiguous range of the original bytes, but unescaping only shrinks,
+ so each field compacts within its own slot and a `string_view` into the
+ rewritten window is valid. The window also holds the transcoded UTF-8, so one
+ buffer solves both. LRU 2–4 windows; the renderer's row-major walk hits them
+ sequentially.
+- `abstract::File` offers `memory_data()` (whole file as a `string_view`, no
+ staging needed) and `disk_path()` (mmap-able). A csv inside a zip has neither
+ — that path stays full in-memory, by decision not by accident.
diff --git a/src/odr/internal/iwork/PLAN.md b/src/odr/internal/iwork/PLAN.md
new file mode 100644
index 000000000..f57831fc3
--- /dev/null
+++ b/src/odr/internal/iwork/PLAN.md
@@ -0,0 +1,296 @@
+# iWork plan
+
+Where an iwork module would go, and in what order. Written before stage 1; keep
+it honest as stages land.
+
+## Today
+
+Nothing decodes, and unlike rtf there is not even a `FileType` yet. A `.pages`
+is a zip, so `magic.cpp:95` reports `FileType::zip`, `list_file_types` probes
+odf then ooxml (`open_strategy.cpp:213-238`), both fail, and the caller gets
+`[zip]`. `odr::open` hands back a `zip::ZipFile` — an archive, not a document.
+
+Two fixtures are already committed:
+`test/data/input/odr-public/pages/{empty.pages,style-various-1.pages}`, both
+written by iWork 13.2 (`Metadata/BuildVersionHistory.plist`). Neither is listed
+in `index.csv` and neither has reference output, so nothing exercises them.
+`style-various-1.pages` carries `Index/Tables/` and nine files under `Data/`,
+which is most of the surface below.
+
+New `FileType` entries append at the end of the enum — `file.hpp:98` says so —
+and **the bindings do need updating** here, unlike rtf: `python/src/bind_file.cpp`,
+`jni/java/app/opendocument/core/FileType.java`,
+`apple/include/OdrCoreObjC/ODRFile.h` + `apple/src/ODRFile.mm`. Wasm does not:
+it derives its enums from `odr::all_file_types()` at runtime
+(`wasm/src/wasm_core.cpp:35`, `:69`).
+
+## Spec
+
+There is none, and none is coming. Nothing is vendored under
+`offline/documentation/`, and Apple has never published the `.proto` schemas.
+
+The evidence available is, in order of trust:
+
+1. **The fixtures.** Byte layout verified against a file in the repo is the only
+ claim this plan treats as fact; everything else below is marked as needing
+ confirmation.
+2. **MIT-licensed reverse engineering** — `numbers-parser`, `keynote-parser`,
+ `obriensp/iWorkFileFormat`.
+3. **`libetonyek`** (Document Liberation Project, MPL-2.0).
+
+Read all of them for facts; **copy code from none of them**. Where `oldms/`
+cites `[MS-XLS] §2.4.1`, this module cites a fixture and an offset instead —
+`empty.pages Index/Document.iwa +0` — because that is the strongest reference
+that exists.
+
+## Target
+
+`.pages` opens as a text document, `.numbers` as a spreadsheet, `.key` as a
+presentation, all through the abstract document model, so the generic HTML
+renderer and every binding get them without format-specific code.
+`is_editable()` false, `save` throws, exactly as `oldms/text`.
+
+**iWork '13 and later only.** The 2005–2009 XML era is a different format and is
+deferred by decision — see below.
+
+## Decisions taken up front
+
+**The module is `iwork`, not `apple`.** `apple/` at the repo root is already the
+Objective-C bindings and the Swift package; a second meaning would be a trap.
+Namespace `odr::internal::iwork`.
+
+**Three file types, one engine.** `iwork_pages`, `iwork_numbers`,
+`iwork_keynote` appended to `FileType`, each with its own
+`file_type_table.cpp` row and `DocumentType`. One `IworkFile` switches on the
+type, exactly as `odf::OpenDocumentFile` (`odf_file.hpp:21`) does for the four
+opendocument types — same constructor over an
+`abstract::ReadableFilesystem`, same `document()` dispatch.
+
+**No new dependencies.** Two pieces would normally be a conan line each, and
+both are wrong here:
+
+- **Snappy.** The `.iwa` framing is Apple's own: a 4-byte header per block,
+ `0x00` then a little-endian 24-bit compressed length, repeated to EOF. Stock
+ Snappy *framing* (the `sNaPpY` stream identifier, per-chunk CRC-32C) is not
+ present, so a stream decoder does not apply — only the **block** decoder does,
+ and that is a varint length plus literal/copy tags in about 150 lines.
+ Verified: `empty.pages Index/Document.iwa` opens `00 fb 1d 00`, i.e. 7675
+ bytes, and the file is 7679; the block then starts `82 6e`, a varint
+ uncompressed length of 14082.
+- **Protobuf.** Only the wire format is needed — varint, 64-bit,
+ length-delimited, 32-bit, with groups skipped. There are no schemas to
+ generate from, so a code generator would have nothing to do, and linking
+ conan `protobuf` would drag one into the wasm, android and apple builds to
+ replace ~150 lines. Read fields by number against hand-written accessors.
+
+Both live inside `iwork/` for now. The csv plan's rule applies — anything
+genuinely shared moves out to its own package *first* — and a wire reader with
+one user has not earned a package.
+
+**An `.iwa` is an object graph, not a tree.** Each file is a sequence of
+`(varint length, TSP.ArchiveInfo, payload bytes)`. `ArchiveInfo` is field 1 =
+identifier, field 2 = repeated `MessageInfo{1: type, 2: version, 3: length,
+4: field_infos}`. Verified on the same fixture: `08 01` (identifier 1),
+`12 52` (an 82-byte `MessageInfo`), `08 90 4e` (type 10000), `12 03 01 00 05`
+(version 1.0.5), `18 e0 0c` (payload length 1632).
+
+Objects reference each other by identifier, so the engine **indexes first and
+resolves lazily**: build `identifier → (type, component, span)` across the
+package, then walk the graph from the document archive. Reading files in
+directory order and hoping the tree falls out is the mistake to avoid.
+
+**Components come from `Index/Metadata.iwa`, not from file names.** It carries
+the package's component list (identifier → locator). The fixture's names carry
+id suffixes — `CalculationEngine-1732585.iwa`,
+`AnnotationAuthorStorage-1732584.iwa`, `Index/Tables/DataList-1732820-2.iwa` —
+so globbing for `CalculationEngine.iwa` finds nothing in one fixture and finds
+it in the other. (`style-various-1.pages` has the unsuffixed spelling.)
+
+**A pinned type table, and fail soft on what is not in it.** A `constexpr`
+table maps message type IDs to the archive kinds we understand, annotated with
+the app versions it was confirmed against, keyed off
+`Metadata/BuildVersionHistory.plist`. An unknown type ID is **skipped**.
+
+This is not a hole in the root `AGENTS.md`'s fail-fast rule, for the same
+reason the rtf plan's leniency is not: that rule says throw *where the spec
+dictates what to expect*. Here there is no spec, and an unknown type ID means
+Apple shipped a version we have not mapped — not that the input is corrupt. A
+reader that throws on one cannot open next year's files.
+
+Do throw on: framing that overruns the file, a Snappy block that decompresses
+past its declared length, a varint that does not terminate, a reference to an
+identifier the index does not hold, and nesting past a depth bound.
+
+**`Data/` needs no decoder.** Media is stored as ordinary zip entries; open it
+through the filesystem, hand it to `ImageAdapter::image_file` with
+`image_is_internal() == true`.
+
+**Flat `ElementRegistry`, copied not shared.** An iWork document's element count
+is bounded by its paragraph and drawable count, so the pattern in the root
+`AGENTS.md` applies unchanged and `oldms/text/doc_element_registry.*` is the
+template — not csv, whose id-is-the-coordinate scheme exists because a sheet has
+an element per cell.
+
+**Pages has two document modes.** Word processing is a text flow; page layout is
+a canvas of floating text boxes, structurally closer to `odg` than `odt`. One
+`FileType` and `DocumentType::text` either way — word processing first, page
+layout rendered as pages of frames once frames exist (stage 4).
+
+**A Numbers sheet holds many tables.** Our `Sheet` (`document_element.hpp:300`)
+is one grid plus `shapes()`. Map **one odr sheet per Numbers table**, named
+` –
`; taking only the first table per sheet would drop data
+silently.
+
+**No writer, no editing.** Out of scope in every stage.
+
+---
+
+## Stage 1 — detection and the container
+
+Nothing renders yet. The point is that the bytes come apart correctly and the
+type is reported, which is also the whole of what a file picker needs.
+
+- `iwork_snappy.{hpp,cpp}` — Apple framing plus block decompression, over the
+ `std::istream *` / `std::streambuf *` shape `pdf::ObjectParser` uses
+ (`pdf_object_parser.hpp`).
+- `iwork_protobuf.{hpp,cpp}` — wire reader: varints, the four wire types, skip
+ for unknown fields, a `NestingGuard` for length-delimited recursion.
+- `iwork_archive.{hpp,cpp}` — `.iwa` → objects; the package index built from
+ `Index/Metadata.iwa`; `identifier → object` lookup.
+- `iwork_file.{hpp,cpp}` — `IworkFile : abstract::DocumentFile` over an
+ `abstract::ReadableFilesystem`, shaped like `odf::OpenDocumentFile`.
+ `password_encrypted()` reports true when `Index/Metadata.iwph` is present and
+ `decrypt` throws (see Deferred).
+- detection: a third probe after odf and ooxml in `list_file_types`
+ (`open_strategy.cpp:213-238`), a branch in `open_file_as` beside odf's
+ (`:48-66`), and in `open_file` (`:328`) and `open_document_file` (`:498`).
+ `Index/Document.iwa` present ⇒ iWork; **which app comes from the root
+ archive's type ID**, read off a fixture per app and put in the pinned table —
+ not guessed, and not taken from the extension, which a caller may have lost.
+- three rows in `file_type_table.cpp`: extensions `pages` / `numbers` / `key`,
+ mime types `application/vnd.apple.{pages,numbers,keynote}` plus the
+ `application/x-iwork-*-sff*` spellings, `FileCategory::document`, the matching
+ `DocumentType`, `{.detect_by_content = true, .open = true}`. `odr_test` fails
+ if declared capabilities exceed what the engine does, so `translate_html`
+ arrives per format in the stages below.
+- `FileType` entries at the end of the enum; the three bindings above.
+- `CMakeLists.txt` `ODR_SOURCE_FILES`.
+
+**Tests** are inline byte strings: a hand-built Snappy block (literal, copy with
+1-byte and 2-byte offsets, a length that overruns), varint edge cases
+(continuation past 10 bytes, a 64-bit field), an `ArchiveInfo` with an unknown
+type ID that must be skipped rather than thrown on, and framing truncated
+mid-block. Only the type-reporting test needs the fixtures — the data repos are
+fetched and optional, so everything that can be inline is.
+
+## Stage 2 — Pages text
+
+- walk from the document archive to the body's text storage
+ (`TSWP.StorageArchive` in the reverse-engineering literature; confirm the type
+ ID against the fixture before pinning it).
+- the storage holds the text as a small number of large strings plus **parallel
+ run tables** — index/value pairs for paragraph styles, character styles and
+ attachments. **Paragraph boundaries come from the paragraph run table**, not
+ from splitting on `\n`; `U+2028` is a line break inside a paragraph and maps
+ to `line_break`. The text is already UTF-8, so `internal/encoding` is not
+ involved.
+- `root → paragraph → span → text` in a flat `ElementRegistry`.
+- `empty.pages` is the regression that matters here: an empty document must
+ produce an empty body and not an exception.
+- table row: `iwork_pages` gains `.translate_html = true`.
+
+## Stage 3 — Pages styles
+
+- `Index/DocumentStylesheet.iwa`. Style archives are sparse property sets with a
+ parent reference, so resolution is an inheritance walk terminating at the
+ theme's default — cache resolved styles by identifier, and let equal property
+ sets share one resolved style the way `.doc` does.
+- character and paragraph properties → `TextStyle` / `ParagraphStyle`; adjacent
+ equal-style runs merge into one span.
+- font names interned in the style registry and never mutated afterwards, so
+ `TextStyle::font_name` (`const char *`) stays valid — the `.doc`/`.xls` rule.
+- page geometry → `TextRootAdapter::text_root_page_layout`, which stage 2
+ returns empty from.
+
+## Stage 4 — drawables, images, frames
+
+- drawable archives carry a geometry (position, size, transform) and a content
+ reference → `Frame` plus `Image`, `Rect`, `Line`, `CustomShape` as they map.
+- images resolve to a `Data/` entry by name; hand the zip entry through
+ unchanged.
+- Pages **page-layout mode** falls out here: it is drawables on pages with no
+ body flow, so once frames exist it is a different root assembly, not new
+ parsing.
+
+## Stage 5 — Keynote
+
+Cheaper than it looks, and therefore before Numbers: slides are a container
+above the *same* text storage stage 2 and 3 already read.
+
+- slide archives → `Slide`, the slide's master → `MasterPage`, text boxes and
+ drawables reuse stages 2–4 unchanged.
+- notes, builds and transitions are out (see Deferred).
+- `iwork_keynote` gains `.translate_html = true`; needs a `.key` fixture in the
+ public data repo first.
+
+## Stage 6 — the tile reader
+
+Tables in iWork are stored as **tiles** — row ranges holding packed cell
+records — with strings, formats and formulas kept in side "data lists" that
+cells reference by index. Several cell-storage layouts exist across app
+versions; this is where the pinned table earns itself, and where a version we
+have not mapped must degrade to an empty cell rather than a wrong one.
+
+Do this for **Pages tables first** (`Table`, `TableRow`, `TableCell`), because
+`style-various-1.pages` already carries `Index/Tables/` and exercises the reader
+without a Numbers fixture existing.
+
+## Stage 7 — Numbers
+
+- sheets → one odr `Sheet` per table (see decisions), on top of stage 6.
+- cached values only. `CalculationEngine.iwa` holds the formula graph and is not
+ read; a cell shows what Numbers last computed, which is the position `.xls`
+ takes.
+- `iwork_numbers` gains `.translate_html = true`.
+
+---
+
+## Deferred, by decision
+
+- **iWork '05–'09** (Pages 1–4, Keynote 1–5, Numbers 1–2) — gzipped XML,
+ `index.xml.gz` for Pages/Numbers and `index.apxl(.gz)` for Keynote. A
+ different format needing a different engine and its own stage 1, for files no
+ application has written since 2013. If it ever happens it lives at
+ `iwork/legacy/` behind the same three `FileType`s, which is why those are
+ named for the app and not the era.
+- **Password-protected files** — `Index/Metadata.iwph` and encrypted `.iwa`s.
+ `password_encrypted()` reports it from stage 1; `decrypt` throws.
+- **The package form.** On macOS a `.pages` can be a directory rather than a
+ zip. The engine would not notice — `common::SystemFilesystem`
+ (`common/filesystem.hpp:16`) roots at a directory and satisfies the same
+ interface — but nothing in the public API opens a directory today, so this is
+ a seam, not a stage.
+- **Templates and iBooks Author** (`.template`, `.kth`, `.nmbtemplate`, `.iba`)
+ — same container; add the extensions to the existing rows once documents
+ work.
+- **Charts, comments, footnotes, change tracking, formulas, `ViewState.iwa`,
+ Keynote builds and presenter notes.**
+- **Writing and editing.**
+- **A preview fallback.** Every iWork file embeds a rendered preview
+ (`preview.jpg`, `preview-web.jpg`, `preview-micro.jpg` in the fixtures; the
+ '09 era wrote `QuickLook/Preview.pdf`). Deliberately not an iWork feature:
+ odf and ooxml carry thumbnails too, so rendering a preview in place of a
+ decode is one cross-cutting mechanism with one honest capability story, to be
+ decided library-wide — not invented here, where it would make
+ `translate_html = true` claim a fidelity this engine does not have.
+
+## Test data
+
+`empty.pages` and `style-various-1.pages` are already in
+`test/data/input/odr-public/pages/` but absent from `index.csv` and from
+reference output. Add them to the index in stage 1, and regenerate reference
+output when stage 2 flips `translate_html` on.
+
+Stages 5 and 7 each need a fixture that does not exist yet — one `.key` and one
+`.numbers` in the public repo. Everything at container level stays inline, per
+stage 1.
diff --git a/src/odr/internal/json/PLAN.md b/src/odr/internal/json/PLAN.md
new file mode 100644
index 000000000..14e20b250
--- /dev/null
+++ b/src/odr/internal/json/PLAN.md
@@ -0,0 +1,185 @@
+# JSON plan
+
+Where the json module is going, and in what order. Written before stage 1; keep
+it honest as stages land. Sibling of [`csv/PLAN.md`](../csv/PLAN.md), and it
+leans on what stage 1 there already landed in `internal/encoding`.
+
+## Today
+
+`JsonFile` is detection only — 35 lines, `is_decodable() == false`,
+`FileCategory::text` — so `html::translate` routes it through
+`translate(file.as_text_file())` to `html::create_text_service` and a json
+renders exactly like a `.txt`: numbered lines, no folding, nothing the raw bytes
+did not already say.
+
+`check_json_file` runs `nlohmann::json::parse` over the **whole** stream and
+throws the result away (`// TODO limit check size`, `// TODO check if that even
+works`, `json_util.cpp:8`). `open_strategy.cpp:288` constructs a `JsonFile`
+purely to *name* the type while listing candidates, so a 200 MB json is fully
+parsed just to be called a json, and the dom is destroyed immediately.
+
+The parse reads the **undecoded** stream — `check_json_file(*m_file->file()->
+stream())`, `json_file.cpp:10` — even though `JsonFile` holds a
+`text::TextFile` whose `text()` has decoded to UTF-8 since the csv plan's stage
+1. nlohmann accepts UTF-8 and skips a UTF-8 BOM, so a UTF-16 json — blessed by
+RFC 4627, still emitted by Windows tooling — fails detection today and falls
+back to plain text, where `html/text_file.cpp` then renders it correctly. The
+rendering path is the one that already got fixed; the parse is what is left.
+
+json is on the skip list in `html_output_test.cpp:115`, and there is no
+`test/src/internal/json/` at all.
+
+`nlohmann_json` is already a direct link dependency (`CMakeLists.txt:272`).
+
+## Target
+
+A json opens on its value tree: indented, foldable, keys visually distinct from
+values, and a collapsed subtree saying how much it hides. Read-only, no
+round-trip, no schema. The text view stays reachable and stays the floor.
+
+## Decisions taken up front
+
+**Render the parsed tree, not the bytes.** Colouring the existing text service
+with a lexer is what "syntax highlighting" would mean, and it is the wrong axis:
+what makes a large json readable is *folding and structure*, not colour, and
+anyone who wants the literal bytes already has the text view. We parse the file
+anyway.
+
+That said, a little colour is nearly free here and worth taking. Walking a typed
+dom, every scalar already knows whether it is a string, a number, a boolean or
+null, so four CSS classes plus one for keys cost an `if` in the writer — no
+lexer, no grammar, nothing that can disagree with the parser. That is the whole
+of the "highlighting" this needs.
+
+**Not a document.** `ElementType` (`document_element.hpp:87`) has no key/value
+pair. Mapping objects onto `list`/`list_item` either drops the keys or smuggles
+them into `text`, and then every binding walking the tree sees prose where the
+file had structure — worse than the honest answer, which is that the element
+model does not describe json. So `is_decodable()` stays `false`, json stays
+`FileCategory::text`, and the html service is hand-written like
+`html/text_file.cpp` and `html/pdf_file.cpp` rather than derived from the
+generic renderer. That is a deliberate deviation from step 3 of "Adding /
+extending a document format" in the root `AGENTS.md`; it belongs in a
+`json/AGENTS.md` once stage 3 lands.
+
+If a json-as-data api is ever wanted, the honest shape is a small value api in
+the public headers, not an `ElementAdapter`. Nothing here forecloses it.
+
+**The service parses; the file detects.** The tempting move is to keep the dom
+on `JsonFile` so it is parsed once. Resist it. It puts `nlohmann/json.hpp` — the
+single heaviest header in the build — into `json_file.hpp` and from there into
+`open_strategy.cpp`; the escape (`nlohmann/json_fwd.hpp` plus a
+`std::unique_ptr` member and an out-of-line destructor) is a real technique but
+it buys nothing, because after stage 2 detection no longer builds a dom to
+share. So: detection reads a bounded prefix and keeps nothing, the html service
+parses `TextFile::text()` once in its constructor and owns the dom for its own
+lifetime, and nlohmann stays confined to `json_util.cpp` and
+`html/json_file.cpp`.
+
+**The text view is the floor.** A bounded probe accepts a prefix; the full parse
+can still fail on byte 900 000. So `html::translate(const TextFile &)` builds
+the json service inside a `try` and falls back to `create_text_service` when the
+parse throws — a broken json renders as text with its syntax error visible,
+which is the useful outcome. This is the one place the fallback lives; no other
+call site needs to know.
+
+**Dispatch by narrowing, not by a new handle.** `translate(const DecodedFile &)`
+switches on category (`html.cpp:213`) and json is `text`, so json arrives at
+`translate(const TextFile &)`. Branch there on
+`file_type() == FileType::javascript_object_notation`. The alternative — a
+public `JsonFile` handle with `as_json_file()`, the way csv just got one — costs
+the handle plus its surface in four bindings, and csv needed it only because it
+had `options()` and `document()` to hang there. json has neither. Revisit if
+json ever grows per-file options; view-shaping knobs belong in `HtmlConfig`
+anyway.
+
+**Detection is a discriminator, not a validity check.** RFC 8259 allows a bare
+scalar at the top level, so `42` and `"hello"` are valid json documents — and
+also valid txt, and valid one-column csv. Requiring the first token to be `{` or
+`[` is the same kind of rule as csv's "at least two columns": it is about
+telling formats apart, not about what json is. The by-type path
+(`open_strategy.cpp:167`, reached when the caller says "this is json" or the
+extension does) parses in full and accepts what RFC 8259 accepts.
+
+**Number spelling is not preserved.** nlohmann keeps `std::int64_t` /
+`std::uint64_t` / `double`, not the source token, so re-serialising a scalar
+gives the shortest round-trip form: `1.50` renders as `1.5`, `1e3` as `1000.0`.
+The value is faithful, the spelling is not. Accepted — the text view is one
+click away and shows the file. Emitting the raw token instead would mean a
+second, token-preserving parse, which is a large cost for a cosmetic gain.
+
+**Strings out of the dom are valid UTF-8.** nlohmann rejects invalid UTF-8 at
+parse time, so the writer never has to sanitise — it takes
+`get_ref()` straight through `html::escape_text`
+(`html/common.hpp:48`). Only the *probe* can hold a `U+FFFD`, from a multi-byte
+sequence cut at the boundary, and a replacement character inside a string
+literal is syntactically fine.
+
+**Folding is ``/``.** Native, scriptless, static html, and it
+survives the fragment-writing `HtmlService` shape without a frontend object. The
+summary line carries what the fold hides — `"users": [ … 128 items ]` — which
+the dom knows for free. Expand-all/collapse-all needs script; add it when
+someone asks.
+
+**Truncation is loud.** The renderer emits one row per scalar, so the browser's
+node budget is the real limit and it arrives long before ours does. Collapsing
+does not help: a ``-hidden subtree is still in the DOM. So cap the
+number of *emitted* values and close the output with a visible
+`… 4 213 891 more values, not rendered`, rather than silently stopping.
+`HtmlConfig::spreadsheet_limit` (`html.hpp:124`) is the precedent for where the
+knob goes. Streaming a json too large for that is out of scope, exactly as csv
+stage 5 is.
+
+---
+
+## Stage 1 — parse decoded text
+
+A bug fix that ships alone, and the smallest possible diff.
+
+- `check_json_file` takes a `std::string_view` of decoded UTF-8 instead of an
+ `std::istream` of raw bytes; `JsonFile` passes `m_file->text()`.
+- a non-decodable encoding is not a json: RFC 8259 §8.1 makes json Unicode by
+ definition, so there is nothing to fall back to. `text()` already throws
+ `UnsupportedTextEncoding`, and `open_strategy`'s `catch (...)` already turns
+ that into "not a json".
+- first `test/src/internal/json/json_util_test.cpp`, inline string literals: an
+ object, an array, a bare scalar, a truncated document, a UTF-16LE json with a
+ BOM.
+
+## Stage 2 — bounded detection
+
+Stops type listing paying for a full dom, and splits detection from parsing the
+way the csv plan did — same reason, same shape.
+
+- probe with `encoding::read_probe` + `encoding::to_utf8`
+ (`encoding/detect.hpp`, `encoding/transcode.hpp`), 64 KiB, the same default
+ the rest of the codebase uses.
+- verdict from `nlohmann::json::sax_parse` with a callback that keeps no dom and
+ stops at the cut: first non-whitespace byte is `{` or `[`, and no syntax error
+ before the end of the probe. A prefix is *supposed* to end mid-document, so
+ running out of input is not a rejection — only a real syntax error is.
+- `open_strategy` keeps constructing a `JsonFile` to probe, because that
+ constructor *is* the probe now and is bounded — as with csv.
+- the by-type path stays a full parse and keeps accepting top-level scalars.
+
+## Stage 3 — the tree view
+
+- `internal/html/json_file.{hpp,cpp}` with `create_json_service`, alongside
+ `create_text_service`; one view, `json.html`.
+- `html::translate(const TextFile &)` narrows on file type and falls back to the
+ text service when the full parse throws.
+- the writer: nested `
`s with a CSS indent (not `
` — we control every
+ byte of the output), ``/`` per object and array with the
+ child count in the summary, `odr-json-key` / `-string` / `-number` /
+ `-literal` classes, `escape_text` on everything, node budget with the loud
+ marker.
+- `write_json_style` in `html/frontend.cpp` next to `write_text_style`.
+- drop `javascript_object_notation` from the skip list at
+ `html_output_test.cpp:115`.
+
+## Stage 4 — only if wanted
+
+Auto-expand depth and node budget as `HtmlConfig` fields; expand/collapse-all
+and search (both need script); copy-a-json-pointer per row; NDJSON
+(`application/x-ndjson`) as a separate file type rendering as a list of trees.
+None of it is needed for the view to be worth having.
diff --git a/src/odr/internal/markdown/PLAN.md b/src/odr/internal/markdown/PLAN.md
new file mode 100644
index 000000000..0de11438b
--- /dev/null
+++ b/src/odr/internal/markdown/PLAN.md
@@ -0,0 +1,243 @@
+# Markdown plan
+
+Where markdown support is going, and in what order. Written before stage 1;
+keep it honest as stages land.
+
+## Today
+
+`FileType::markdown` exists (`file.hpp:73`) and has a table row with extensions
+and mime types (`file_type_table.cpp:431`), declared classification-only:
+capabilities `{}`, `FileCategory::text`, `DocumentType::unknown`. Nothing
+constructs it. `magic` does not know it, `open_strategy` never names it, and
+`open_file(file, FileType::markdown)` falls through to
+`UnsupportedFileType` (`open_strategy.cpp:198`). A `.md` opened today comes back
+as `text_file` and renders as a line list through
+`html::create_text_service`.
+
+So there is no code to undo. The work is a decoder, plus flipping the row.
+
+## Target
+
+A markdown file opens as a **text document** — `TextRoot` with paragraphs,
+spans, lists, tables, links and images — so the generic HTML renderer and every
+binding get it without format-specific code. That is the whole argument for
+doing it this way rather than writing a markdown→HTML renderer next to
+`html/text_file.cpp`: the latter would produce html output only, with no
+element api, nothing for JNI/embind/pybind/ObjC, and no path to
+`back_translate`.
+
+CommonMark is the base; GFM's tables, strikethrough, task lists and permissive
+autolinks are the extensions worth having. Everything beyond that is somebody's
+dialect and stays out.
+
+## Decisions taken up front
+
+**A library, not a hand-rolled parser.** This is the opposite call from
+`d8c8715` (dropping vincentlaucsb-csv-parser to scan csv in-tree), and
+deliberately so. Csv is a hundred lines of quote-aware scanning. CommonMark has
+~650 conformance cases, and the places hand-written parsers rot — lazy
+continuation, link reference definitions, the emphasis left/right-flanking
+rules, list-item indent arithmetic — are exactly the ones that look easy for a
+weekend and are then wrong forever.
+
+**md4c** (`md4c/0.5.2` on conancenter, MIT, ~5k LOC C). Chosen over cmark-gfm:
+cmark-gfm has no conan recipe at all, builds an allocating AST we would
+immediately walk once and throw away, and is a GitHub fork with a lax release
+cadence. md4c is a SAX parser — `enter_block` / `leave_block` / `enter_span` /
+`leave_span` / `text` — which maps onto `create_element` / `append_child` with a
+single id stack and no intermediate tree. Its `MD_DIALECT_GITHUB` flag is
+exactly the extension set above. Add it to `conanfile.py:51` next to
+`nlohmann_json` / `uchardet`, and `find_package` + link in `CMakeLists.txt:59`
+/ `:271`.
+
+Two things to check when wiring it up, because they decide small amounts of
+work in stage 2:
+- whether the conan package exports the `md4c-html` component, whose
+ `entity.h`/`entity_lookup` resolves the ~2000 named html entities. If not, we
+ handle `&`-class and numeric references ourselves and pass the rest
+ through literally.
+- md4c parses bytes and assumes UTF-8 (`MD4C_USE_UTF8`), so decoding happens
+ before it, not inside it.
+
+**Markdown is a `DocumentFile`, not a `TextFile`.** `abstract::TextFile` fixes
+`file_category()` to `text` (`abstract/file.hpp`); a document has to be
+`FileCategory::document` with `DocumentType::text`. The table row changes
+category with it. This is api-visible for `FileType::markdown` — and free,
+because the row declares no capabilities today, so nothing can be relying on
+it.
+
+**Input is UTF-8, produced by `internal/encoding`.** `MarkdownFile` takes a
+`std::shared_ptr` exactly as `CsvFile` and `JsonFile` do
+(`csv_file.hpp`), and calls `text()` for the decoded bytes. An encoding we
+cannot decode throws `UnsupportedTextEncoding` (`exceptions.hpp:42`) — the same
+rule the csv plan sets for the sheet path, and for the same reason:
+`Text::content()` is UTF-8 to every binding, so passing legacy bytes through
+and letting a browser sort it out is not available to us.
+
+**Detection is by caller, not by content.** `open_strategy` is entirely
+content-driven — magic plus speculative probes — and has no extension path
+anywhere. Markdown has no signature, and a content probe for it is a probe for
+"prose with occasional punctuation", which is every plain text file with a `#`
+comment or an `*` bullet in it. Sniffing would steal `text_file` matches and be
+confidently wrong. So: `detect_by_content` stays **false**, markdown never joins
+the speculative chain in `list_file_types` (`open_strategy.cpp:272`), and the
+only way in is `DecodedFile(file, FileType::markdown)` (`file.hpp:341`) via a
+new branch in `open_file` next to the text/csv/json ones
+(`open_strategy.cpp:146`). Callers route on the filename, which is what they
+already have.
+
+The consequence to accept: `.md` still opens as `text_file` by default. That is
+correct behaviour for a format that is, by construction, valid plain text.
+
+**There is no `NoMarkdownFile`.** Every other format's exception exists because
+detection rejects. Nothing rejects here: md4c is total — any UTF-8 byte
+sequence is some markdown document — and there is no detection to fail. The
+only failure mode is the undecodable encoding above.
+
+**Headings ride on paragraphs, as ODF already does.** `ElementType` has no
+heading (`document_element.hpp:87`), and `odf_parser.cpp:304` maps `text:h` to
+`ElementType::paragraph` with the level carried in the style. Markdown does the
+same: `MD_BLOCK_H` with `detail->level` becomes a paragraph plus a `TextStyle`
+holding `font_size` and `font_weight` from a small `StyleRegistry`. The cost is
+honest and shared with odt — the html output is `
` with inline styles, not
+`
`, so heading semantics are lost to screen readers and to anything walking
+the api for an outline. Stage 5 is where that gets fixed properly, once, for
+both engines.
+
+**Raw html is dropped.** CommonMark passes html blocks and inline html through
+verbatim. There is no passthrough element in the model, and inventing one means
+deciding what `Text::content()` returns for it in four bindings — a real
+question, not a markdown question. `MD_BLOCK_HTML` and `MD_SPAN`-level raw html
+are skipped in stage 1. Inline `