# @ariadng/office

A TypeScript library that reads and writes modern Microsoft Office files —
Word, Excel, and PowerPoint, plus every macro, template, slideshow, and
add-in variant. Its core promise is simple: **anything you don't touch comes
back exactly as it went in**, byte for byte.

```sh
npm install @ariadng/office
```

- **Zero runtime dependencies.** ZIP and XML are implemented in-house on
  platform primitives (`Uint8Array`, `TextEncoder`/`TextDecoder`,
  `CompressionStream`/`DecompressionStream('deflate-raw')`). Runs on Node ≥ 20
  and evergreen browsers; Deno and Bun ride along. ESM only.
- **Open → edit → save without collateral damage.** Parts you never touch are
  copied byte-for-byte from the source file. Content the library has no API
  for — pivot tables, animations, macros, vendor extensions — survives
  automatically, because nothing ever rebuilds it.
- **Create from scratch.** `Document.create()`, `Workbook.create()`, and
  `Presentation.create()` produce minimal files that real Office opens with no
  repair prompt.
- **Content-based detection.** The format is detected from the package bytes
  (main-part content type), never from the file extension.

## Scope

All 16 modern OOXML extensions are supported for open → edit → save:

| App | Formats |
|---|---|
| Word | `.docx` `.docm` `.dotx` `.dotm` |
| Excel | `.xlsx` `.xlsm` `.xltx` `.xltm` `.xlam` |
| PowerPoint | `.pptx` `.pptm` `.potx` `.potm` `.ppsx` `.ppsm` `.ppam` |

Macro-enabled variants keep their `vbaProject.bin` as an opaque part — it is
preserved bit-for-bit, never parsed.

Deliberately **out of scope**:

- Legacy binary formats (`.doc`, `.xls`, `.ppt`) — permanently.
- `.xlsb` (Excel Binary Workbook) — permanently.
- Rendering, layout, PDF export, page counting.
- Formula **evaluation** (formula *parsing* to a typed AST is fully
  supported; computing values never will be).
- Reading/writing VBA module source (preserve-only).

## The fidelity ladder

No library can promise byte-identical output for XML it has *edited* —
attribute order, whitespace, and ZIP metadata all shift. So fidelity is
defined as three concrete levels, and every conformance test says which
level it checks.

| Level | Name | Guarantee |
|---|---|---|
| **L1** | Semantic equivalence | A reserialized part compares structurally equal to its source (namespace-aware, prefix-insensitive, attribute-order-insensitive). Minimum bar for every part we reserialize. |
| **L2** | Office-clean | The output opens in real Word/Excel/PowerPoint with **no repair prompt**. Acceptance bar for `create()` outputs and edited files, enforced by the COM harness. |
| **L3** | Byte-stable | Every part **not touched** since open is written byte-for-byte identical (its compressed stream is copied straight through the zip layer). Mandatory behavior of `save()`. |

Open → save with zero mutations is L3 for *all* parts — you can prove it
yourself:

```ts
import { readFile } from 'node:fs/promises';
import { OfficeDocument } from '@ariadng/office/document';
import { comparePackages } from '@ariadng/office/preservation';

const bytes = await readFile('budget.xlsx');
const office = await OfficeDocument.open(bytes);
console.log(office.format.extension, office.family); // 'xlsx' 'excel'

// Zero mutations, so every part raw-copies at save (L3):
const out = await office.save();
const report = await comparePackages(bytes, out);
console.log(report.equal); // true — every entry byte-identical
```

The normative rules (L1 comparison, dirty tracking, the L3 save algorithm)
live in [`PRESERVATION.md`](./PRESERVATION.md).

## Architecture: lenses over the DOM

One rule underpins the whole library:

> High-level models (`Paragraph`, `Worksheet`, `Slide`, …) are **lenses over
> the parsed XML tree**. They read and edit the live tree **in place**. They
> never copy it into their own data structures and rebuild the XML from those.

Consequences:

- **Unknown content survives by construction.** Markup the model layer has
  never heard of is just a tree node nobody touches; saving reserializes the
  same tree.
- **Reading is free.** Walking the DOM, resolving namespaces, or computing
  MCE reading views mutates nothing — the part stays on the byte-copy path.
- **One tree, many views.** A mutation through any lens is immediately
  visible through the raw DOM and every other lens over the same nodes.
