ooxml.js 2.11.8 → 2.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +40 -47
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  > Type-safe, lossless round-trip conversion between OOXML packages (`.docx`, `.pptx`, `.xlsx`) and a faithful JSON model, built on [Zod 4](https://zod.dev) codecs.
6
6
 
7
- An OOXML file is a ZIP archive of parts (an OPC "package"): `[Content_Types].xml`, relationships (`*.rels`), XML content parts, and binary parts (images, embedded objects). `ooxml.js` decodes the **whole package** into a faithful JSON model and encodes it back part for part — so `encode(decode(file))` reproduces the original content.
7
+ An OOXML file is a ZIP of parts (an OPC "package"): `[Content_Types].xml`, relationships, XML content, and binary parts. `ooxml.js` decodes the whole package to faithful JSON and encodes it back part-for-part.
8
8
 
9
9
  ```mermaid
10
10
  graph TD
@@ -50,7 +50,7 @@ graph TD
50
50
 
51
51
  ## Why
52
52
 
53
- Semantic, typed document models are lossy and one-directional: they cannot round-trip. True round-trip requires capturing every part, relationship, and binary byte-for-byte at the content level. `ooxml.js` provides that lossless generic foundation, with ergonomic typed reading views (`Document`, `Presentation`, `Workbook`) layered on top for convenient access.
53
+ Semantic typed models are lossy and one-directional: they cannot round-trip. True round-trip needs every part, relationship, and binary byte-for-byte at the content level. `ooxml.js` provides that lossless foundation, with typed reading views (`Document`/`Presentation`/`Workbook`) on top.
54
54
 
55
55
  ## Getting started
56
56
 
@@ -92,7 +92,7 @@ const pkg = z.decode(packageCodec, bytes);
92
92
  const out = z.encode(packageCodec, pkg);
93
93
  ```
94
94
 
95
- Typed reading views project the generic `Package` into ergonomic models (lossy; for reading, not round-trip). `readDocx`/`readPptx` resolve the full style/theme cascade, so document order, run/paragraph styling, and geometry all come through, not just flattened text:
95
+ Typed views project `Package` into ergonomic models (lossy; read-only). `readDocx`/`readPptx` resolve the full style/theme cascade, so order, styling, and geometry come through, not just flattened text:
96
96
 
97
97
  ```ts
98
98
  import { decodePackage, readDocx } from 'ooxml.js';
@@ -102,7 +102,7 @@ const doc = readDocx(decodePackage(bytes));
102
102
  // inside tables); each run already carries its cascade-resolved bold/italic/colour/font.
103
103
  ```
104
104
 
105
- `readXlsx` is the equivalent lossy, cell-values-only view for xlsx (see below). Alongside it, `readXlsxContent`/`buildXlsxPackage` are a separate, `ContentDocument`-shaped pair — a richer reader (column widths, row heights, hidden rows/columns, merged ranges, every cell value kind xlsx distinguishes, and print settings) matched with this package's first writer, so a caller can round-trip a spreadsheet through the same `ContentDocument` shape `documents.js` and `odf.js` already use:
105
+ `readXlsx` is the lossy, cell-values-only view. `readXlsxContent`/`buildXlsxPackage` are a separate `ContentDocument`-shaped pair — a richer reader (column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, print settings) matched with this package's first writer, round-tripping a spreadsheet through the same `ContentDocument` shape `documents.js`/`odf.js` use:
106
106
 
107
107
  ```ts
108
108
  import { buildXlsxPackage, decodePackage, readXlsxContent } from 'ooxml.js';
@@ -111,7 +111,7 @@ const content = readXlsxContent(decodePackage(bytes)); // ContentDocument, kind:
111
111
  const pkg = buildXlsxPackage(content); // a fresh Package built from scratch, not a write-back into `pkg`
112
112
  ```
113
113
 
114
- Every module under `src/` is also importable directly, by the same path it has relative to `src/`, without going through the barrel — useful for a caller that only needs one small piece and wants to avoid pulling in the rest:
114
+ Every module under `src/` is importable directly, by the same path it has relative to `src/`, without going through the barrel:
115
115
 
116
116
  ```ts
117
117
  import { bytesToBase64, base64ToBytes } from 'ooxml.js/util/base64';
@@ -120,7 +120,7 @@ import { readXlsxContent } from 'ooxml.js/typed/xlsx/content';
120
120
 
121
121
  ## The ooxml.js format
122
122
 
123
- The verbose `Package` JSON is faithful but repetitive: every node repeats its `type`/`tag`/`attributes`/`children` keys, and tag and namespace strings recur thousands of times across a real document. **The ooxml.js format** is a compact, still-plain-JSON alternative — tuple-encoded nodes plus a single interned string table that composes on top of `packageCodec` without changing what it guarantees:
123
+ **The ooxml.js format** is a compact, still-plain-JSON alternative to the verbose `Package` (which repeats `type`/`tag`/`attributes`/`children` keys per node, with tag/namespace strings recurring thousands of times) — tuple-encoded nodes plus one interned string table, composing on `packageCodec`:
124
124
 
125
125
  ```
126
126
  OOXML bytes --[packageCodec]--> Package --[compactCodec]--> CompactPackage (the ooxml.js format)
@@ -134,7 +134,7 @@ const compact = toCompact(pkg); // { s: string[], p: Record<path, CompactPart> }
134
134
  const roundTripped = fromCompact(compact); // deep-equals pkg
135
135
  ```
136
136
 
137
- For example, a `word/document.xml` part holding a single run of text:
137
+ A `word/document.xml` part holding a single run of text:
138
138
 
139
139
  ```xml
140
140
  <w:p><w:r><w:t>Hi</w:t></w:r></w:p>
@@ -174,7 +174,7 @@ decodes to this `Package` (one entry in `parts`, each element an `XmlNode`):
174
174
  }
