ooxml.js 1.3.0 → 2.0.0

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.
package/README.md CHANGED
@@ -50,12 +50,14 @@ const pkg = z.decode(packageCodec, bytes);
50
50
  const out = z.encode(packageCodec, pkg);
51
51
  ```
52
52
 
53
- Typed reading views project the generic `Package` into ergonomic models (lossy; for reading, not round-trip):
53
+ 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:
54
54
 
55
55
  ```ts
56
56
  import { decodePackage, readDocx } from 'ooxml.js';
57
57
 
58
58
  const doc = readDocx(decodePackage(bytes));
59
+ // doc.sections[0].blocks holds paragraphs/tables/page-breaks in document order (including
60
+ // inside tables); each run already carries its cascade-resolved bold/italic/colour/font.
59
61
  ```
60
62
 
61
63
  ## The ooxml.js format
@@ -165,18 +167,19 @@ The package is layered from a lossless core outward to lossy convenience views:
165
167
  - **`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.
166
168
  - **`src/codec.ts`** — the public round-trip surface: `packageCodec`/`xmlCodec` are `z.codec()` pairs, and `decodePackage`/`encodePackage` are the ergonomic wrappers around them.
167
169
  - **`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.
168
- - **`src/typed/`** — one-way, lossy projections (`docx.ts`, `pptx.ts`, `xlsx.ts`) that read the generic `Package` into ergonomic document/presentation/workbook models: `readDocx` covers paragraphs, runs, tables, resolved hyperlinks, comments, footnotes, headers/footers and list membership; `readPptx` covers slide text, shapes, tables and speaker notes; `readXlsx` covers cell values and formulas, merged ranges and defined names. These cannot be encoded back to a `Package` — round-tripping always goes through `decodePackage`/`encodePackage`, never through a typed view. `util.ts` holds the shared XML-walking helpers (`walk`, `elementsWithTag`, `childrenWithTag`, `attr`, `rootElement`, `textContent`, entity decoding, `resolveRelationships`) that all three typed readers build on.
170
+ - **`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 (`typed/shared/content.ts`'s `ContentParagraph`/`ContentTable`/`ContentImageBlock`/`ContentPageBreak`, discriminated as `ContentBlock`) 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 primitives both `docx/` and `pptx/` build on: `content.ts` (the block model), `drawingml.ts` (DrawingML `a:xfrm` geometry, theme/colour resolution, group-transform composition), `geometry.ts`/`units.ts`/`color.ts`/`style.ts` (points-based geometry, OOXML unit conversions, sRGB colour + DrawingML shade/tint/lumMod/lumOff transforms, alignment), and `metadata.ts` (`docProps/core.xml` + `docProps/app.xml` → `DocumentMetadata`, shared verbatim across docx/pptx/xlsx). `src/image/sniff.ts` (magic-byte PNG/JPEG detection) supports `readPptx`'s picture-shape reading. None of this can be encoded back to a `Package` — round-tripping always goes through `decodePackage`/`encodePackage`, never through a typed view. `typed/util.ts` holds the shared XML-walking helpers (`walk`, `elementsWithTag`, `childrenWithTag`, `attr`, `rootElement`, `textContent`, entity decoding, `resolveRelationships`) every typed reader builds on.
169
171
 
170
172
  ## Conventions
171
173
 
172
174
  - **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.
173
- - **`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` reuses the same pattern (`isCompactXmlNode` + `z.custom`) for the same reason.
174
- - **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/*` readers are explicitly one-way and are allowed to drop information (documented per-reader, e.g. `readDocx`'s bold/italic toggle presence check ignores `w:val`, and `readXlsx` drops cell styles, formats and charts). Don't blur this line by adding write-back support to a typed reader; a full round-trip always goes through the generic `Package`.
175
+ - **`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`) and `typed/shared/content.ts`'s `ContentBlock` (`isContentBlock` + `z.custom`, since a table cell's blocks can themselves contain a table) reuse the same pattern for the same reason.
176
+ - **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/*` readers 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:themeColor` references aren't resolved, and `readXlsx` drops cell styles, formats and charts). Don't blur this line by adding write-back support to a typed reader; a full round-trip always goes through the generic `Package`.
175
177
  - **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.
176
178
  - **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.
177
179
 
178
180
  ## Gotchas and quirks
179
181
 
182
+ - **`readDocx`/`readPptx` are richer than a flat text/shape dump, but still not a round-trip path — here is what's still not captured.** Not modelled: numbering *definitions* themselves (glyph, format, restart-at-level, from `word/numbering.xml`) — only each paragraph's own `numId`/`level` *membership* is captured, so a consumer can group paragraphs into a list but can't render the list's own markers without separately reading `word/numbering.xml`; table cell border styling (`w:tcBorders`, `a:tcPr` line properties — only cell shading/fill is read); docx's own `w:themeColor` run-colour references (real-world runs overwhelmingly use direct `w:val` hex instead); live `PAGE`/`NUMPAGES` field re-evaluation (fields resolve to Word's own cached result text, correct unless a different pagination would change the value); and docx inline/floating images (`w:drawing`) — `readPptx`'s picture-shape reading (`p:pic`) has no docx-side equivalent yet. On the pptx side specifically: connector shapes (`p:cxnSp`) are skipped entirely (decorative, no text); a shape's rotation is passed through from its own local `a:xfrm/@rot` rather than composed through a rotated or flipped parent group (ECMA-376's real composition rule there is one of DrawingML's more arcane corners); and non-table graphic frames (chart/SmartArt/OLE) come through with correct geometry but empty content.
180
183
  - **`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.
181
184
  - **`--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.
182
185
  - **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.