- **Dirtiness is a per-part bit.** Every mutating lens method marks the
  affected part(s) dirty; `save()` reserializes dirty parts and raw-copies
  the rest.

The module layering (each is also a subpath export, e.g.
`@ariadng/office/opc`):

```
zip ─── opc ─── document ─── docx / xlsx / pptx     (task-shaped lenses)
xml ─── opc, mce, preservation, document, docx, xlsx, pptx
mce ─── non-destructive MCE reading views (AlternateContent, Ignorable)
preservation ─── the L1 comparator + package-level round-trip oracle
```

The lens API never locks you out of the underlying XML — drop down whenever
the task-shaped surface runs out, and take over the dirty flag yourself:

```ts
import { readFile, writeFile } from 'node:fs/promises';
import { Document, WML_NAMESPACE } from '@ariadng/office/docx';

const doc = await Document.open(await readFile('report.docx'));

// The live parsed w:body of /word/document.xml — not a copy.
const body = doc.body();
const pgSz = body.find(WML_NAMESPACE, 'sectPr')?.find(WML_NAMESPACE, 'pgSz');
console.log('page width (twips):', pgSz?.getAttributeNs(WML_NAMESPACE, 'w'));

// Direct DOM mutations are allowed — you then own the dirty flag:
pgSz?.setAttributeNs(WML_NAMESPACE, 'w', '12240'); // resize to US Letter
pgSz?.setAttributeNs(WML_NAMESPACE, 'h', '15840');
doc.office.markDirty(doc.office.mainPart.name);
await writeFile('report-edited.docx', await doc.save());
```

The full per-module behavior contract is in
[`CONTRACTS.md`](./CONTRACTS.md).

## Quickstart

### Word (`@ariadng/office/docx`)

```ts
import { readFile, writeFile } from 'node:fs/promises';
import { Document } from '@ariadng/office/docx';

// Open → edit → save. Styles, numbering, images, themes, and unknown
// markup you don't touch round-trip byte-identically.
const doc = await Document.open(await readFile('report.docx'));

for (const p of doc.paragraphs()) {
  console.log(p.styleId() ?? '(default)', JSON.stringify(p.text()));
}

doc.addParagraph('Reviewed and approved.', { bold: true });
await writeFile('report-reviewed.docx', await doc.save());
```

```ts
import { writeFile } from 'node:fs/promises';
import { Document } from '@ariadng/office/docx';

// Create from scratch — opens in Word with no repair prompt.
const doc = Document.create();
doc.addParagraph('Quarterly Report');
doc.addParagraph('Everything is on track.', { italic: true });
await writeFile('fresh.docx', await doc.save());
```

Text extraction is MCE-aware: content inside `mc:AlternateContent` is read
through the branch a current Office build would select, never both.

Word modeling goes well beyond plain text. `format()` resolves the full
style cascade — docDefaults → the `w:basedOn` style chain → numbering →
direct formatting — with correct ECMA-376 §17.7.3 toggle (XOR) semantics
and theme fonts resolved at open. `list()` reports a paragraph's numbering:

```ts
import { readFile } from 'node:fs/promises';
import { Document } from '@ariadng/office/docx';

const doc = await Document.open(await readFile('report.docx'));

const heading = doc.paragraphs()[0];
const pf = heading.format(); // resolved paragraph facts
console.log(pf.styleName, pf.spacingBeforeTwips); // 'heading 1' 360

const rf = heading.runs()[0].format(); // resolved run facts
console.log(rf.sizePt, rf.color, rf.font); // 20 '0F4761' 'Aptos Display'

console.log(heading.list()); // null — the heading is not in a list
```

Tables, inline images, headers/footers, and lists all have task-shaped
create/read APIs:

```ts
import { writeFile } from 'node:fs/promises';
import { Document } from '@ariadng/office/docx';

const doc = Document.create();
doc.addParagraph('Quarterly Report', { style: 'Heading1' });

// A visible table (single-line borders when no style is given):
const table = doc.addTable(2, 3);
['Region', 'Product', 'Revenue'].forEach((h, c) => table.cell(0, c).setText(h));
table.cell(1, 0).setText('North');
const row = table.addRow(); // copies the last row's cell widths
row.cells()[0].setText('South');

// Each list call is its own numbering definition (numbered lists restart at 1):
doc.addNumberedList(['Prepare', 'Execute', 'Verify']);
const bullets = doc.addBulletList(['Alpha', { text: 'Nested', level: 1 }, 'Beta']);
console.log(bullets.at(-1)?.list()?.isBullet); // true — round-trips through the resolver

// An inline image — PNG/JPEG sniffed from magic bytes, sized at 96 DPI:
const png = Uint8Array.from(
  atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='),
  (c) => c.charCodeAt(0),
);
const img = doc.addImage(png, { widthPx: 120, altText: 'logo' });
console.log(img.format, img.partName); // 'png' '/word/media/image1.png'

// Headers/footers are wired into every section reference for you:
doc.setHeader('ACME Corp — Confidential');
doc.setFooter('Page footer', { type: 'first' }); // sets w:titlePg

await writeFile('built.docx', await doc.save());
```

### Excel (`@ariadng/office/xlsx`)

```ts
import { readFile, writeFile } from 'node:fs/promises';
import { Workbook } from '@ariadng/office/xlsx';

const wb = await Workbook.open(await readFile('budget.xlsx'));

const data = wb.sheet(0); // by index — or wb.sheet('Data') by name
if (data === undefined) throw new Error('workbook has no sheets');
console.log(data.name, data.getCell('A1'));

// Typed reads: shared/inline strings, numbers, booleans, Dates for
// date-formatted cells, cached formula results:
console.log(data.getCellInfo('B2'));

data.setCell('B2', 1234.5);
data.setCell('B3', 'paid'); // interned in the shared string table
data.setCell('B4', new Date(Date.UTC(2026, 6, 19))); // serial + date format
await writeFile('budget-edited.xlsx', await wb.save());
```

```ts
import { writeFile } from 'node:fs/promises';
import { Workbook } from '@ariadng/office/xlsx';

// Create from scratch — one empty 'Sheet1', minimal stylesheet.
const wb = Workbook.create();
const sheet = wb.sheet('Sheet1');
if (sheet === undefined) throw new Error('unreachable');
sheet.setCell('A1', 'Item');
sheet.setCell('B1', 'Price');
sheet.setCell('A2', 'Rice (5 kg)');
sheet.setCell('B2', 79000);

const notes = wb.addSheet('Notes');
notes.setCell('A1', 'Generated by @ariadng/office');
await writeFile('fresh.xlsx', await wb.save());
```

Writes match what Excel itself produces: strings go through
`/xl/sharedStrings.xml` (deduplicated), dates get a cell format derived from
the cell's current style so fills/borders/fonts survive, and rows/cells are
kept in the order Excel requires.

Styling, merges, real Excel tables, defined names, formulas, and
constant-memory row streaming:

```ts
import { writeFile } from 'node:fs/promises';
import { Workbook, readRows } from '@ariadng/office/xlsx';

const wb = Workbook.create();
const sheet = wb.sheet('Sheet1');
if (sheet === undefined) throw new Error('unreachable');

const rows = [
  ['Region', 'Product', 'Revenue'],
  ['North', 'Widget', 5300],
  ['South', 'Gadget', 4100],
] as const;
rows.forEach(([region, product, revenue], i) => {
  sheet.setCell(`A${i + 1}`, region);
  sheet.setCell(`B${i + 1}`, product);
  sheet.setCell(`C${i + 1}`, revenue);
});

// Styles are flat, partial writes — only what you name changes, and
// repeated styling never bloats styles.xml (everything is deduplicated).
sheet.setCellStyle('A1:C1', { bold: true, fill: '#4472C4', color: '#FFFFFF' });
sheet.setCellStyle('C2:C3', { numberFormat: '#,##0.00' });
sheet.setColumnWidth('B', 14.5);
console.log(sheet.getCellStyle('A1').font.bold); // true — fully resolved read

// A real Excel table (with autofilter) over the data:
const table = await sheet.addTable('A1:C3', { name: 'Sales' });
console.log(table.columns); // ['Region', 'Product', 'Revenue']

// Formulas are validated by a real parser before anything is written:
sheet.setFormula('C4', '=SUM(Sales[Revenue])');
console.log(sheet.getFormula('C4')); // 'SUM(Sales[Revenue])'

wb.setDefinedName('Total', 'Sheet1!$C$4');
console.log(wb.getDefinedName('Total')?.ref); // 'Sheet1!$C$4'

const bytes = await wb.save();
await writeFile('sales.xlsx', bytes);

// Read rows back without ever building the sheet DOM (million-row safe):
for await (const row of readRows(bytes, { sheet: 'Sheet1', range: 'A2:C3' })) {
  console.log(row.index, row.values); // 2 ['North', 'Widget', 5300] …
}
```