175
175
  ```
176
176
 
177
- `toCompact` interns every tag and text value once, in first-occurrence order, and replaces each node with a tuple (a leading type code `0` for element, `1` for text followed by string-table indices):
177
+ `toCompact` interns every tag and text value once (first-occurrence order) and replaces each node with a tuple (type code `0`=element/`1`=text, then string-table indices):
178
178
 
179
179
  ```json
180
180
  {
@@ -185,11 +185,9 @@ decodes to this `Package` (one entry in `parts`, each element an `XmlNode`):
185
185
  }
186
186
  ```
187
187
 
188
- Reading the outer tuple: `[0, 0, [], [...]]` is an element (`0`) whose tag is `s[0]` (`"w:p"`), with no attributes (`[]`), wrapping one child the same shape recursively for `w:r` and `w:t`, down to the text leaf `[1, 3]` (type `1` = text, value `s[3]` = `"Hi"`).
188
+ Reading the outer tuple: `[0, 0, [], [...]]` is an element whose tag is `s[0]` (`"w:p"`), wrapping a child recursing down to the text leaf `[1, 3]` (`s[3]` = `"Hi"`). It is a JSON shape, not a compression layer: every string stays human-readable, so it stays diffable and debuggable. `fromCompact(toCompact(pkg))` round-trips exactly; `toCompact` is deterministic.
189
189
 
190
- It is a JSON shape, not a compression layer: every string is still human-readable text, so it stays diffable and debuggable, just without the repeated structural keys and duplicate strings of the verbose `Package` model. `fromCompact(toCompact(pkg))` round-trips exactly, and `toCompact` is deterministic for a given `Package` value (the same input always produces the same string table).
191
-
192
- Every pair of the three formats — OOXML bytes, `Package`, and `CompactPackage` — has a direct codec, so you never have to hand-compose two calls: `packageCodec` (bytes ⇄ `Package`), `compactCodec` (`Package` ⇄ `CompactPackage`), and `compactPackageCodec` (bytes ⇄ `CompactPackage` directly, via `decodeCompactPackage`/`encodeCompactPackage`):
190
+ All three format pairs have a direct codec `packageCodec` (bytes `Package`), `compactCodec` (`Package` `CompactPackage`), and `compactPackageCodec` (bytes `CompactPackage` directly):
193
191
 
194
192
  ```ts
195
193
  import { decodeCompactPackage, encodeCompactPackage } from 'ooxml.js';
@@ -210,67 +208,62 @@ pnpm test:workers # turbo run _test:workers (vitest run --config vitest.worker
210
208
  pnpm test:smoke # turbo run _test:smoke (builds dist/, then runs test/smoke.test.mjs to verify the built ESM and CJS artifacts both load and behave identically)
