ooxml.js 0.0.0 → 1.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/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/index.cjs +938 -0
- package/dist/index.d.cts +581 -0
- package/dist/index.d.ts +581 -0
- package/dist/index.js +880 -0
- package/package.json +77 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joseph Mearman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# ooxml.js
|
|
2
|
+
|
|
3
|
+
> 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.
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
|
|
13
|
+
Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `package.json`).
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Install as a dependency in another project:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add ooxml.js
|
|
23
|
+
# or
|
|
24
|
+
npm install ooxml.js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { decodePackage, encodePackage } from 'ooxml.js';
|
|
31
|
+
|
|
32
|
+
// .docx / .pptx / .xlsx bytes -> faithful JSON Package
|
|
33
|
+
const pkg = decodePackage(new Uint8Array(await file.arrayBuffer()));
|
|
34
|
+
|
|
35
|
+
// ...inspect or modify pkg.parts...
|
|
36
|
+
|
|
37
|
+
// Package -> bytes (content-identical to the original)
|
|
38
|
+
const bytes = encodePackage(pkg);
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The core is a Zod 4 codec, so both directions are schema-validated:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { z } from 'zod';
|
|
45
|
+
import { packageCodec } from 'ooxml.js';
|
|
46
|
+
|
|
47
|
+
const pkg = z.decode(packageCodec, bytes);
|
|
48
|
+
const out = z.encode(packageCodec, pkg);
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Typed reading views project the generic `Package` into ergonomic models (lossy; for reading, not round-trip):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { decodePackage, readDocx } from 'ooxml.js';
|
|
55
|
+
|
|
56
|
+
const doc = readDocx(decodePackage(bytes));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## The ooxml.js format
|
|
60
|
+
|
|
61
|
+
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:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
OOXML bytes --[packageCodec]--> Package --[compactCodec]--> CompactPackage (the ooxml.js format)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { decodePackage, toCompact, fromCompact } from 'ooxml.js';
|
|
69
|
+
|
|
70
|
+
const pkg = decodePackage(bytes);
|
|
71
|
+
const compact = toCompact(pkg); // { s: string[], p: Record<path, CompactPart> }
|
|
72
|
+
const roundTripped = fromCompact(compact); // deep-equals pkg
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For example, a `word/document.xml` part holding a single run of text:
|
|
76
|
+
|
|
77
|
+
```xml
|
|
78
|
+
<w:p><w:r><w:t>Hi</w:t></w:r></w:p>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
decodes to this `Package` (one entry in `parts`, each element an `XmlNode`):
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"parts": {
|
|
86
|
+
"word/document.xml": {
|
|
87
|
+
"kind": "xml",
|
|
88
|
+
"nodes": [
|
|
89
|
+
{
|
|
90
|
+
"type": "element",
|
|
91
|
+
"tag": "w:p",
|
|
92
|
+
"attributes": [],
|
|
93
|
+
"children": [
|
|
94
|
+
{
|
|
95
|
+
"type": "element",
|
|
96
|
+
"tag": "w:r",
|
|
97
|
+
"attributes": [],
|
|
98
|
+
"children": [
|
|
99
|
+
{
|
|
100
|
+
"type": "element",
|
|
101
|
+
"tag": "w:t",
|
|
102
|
+
"attributes": [],
|
|
103
|
+
"children": [{ "type": "text", "value": "Hi" }]
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`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):
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"s": ["w:p", "w:r", "w:t", "Hi"],
|
|
120
|
+
"p": {
|
|
121
|
+
"word/document.xml": [[0, 0, [], [[0, 1, [], [[0, 2, [], [[1, 3]]]]]]]]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
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"`).
|
|
127
|
+
|
|
128
|
+
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).
|
|
129
|
+
|
|
130
|
+
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`):
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
import { decodeCompactPackage, encodeCompactPackage } from 'ooxml.js';
|
|
134
|
+
|
|
135
|
+
const compact = decodeCompactPackage(bytes); // OOXML bytes -> CompactPackage directly
|
|
136
|
+
const out = encodeCompactPackage(compact); // CompactPackage -> OOXML bytes directly
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Build, test, and lint
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pnpm build # tsdown -> dist/ (ESM + CJS + .d.ts, via tsdown.config.ts)
|
|
143
|
+
pnpm typecheck # tsc --noEmit
|
|
144
|
+
pnpm test # vitest run
|
|
145
|
+
pnpm test:watch # vitest
|
|
146
|
+
pnpm test:smoke # builds dist/, then runs test/smoke.test.mjs to verify the built ESM and CJS artifacts both load and behave identically
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
There is no separate lint script or ESLint config; `pnpm typecheck` and `vitest` are the enforced gates. `pnpm prepublishOnly` runs `typecheck`, `tsdown`, `publint`, and `@arethetypeswrong/cli` (`attw --pack`) — the full publish-readiness check — before a release.
|
|
150
|
+
|
|
151
|
+
`test/smoke.test.mjs` loads the actual built `dist/index.js` (ESM) and `dist/index.cjs` (CJS) 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.
|
|
152
|
+
|
|
153
|
+
To run a single test file: `pnpm vitest run src/typed/docx.test.ts`.
|
|
154
|
+
|
|
155
|
+
## Architecture
|
|
156
|
+
|
|
157
|
+
The package is layered from a lossless core outward to lossy convenience views:
|
|
158
|
+
|
|
159
|
+
- **`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).
|
|
160
|
+
- **`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.
|
|
161
|
+
- **`src/zip.ts`** — thin wrapper over `fflate`'s synchronous `zipSync`/`unzipSync`, isomorphic and dependency-free.
|
|
162
|
+
- **`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.
|
|
163
|
+
- **`src/codec.ts`** — the public round-trip surface: `packageCodec`/`xmlCodec` are `z.codec()` pairs, and `decodePackage`/`encodePackage` are the ergonomic wrappers around them.
|
|
164
|
+
- **`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.
|
|
165
|
+
- **`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.
|
|
166
|
+
|
|
167
|
+
## Conventions
|
|
168
|
+
|
|
169
|
+
- **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.
|
|
170
|
+
- **`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.
|
|
171
|
+
- **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`.
|
|
172
|
+
- **XML entities stay raw in the lossless layer.** `parseXml` runs with `processEntities: false` so encoded entities (e.g. `&`) 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.
|
|
173
|
+
|
|
174
|
+
## Gotchas and quirks
|
|
175
|
+
|
|
176
|
+
- **No git remote is configured yet.** `git remote -v` is empty; this repository has not been pushed anywhere. Confirm the intended origin before assuming a `git push` target.
|
|
177
|
+
- **`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.
|
|
178
|
+
- **`--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.
|
|
179
|
+
- **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.
|
|
180
|
+
- **`fflate`'s bundled types are ahead of what it actually allocates.** `zip.ts` casts `fflate`'s `Uint8Array<ArrayBufferLike>` results to `Uint8Array<ArrayBuffer>` with a comment explaining why the narrowing is safe (fflate only ever allocates a real `ArrayBuffer`) — don't remove the cast without preserving that guarantee elsewhere.
|
|
181
|
+
- **No CI workflow exists yet.** There is no `.github/workflows/` directory; `pnpm build`, `pnpm typecheck`, and `pnpm test` are run locally/manually, not gated by GitHub Actions.
|
|
182
|
+
|
|
183
|
+
## Fidelity
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
## Contributing
|
|
190
|
+
|
|
191
|
+
Commits follow Conventional Commits (`feat:`, `fix:`, `test:`, `chore:`), evidenced by the existing git history; there is no `CONTRIBUTING.md` or enforced commit hook yet. There is a single `main` branch and no open pull request workflow established so far.
|
|
192
|
+
|
|
193
|
+
## License
|
|
194
|
+
|
|
195
|
+
MIT
|