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.
- package/README.md +40 -47
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
219
|
+
The package layers a lossless core outward to lossy convenience views:
|
|
224
220
|
|
|
225
|
-
- **`src/model/`** —
|
|
226
|
-
- **`src/xml/`** — `parse.ts
|
|
227
|
-
- **`src/zip.ts`** — thin
|
|
228
|
-
- **`src/package-io/`** — `read.ts
|
|
229
|
-
- **`src/codec.ts`** —
|
|
230
|
-
- **`src/compact.ts`** — the ooxml.js format: `compactCodec`
|
|
231
|
-
- **`src/typed/`** — one-way, lossy projections
|
|
232
|
-
- **`src/typed/xlsx/`** — a
|
|
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
|
|
237
|
-
- **`XmlNode` uses a recursive structural guard, not `z.lazy`.** `z.lazy` collapses to `unknown` for
|
|
238
|
-
- **Lossless core vs. lossy views is a hard boundary.** `decodePackage`/`encodePackage`
|
|
239
|
-
- **XML entities stay raw in the lossless layer.** `parseXml` runs with `processEntities: false
|
|
240
|
-
- **No type assertions.** `eslint.config.ts`
|
|
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
|
|
245
|
-
- **xlsx has no native percentage/currency/date/time cell type
|
|
246
|
-
- **`test:smoke` depends on a fresh build.** It runs `tsdown && vitest run --project smoke`,
|
|
247
|
-
-
|
|
248
|
-
-
|
|
249
|
-
-
|
|
250
|
-
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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) —
|
|
272
|
-
- [odf.js](https://github.com/ExaDev/odf.js) —
|
|
273
|
-
- [documents.js](https://github.com/ExaDev/documents.js) —
|
|
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