211
209
  ```
212
210
 
213
- `pnpm prepublishOnly` runs `lint`, `typecheck`, `tsdown`, `publint`, and `@arethetypeswrong/cli` (`attw --pack`) the full publish-readiness checkbefore a release.
214
-
215
- `test/smoke.test.mjs` loads the actual built `dist/index.js` (ESM) and `dist/index.cjs` (CJS) barrel artifacts and checks they load and behave identically — a check none of `vitest`'s normal run, `tsc`, `publint`, or `attw` can do, since those either run against source or statically analyse package metadata without executing the compiled output. `vitest.config.ts` defines it as its own `smoke` project (vitest's `test.projects`), separate from the `unit` project (`src/**/*.test.ts`); `pnpm test`/`test:watch` pass `--project unit` and `pnpm test:smoke` passes `--project smoke` after `tsdown` rebuilds `dist/`, so neither run touches the other project's files.
211
+ `pnpm prepublishOnly` runs `lint`, `typecheck`, `tsdown`, `publint`, and `attw --pack`. `test/smoke.test.mjs` loads the built ESM/CJS barrels and checks they behave identically — a check `tsc`/`publint`/`attw` cannot do.
216
212
 
217
- `tsdown.config.ts`'s `entry` is a glob (`src/**/*.ts`, excluding tests and `.d.ts` files) rather than a single `src/index.ts` bundle, so `dist/` holds one ESM/CJS/`.d.ts`/`.d.cts` set per source module, laid out under `dist/` at the same relative path each module has under `src/` (pinned via the sibling `root: 'src'` option). `dist/index.js`/`dist/index.cjs` are just the file `tsdown` produces for `src/index.ts`, matched by that same glob — they still re-export everything the barrel always has. `package.json`'s `exports` map adds a `"./*"` wildcard alongside `"."`, so any module is importable by its own path, not only through the barrel.
213
+ `tsdown.config.ts`'s `entry` is a `src/**/*.ts` glob (excluding tests/`.d.ts`), so `dist/` mirrors `src/` one ESM/CJS/.d.ts/.d.cts set per module; `package.json`'s `exports` adds a `"./*"` wildcard for deep imports.
218
214
 
219
215
  To run a single test file: `pnpm vitest run src/typed/docx.test.ts`.
220
216
 
221
217
  ## Architecture
222
218
 
223
- The package is layered from a lossless core outward to lossy convenience views:
219
+ The package layers a lossless core outward to lossy convenience views:
224
220
 
225
- - **`src/model/`** — the schemas. `node.ts` defines `XmlNode` (`text` / `cdata` / `comment` / `declaration` / `pi` / `element`) as an ordered forest, matching XML's mixed-content model exactly. `package.ts` defines `Package` as a record of zip-entry path to `Part` (`xml` parts hold a parsed node forest; `binary` parts hold base64 bytes, keeping the whole `Package` a plain JSON value).
226
- - **`src/xml/`** — `parse.ts` and `build.ts` convert between an XML string and the `XmlNode[]` forest, via `fast-xml-parser` in `preserveOrder` mode with entity re-encoding disabled, so element order, mixed content, and original entity encoding survive unchanged.
227
- - **`src/zip.ts`** — thin wrapper over `fflate`'s synchronous `zipSync`/`unzipSync`, isomorphic and dependency-free.
228
- - **`src/package-io/`** — `read.ts` and `write.ts` sit between the zip and XML layers: unzip a package into path -> bytes, classify each entry as XML or binary (`looksLikeXml` sniffs the leading non-whitespace byte for `<`), and parse/serialize accordingly.
229
- - **`src/codec.ts`** — the public round-trip surface: `packageCodec`/`xmlCodec` are `z.codec()` pairs, and `decodePackage`/`encodePackage` are the ergonomic wrappers around them.
230
- - **`src/compact.ts`** — the ooxml.js format: `compactCodec` (`z.codec(PackageSchema, CompactPackageSchema, …)`) maps `Package ⇄ CompactPackage`, with `toCompact`/`fromCompact` as the ergonomic wrappers. `compactPackageCodec` composes `packageCodec` and `compactCodec` into a direct bytes ⇄ `CompactPackage` codec (`decodeCompactPackage`/`encodeCompactPackage`), so all three format pairs — bytes/`Package`, `Package`/`CompactPackage`, bytes/`CompactPackage` — have a named codec rather than requiring callers to chain two.
231
- - **`src/typed/`** — one-way, lossy projections that read the generic `Package` into ergonomic document/presentation/workbook models. `docx/` and `pptx/` share one block content model — `ContentParagraph`/`ContentTable`/`ContentImageBlock`/`ContentPageBreak`, discriminated as `ContentBlock`, imported from the sibling [`document-schema.js`](https://github.com/ExaDev/document-schema.js) package rather than defined here (see below) — instead of each keeping its own, disjoint shape: `readDocx` resolves the full WordprocessingML style cascade (`docx/styles.ts`: `docDefaults` → named-style `basedOn` chains → paragraph-mark run properties → character styles → direct formatting) into ordered `sections` of paragraphs/tables/page-breaks (document order preserved, including inside tables), plus `comments`, `footnotes`, and `headers`/`footers`; `readPptx` resolves the placeholder → layout → master → theme inheritance cascade (`pptx/inherit.ts`) into `slides` of positioned, styled `shapes` (geometry, run/paragraph formatting, embedded images, tables, speaker notes) in presentation order (`p:sldIdLst`, never slide filename order); `readXlsx` covers cell values and formulas, merged ranges and defined names. `typed/shared/` holds the OOXML-specific primitives both `docx/` and `pptx/` build on: `drawingml.ts` (DrawingML `a:xfrm` geometry, theme/colour resolution, group-transform composition), `color.ts` (`ColorTransform`/`applyColorTransforms`, the shade/tint/lumMod/lumOff cascade maths `drawingml.ts`'s own colour resolution applies — kept in its own module rather than `document-schema.js` since it's OOXML-cascade-resolution logic, not a content-model shape), `units.ts` (OOXML unit conversions — EMU/twip/half-point), `metadata.ts` (`docProps/core.xml` + `docProps/app.xml` → `DocumentMetadata`, shared verbatim across docx/pptx/xlsx), and `source-path.ts` (stamps a deterministic, document-order path like `sections[0].blocks[2].runs[1]` onto every `ContentRun`/`ContentBlock`/`ContentShape`, so a downstream consumer can trace a rendered item back to where it came from — see `document-schema.js`'s own `sourcePath` field). Geometry (`Box`/`PageSize`/`Margins`), colour (`Color`/`ColorSchema`), and alignment (`Alignment`) types are imported from `document-schema.js`, not defined locally. `src/image/sniff.ts` (magic-byte PNG/JPEG detection) supports `readPptx`'s picture-shape reading. None of `readDocx`/`readPptx`/`readXlsx` can be encoded back to a `Package` — round-tripping those always goes through `decodePackage`/`encodePackage`, never through a typed view; see `src/typed/xlsx/` below for this package's one write-back exception. `typed/util.ts` holds the shared XML-walking helpers (`walk`, `elementsWithTag`, `childrenWithTag`, `attr`, `rootElement`, `textContent`, entity decoding, `resolveRelationships`) every typed reader builds on.
232
- - **`src/typed/xlsx/`** — a second, `ContentDocument`-shaped xlsx pair alongside the lossy `readXlsx` above, not a replacement for it (both stay exported; they serve different callers). `content.ts`'s `readXlsxContent` reads a `Package` straight into `ContentDocument` (`kind: 'spreadsheet'`): real column widths, row heights, hidden rows/columns, merged ranges (resolved onto the anchor cell's `colSpan`/`rowSpan`), every cell value kind xlsx itself distinguishes, and a genuinely populated `ContentSheetPrintSettings` — matching the bar the sibling `odf.js` package's own `readOds` already sets, rather than `readXlsx`'s flattened `XlsxWorkbook`/`XlsxCell` shape. `build.ts`'s `buildXlsxPackage` is `readXlsxContent`'s write-side inverse and this package's first writer of genuinely new content: given a `ContentDocument`, it constructs a complete xlsx `Package` from scratch workbook, worksheets, a minimal-but-real `xl/styles.xml`, shared strings, core/app properties — via `xml/fragment.ts`'s `el`/`txt`, rather than editing whatever package `readXlsxContent` itself decoded. `number-format.ts`/`styles.ts`/`serial.ts` are the number-format engine, and run both ways: xlsx has no cell type for a percentage, an amount of money, a date, or a time — all four are plain numeric cells wearing a `numFmt` style — so, reading, `styles.ts` resolves a cell's own style index to a format code, `number-format.ts` tokenizes and classifies that code (its `BUILTIN_NUMBER_FORMATS` table for `numFmtId` 0-49 is fed through the same classifier as a producer-declared code, never a second table of pre-decided kinds), and `serial.ts` converts a date/time serial to the canonical ISO spelling `document-schema.js` fixes. Writing, the same three run in reverse: `number-format.ts` holds the small vocabulary of formats `buildXlsxPackage` emits (built-in ids for a percentage and a time of day; ISO-ordered date/dateTime codes; `[$GBP]#,##0.00` for a currency, so the ISO code itself survives where a bare symbol would not; LibreOffice's own `"TRUE";"TRUE";"FALSE"` for a boolean, so real Excel and Calc display one as TRUE/FALSE rather than as a bare 1/0), `styles.ts`'s `CellFormatTable` interns them on demand into a real `<numFmts>`/`<cellXfs>` pair (a `SharedStringTable` for cell formats — same format, same index; no custom format used, no `<numFmts>` element written at all), and `serial.ts` converts an ISO spelling back to the serial the cell actually carries. Every code the writer emits is fed back through the reader's own classifier in the test suite, so the two halves cannot drift. It classifies rather than renders: `displayText` stays the plain typed-value spelling, not the producer's own formatted string. The remaining scope limits: a `currency` value naming no ISO 4217 code writes as a plain amount format and so reads back as a `number` (nothing in `#,##0.00` says money, and prefixing every such amount with a generic ¤ to keep the kind would change a file's appearance for something its author never asked for); a temporal value that is not the canonical `ContentCellValue` spelling, or that names a moment with no serial, degrades to a text cell carrying the original string verbatim rather than to a fabricated serial; and no per-cell rich-text runs on either side.
221
+ - **`src/model/`** — schemas: `node.ts` (`XmlNode`: `text`/`cdata`/`comment`/`declaration`/`pi`/`element`, an ordered forest matching XML mixed content) and `package.ts` (`Package`: path `Part`; `xml` parts hold parsed nodes, `binary` parts hold base64 bytes keeping `Package` plain JSON).
222
+ - **`src/xml/`** — `parse.ts`/`build.ts` convert XML strings `XmlNode[]` via `fast-xml-parser` (`preserveOrder`, entity re-encoding disabled, so order, mixed content, and entity encoding survive).
223
+ - **`src/zip.ts`** — thin `fflate` wrapper (`zipSync`/`unzipSync`).
224
+ - **`src/package-io/`** — `read.ts`/`write.ts` unzip, classify each entry as XML or binary (`looksLikeXml` byte sniff), and parse/serialize.
225
+ - **`src/codec.ts`** — public round-trip surface: `packageCodec`/`xmlCodec` (`z.codec()` pairs) plus `decodePackage`/`encodePackage` wrappers.
226
+ - **`src/compact.ts`** — the ooxml.js format: `compactCodec`/`compactPackageCodec` plus `toCompact`/`fromCompact`/`decodeCompactPackage`/`encodeCompactPackage` wrappers.
227
+ - **`src/typed/`** — one-way, lossy projections. `readDocx` resolves the full style cascade (`docDefaults` → `basedOn` → paragraph-mark → character styles → direct formatting) into ordered `sections` plus comments/footnotes/headers/footers/numbering; `readPptx` resolves placeholder → layout → master → theme inheritance into `slides` (presentation order via `p:sldIdLst`); `readXlsx` covers cell values/formulas, merged ranges, defined names. `typed/shared/` holds shared OOXML primitives (`drawingml.ts` geometry/theme/colour, `color.ts` `ColorTransform` cascade, `units.ts`, `metadata.ts`, `source-path.ts`). Types come from `document-schema.js`. None encodes back to a `Package` — round-trip goes through `decodePackage`/`encodePackage` (see `src/typed/xlsx/` for the one write-back exception).
228
+ - **`src/typed/xlsx/`** — a `ContentDocument`-shaped read/write pair alongside the lossy `readXlsx` (both exported; different callers). `readXlsxContent` reads column widths, row heights, hidden rows/columns, merged ranges, every cell value kind, print settings; `buildXlsxPackage` builds a complete xlsx `Package` from scratch (never editing the decoded package). `number-format.ts`/`styles.ts`/`serial.ts` run both ways: reading classifies style index format code kind (`percentage`/`currency`/`date`/`time`/`dateTime`); writing emits interned `numFmt` codes, fed back through the classifier in tests. The classifier is not a formatter (`displayText` is the typed-value spelling). Scope limits: `currency` with no ISO code writes as plain `number`; non-canonical temporal values degrade to text.
233
229
 