The formula toolkit is available standalone — parse to a typed AST,
serialize back, convert between A1 and R1C1:

```ts
import { parseFormula, formulaToText, a1ToR1C1, r1c1ToA1 } from '@ariadng/office/xlsx';

const ast = parseFormula('=XLOOKUP(A2,Table1[SKU],Table1[Price])');
console.log(ast.kind); // 'call' — stored XML says _xlfn.XLOOKUP; the AST says XLOOKUP
console.log(formulaToText(ast)); // 'XLOOKUP(A2,Table1[SKU],Table1[Price])'

console.log(a1ToR1C1('SUM(B2:C2)', 'D2')); // 'SUM(RC[-2]:RC[-1])'
console.log(r1c1ToA1('SUM(RC[-2]:RC[-1])', 'D2')); // 'SUM(B2:C2)'
```

### PowerPoint (`@ariadng/office/pptx`)

```ts
import { readFile, writeFile } from 'node:fs/promises';
import { Presentation } from '@ariadng/office/pptx';

const deck = await Presentation.open(await readFile('deck.pptx'));

for (const slide of deck.slides()) {
  console.log(slide.partName, slide.text()); // one string per a:p
}

deck.slides()[0].setTitle('FY26 Kickoff');
deck.addSlide(); // new slide referencing the deck's first layout
await writeFile('deck-edited.pptx', await deck.save());
```

```ts
import { writeFile } from 'node:fs/promises';
import { Presentation } from '@ariadng/office/pptx';

// Create from scratch: master + layout + a complete theme, all wired —
// PowerPoint opens it with no repair prompt.
const deck = Presentation.create();
deck.addSlide();
console.log(deck.slides().length); // 2
await writeFile('fresh.pptx', await deck.save());
```

Transitions, animations, and media are untouched by slide operations and
therefore preserved byte-for-byte.

## Support matrix

Two tiers of support for every feature of every format:

- **Modeled** — a task-shaped API reads and writes it. Mutations mark only
  the affected part(s) dirty.
