ai-file 0.0.0-stage → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -2
  3. package/dist/bytes.d.ts +33 -0
  4. package/dist/bytes.js +154 -0
  5. package/dist/container/ai.d.ts +37 -0
  6. package/dist/container/ai.js +149 -0
  7. package/dist/container/pdf.d.ts +55 -0
  8. package/dist/container/pdf.js +423 -0
  9. package/dist/create.d.ts +16 -0
  10. package/dist/create.js +90 -0
  11. package/dist/edit.d.ts +33 -0
  12. package/dist/edit.js +215 -0
  13. package/dist/index.d.ts +28 -0
  14. package/dist/index.js +44 -0
  15. package/dist/model/bounds.d.ts +9 -0
  16. package/dist/model/bounds.js +63 -0
  17. package/dist/model/color.d.ts +10 -0
  18. package/dist/model/color.js +71 -0
  19. package/dist/model/types.d.ts +335 -0
  20. package/dist/model/types.js +9 -0
  21. package/dist/parse/document.d.ts +33 -0
  22. package/dist/parse/document.js +963 -0
  23. package/dist/render/mesh.d.ts +13 -0
  24. package/dist/render/mesh.js +38 -0
  25. package/dist/render/png.d.ts +23 -0
  26. package/dist/render/png.js +54 -0
  27. package/dist/render/svg.d.ts +26 -0
  28. package/dist/render/svg.js +347 -0
  29. package/dist/syntax/dict.d.ts +54 -0
  30. package/dist/syntax/dict.js +316 -0
  31. package/dist/syntax/lexer.d.ts +53 -0
  32. package/dist/syntax/lexer.js +313 -0
  33. package/dist/text/txt2.d.ts +110 -0
  34. package/dist/text/txt2.js +400 -0
  35. package/dist/wasm/binary.d.ts +1 -0
  36. package/dist/wasm/binary.js +3 -0
  37. package/dist/wasm/codec.d.ts +36 -0
  38. package/dist/wasm/codec.js +162 -0
  39. package/dist/write/aifile.d.ts +23 -0
  40. package/dist/write/aifile.js +150 -0
  41. package/dist/write/pdfobj.d.ts +37 -0
  42. package/dist/write/pdfobj.js +90 -0
  43. package/dist/write/rendition.d.ts +28 -0
  44. package/dist/write/rendition.js +336 -0
  45. package/dist/write/stream.d.ts +12 -0
  46. package/dist/write/stream.js +404 -0
  47. package/docs/FORMAT.md +217 -0
  48. package/package.json +54 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Goncalves
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 CHANGED
@@ -1,3 +1,442 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # ai-file
4
+
5
+ **Read, edit and write Adobe Illustrator `.ai` files natively — in Node and the browser.**
6
+
7
+ [![CI](https://github.com/goncalveslucasv/ai-file/actions/workflows/ci.yml/badge.svg)](https://github.com/goncalveslucasv/ai-file/actions/workflows/ci.yml)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+ ![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)
10
+ ![Runtime deps](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
11
+ ![Rust + WebAssembly](https://img.shields.io/badge/codecs-Rust%20%2B%20WebAssembly-dea584?logo=rust&logoColor=white)
12
+ ![Node](https://img.shields.io/badge/node-%E2%89%A522.18-339933?logo=node.js&logoColor=white)
13
+
14
+ [Quick start](#quick-start) · [Recipes](#recipes) · [API](#api) · [Performance](#performance) · [How it works](#how-it-works) · [Format spec](docs/FORMAT.md) · [Status](#status)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ Every other tool opens an `.ai` file through its **PDF preview**: a flattened picture of the
21
+ artwork with no layers, no editable text, no live appearances and no way to save back.
22
+
23
+ `ai-file` decodes **Illustrator's own private data** — the compressed native stream that
24
+ Illustrator itself reads — so you get the document the way the designer built it, and you can
25
+ write it back.
26
+
27
+ ```ts
28
+ import { readAiFile, renderArtboardSvg } from "ai-file";
29
+
30
+ const { document } = await readAiFile(file); // File, Blob, ArrayBuffer or Uint8Array
31
+
32
+ document.artboards; // 8 artboards, names and bounds
33
+ document.layers; // layers → groups → clip groups → paths…
34
+ document.text?.stories.map((s) => s.text); // real, editable text with fonts and runs
35
+
36
+ const svg = renderArtboardSvg(document, 0); // high-fidelity SVG of artboard 1
37
+ ```
38
+
39
+ ## Highlights
40
+
41
+ - 🧬 **Native, not a preview.** Parses the AI24 (zstd), AI12 (zlib) and plain native streams
42
+ embedded in PDF-compatible `.ai` files, plus legacy EPS-style files.
43
+ - 🌳 **The real object tree.** Layers, groups, clip groups, compound paths, Bézier paths,
44
+ gradients (linear/radial, with midpoints and opacity stops), gradient meshes, embedded images
45
+ with alpha, swatches, symbols, art styles, artboards.
46
+ - 🔤 **Real text.** Stories, paragraphs and character runs with font, size, tracking, leading,
47
+ fill and stroke, positioned exactly where Illustrator puts them.
48
+ - 🎭 **Live appearances preserved.** Effects are stored by Illustrator as *expanded result +
49
+ hidden editable source*; both are exposed (`art.source`) and round-tripped.
50
+ - ✏️ **Edit and save.** Move, transform, recolor, change opacity, replace text, insert or remove
51
+ objects, create documents from scratch — then write a valid `.ai` file.
52
+ - 🔁 **Lossless round-trip.** Untouched objects are copied byte-for-byte; an unedited document
53
+ serializes **byte-identical**. Only what you change is regenerated.
54
+ - 🖼️ **Rendering built in.** Artboard → SVG (gradients, masks, blend modes, meshes, images,
55
+ text), raster → PNG, and a fresh PDF preview when saving.
56
+ - ⚡ **Fast.** Opens a 647 MB file (1 GB native stream) in **1.5 s**, renders all its
57
+ artboards in **0.6 s** and re-saves it unchanged in **0.5 s**. [Numbers below](#performance).
58
+ - 🦀 **Rust + WebAssembly where it counts.** Compression and pixel work run in a single
59
+ embedded WebAssembly module built from battle-tested libraries: the reference
60
+ [`libzstd`](https://github.com/facebook/zstd), [`zlib-rs`](https://github.com/trifectatechfoundation/zlib-rs)
61
+ and the [`png`](https://github.com/image-rs/image-png) crate. Data streams through it in
62
+ chunks, so a 1 GB payload never has to fit in wasm memory.
63
+ - 🌐 **Universal, zero dependencies.** Strict TypeScript, ESM. The same build runs in Node,
64
+ browsers, workers and bundlers — no native addons, no `.wasm` file to serve, no async init
65
+ required.
66
+ - 📖 **Documented format.** The reverse-engineered specification lives in
67
+ [`docs/FORMAT.md`](docs/FORMAT.md).
68
+
69
+ ## Install
70
+
71
+ ```sh
72
+ npm install github:goncalveslucasv/ai-file
73
+ ```
74
+
75
+ The package builds itself on install (`prepare`); the WebAssembly module ships prebuilt, so no
76
+ Rust toolchain is needed to use it. Requires Node ≥ 22.18 for development (native TypeScript);
77
+ the built output runs in any modern browser or bundler.
78
+
79
+ ## Quick start
80
+
81
+ ```ts
82
+ import fs from "node:fs";
83
+ import {
84
+ readAiBytes, saveAiFile, renderArtboardSvg,
85
+ findArt, setText, translateArt, setFillColor, insertArt, createRect, rgb,
86
+ } from "ai-file";
87
+
88
+ // 1. Read
89
+ const { container, document: doc } = readAiBytes(fs.readFileSync("card.ai"));
90
+
91
+ // 2. Inspect
92
+ console.log(doc.artboards.map((a) => a.name));
93
+ console.log(doc.text?.fonts.map((f) => f.postScriptName));
94
+
95
+ // 3. Edit
96
+ const name = findArt(doc, (a) => a.type === "text");
97
+ if (name) {
98
+ setText(doc, name, "Lucía Fernández");
99
+ translateArt(doc, name, 0, 12);
100
+ }
101
+ const badge = createRect(40, -40, 120, 32, { fill: { type: "color", color: rgb(0.1, 0.6, 0.3) } });
102
+ insertArt(doc.layers[0], badge);
103
+ setFillColor(badge, rgb(0.9, 0.2, 0.2));
104
+
105
+ // 4. Save — a real .ai file, reopenable by this library (and meant for Illustrator)
106
+ fs.writeFileSync("card-edited.ai", saveAiFile(doc, container, { rendition: "generate" }));
107
+
108
+ // 5. Render
109
+ fs.writeFileSync("artboard-1.svg", renderArtboardSvg(doc, 0));
110
+ ```
111
+
112
+ ## Recipes
113
+
114
+ <details>
115
+ <summary><b>Open a file in the browser</b></summary>
116
+
117
+ ```ts
118
+ import { readAiFile, renderArtboardSvg } from "ai-file";
119
+
120
+ input.addEventListener("change", async () => {
121
+ const { document } = await readAiFile(input.files![0], {
122
+ onProgress: (done, total) => (progress.value = done / total),
123
+ });
124
+ preview.innerHTML = renderArtboardSvg(document, 0);
125
+ });
126
+ ```
127
+ </details>
128
+
129
+ <details>
130
+ <summary><b>Walk the object tree</b></summary>
131
+
132
+ ```ts
133
+ import { walk } from "ai-file";
134
+
135
+ walk(doc, (art, parent, depth) => {
136
+ console.log(" ".repeat(depth) + art.type, art.type === "path" ? art.style.fill : "");
137
+ });
138
+
139
+ // Pass `true` to also visit the hidden editable sources of live appearances.
140
+ walk(doc, (art) => { /* … */ }, true);
141
+ ```
142
+ </details>
143
+
144
+ <details>
145
+ <summary><b>Read text with styling</b></summary>
146
+
147
+ ```ts
148
+ for (const story of doc.text?.stories ?? []) {
149
+ for (const run of story.chars) {
150
+ const chunk = story.text.slice(run.start, run.start + run.length);
151
+ console.log(JSON.stringify(chunk), run.style.font?.postScriptName, run.style.size, run.style.fill);
152
+ }
153
+ }
154
+ ```
155
+ </details>
156
+
157
+ <details>
158
+ <summary><b>Export embedded images as PNG</b></summary>
159
+
160
+ ```ts
161
+ import { walk, rasterToRgba, encodePng } from "ai-file";
162
+
163
+ let i = 0;
164
+ walk(doc, (art) => {
165
+ if (art.type !== "raster") return;
166
+ const img = rasterToRgba(art, doc.source); // color + alpha planes → RGBA
167
+ if (img) fs.writeFileSync(`image-${i++}.png`, encodePng(img.rgba, img.width, img.height));
168
+ });
169
+ ```
170
+ </details>
171
+
172
+ <details>
173
+ <summary><b>Create a document from scratch</b></summary>
174
+
175
+ ```ts
176
+ import { createDocument, insertArt, createEllipse, createGroup, saveAiFile, rgb } from "ai-file";
177
+
178
+ const doc = createDocument({
179
+ artboards: [{ name: "Front", width: 252, height: 144 }, { name: "Back", width: 252, height: 144 }],
180
+ });
181
+ const logo = createGroup([
182
+ createEllipse(126, -72, 40, 40, { fill: { type: "color", color: rgb(0, 0, 0) } }),
183
+ ]);
184
+ insertArt(doc.layers[0], logo);
185
+
186
+ fs.writeFileSync("new.ai", saveAiFile(doc));
187
+ ```
188
+ </details>
189
+
190
+ <details>
191
+ <summary><b>Dump the raw native stream</b></summary>
192
+
193
+ ```ts
194
+ import { openAiContainer, lex } from "ai-file";
195
+
196
+ const c = openAiContainer(bytes);
197
+ console.log(c.compression, c.privateInfo); // "zstd", { ContainerVersion: 12, … }
198
+ for (const st of lex(c.stream).slice(0, 50)) console.log(st.hidden ? "%_" : " ", st.op, st.args);
199
+ ```
200
+ </details>
201
+
202
+ ## API
203
+
204
+ ### Reading
205
+
206
+ | Function | Description |
207
+ |---|---|
208
+ | `readAiFile(input, options?)` | Async. Accepts `Blob` / `File` / `ArrayBuffer` / `Uint8Array`. Returns `{ container, document }`. |
209
+ | `readAiBytes(bytes, options?)` | Sync variant for bytes already in memory. |
210
+ | `openAiContainer(bytes)` | Container layer only: PDF objects, private-data blocks, decompressed native stream. |
211
+ | `parseAiStream(stream)` | Native stream → `AiDocument`. |
212
+ | `looksLikeAi(head)` | Sniffs the first bytes of a file. |
213
+
214
+ Options: `onProgress(done, total)` during decompression, `skipText` to skip the text engine.
215
+
216
+ The WebAssembly codecs instantiate synchronously on first use. `readAiFile` (and an optional
217
+ `await initWasm()`) compile them asynchronously instead, which keeps a browser's main thread free.
218
+
219
+ ### The document model
220
+
221
+ ```ts
222
+ interface AiDocument {
223
+ header: AiHeader; // creator, version, bounding box…
224
+ artboards: Artboard[]; // { name, left, top, right, bottom } — art space, y up
225
+ layers: LayerArt[]; // the object tree
226
+ swatches: Swatch[];
227
+ gradients: Map<string, GradientDef>;
228
+ patterns: Map<string, PatternDef>;
229
+ symbols: Map<string, SymbolDef>;
230
+ artStyles: Map<string, ArtStyle>;
231
+ text?: TextDocument; // stories, frames, fonts, styles
232
+ documentData?: DictNode; // raw document dictionary
233
+ warnings: string[];
234
+ }
235
+
236
+ type Art = LayerArt | GroupArt | PathArt | CompoundPathArt | RasterArt | TextArt | MeshArt | UnknownArt;
237
+ ```
238
+
239
+ Every art node carries its `style` (fill, stroke, dash, blend mode, opacity), optional `source`
240
+ (editable original of a live appearance) and anything the library does not model yet as
241
+ `UnknownArt` — kept verbatim, so it survives a save.
242
+
243
+ ### Editing
244
+
245
+ | Function | Description |
246
+ |---|---|
247
+ | `walk(doc, fn, includeSources?)` · `findArt(doc, pred)` · `parentOf(doc, art)` | Traversal |
248
+ | `insertArt(parent, art, index?)` · `removeArt(doc, art)` | Structure |
249
+ | `transformArt(doc, art, matrix)` · `translateArt(doc, art, dx, dy)` | Geometry — paths, gradients, images and text frames |
250
+ | `setFillColor(art, color)` · `setStrokeColor(art, color, width?)` · `setOpacity(art, value)` | Appearance |
251
+ | `setText(doc, textArt, string)` | Replace the content of a text object |
252
+ | `createPath` · `createRect` · `createEllipse` · `createGroup` · `rgb(r, g, b)` | Factories |
253
+ | `createDocument({ artboards, gap, layerName, title })` | New empty document |
254
+
255
+ ### Writing
256
+
257
+ ```ts
258
+ saveAiFile(doc, container?, {
259
+ rendition: "keep" | "generate", // reuse the original PDF preview, or build a new one from the model
260
+ compression: "zstd" | "deflate" | "none", // default: the original file's, else zstd (like Illustrator 2020+)
261
+ compressionLevel: 3, // zstd 1-22 / deflate 0-9
262
+ title: "Business card",
263
+ }): Uint8Array
264
+ ```
265
+
266
+ If nothing was edited, the original compressed private data is reused as is — saving costs
267
+ little more than writing the file.
268
+
269
+ `serializeAiStream(doc)` returns just the native stream if you want to embed it yourself.
270
+
271
+ ### Rendering
272
+
273
+ | Function | Description |
274
+ |---|---|
275
+ | `renderArtboardSvg(doc, index, { maxImageSize, imageLevel, preferSource, imageHref, meshSteps, background })` | Artboard → SVG string. Only art that touches the artboard is emitted; encoded images are cached per document. |
276
+ | `rasterToPng(art, doc.source, maxSize?)` · `rasterToRgba(…)` · `rasterToPlanes(…)` · `encodePng(rgba, w, h)` | Embedded images |
277
+ | `artBounds(art)` | Conservative bounding box in art space |
278
+ | `pathData(pathArt)` | Path → SVG `d` attribute |
279
+
280
+ ### Low level
281
+
282
+ `lex` (tokenizer for the PostScript dialect), `parseDictBlock` / `writeDict` (Illustrator's
283
+ dictionary language), `parseTxt2` / `writeTxt2` (text engine payloads), `PdfReader`, and the
284
+ codecs themselves: `zstdDecompress` / `zstdCompress` / `inflate` / `deflate` (streaming, accept
285
+ a `Uint8Array` or any iterable of chunks).
286
+
287
+ ## Performance
288
+
289
+ Measured on a 647 MB Illustrator 2026 document (8 artboards, 32 embedded images, 1 GB native
290
+ stream), Node 24, Apple Silicon:
291
+
292
+ | Operation | v0.1 (pure JS) | Now (Rust/WASM codecs) | |
293
+ |---|--:|--:|--:|
294
+ | Open + parse | 7.2 s | **1.5 s** | 4.8× |
295
+ | Render all 8 artboards to SVG | 30 s | **0.6 s** (0.07 s warm) | 50× |
296
+ | Save, unchanged | 18 s | **0.5 s** | 36× |
297
+ | Save, edited, keep PDF preview | 18 s | **5.9 s** | 3× |
298
+ | Save, edited, new PDF preview | 28 s | **11 s** | 2.5× |
299
+
300
+ Where the time goes: zstd decompression 1.3 s (fzstd took 6.8 s; native libzstd takes 1.1 s),
301
+ tokenizing + parsing 0.24 s, zstd level-3 compression 5 s. The SVG speed-up comes from
302
+ artboard culling and a per-document image cache, plus PNG encoding in Rust.
303
+
304
+ The parser stays in TypeScript on purpose: it is already ~0.2 s for 1 GB, and moving it into
305
+ WebAssembly would mean copying the whole stream into wasm memory (capped at 4 GB, and 64-bit
306
+ memory is not available in Safari) for almost no gain.
307
+
308
+ ## How it works
309
+
310
+ ```
311
+ .ai file (PDF)
312
+ ├── PDF pages ─────────────────────────── preview for other apps (what everyone else reads)
313
+ └── /PieceInfo/Illustrator/Private
314
+ ├── AIMetaData header comments
315
+ └── AIPrivateData1 … N (64 KB each) ─┐
316
+ │ concatenate, strip "%AI24_ZStandard_Data"
317
+ ▼ zstd / zlib (streaming, Rust → WebAssembly)
318
+ native stream (PostScript dialect, ~1 GB in the sample)
319
+ │
320
+ lex ─────────────────────┤ statements tile the source exactly
321
+ ▼
322
+ parse: graphics state machine, art grammar, dictionaries, Txt2 text engine
323
+ │
324
+ ▼
325
+ AiDocument ──► edit ──► serialize (copy untouched
326
+ │ spans, regenerate dirty ones)
327
+ ▼ │
328
+ SVG / PNG / PDF ◄────────────────┘ saveAiFile → .ai
329
+ ```
330
+
331
+ Some of what had to be figured out (all in [`docs/FORMAT.md`](docs/FORMAT.md)):
332
+
333
+ - Anything after `%_` is **Illustrator-only** content that a PostScript RIP skips — about 40%
334
+ of the artwork, including every editable source of a live effect.
335
+ - `%%BeginData: N` **under-reports** its length for images with alpha: the alpha plane is
336
+ written but not counted.
337
+ - A clip group's clipping path is written **last**, not first.
338
+ - Gradient geometry is the unit vector mapped through `Xm`; midpoints are exponent curves.
339
+ - Text uses a Photoshop-EngineData-like format with **numeric keys**; text space is y-down and
340
+ offset by the ruler origin. Copies of text inside expanded appearances index a *second* text
341
+ document.
342
+
343
+ Every interpretation was checked against the PDF rendition Illustrator stores in the same file:
344
+ positions, colors, gradient vectors, fonts and text baselines match.
345
+
346
+ ## Status
347
+
348
+ | Feature | Read | Render | Write |
349
+ |---|:-:|:-:|:-:|
350
+ | Layers, groups, clip groups, compound paths, paths | ✅ | ✅ | ✅ |
351
+ | Solid colors (RGB, CMYK, gray, spot/global) | ✅ | ✅ | ✅ |
352
+ | Strokes, dashes, blend modes, opacity | ✅ | ✅ | ✅ |
353
+ | Linear & radial gradients | ✅ | ✅ | ✅ |
354
+ | Embedded images (with alpha) | ✅ | ✅ | ✅ |
355
+ | Live appearances (result + editable source) | ✅ | ✅ | ✅ |
356
+ | Point text (stories, runs, fonts, frames) | ✅ | ✅ | ✅ |
357
+ | Gradient meshes | ✅ | ✅ | ⚪ verbatim |
358
+ | Artboards, swatches, symbols, art styles | ✅ | — | ⚪ verbatim |
359
+ | Area text, text on a path | ⚪ raw | ❌ | ⚪ verbatim |
360
+ | Symbol instances, linked art, Live Paint, blends, charts | ⚪ raw | ❌ | ⚪ verbatim |
361
+
362
+ ⚪ = preserved untouched through a save, but not editable through the model yet.
363
+
364
+ **Validation.** Developed against a 647 MB Illustrator 2026 (v30) document with 8 artboards,
365
+ 17 gradients, meshes, alpha images, 24 text stories and live effects:
366
+
367
+ - parses with 0 warnings; SVG renderings match Illustrator's own PDF rendition;
368
+ - unedited save is byte-identical; regenerating *every* object reproduces an identical model and
369
+ identical renderings;
370
+ - edited files reopen cleanly and their generated PDF previews render in macOS PDFKit;
371
+ - the WebAssembly build runs in Chrome (synchronous and asynchronous instantiation).
372
+
373
+ **Not yet verified:** reopening written files in Illustrator itself. Saving writes
374
+ `%AI24_ZStandard_Data` (a single zstd frame, like Illustrator 2020+) by default, or
375
+ `%AI12_CompressedData` (zlib) on request. If you try it, please
376
+ [open an issue](https://github.com/goncalveslucasv/ai-file/issues) with the result.
377
+
378
+ ## CLI
379
+
380
+ ```sh
381
+ node scripts/ai-inspect.ts file.ai # summary + object tree
382
+ node scripts/ai-inspect.ts file.ai --svg out/ # one SVG per artboard
383
+ node scripts/ai-inspect.ts file.ai --stream raw.txt # dump the decompressed native stream
384
+ node --max-old-space-size=12000 scripts/demo-edit.ts file.ai out/ # end-to-end edit demo
385
+ ```
386
+
387
+ ## Development
388
+
389
+ ```sh
390
+ npm install
391
+ npm test # unit + fixture tests
392
+ AI_SAMPLE=/path/to/file.ai npm test # + parse/render/regenerate check on a real file
393
+ npm run typecheck
394
+ npm run build # → dist/
395
+ ```
396
+
397
+ The source runs directly on Node's native TypeScript support — no build step for tests or scripts.
398
+
399
+ ### The WebAssembly codecs
400
+
401
+ The Rust crate lives in [`wasm/`](wasm/src/lib.rs) and is embedded into
402
+ `src/wasm/binary.ts`, which is committed — you only need Rust to change it:
403
+
404
+ ```sh
405
+ rustup target add wasm32-unknown-unknown
406
+ # libzstd is C: point to a clang that targets wasm32, e.g. a wasi-sdk install
407
+ WASI_SDK_PATH=/path/to/wasi-sdk npm run build:wasm
408
+ ```
409
+
410
+ The module has a tiny C ABI (no wasm-bindgen): codecs pull input through an imported
411
+ `js_read` and push output through `js_write`, so JavaScript owns every large buffer.
412
+
413
+ ## Roadmap
414
+
415
+ - [ ] Verify round-trip in Illustrator; collect a corpus of files across versions (CS6 → 2026)
416
+ - [x] zstd encoding for `%AI24_ZStandard_Data`
417
+ - [ ] Parse and render in a Web Worker with transferable buffers
418
+ - [ ] Area text and text on a path
419
+ - [ ] Symbol instances and placed (linked) art
420
+ - [ ] Editable meshes
421
+ - [ ] Rich text editing (per-run style changes)
422
+
423
+ ## Contributing
424
+
425
+ Issues and PRs are welcome. The most valuable contribution is a **sample file**: if something
426
+ parses wrong, a minimal `.ai` that reproduces it (and a screenshot from Illustrator) makes it
427
+ fixable. Please don't attach files you don't have the rights to share.
428
+
429
+ ## Acknowledgments
430
+
431
+ The initial binary map of the container was made with [REA](https://github.com/morluto/rea)'s
432
+ byte-entropy analysis. Codecs: [zstd](https://github.com/facebook/zstd) via
433
+ [zstd-rs](https://github.com/gyscos/zstd-rs), [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs),
434
+ [image-png](https://github.com/image-rs/image-png). Format knowledge builds on Adobe's public *Adobe Illustrator File Format
435
+ Specification* (AI 3/7), extended here up to the AI24 stream.
436
+
437
+ ## License
438
+
439
+ [MIT](LICENSE) © Lucas Goncalves
440
+
441
+ <sub>Adobe and Illustrator are trademarks of Adobe Inc. This project is not affiliated with or
442
+ endorsed by Adobe. It is an independent implementation for interoperability.</sub>
@@ -0,0 +1,33 @@
1
+ /** Decodes bytes 1:1 into a JS string (each byte becomes one UTF-16 code unit). */
2
+ export declare function latin1(bytes: Uint8Array, start?: number, end?: number): string;
3
+ /** Encodes a string whose code units are all < 256 back into bytes. */
4
+ export declare function fromLatin1(text: string): Uint8Array;
5
+ export declare function utf8(text: string): Uint8Array;
6
+ /** Interprets a byte string as UTF-8 when valid, otherwise as latin1. */
7
+ export declare function decodeMaybeUtf8(raw: string): string;
8
+ /** Encodes a JS string as UTF-8 and returns it as a byte string (one char per byte). */
9
+ export declare function toUtf8ByteString(text: string): string;
10
+ export declare function concatBytes(parts: Uint8Array[]): Uint8Array;
11
+ export declare function startsWith(bytes: Uint8Array, prefix: string, at?: number): boolean;
12
+ /** Finds `needle` (ASCII) in `bytes` starting at `from`; returns -1 when absent. */
13
+ export declare function indexOf(bytes: Uint8Array, needle: string, from?: number, to?: number): number;
14
+ export declare function lastIndexOf(bytes: Uint8Array, needle: string, from?: number): number;
15
+ /** Growable byte buffer used by the writers. Large payloads are kept as separate chunks (no copy). */
16
+ export declare class ByteWriter {
17
+ private chunks;
18
+ private buf;
19
+ private used;
20
+ length: number;
21
+ private flush;
22
+ bytes(data: Uint8Array): this;
23
+ /** Appends a byte string (chars < 256). */
24
+ str(text: string): this;
25
+ toBytes(): Uint8Array;
26
+ /** The chunks written so far (avoids one large concatenation). */
27
+ toChunks(): Uint8Array[];
28
+ }
29
+ /**
30
+ * Formats a number the way Illustrator does: shortest round-trip decimal, never exponent
31
+ * notation. `digits` caps the number of decimals (default: as many as needed).
32
+ */
33
+ export declare function fmt(n: number, digits?: number): string;
package/dist/bytes.js ADDED
@@ -0,0 +1,154 @@
1
+ // Small byte helpers shared by the container, lexer and writers.
2
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
3
+ const utf8Encoder = new TextEncoder();
4
+ /** Decodes bytes 1:1 into a JS string (each byte becomes one UTF-16 code unit). */
5
+ export function latin1(bytes, start = 0, end = bytes.length) {
6
+ // TextDecoder("latin1") is really windows-1252; decode manually for exact 0x80-0x9F round-trips.
7
+ let out = "";
8
+ const CHUNK = 0x8000;
9
+ for (let i = start; i < end; i += CHUNK) {
10
+ out += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, Math.min(end, i + CHUNK))));
11
+ }
12
+ return out;
13
+ }
14
+ /** Encodes a string whose code units are all < 256 back into bytes. */
15
+ export function fromLatin1(text) {
16
+ const out = new Uint8Array(text.length);
17
+ for (let i = 0; i < text.length; i++)
18
+ out[i] = text.charCodeAt(i) & 0xff;
19
+ return out;
20
+ }
21
+ export function utf8(text) {
22
+ return utf8Encoder.encode(text);
23
+ }
24
+ /** Interprets a byte string as UTF-8 when valid, otherwise as latin1. */
25
+ export function decodeMaybeUtf8(raw) {
26
+ if (!/[\x80-\xff]/.test(raw))
27
+ return raw;
28
+ try {
29
+ return utf8Decoder.decode(fromLatin1(raw));
30
+ }
31
+ catch {
32
+ return raw;
33
+ }
34
+ }
35
+ /** Encodes a JS string as UTF-8 and returns it as a byte string (one char per byte). */
36
+ export function toUtf8ByteString(text) {
37
+ if (!/[^\x00-\x7f]/.test(text))
38
+ return text;
39
+ return latin1(utf8(text));
40
+ }
41
+ export function concatBytes(parts) {
42
+ let total = 0;
43
+ for (const p of parts)
44
+ total += p.length;
45
+ const out = new Uint8Array(total);
46
+ let pos = 0;
47
+ for (const p of parts) {
48
+ out.set(p, pos);
49
+ pos += p.length;
50
+ }
51
+ return out;
52
+ }
53
+ export function startsWith(bytes, prefix, at = 0) {
54
+ if (at + prefix.length > bytes.length)
55
+ return false;
56
+ for (let i = 0; i < prefix.length; i++)
57
+ if (bytes[at + i] !== prefix.charCodeAt(i))
58
+ return false;
59
+ return true;
60
+ }
61
+ /** Finds `needle` (ASCII) in `bytes` starting at `from`; returns -1 when absent. */
62
+ export function indexOf(bytes, needle, from = 0, to = bytes.length) {
63
+ const first = needle.charCodeAt(0);
64
+ const last = to - needle.length;
65
+ outer: for (let i = from; i <= last; i++) {
66
+ if (bytes[i] !== first)
67
+ continue;
68
+ for (let j = 1; j < needle.length; j++)
69
+ if (bytes[i + j] !== needle.charCodeAt(j))
70
+ continue outer;
71
+ return i;
72
+ }
73
+ return -1;
74
+ }
75
+ export function lastIndexOf(bytes, needle, from = bytes.length - needle.length) {
76
+ const first = needle.charCodeAt(0);
77
+ outer: for (let i = Math.min(from, bytes.length - needle.length); i >= 0; i--) {
78
+ if (bytes[i] !== first)
79
+ continue;
80
+ for (let j = 1; j < needle.length; j++)
81
+ if (bytes[i + j] !== needle.charCodeAt(j))
82
+ continue outer;
83
+ return i;
84
+ }
85
+ return -1;
86
+ }
87
+ /** Growable byte buffer used by the writers. Large payloads are kept as separate chunks (no copy). */
88
+ export class ByteWriter {
89
+ chunks = [];
90
+ buf = new Uint8Array(1 << 16);
91
+ used = 0;
92
+ length = 0;
93
+ flush() {
94
+ if (this.used === 0)
95
+ return;
96
+ this.chunks.push(this.buf.slice(0, this.used));
97
+ this.used = 0;
98
+ }
99
+ bytes(data) {
100
+ if (data.length > 1 << 16) {
101
+ this.flush();
102
+ this.chunks.push(data);
103
+ }
104
+ else {
105
+ if (this.used + data.length > this.buf.length)
106
+ this.flush();
107
+ this.buf.set(data, this.used);
108
+ this.used += data.length;
109
+ }
110
+ this.length += data.length;
111
+ return this;
112
+ }
113
+ /** Appends a byte string (chars < 256). */
114
+ str(text) {
115
+ if (text.length > 1 << 15)
116
+ return this.bytes(fromLatin1(text));
117
+ if (this.used + text.length > this.buf.length)
118
+ this.flush();
119
+ for (let i = 0; i < text.length; i++)
120
+ this.buf[this.used + i] = text.charCodeAt(i) & 0xff;
121
+ this.used += text.length;
122
+ this.length += text.length;
123
+ return this;
124
+ }
125
+ toBytes() {
126
+ this.flush();
127
+ if (this.chunks.length === 1)
128
+ return this.chunks[0];
129
+ return concatBytes(this.chunks);
130
+ }
131
+ /** The chunks written so far (avoids one large concatenation). */
132
+ toChunks() {
133
+ this.flush();
134
+ return this.chunks;
135
+ }
136
+ }
137
+ /**
138
+ * Formats a number the way Illustrator does: shortest round-trip decimal, never exponent
139
+ * notation. `digits` caps the number of decimals (default: as many as needed).
140
+ */
141
+ export function fmt(n, digits) {
142
+ if (!Number.isFinite(n))
143
+ return "0";
144
+ let v = digits === undefined ? n : Number(n.toFixed(Math.min(20, digits)));
145
+ if (Object.is(v, -0))
146
+ v = 0;
147
+ let s = String(v);
148
+ if (/e/i.test(s)) {
149
+ s = Math.abs(v) < 1 ? v.toFixed(20) : v.toFixed(0);
150
+ if (s.includes("."))
151
+ s = s.replace(/0+$/, "").replace(/\.$/, "");
152
+ }
153
+ return s === "-0" ? "0" : s;
154
+ }