234
230
  ## Conventions
235
231
 
236
- - **Zod-first schema/type/guard.** Every model type is inferred from its Zod schema (`z.infer<typeof XSchema>`), not hand-written — schema, type, and validator stay in lockstep.
237
- - **`XmlNode` uses a recursive structural guard, not `z.lazy`.** `z.lazy` collapses to `unknown` for the element-children case in the Zod version this project pins, so `XmlElementSchema` validates `children` via `z.custom<XmlNode>(isXmlNode)`, a hand-written recursive type guard in `model/node.ts`. Any change to `XmlNode`'s shape must update `isXmlNode` in step. `src/compact.ts`'s `CompactXmlNode` (`isCompactXmlNode` + `z.custom`) reuses the same pattern for the same reason; `document-schema.js`'s own `ContentBlock` (`isContentBlock` + `z.custom`, since a table cell's blocks can themselves contain a table) does too, one level up the dependency graph.
238
- - **Lossless core vs. lossy views is a hard boundary.** `decodePackage`/`encodePackage` (and the underlying codecs) must stay byte/part faithful — every part round-trips unchanged. `src/typed/*`'s lossy readers (`readDocx`, `readPptx`, `readXlsx`) are explicitly one-way and are allowed to drop information (documented per-reader, e.g. `readDocx` resolves cached field-result text rather than re-evaluating live `PAGE`/`NUMPAGES` fields, docx's own `w:themeShade`/`w:themeTint` refinement of an already-resolved theme colour isn't applied, and `readXlsx` drops cell styles, formats and charts). Don't blur this line by adding write-back support to one of those readers; a full round-trip of a package one of them decoded always goes through the generic `Package`. `readXlsxContent`/`buildXlsxPackage` (`src/typed/xlsx/`) are a deliberate, separate exception, not a violation of this rule: they were designed together as a genuine read/write pair around the shared `ContentDocument` model — matching the sibling `odf.js`/`documents.js` packages' own established convention of building fresh output from a `ContentDocument`, rather than editing a decoded package in place — and `buildXlsxPackage` never touches whatever package `readXlsxContent` itself decoded.
239
- - **XML entities stay raw in the lossless layer.** `parseXml` runs with `processEntities: false` so encoded entities (e.g. `&amp;`) are preserved verbatim for round-trip fidelity; typed readers decode the five standard entities (`decodeEntities` in `typed/util.ts`) only in their own lossy projection, never in the core model.
240
- - **No type assertions.** `eslint.config.ts` runs `@typescript-eslint/consistent-type-assertions` with `assertionStyle: "never"`, banning `as` and angle-bracket casts outright, with `linterOptions.noInlineConfig: true` so there is no `eslint-disable` escape hatch either — narrow with a guard or parse with Zod. An exception would have to be scoped structurally, as a `files`-matched override block in `eslint.config.ts`, not an inline comment.
232
+ - **Zod-first schema/type/guard.** Every model type is inferred from its Zod schema (`z.infer<typeof XSchema>`), not hand-written.
233
+ - **`XmlNode` uses a recursive structural guard, not `z.lazy`.** `z.lazy` collapses to `unknown` for element-children in the pinned Zod version, so `XmlElementSchema` validates `children` via `z.custom<XmlNode>(isXmlNode)`. Any change to `XmlNode`'s shape must update `isXmlNode` in step. `CompactXmlNode` and `document-schema.js`'s `ContentBlock` reuse this pattern.
234
+ - **Lossless core vs. lossy views is a hard boundary.** `decodePackage`/`encodePackage` stay byte/part faithful. `src/typed/*` readers are one-way; round-tripping goes through the generic `Package`. `readXlsxContent`/`buildXlsxPackage` are the deliberate exception a read/write pair around `ContentDocument`, where `buildXlsxPackage` never touches the decoded package.
235
+ - **XML entities stay raw in the lossless layer.** `parseXml` runs with `processEntities: false`; typed readers decode the five standard entities (`decodeEntities` in `typed/util.ts`) only in their own lossy projection.
236
+ - **No type assertions.** `eslint.config.ts` bans `as` and angle-bracket casts (`assertionStyle: "never"`, `noInlineConfig: true` no `eslint-disable` escape hatch). Narrow with a guard or parse with Zod.
241
237
 
