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.
- package/LICENSE +21 -0
- package/README.md +441 -2
- package/dist/bytes.d.ts +33 -0
- package/dist/bytes.js +154 -0
- package/dist/container/ai.d.ts +37 -0
- package/dist/container/ai.js +149 -0
- package/dist/container/pdf.d.ts +55 -0
- package/dist/container/pdf.js +423 -0
- package/dist/create.d.ts +16 -0
- package/dist/create.js +90 -0
- package/dist/edit.d.ts +33 -0
- package/dist/edit.js +215 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +44 -0
- package/dist/model/bounds.d.ts +9 -0
- package/dist/model/bounds.js +63 -0
- package/dist/model/color.d.ts +10 -0
- package/dist/model/color.js +71 -0
- package/dist/model/types.d.ts +335 -0
- package/dist/model/types.js +9 -0
- package/dist/parse/document.d.ts +33 -0
- package/dist/parse/document.js +963 -0
- package/dist/render/mesh.d.ts +13 -0
- package/dist/render/mesh.js +38 -0
- package/dist/render/png.d.ts +23 -0
- package/dist/render/png.js +54 -0
- package/dist/render/svg.d.ts +26 -0
- package/dist/render/svg.js +347 -0
- package/dist/syntax/dict.d.ts +54 -0
- package/dist/syntax/dict.js +316 -0
- package/dist/syntax/lexer.d.ts +53 -0
- package/dist/syntax/lexer.js +313 -0
- package/dist/text/txt2.d.ts +110 -0
- package/dist/text/txt2.js +400 -0
- package/dist/wasm/binary.d.ts +1 -0
- package/dist/wasm/binary.js +3 -0
- package/dist/wasm/codec.d.ts +36 -0
- package/dist/wasm/codec.js +162 -0
- package/dist/write/aifile.d.ts +23 -0
- package/dist/write/aifile.js +150 -0
- package/dist/write/pdfobj.d.ts +37 -0
- package/dist/write/pdfobj.js +90 -0
- package/dist/write/rendition.d.ts +28 -0
- package/dist/write/rendition.js +336 -0
- package/dist/write/stream.d.ts +12 -0
- package/dist/write/stream.js +404 -0
- package/docs/FORMAT.md +217 -0
- 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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ai-file
|
|
4
|
+
|
|
5
|
+
**Read, edit and write Adobe Illustrator `.ai` files natively — in Node and the browser.**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/goncalveslucasv/ai-file/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+

|
|
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>
|
package/dist/bytes.d.ts
ADDED
|
@@ -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
|
+
}
|