- **Preserve-only** — no API surface yet, but the content survives open →
  edit → save **byte-for-byte** (see [why this is safe](#why-preserve-only-content-is-safe)
  below). You can still reach it through the raw XML DOM (`body()`,
  `root()`, `OfficeDocument.xml()`) and own the dirty flag yourself.

### Word (`@ariadng/office/docx`)

| Modeled | Preserve-only |
|---|---|
| `open` / `create` / `save` | Style *definitions* authoring (no new `w:style` elements; existing styles are read and resolved) |
| Paragraph enumeration (`paragraphs()`), body text extraction (`text()`, MCE-aware) | Footnotes, endnotes, comments, tracked changes, fields, content controls |
| `addParagraph(text, { style, bold, italic })`; per-paragraph `text()` / `styleId()` / `runs()`; per-run `text()` / `bold()` / `italic()` / `setText()` | Anchored/floating image *creation* (existing anchored images are listed read-only; inline creation is supported) |
| **Effective formatting**: `Paragraph.format()` / `Run.format()` — full cascade (docDefaults → `w:basedOn` chain → numbering → direct), toggle XOR (§17.7.3), theme fonts | Cell merging *creation* (`w:gridSpan` / `w:vMerge`), table-style authoring, `w:tblStylePr` folding into effective formats |
| **Numbering**: `Paragraph.list()` read; list creation `addBulletList` / `addNumberedList` | Section insertion & page setup editing (page size/margins/columns) |
| **Tables**: `tables()` / `addTable`; `Table` / `TableRow` / `TableCell` (read, `setText`, `addRow`) | Themes beyond font resolution, drawings/shapes/text boxes, settings, macros (`vbaProject.bin`) |
| **Images**: `images()` / `addImage` (inline PNG/JPEG, magic-byte sniffed); `pxToEmu` / `emuToPx` / `EMU_PER_PIXEL`. **Headers/footers**: `headers()` / `footers()` / `setHeader` / `setFooter`. Raw DOM: `body()`, `WML_NAMESPACE` | |

### Excel (`@ariadng/office/xlsx`)

| Modeled | Preserve-only |
|---|---|
| `open` / `create` / `save` | Formula **evaluation** (permanent non-goal; cached results are readable) |
| Sheet enumeration/lookup (`sheets()`, `sheet(nameOrIndex)`), `addSheet(name)` | Theme & indexed **colors** (read as `color: undefined`; survive untouched unless that property is overwritten) |
| Typed cell reads: `getCell`, `getCellInfo` — shared/inline strings, numbers, booleans, dates (via number formats), errors, formula text + cached results | Conditional formatting, data validation, comments/notes |
| `setCell(ref, value)` — string (shared-string interned), number, boolean, `Date`, `null` to clear | Pivot tables, charts, rich-text run styling inside cells |
| Cell styling: `getCellStyle` (fully resolved read) / `setCellStyle` (flat partial writes: font, fill, border, number format, alignment — deduplicated into styles.xml) | Table totals rows & table style *definitions* (referenced by name only) |
| Column widths / row heights: `getColumnWidth` / `setColumnWidth`, `getRowHeight` / `setRowHeight` | Shared-formula group rewriting, array-formula creation (existing ones preserved) |
| Formulas: `setFormula` (parser-validated, calc chain handled) / `getFormula` (shared-formula dependents translated); AST toolkit `parseFormula` / `formulaToText`, `a1ToR1C1` / `r1c1ToA1` | Sheet rename/remove/reorder, freeze panes, print setup, other row/column properties (hidden, outline), macros |
| Merged cells (`merge` / `unmerge` / `merges`); Excel tables (`addTable` / `tables`); defined names (`definedNames` / `getDefinedName` / `setDefinedName` / `removeDefinedName`) | |
| Streaming row reads: `readRows` — iterate any sheet without building its DOM | |
| A1/range helpers: `parseCellRef` / `formatCellRef` / `parseRangeRef` / `formatRangeRef`; 1904 date system; calcChain invalidation on formula overwrite | |

### PowerPoint (`@ariadng/office/pptx`)

| Modeled | Preserve-only |
|---|---|
| `open` / `create` / `save` | Adding/positioning shapes, text boxes, pictures |
| Slide enumeration (`slides()`), per-slide `text()` (one string per `a:p`, MCE-aware) | Editing non-title text on existing slides (title only via `setTitle`) |
| `setTitle(text)` — replaces the title placeholder's text (throws if the slide has none; `create()` slides are blank) | Animations, transitions, media |
| `addSlide({ layoutIndex \| layoutPartName })` / `removeSlide(index)`; `layouts()` introspection | Speaker notes, comments |
| Raw DOM access: `root()`, `partName` / `layoutPartName` | Themes, masters & layout *editing*, SmartArt, charts, macros |

### Why preserve-only content is safe

The [fidelity ladder](#the-fidelity-ladder) is what makes "no API yet"
different from "unsupported":

- **L3 — untouched parts never get rewritten.** Dirtiness is tracked per
  part; `save()` copies every non-dirty part's compressed bytes straight
  through the ZIP layer. A pivot table, animation, or macro lives in parts
  your edits never dirty, so it comes back **byte-identical** — not
  "re-emitted and hopefully equivalent".
- **L1 — within a dirty part, unknown markup survives by construction.**
  Models are lenses over the parsed XML DOM: adding a paragraph appends
  nodes to the live tree, and everything else in that tree (unknown
  elements, attributes, namespaces, vendor extensions) reserializes with
  structural equality guaranteed by the L1 comparator.
- **L2 — real Office is the referee.** The conformance suite opens every
  runner output in actual Word/Excel/PowerPoint via COM with auto-repair
  disabled; a repair prompt is a test failure.

So a file full of features this library has never heard of can be opened,
edited at the cells/paragraphs/slides level, and saved — and every one of
those features survives.

## Repository layout: two repos, two products

This project ships as two sibling git repositories:

| Repo | Product |
|---|---|
| `office` (this repo) | **Product 1** — the TypeScript reference implementation: `src/**`, the two conformance runners (`conformance/runner`, `conformance/runner-ts`), and the library docs (`CONTRACTS.md`, `PRESERVATION.md`). |
| `office-spec` | **Product 2** — the language-agnostic specification (`spec/` chapters), the machine-readable conformance suite (`conformance/cases`, `conformance/corpus`), the fixture generators (`tools/gen-fixtures`), and the real-Office L2 harness (`tools/office-harness`). |

Everything here that needs spec-repo files (fixtures, cases, the harness)
locates the spec repo through one module —
`src/test-support/spec-dir.ts`:

1. `OFFICE_SPEC_DIR` environment variable, if set, is the spec repo root;
2. otherwise the sibling checkout `<thisRepo>/../office-spec` is used.

So the default setup is simply two sibling clones:

```
<parent>/office        this repo
<parent>/office-spec   the spec + conformance suite
```

Set `OFFICE_SPEC_DIR` only when the spec repo lives somewhere else.

## Testing, conformance & the Office harness

Three layers of validation, all run from the repo root (all three need the
spec repo checkout — see the layout section above):

```sh
npx tsc --noEmit          # strict typecheck
npx vitest run            # unit tests, colocated as src/**/*.test.ts
npm run conformance:cases # machine-readable conformance cases (spec 14)
npm run conformance       # round-trip matrix over the spec repo's corpus
```

The conformance runner exercises every package in the spec repo's
`conformance/corpus/**` (hand-crafted minimal packages plus files generated
by COM-driving real Office) through three steps:

- **A — untouched round-trip:** open → save → every part must be
  byte-identical (L3).
- **B — trivial edit per family:** add a paragraph / set a cell / add a
  slide → save → reopen → the edit is visible and only the documented parts
  differ.
- **C — L2 harness:** every output is opened in real Word, Excel, and
  PowerPoint via COM, with auto-repair disabled so corruption fails loudly.

Step C needs Windows with Microsoft Office installed; elsewhere it is
skipped automatically (or explicitly with
`npm run conformance -- --skip-harness`). The harness (which lives in the
spec repo) can also be pointed at any folder directly:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ../office-spec/tools/office-harness/check.ps1 -Folder conformance/out
```

It prints a JSON report per file and exits with the number of files that
failed to open clean. Corpus regeneration scripts
(`../office-spec/tools/gen-fixtures/`) and harness details are documented in
the spec repo's `conformance/README.md`; the runner contract is in
[`conformance/runner/README.md`](./conformance/runner/README.md).

The spec repo's T3 adversarial corpus is generated from *this* repo:
`npx tsx tools/gen-t3/generate.ts` deterministically (byte-identically)
rewrites `../office-spec/conformance/corpus/t3-adversarial/` using this
library's ZIP writer, locating the spec repo via the same
`src/test-support/spec-dir.ts` contract.

Every `ts` code block in this README is executable: `scratch/readme-check.mts`
extracts and runs them all against the real corpus fixtures
(`npx tsx scratch/readme-check.mts` from the repo root).

## Roadmap

This repo is the TypeScript reference implementation of a larger phased plan:

- **Here today:** the container core (zip, xml, opc, mce, preservation),
  format detection and the L3 save pipeline, a schema layer generated from
  the ECMA-376 XSDs (element ordering), task-shaped lenses for Word, Excel,
  and PowerPoint, deep Word modeling — the style-cascade and numbering
  resolvers (`format()` / `list()`), tables, inline images, and
  headers/footers — deep Excel modeling — cell styles, column/row sizing,
  merged cells, tables, defined names, a full formula parser (typed AST,
  A1↔R1C1) and constant-memory row streaming — plus fuzzing gates, all
  validated against a real-Office corpus.
- **Next, per the plan:** Word tracked changes and section/page-setup
  editing; PowerPoint placeholder/property inheritance resolution and
  slide copy/reorder; Excel conditional formatting and data validation.
- **Post-1.0:** encryption ([MS-OFFCRYPTO]), digital signatures, deeper
  chart/pivot/animation models.

The companion product is a **language-agnostic specification** plus a
machine-readable conformance suite (the sibling `office-spec` repo: `spec/`
chapters and `conformance/cases/`) so the same behavior can be
re-implemented in any language and *proven* against the same fixtures.

Reference documents:

| Document | Contents |
|---|---|
| [`CONTRACTS.md`](./CONTRACTS.md) | Normative per-module API behavior contract (this repo) |
| [`PRESERVATION.md`](./PRESERVATION.md) | Fidelity ladder, dirty tracking, L1 comparison rules (this repo) |
| `../office-spec/conformance/README.md` | Corpus tiers, fixture provenance, harness usage (spec repo) |
| `../office-spec/spec/` | Language-agnostic spec chapters (spec repo) |

## License

MIT