242
238
  ## Gotchas and quirks
243
239
 
244
- - **`readDocx`/`readPptx` are richer than a flat text/shape dump, but still not a round-trip path — here is what's still not captured.** `word/numbering.xml`'s own `abstractNum`/`num` level definitions (glyph, format, restart-at-level) are now modelled (`numbering.ts`'s `readNumberingDefinitions`, surfaced as `DocxDocument.numbering`, keyed by `w:numId`) alongside each paragraph's own `numId`/`level` *membership*, so a consumer can both group paragraphs into a list and render the list's own markers. Table cell border styling (`w:tcBorders`) is now read too, alongside cell shading/fill. docx's own `w:themeColor` run-colour references are now resolved against the theme's colour scheme (`w:themeShade`/`w:themeTint` refinement of an already-resolved theme colour is the one piece still not read). docx inline/floating images (`w:drawing`) are now read into `ContentImageBlock` — sniffed from the actual media-part bytes, placed in block flow at the point the drawing was encountered, though a floating image's own `wp:anchor` position (page/margin/paragraph-relative offset) is not recorded, since `ContentImageBlock` has no absolute-positioning field to hold it. Still not modelled: live `PAGE`/`NUMPAGES` field re-evaluation (fields resolve to Word's own cached result text, correct unless a different pagination would change the value). On the pptx side specifically: connector shapes (`p:cxnSp`) are skipped entirely (decorative, no text); a shape's rotation is now composed through every enclosing group's own rotation/flip (`composeShapeRotationDeg`, `typed/shared/drawingml.ts`), not merely passed through from its own local `a:xfrm/@rot`; and non-table graphic frames (chart/SmartArt/OLE) come through with correct geometry but empty content.
245
- - **xlsx has no native percentage/currency/date/time cell type both directions of that gap are now closed.** Reading, `typed/xlsx/styles.ts` resolves a cell's style index to a number-format code and `typed/xlsx/number-format.ts` classifies it, so a plain numeric cell wearing the right `numFmt` reads as `ContentCellValue`'s `percentage`/`currency`/`date`/`time`/`dateTime` kind rather than a bare `number` (a currency only carries an ISO 4217 code when the format names one explicitly; a symbol-only format like `[$£-809]` leaves it absent rather than guessing). Writing, `buildXlsxPackage` emits real `numFmt` codes for the same five kinds (including LibreOffice's own `"TRUE";"TRUE";"FALSE"` boolean format, so Excel/Calc display `TRUE`/`FALSE` rather than a bare `1`/`0`), interned on demand into a real `<numFmts>`/`<cellXfs>` pair — every code the writer emits is fed back through the reader's own classifier in the test suite, so the two halves cannot drift. The classifier is not a formatter: `displayText` stays a plain typed-value spelling (`0.4256`, not `"42.56%"`), never the producer's own rendered string.
246
- - **`test:smoke` depends on a fresh build.** It runs `tsdown && vitest run --project smoke`, so it always rebuilds `dist/` first don't run it expecting to test a stale build.
247
- - **`--project` matters for `test/smoke.test.mjs`.** `vitest.config.ts` defines `unit` and `smoke` as separate projects; `pnpm test`/`test:watch`/`test:smoke` always pass the right `--project` flag. A bare `vitest`/`vitest run` with no `--project` filter runs both projects, and `smoke` fails loudly (`Cannot find module '../dist/index.js'`) if `dist/` hasn't been built yet — a clear failure pointing at the cause, not a silent false pass, but still worth knowing if you invoke `vitest` directly instead of through the npm scripts.
248
- - **Binary-vs-XML part classification is a byte sniff, not an extension check.** `package-io/read.ts`'s `looksLikeXml` looks for a leading `<` after skipping a UTF-8 BOM and whitespace; this is deliberate (no standard OOXML binary part starts with `<`) but means any future binary format starting with `<` would misclassify.
249
- - **`Array.isArray` narrows `unknown` to `any[]`, not `unknown[]`.** `lib.es5.d.ts` types its parameter as `any`, so TypeScript can't do better even after the check succeeds — indexing straight into the result (e.g. `value[0]`) silently reintroduces `any` and trips `@typescript-eslint/no-unsafe-assignment`. `compact.ts` and `xml/parse.ts` each define a local `isUnknownArray` guard (`value is unknown[]`) for exactly this reason; reach for it instead of `Array.isArray` wherever the narrowed element is going to be read.
250
- - **TypeScript is pinned to the latest 6.x, not 7.** TypeScript 7 restructured its JS-facing API surface heavily enough that both `typescript-eslint` (peer range `<6.1.0`) and `cosmiconfig`'s TypeScript loader (used by `semantic-release` to read `release.config.ts`, via `typescript.findConfigFile`, which TS 7 no longer exports) break under it. Upgrading past 6.x has to wait for that ecosystem tooling to add TS 7 support.
251
- - **`release-notes-generator`'s `preset` is `angular`, not `conventionalcommits`, unlike `commit-analyzer`'s.** `conventional-changelog-conventionalcommits@10.2.1` exports its changelog body under the key `template`, but the `conventional-changelog-writer` version `@semantic-release/release-notes-generator@14.1.1` bundles only reads `options.mainTemplate` — so the body silently falls back to the writer's own generic default, whose commit partial doesn't match conventionalcommits' function-based partial signature either. The result is a changelog with a version header and nothing under it, confirmed even with zero custom configuration (`preset: 'conventionalcommits'`, no `presetConfig` at all) — not something introduced by this project's own config. `commit-analyzer` is unaffected because it only reads `whatBump` data from the same preset, no template rendering involved. Don't "fix the inconsistency" by switching `release-notes-generator` to `conventionalcommits` too without first checking whether this upstream mismatch has been resolved.
240
+ - **`readDocx`/`readPptx` are not a round-trip path.** Numbering definitions, cell border styling/shading, and `w:themeColor` (without `themeShade`/`themeTint`) are read; images read into `ContentImageBlock` (floating `wp:anchor` position not recorded); `PAGE`/`NUMPAGES` fields resolve to Word's cached text. On pptx: connector shapes (`p:cxnSp`) are skipped; shape rotation composes through groups; non-table graphic frames (chart/SmartArt/OLE) come through with geometry but empty content.
241
+ - **xlsx has no native percentage/currency/date/time cell type.** Both directions are closed via the number-format engine: reading classifies style format code kind; writing emits interned `numFmt` codes, fed back through the classifier in tests. `displayText` is the typed-value spelling, not the producer's rendered string.
242
+ - **`test:smoke` depends on a fresh build.** It runs `tsdown && vitest run --project smoke`, always rebuilding `dist/` first. A bare `vitest` runs both projects; `smoke` fails loudly (`Cannot find module '../dist/index.js'`) if `dist/` is unbuilt.
243
+ - **Binary-vs-XML part classification is a byte sniff, not an extension check.** `looksLikeXml` looks for a leading `<` after skipping a UTF-8 BOM and whitespace; any future binary format starting with `<` would misclassify.
244
+ - **`Array.isArray` narrows `unknown` to `any[]`, not `unknown[]`.** Indexing the result reintroduces `any` and trips `no-unsafe-assignment`. `compact.ts` and `xml/parse.ts` each define a local `isUnknownArray` guard (`value is unknown[]`) use it wherever the narrowed element is read.
245
+ - **TypeScript is pinned to the latest 6.x, not 7.** TS 7 breaks `typescript-eslint` (peer range `<6.1.0`) and `cosmiconfig`'s TS loader (via `typescript.findConfigFile`, which TS 7 no longer exports). Wait for ecosystem support.
246
+ - **`release-notes-generator`'s `preset` is `angular`, not `conventionalcommits` (unlike `commit-analyzer`).** `conventional-changelog-conventionalcommits@10.x` exports its body under `template`, but the bundled `conventional-changelog-writer` reads only `options.mainTemplate`, so the body falls back to a generic default producing an empty changelog. Don't switch without checking upstream.
252
247
 
253
248
  ## Fidelity
254
249
 
255
- Conversion is **part-content-faithful**: every XML part re-serialises to equivalent XML, every binary part to identical bytes, and no parts are dropped or added. The re-zipped file is content-identical and opens correctly in Word, Excel, and PowerPoint.
256
-
257
- It is **not** guaranteed to be byte-for-byte identical at the ZIP-container level — re-zipping changes archive entry layout (entry order, compression, metadata), and that is not achievable deterministically across the tools that produce OOXML files.
250
+ Conversion is **part-content-faithful**: every XML part re-serialises to equivalent XML, every binary part to identical bytes, no parts dropped or added. The re-zipped file opens correctly in Word, Excel, and PowerPoint. It is **not** byte-for-byte identical at the ZIP-container level — re-zipping changes archive entry layout (order, compression, metadata), not achievable deterministically across tools.
258
251
 
259
252
  ## Release and publishing
260
253
 
261
- `.github/workflows/ci.yml` runs commitlint, lint, typecheck, the unit suite, and the smoke test on every push and pull request. On a push to `main` where those all pass, `release.config.ts` drives [semantic-release](https://semantic-release.gitbook.io/semantic-release): commit history since the last tag decides the version bump, `CHANGELOG.md` and `package.json` are committed back to `main`, a GitHub Release is cut, and the package publishes to [npmjs.org](https://www.npmjs.com/package/ooxml.js) via npm's OIDC trusted publishing, so no `NPM_TOKEN` exists anywhere in the pipeline.
254
+ `.github/workflows/ci.yml` runs commitlint, lint, typecheck, unit, and smoke on every push/PR. On push to `main`, `release.config.ts` drives [semantic-release](https://semantic-release.gitbook.io/semantic-release): commit history decides the bump, `CHANGELOG.md`/`package.json` commit back to `main`, a GitHub Release is cut, and the package publishes to [npmjs.org](https://www.npmjs.com/package/ooxml.js) via OIDC trusted publishing (no `NPM_TOKEN`).
262
255
 
263
- Whether that release actually published a new version is detected by diffing `package.json`'s version before and after the release step, not by trusting a third-party action's own detection. Two further jobs gate on that: one republishes the same build under the scoped `@exadev/ooxml.js` alias to GitHub Packages (which has no OIDC exchange of its own, so it authenticates with `GITHUB_TOKEN` instead), and one packs the release into its own directory, generates an SPDX SBOM (`pnpm sbom`), and signs both an SBOM and a build-provenance attestation against that exact tarball — verifiable independently of the registry, and still present if the package is later unpublished.
256
+ Release success is detected by diffing `package.json`'s version before/after. Two further jobs gate on that: one republishes under `@exadev/ooxml.js` to GitHub Packages (via `GITHUB_TOKEN`), and one packs the release, generates an SPDX SBOM (`pnpm sbom`), and signs an SBOM and a build-provenance attestation — verifiable independently of the registry, and present even if the package is later unpublished.
264
257
 
265
258
  ## Contributing
266
259
 
267
- Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint (`commitlint.config.ts`) via a husky `commit-msg` hook and a CI `commitlint` job — semantic-release's version bump depends on these being well-formed, not just style. A husky `pre-commit` hook runs `lint-staged` (`eslint --fix` on staged `*.ts` files) and `pre-push` runs the test suite. There is a single `main` branch and no open pull request workflow established so far.
260
+ Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`, …), enforced by commitlint (`commitlint.config.ts`) via a husky `commit-msg` hook and a CI job — semantic-release's version bump depends on these being well-formed. A husky `pre-commit` runs `lint-staged` (`eslint --fix` on staged `*.ts`); `pre-push` runs the test suite. Single `main` branch; no open PR workflow established.
268
261
 
269
262
  ## References
270
263
 
271
- - [document-schema.js](https://github.com/ExaDev/document-schema.js) — the canonical `ContentBlock`/`ContentSection`/`ContentSlide`/geometry/colour/alignment schemas `readDocx`/`readPptx` return, plus the `ContentDocument`/`LayoutMetadata` types `readXlsxContent`/`buildXlsxPackage` read and write directly, all imported here rather than defined locally — the single source of truth this package shares with `odf.js` and `documents.js` so none of the three maintains an independent, drift-prone copy.
272
- - [odf.js](https://github.com/ExaDev/odf.js) — a sibling package doing the equivalent job for the OpenDocument Format (odt/ods/odp/odg/…), also built on `document-schema.js`.
273
- - [documents.js](https://github.com/ExaDev/documents.js) — depends on this package for lossless OOXML handling and its cascade-resolved typed readers, adding PDF conversion and a read-and-write docx/pptx editor on top.
264
+ - [document-schema.js](https://github.com/ExaDev/document-schema.js) — canonical `ContentBlock`/`ContentSection`/geometry/colour schemas and `ContentDocument`/`LayoutMetadata` types shared with `odf.js` and `documents.js`.
265
+ - [odf.js](https://github.com/ExaDev/odf.js) — sibling OpenDocument Format package, also on `document-schema.js`.
266
+ - [documents.js](https://github.com/ExaDev/documents.js) — adds PDF conversion and a read-and-write docx/pptx editor on top of this package.
274
267
 
275
268
  ## License
276
269
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ooxml.js",
3
- "version": "2.11.8",
3
+ "version": "2.11.9",
4
4
  "description": "Type-safe, lossless round-trip conversion between OOXML packages (docx, pptx, xlsx) and JSON, built on Zod 4 codecs.",
5
5
  "type": "module",
6
6
  "repository": {