From ccfbbab0367e7f5b70a53f24ee7be2a8f296cf85 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Wed, 20 May 2026 20:28:58 -0400 Subject: [PATCH] feat(pipeline): add IDML parser and intermediate representation (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #62. Foundational sub-issue for the InDesign-to-WordPress pipeline (#61). Ships: - `@flavian/pipeline` workspace package (new `packages/*` workspace; this is the first occupant) - `parseIdml(path)` / `parseIdmlBuffer(bytes)` — unzip via fflate, parse XML via fast-xml-parser, resolve Self/ParentStory cross-refs, validate the final shape with zod - IR covering Document, Spread, Page, Frame (discriminated union of TextFrame + ImageFrame), Story, Style, Swatch, Font, MasterSpread - Unit normalizer (pt/pc/mm/cm/in/px → px at configurable DPI, default 96) - Warning collector — non-fatal issues (missing optional resources, dangling style refs, unknown color spaces, empty stories) surface on the IR rather than throwing - CLI: `flavian-parse-idml ` (JSON on stdout, warnings on stderr) - 20 tests via `node --test`: happy path (parses full IDML with swatches, fonts, styles, story, spread, master), three malformed cases called out in the issue (missing manifest throws; unknown style refs warn; empty stories produce empty run lists), plus unit conversion coverage Notes on language choice: the issue spec says `ir.ts` but the repo has zero TypeScript today. Used `.js` + JSDoc + zod instead to match the existing `.mjs` + node:test stack. `z.infer` gives any future TS consumer the types for free. Notes on fixtures: built programmatically by `tests/indesign/helpers/build-idml.js` — no binary blobs in git, no InDesign required to maintain or evolve them. The epic's AC for committed `.idml` files lives on #61, not here. CI: extended `pipeline-tests.yml` with a `packages/pipeline/**` path filter and a new job that runs `pnpm --filter @flavian/pipeline test`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/pipeline-tests.yml | 45 ++- package.json | 1 + packages/pipeline/README.md | 88 ++++++ packages/pipeline/bin/parse-idml.mjs | 69 +++++ packages/pipeline/package.json | 25 ++ packages/pipeline/src/indesign/index.js | 4 + packages/pipeline/src/indesign/ir.js | 172 ++++++++++++ packages/pipeline/src/indesign/parse-idml.js | 250 +++++++++++++++++ .../src/indesign/parsers/designmap.js | 45 +++ .../src/indesign/parsers/resources.js | 191 +++++++++++++ .../pipeline/src/indesign/parsers/spreads.js | 121 ++++++++ .../pipeline/src/indesign/parsers/stories.js | 83 ++++++ packages/pipeline/src/indesign/parsers/xml.js | 44 +++ packages/pipeline/src/indesign/units.js | 78 ++++++ packages/pipeline/src/indesign/warnings.js | 24 ++ packages/pipeline/src/index.js | 3 + .../tests/indesign/helpers/build-idml.js | 258 ++++++++++++++++++ .../tests/indesign/malformed.test.mjs | 121 ++++++++ .../tests/indesign/parse-idml.test.mjs | 140 ++++++++++ .../pipeline/tests/indesign/units.test.mjs | 37 +++ pnpm-lock.yaml | 30 ++ pnpm-workspace.yaml | 2 + 22 files changed, 1824 insertions(+), 7 deletions(-) create mode 100644 packages/pipeline/README.md create mode 100644 packages/pipeline/bin/parse-idml.mjs create mode 100644 packages/pipeline/package.json create mode 100644 packages/pipeline/src/indesign/index.js create mode 100644 packages/pipeline/src/indesign/ir.js create mode 100644 packages/pipeline/src/indesign/parse-idml.js create mode 100644 packages/pipeline/src/indesign/parsers/designmap.js create mode 100644 packages/pipeline/src/indesign/parsers/resources.js create mode 100644 packages/pipeline/src/indesign/parsers/spreads.js create mode 100644 packages/pipeline/src/indesign/parsers/stories.js create mode 100644 packages/pipeline/src/indesign/parsers/xml.js create mode 100644 packages/pipeline/src/indesign/units.js create mode 100644 packages/pipeline/src/indesign/warnings.js create mode 100644 packages/pipeline/src/index.js create mode 100644 packages/pipeline/tests/indesign/helpers/build-idml.js create mode 100644 packages/pipeline/tests/indesign/malformed.test.mjs create mode 100644 packages/pipeline/tests/indesign/parse-idml.test.mjs create mode 100644 packages/pipeline/tests/indesign/units.test.mjs create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/pipeline-tests.yml b/.github/workflows/pipeline-tests.yml index a16fe73..0e0d600 100644 --- a/.github/workflows/pipeline-tests.yml +++ b/.github/workflows/pipeline-tests.yml @@ -10,6 +10,7 @@ jobs: runs-on: ubuntu-latest outputs: should-test: ${{ steps.filter.outputs.scripts || steps.filter.outputs.tests }} + should-test-pipeline-pkg: ${{ steps.filter.outputs.pipeline-pkg }} steps: - uses: actions/checkout@v4 - uses: dorny/paths-filter@v3 @@ -21,6 +22,9 @@ jobs: - 'scripts/canva-fse/**' tests: - 'tests/**' + pipeline-pkg: + - 'packages/pipeline/**' + - 'pnpm-workspace.yaml' test: needs: check-paths @@ -49,21 +53,48 @@ jobs: - name: Run Canva-to-FSE pipeline tests run: ./tests/libs/bats-core/bin/bats tests/canva-fse/ + test-indesign-pkg: + needs: check-paths + if: needs.check-paths.outputs.should-test-pipeline-pkg == 'true' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Run @flavian/pipeline tests + run: pnpm --filter @flavian/pipeline test + test-status: runs-on: ubuntu-latest - needs: [check-paths, test] + needs: [check-paths, test, test-indesign-pkg] if: always() name: Pipeline Tests Status steps: - name: Report status run: | - if [ "${{ needs.check-paths.outputs.should-test }}" != "true" ]; then + if [ "${{ needs.check-paths.outputs.should-test }}" != "true" ] && [ "${{ needs.check-paths.outputs.should-test-pipeline-pkg }}" != "true" ]; then echo "No pipeline/test changes detected — skipping tests" exit 0 fi - if [ "${{ needs.test.result }}" = "success" ]; then - echo "Pipeline tests passed" - exit 0 + fail=0 + if [ "${{ needs.check-paths.outputs.should-test }}" = "true" ] && [ "${{ needs.test.result }}" != "success" ]; then + echo "bats pipeline tests failed" + fail=1 + fi + if [ "${{ needs.check-paths.outputs.should-test-pipeline-pkg }}" = "true" ] && [ "${{ needs.test-indesign-pkg.result }}" != "success" ]; then + echo "@flavian/pipeline tests failed" + fail=1 fi - echo "Pipeline tests failed" - exit 1 + exit $fail diff --git a/package.json b/package.json index b4089af..a9b1457 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "init": "node scripts/init.mjs", "test:init": "node --test \"tests/init/**/*.test.mjs\"", + "test:pipeline": "pnpm --filter @flavian/pipeline test", "visual:capture": "node tests/visual/capture.mjs", "visual:diff": "node scripts/visual-diff.js --batch tests/visual/actual tests/visual/baselines --output-dir tests/visual/diffs --threshold 0.005 --json > tests/visual/report.json && node tests/visual/print-report.mjs tests/visual/report.json", "visual:update": "bash scripts/visual-update-baselines.sh", diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md new file mode 100644 index 0000000..1c1ebec --- /dev/null +++ b/packages/pipeline/README.md @@ -0,0 +1,88 @@ +# @flavian/pipeline + +Conversion pipeline for InDesign (and future) sources into WordPress FSE themes. + +## Status + +This package currently ships the **IDML parser and intermediate representation** (sub-issue #62 of the InDesign-to-WordPress epic). Downstream stages — PDF fallback (#63), style + token mapper (#64), output generator (#65) — will land as separate PRs. The IR shape produced here is the contract those stages consume. + +## Layout + +``` +packages/pipeline/ +├── bin/parse-idml.mjs CLI entry; prints validated IR JSON on stdout +└── src/ + ├── index.js Re-exports the InDesign surface + └── indesign/ + ├── ir.js zod schemas + JSDoc typedefs for the IR + ├── parse-idml.js Main entry: unzips + orchestrates + cross-refs + validates + ├── units.js pt/pc/mm/cm/in → px at configurable DPI + ├── warnings.js Non-fatal warning collector + └── parsers/ + ├── xml.js fast-xml-parser wrapper + ├── designmap.js designmap.xml → manifest with paths + ├── resources.js Graphic.xml + Fonts.xml + Styles.xml + ├── stories.js Stories/Story_*.xml → text runs + └── spreads.js Spreads/*.xml + MasterSpreads/*.xml +``` + +## Quick start + +```js +import { parseIdml } from '@flavian/pipeline'; + +const ir = await parseIdml('./brochure.idml', { dpi: 96 }); + +for (const warning of ir.warnings) { + console.warn(`[${warning.code}] ${warning.message}`); +} +for (const swatch of ir.swatches) { + console.log(swatch.name, swatch.color.hex); +} +``` + +Or from the command line: + +```bash +node packages/pipeline/bin/parse-idml.mjs my-document.idml > ir.json +``` + +## IR shape + +The intermediate representation is described in [`src/indesign/ir.js`](src/indesign/ir.js). At the top level: + +```js +{ + irVersion: 1, + meta: { idmlVersion: '16.0', name: 'Brochure' }, + dpi: 96, + swatches: [{ id, name, color: { hex, space } }], + fonts: [{ id, family, style, postScriptName }], + styles: [{ id, name, kind, fontSize, leading, tracking, fontRef, fillColorRef, properties }], + stories: [{ id, source, runs: [{ text, paragraphStyleRef, characterStyleRef }] }], + spreads: [{ id, source, pages, frames, appliedMasterRef }], + masterSpreads: [{ id, source, name, pages, frames }], + warnings: [{ code, message, context }], +} +``` + +Geometry (`Page.bounds`, `Frame.bounds`) is normalized to pixels at `dpi` (default 96). Frames are a discriminated union (`kind: 'text'` or `kind: 'image'`). + +## Failure mode + +- **Throws** on structural problems that make the IR meaningless: missing `designmap.xml`, malformed zip, a `` element that lacks `Self`. +- **Warns and continues** on everything else: missing optional resource files, dangling style references, unknown color spaces, empty stories, unrecognized unit suffixes. + +The CLI surfaces warnings on stderr and exits 0 unless the IR itself failed to build. + +## Testing + +```bash +pnpm --filter @flavian/pipeline test +``` + +Tests build minimal IDML zips programmatically (see `tests/indesign/helpers/build-idml.js`) — no binary fixtures in git. The fixture builder mirrors the IDML XML grammar the parser reads, so adding a new test case is usually one option flag. + +## Adding a new input format + +When sub-issues for Figma / Canva migrations land, mirror the InDesign layout: a sibling directory under `src/`, its own IR schema, and a `parsers/` subdir for any input-format-specific decoders. The top-level `src/index.js` re-exports each surface so consumers `import { parseIdml, parseFigma } from '@flavian/pipeline'`. diff --git a/packages/pipeline/bin/parse-idml.mjs b/packages/pipeline/bin/parse-idml.mjs new file mode 100644 index 0000000..a79c1ef --- /dev/null +++ b/packages/pipeline/bin/parse-idml.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// CLI: print the parsed IR as JSON on stdout, warnings on stderr. +// +// flavian-parse-idml [--dpi ] [--quiet] + +import { parseIdml } from '../src/indesign/parse-idml.js'; + +const args = process.argv.slice(2); +let inputPath; +let dpi; +let quiet = false; + +for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--dpi') { + const next = args[i + 1]; + if (!next || Number.isNaN(Number(next))) { + console.error('--dpi requires a positive number'); + process.exit(2); + } + dpi = Number(next); + i += 1; + } else if (arg === '--quiet') { + quiet = true; + } else if (arg === '-h' || arg === '--help') { + printUsage(); + process.exit(0); + } else if (!inputPath && !arg.startsWith('-')) { + inputPath = arg; + } else { + console.error(`Unknown argument: ${arg}`); + printUsage(); + process.exit(2); + } +} + +if (!inputPath) { + printUsage(); + process.exit(2); +} + +try { + const ir = await parseIdml(inputPath, dpi !== undefined ? { dpi } : undefined); + if (!quiet && ir.warnings.length > 0) { + for (const w of ir.warnings) { + const where = w.context?.file ? ` (${w.context.file}${w.context.id ? `#${w.context.id}` : ''})` : ''; + process.stderr.write(`[${w.code}] ${w.message}${where}\n`); + } + process.stderr.write(`\n${ir.warnings.length} warning(s).\n`); + } + process.stdout.write(JSON.stringify(ir, null, 2) + '\n'); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); +} + +function printUsage() { + process.stderr.write( + [ + 'Usage: flavian-parse-idml [options]', + '', + 'Options:', + ' --dpi Pixels per inch for unit normalization (default 96)', + ' --quiet Suppress warnings on stderr', + ' -h, --help Show this help', + '', + ].join('\n'), + ); +} diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json new file mode 100644 index 0000000..d1beae6 --- /dev/null +++ b/packages/pipeline/package.json @@ -0,0 +1,25 @@ +{ + "name": "@flavian/pipeline", + "version": "0.1.0", + "private": true, + "description": "Conversion pipeline for InDesign (and future) sources into WordPress FSE themes.", + "type": "module", + "exports": { + ".": "./src/index.js", + "./indesign": "./src/indesign/index.js" + }, + "bin": { + "flavian-parse-idml": "./bin/parse-idml.mjs" + }, + "scripts": { + "test": "node --test \"tests/**/*.test.mjs\"" + }, + "dependencies": { + "fast-xml-parser": "^4.5.0", + "fflate": "^0.8.2", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js new file mode 100644 index 0000000..8fa1f9b --- /dev/null +++ b/packages/pipeline/src/indesign/index.js @@ -0,0 +1,4 @@ +export { parseIdml, parseIdmlBuffer } from './parse-idml.js'; +export * as ir from './ir.js'; +export { WarningCollector } from './warnings.js'; +export { lengthToPx, ptToPx, roundPx } from './units.js'; diff --git a/packages/pipeline/src/indesign/ir.js b/packages/pipeline/src/indesign/ir.js new file mode 100644 index 0000000..859d2ea --- /dev/null +++ b/packages/pipeline/src/indesign/ir.js @@ -0,0 +1,172 @@ +// Intermediate representation for parsed IDML documents. +// +// Downstream stages (style mapper, output generator) consume this shape. +// The zod schemas serve two purposes: +// 1. Runtime validation — `Document.parse(value)` rejects malformed IRs +// with a precise path to the bad field. +// 2. JSDoc types — `@typedef {import('./ir.js').Document}` works in +// editors without a TS toolchain. +// +// Why zod and not hand-rolled validators: composability and free type +// inference for any future TS consumer (`z.infer`). + +import { z } from 'zod'; + +// IDML IDs ("Self" attributes) are short tokens like "u123" or +// "ParagraphStyle/$ID/[No paragraph style]". Permissive on purpose. +export const IdmlId = z.string().min(1); + +export const Color = z.object({ + // "#RRGGBB" once normalized; CMYK values are converted in the mapper. + hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/), + // Source color space as declared by IDML, kept for the mapper to flag. + space: z.enum(['RGB', 'CMYK', 'LAB', 'Spot', 'Unknown']), +}); + +export const Swatch = z.object({ + id: IdmlId, + name: z.string(), + color: Color, +}); + +export const Font = z.object({ + id: IdmlId, + family: z.string(), + // e.g. "Regular", "Bold", "Italic". InDesign's PostScript-style key. + style: z.string(), + // Font is referenced by exact PostScript name in style declarations. + postScriptName: z.string().optional(), +}); + +export const Style = z.object({ + id: IdmlId, + name: z.string(), + kind: z.enum(['paragraph', 'character']), + // Captured here; the mapper decides which become block presets. + fontSize: z.number().positive().optional(), // px (normalized) + leading: z.number().positive().optional(), // px (normalized) + tracking: z.number().optional(), // thousandths of an em + fontRef: IdmlId.optional(), + fillColorRef: IdmlId.optional(), + // Free-form bag for properties we don't promote to first-class fields yet. + // The mapper can promote them later without breaking the schema. + properties: z.record(z.unknown()).default({}), +}); + +// Geometry is normalized to pixels at the parse-time DPI (default 96). +// Origin (0,0) is the spread's top-left. +export const Rect = z.object({ + x: z.number(), + y: z.number(), + width: z.number().nonnegative(), + height: z.number().nonnegative(), +}); + +// A run of text with its style references resolved later. +export const TextRun = z.object({ + text: z.string(), + paragraphStyleRef: IdmlId.optional(), + characterStyleRef: IdmlId.optional(), +}); + +export const Story = z.object({ + id: IdmlId, + // Source XML file — kept for debugging round-trips. + source: z.string(), + runs: z.array(TextRun), +}); + +const FrameBase = z.object({ + id: IdmlId, + bounds: Rect, + // Frames on a master inherit this; on a spread it's the spread id. + spreadRef: IdmlId.optional(), + masterRef: IdmlId.optional(), +}); + +export const TextFrame = FrameBase.extend({ + kind: z.literal('text'), + // Resolved by the cross-ref pass; may be undefined if the IDML had a + // dangling reference (warning emitted). + storyRef: IdmlId.optional(), +}); + +export const ImageFrame = FrameBase.extend({ + kind: z.literal('image'), + // Path inside the IDML package for embedded resources, or the original + // link href for linked resources. Asset resolution lives downstream. + href: z.string().optional(), + // true if the asset bytes are inside the IDML zip. + embedded: z.boolean().default(false), +}); + +export const Frame = z.discriminatedUnion('kind', [TextFrame, ImageFrame]); + +export const Page = z.object({ + id: IdmlId, + // Page geometry in spread coordinates. + bounds: Rect, +}); + +export const Spread = z.object({ + id: IdmlId, + source: z.string(), + pages: z.array(Page), + frames: z.array(Frame), + // If this spread inherits from a master, the master's id goes here. + appliedMasterRef: IdmlId.optional(), +}); + +export const MasterSpread = z.object({ + id: IdmlId, + source: z.string(), + name: z.string(), + pages: z.array(Page), + frames: z.array(Frame), +}); + +export const ParseWarning = z.object({ + // "missing-style-ref", "unknown-element", "embedded-color-fallback", ... + code: z.string(), + message: z.string(), + // Where the warning fires — file path inside the IDML, plus optional id. + context: z.object({ + file: z.string().optional(), + id: IdmlId.optional(), + }).default({}), +}); + +export const Document = z.object({ + // Version of this IR shape. Bump when we make breaking changes downstream. + irVersion: z.literal(1).default(1), + // IDML metadata pulled from designmap.xml. + meta: z.object({ + // IDML format version (e.g. "16.0"). + idmlVersion: z.string().optional(), + // Document name if the designer set it; falls back to filename. + name: z.string().optional(), + }).default({}), + // DPI used to normalize geometry. Default 96 (CSS px). + dpi: z.number().positive(), + swatches: z.array(Swatch), + fonts: z.array(Font), + styles: z.array(Style), + stories: z.array(Story), + spreads: z.array(Spread), + masterSpreads: z.array(MasterSpread), + warnings: z.array(ParseWarning).default([]), +}); + +/** + * @typedef {z.infer} DocumentIR + * @typedef {z.infer} SpreadIR + * @typedef {z.infer} FrameIR + * @typedef {z.infer} TextFrameIR + * @typedef {z.infer} ImageFrameIR + * @typedef {z.infer} StoryIR + * @typedef {z.infer} StyleIR + * @typedef {z.infer} SwatchIR + * @typedef {z.infer} FontIR + * @typedef {z.infer} MasterSpreadIR + * @typedef {z.infer} ParseWarningIR + */ diff --git a/packages/pipeline/src/indesign/parse-idml.js b/packages/pipeline/src/indesign/parse-idml.js new file mode 100644 index 0000000..585ea3f --- /dev/null +++ b/packages/pipeline/src/indesign/parse-idml.js @@ -0,0 +1,250 @@ +// Main entry: takes a path to an IDML file (or its raw bytes) and returns the +// validated IR. Orchestrates the per-XML-file parsers, runs the cross-ref +// resolution pass, and validates the final shape with zod. +// +// Failure mode philosophy: we throw on structural problems that make the IR +// meaningless (missing designmap.xml, malformed zip). Anything we can recover +// from — dangling references, unknown swatch color spaces, empty stories — +// goes into the warnings collector instead. + +import { promises as fs } from 'node:fs'; +import { unzipSync, strFromU8 } from 'fflate'; + +import { Document } from './ir.js'; +import { WarningCollector } from './warnings.js'; +import { parseDesignMap } from './parsers/designmap.js'; +import { parseGraphic, parseFonts, parseStyles } from './parsers/resources.js'; +import { parseStory } from './parsers/stories.js'; +import { parseSpread, parseMasterSpread } from './parsers/spreads.js'; + +const DEFAULT_DPI = 96; + +/** + * @typedef {Object} ParseOptions + * @property {number} [dpi] Pixels-per-inch for unit normalization. Default 96. + * @property {string} [name] Override the document name (defaults to designmap.xml's @Name or the file basename). + */ + +/** + * Parse an IDML file from disk. + * + * @param {string} path + * @param {ParseOptions} [options] + * @returns {Promise} + */ +export async function parseIdml(path, options = {}) { + const bytes = await fs.readFile(path); + const fallbackName = path.split(/[\\/]/).pop()?.replace(/\.idml$/i, ''); + return parseIdmlBuffer(bytes, { ...options, name: options.name ?? fallbackName }); +} + +/** + * Parse IDML bytes already in memory — used by tests and any consumer that + * already has the package as a buffer (e.g. from an HTTP upload). + * + * @param {Uint8Array} bytes + * @param {ParseOptions} [options] + * @returns {import('./ir.js').DocumentIR} + */ +export function parseIdmlBuffer(bytes, options = {}) { + const dpi = options.dpi ?? DEFAULT_DPI; + const warnings = new WarningCollector(); + + let entries; + try { + entries = unzipSync(bytes); + } catch (err) { + throw new Error(`IDML package is not a valid zip: ${err.message}`); + } + + const get = (path) => { + const buf = entries[path]; + if (!buf) return undefined; + return strFromU8(buf); + }; + + const designMapXml = get('designmap.xml'); + if (!designMapXml) { + throw new Error('IDML package is missing designmap.xml'); + } + const designMap = parseDesignMap(designMapXml); + + // Resources are nominally optional — they're usually present, but a tiny + // or hand-crafted IDML might omit one. Either case (designmap doesn't + // declare the file, or declares it but the file isn't in the zip) fires + // the same warning code so consumers don't have to distinguish. + const swatches = readOptionalResource( + designMap.graphicSrc, get, 'missing-graphic', 'Resources/Graphic.xml', + (xml) => parseGraphic(xml, warnings), + warnings, + ); + const fonts = readOptionalResource( + designMap.fontsSrc, get, 'missing-fonts', 'Resources/Fonts.xml', + (xml) => parseFonts(xml), + warnings, + ); + const styles = readOptionalResource( + designMap.stylesSrc, get, 'missing-styles', 'Resources/Styles.xml', + (xml) => parseStyles(xml, dpi), + warnings, + ); + + const stories = []; + for (const src of designMap.storySrcs) { + const xml = get(src); + if (!xml) { + warnings.add('missing-story', `Story file referenced but not in package: ${src}`, { file: src }); + continue; + } + try { + stories.push(...parseStory(xml, src)); + } catch (err) { + warnings.add('story-parse-error', `${src}: ${err.message}`, { file: src }); + } + } + + const spreads = []; + for (const src of designMap.spreadSrcs) { + const xml = get(src); + if (!xml) { + warnings.add('missing-spread', `Spread file referenced but not in package: ${src}`, { file: src }); + continue; + } + try { + const s = parseSpread(xml, src, dpi); + spreads.push({ ...s, source: src }); + } catch (err) { + warnings.add('spread-parse-error', `${src}: ${err.message}`, { file: src }); + } + } + + const masterSpreads = []; + for (const src of designMap.masterSpreadSrcs) { + const xml = get(src); + if (!xml) { + warnings.add('missing-master', `MasterSpread file referenced but not in package: ${src}`, { file: src }); + continue; + } + try { + masterSpreads.push(parseMasterSpread(xml, src, dpi)); + } catch (err) { + warnings.add('master-parse-error', `${src}: ${err.message}`, { file: src }); + } + } + + resolveCrossReferences({ stories, spreads, masterSpreads, styles, swatches, fonts }, warnings); + + const document = Document.parse({ + irVersion: 1, + meta: { + idmlVersion: designMap.idmlVersion, + name: designMap.name ?? options.name, + }, + dpi, + swatches, + fonts, + styles, + stories, + spreads, + masterSpreads, + warnings: warnings.list(), + }); + + return document; +} + +/** + * @template T + * @param {string|undefined} src + * @param {(path: string) => string|undefined} get + * @param {string} code + * @param {string} defaultPath + * @param {(xml: string) => T[]} parse + * @param {WarningCollector} warnings + * @returns {T[]} + */ +function readOptionalResource(src, get, code, defaultPath, parse, warnings) { + if (!src) { + warnings.add(code, `designmap omits ${defaultPath}`); + return []; + } + const xml = get(src); + if (!xml) { + warnings.add(code, `${src} referenced by designmap but missing from package`, { file: src }); + return []; + } + return parse(xml); +} + +/** + * Walk frames + style references and warn about anything pointing at an id + * that didn't come back from the parsers. Doesn't drop the reference — the + * mapper might still recognise the well-known IDML defaults (e.g. "n" for + * "none"). + * + * @param {{stories: import('./ir.js').StoryIR[], spreads: import('./ir.js').SpreadIR[], masterSpreads: import('./ir.js').MasterSpreadIR[], styles: import('./ir.js').StyleIR[], swatches: import('./ir.js').SwatchIR[], fonts: import('./ir.js').FontIR[]}} data + * @param {WarningCollector} warnings + */ +function resolveCrossReferences(data, warnings) { + const storyIds = new Set(data.stories.map((s) => s.id)); + const styleIds = new Set(data.styles.map((s) => s.id)); + const swatchIds = new Set(data.swatches.map((s) => s.id)); + const fontIds = new Set(data.fonts.map((f) => f.id)); + + for (const spread of [...data.spreads, ...data.masterSpreads]) { + for (const frame of spread.frames) { + if (frame.kind === 'text' && frame.storyRef && !storyIds.has(frame.storyRef)) { + warnings.add( + 'missing-story-ref', + `TextFrame ${frame.id} references unknown story ${frame.storyRef}`, + { file: spread.source, id: frame.id }, + ); + } + } + } + + for (const story of data.stories) { + for (const run of story.runs) { + if (run.paragraphStyleRef && !styleIds.has(run.paragraphStyleRef) && !isWellKnown(run.paragraphStyleRef)) { + warnings.add( + 'missing-paragraph-style-ref', + `Story ${story.id} references unknown paragraph style ${run.paragraphStyleRef}`, + { file: story.source, id: story.id }, + ); + } + if (run.characterStyleRef && !styleIds.has(run.characterStyleRef) && !isWellKnown(run.characterStyleRef)) { + warnings.add( + 'missing-character-style-ref', + `Story ${story.id} references unknown character style ${run.characterStyleRef}`, + { file: story.source, id: story.id }, + ); + } + } + } + + for (const style of data.styles) { + if (style.fillColorRef && !swatchIds.has(style.fillColorRef) && !isWellKnown(style.fillColorRef)) { + warnings.add( + 'missing-swatch-ref', + `Style ${style.id} references unknown swatch ${style.fillColorRef}`, + { id: style.id }, + ); + } + if (style.fontRef && !fontIds.has(style.fontRef) && !isWellKnown(style.fontRef)) { + warnings.add( + 'missing-font-ref', + `Style ${style.id} references unknown font ${style.fontRef}`, + { id: style.id }, + ); + } + } +} + +function isWellKnown(ref) { + // IDML uses "n" for "none" and several "[No paragraph style]" / "$ID" tokens + // that are valid references to built-ins rather than missing. + if (ref === 'n') return true; + if (ref.startsWith('$ID/')) return true; + if (ref.includes('[No ')) return true; + return false; +} diff --git a/packages/pipeline/src/indesign/parsers/designmap.js b/packages/pipeline/src/indesign/parsers/designmap.js new file mode 100644 index 0000000..901a521 --- /dev/null +++ b/packages/pipeline/src/indesign/parsers/designmap.js @@ -0,0 +1,45 @@ +// designmap.xml is the IDML manifest: it lists the document's stories, +// spreads, master spreads, and resource files. The root element is +// , with etc. children. + +import { parseXml, asArray } from './xml.js'; + +/** + * @typedef {Object} DesignMap + * @property {string=} idmlVersion + * @property {string=} name + * @property {string[]} storySrcs + * @property {string[]} spreadSrcs + * @property {string[]} masterSpreadSrcs + * @property {string=} graphicSrc + * @property {string=} fontsSrc + * @property {string=} stylesSrc + */ + +/** + * @param {string|Uint8Array} input + * @returns {DesignMap} + */ +export function parseDesignMap(input) { + const tree = parseXml(input); + const doc = tree?.['Document'] ?? tree?.['document']; + if (!doc) { + throw new Error('designmap.xml has no root'); + } + + const collectSrcs = (key) => + asArray(doc[key]) + .map((node) => node?.['@src']) + .filter((src) => typeof src === 'string'); + + return { + idmlVersion: doc['@DOMVersion'], + name: doc['@Name'], + storySrcs: collectSrcs('idPkg:Story'), + spreadSrcs: collectSrcs('idPkg:Spread'), + masterSpreadSrcs: collectSrcs('idPkg:MasterSpread'), + graphicSrc: asArray(doc['idPkg:Graphic'])[0]?.['@src'], + fontsSrc: asArray(doc['idPkg:Fonts'])[0]?.['@src'], + stylesSrc: asArray(doc['idPkg:Styles'])[0]?.['@src'], + }; +} diff --git a/packages/pipeline/src/indesign/parsers/resources.js b/packages/pipeline/src/indesign/parsers/resources.js new file mode 100644 index 0000000..82c81c0 --- /dev/null +++ b/packages/pipeline/src/indesign/parsers/resources.js @@ -0,0 +1,191 @@ +// Resources/Graphic.xml — swatches and colors +// Resources/Fonts.xml — font families +// Resources/Styles.xml — paragraph + character styles +// +// Each is a separate parse so callers can short-circuit if any file is +// missing (warn + continue, don't abort). + +import { parseXml, asArray } from './xml.js'; +import { lengthToPx, roundPx } from '../units.js'; + +/** + * @param {string|Uint8Array} input + * @param {import('../warnings.js').WarningCollector} warnings + * @returns {Array} + */ +export function parseGraphic(input, warnings) { + const tree = parseXml(input); + const root = tree?.['idPkg:Graphic'] ?? tree; + + /** @type {Array} */ + const swatches = []; + + for (const color of asArray(root?.['Color'])) { + const id = color?.['@Self']; + if (!id) continue; + const name = color?.['@Name'] ?? id; + const spaceRaw = String(color?.['@Space'] ?? 'Unknown'); + const values = String(color?.['@ColorValue'] ?? '').trim().split(/\s+/).map(Number); + swatches.push({ + id, + name, + color: toIrColor(spaceRaw, values, warnings, id), + }); + } + + // IDML also wraps colors in elements that point at the color by id. + // For the IR we only emit one swatch per *named* entry; the redirection + // happens in the mapper. + return swatches; +} + +/** + * @param {string} spaceRaw + * @param {number[]} values + * @param {import('../warnings.js').WarningCollector} warnings + * @param {string} id + * @returns {import('../ir.js').SwatchIR['color']} + */ +function toIrColor(spaceRaw, values, warnings, id) { + const space = normalizeColorSpace(spaceRaw); + if (space === 'RGB' && values.length === 3) { + return { hex: rgbToHex(values), space }; + } + if (space === 'CMYK' && values.length === 4) { + return { hex: cmykToHexApprox(values), space }; + } + warnings.add( + 'color-fallback', + `Swatch ${id} has unsupported color space "${spaceRaw}" with ${values.length} values; defaulted to black`, + { file: 'Resources/Graphic.xml', id }, + ); + return { hex: '#000000', space: 'Unknown' }; +} + +function normalizeColorSpace(raw) { + const v = raw.trim(); + if (v === 'RGB' || v === 'CMYK' || v === 'LAB' || v === 'Spot') return v; + return 'Unknown'; +} + +function rgbToHex([r, g, b]) { + // IDML RGB is in 0-255 already. + return '#' + [r, g, b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')).join(''); +} + +function cmykToHexApprox([c, m, y, k]) { + // IDML CMYK is in 0-100. Naive conversion; a real ICC profile pass is + // the mapper's job. This is enough to produce a recognisable preview. + const cv = c / 100, mv = m / 100, yv = y / 100, kv = k / 100; + const r = Math.round(255 * (1 - cv) * (1 - kv)); + const g = Math.round(255 * (1 - mv) * (1 - kv)); + const b = Math.round(255 * (1 - yv) * (1 - kv)); + return rgbToHex([r, g, b]); +} + +/** + * @param {string|Uint8Array} input + * @returns {Array} + */ +export function parseFonts(input) { + const tree = parseXml(input); + const root = tree?.['idPkg:Fonts'] ?? tree; + + /** @type {Array} */ + const fonts = []; + + for (const family of asArray(root?.['FontFamily'])) { + const familyName = family?.['@Name'] ?? ''; + for (const font of asArray(family?.['Font'])) { + const id = font?.['@Self']; + if (!id) continue; + fonts.push({ + id, + family: familyName, + style: font?.['@FontStyleName'] ?? 'Regular', + postScriptName: font?.['@PostScriptName'], + }); + } + } + return fonts; +} + +/** + * @param {string|Uint8Array} input + * @param {number} dpi + * @returns {Array} + */ +export function parseStyles(input, dpi) { + const tree = parseXml(input); + const root = tree?.['idPkg:Styles'] ?? tree; + + /** @type {Array} */ + const styles = []; + + const collect = (key, kind) => { + for (const group of asArray(root?.[key])) { + // IDML wraps individual styles inside the group element. The single + // style case is also handled by `asArray`. + for (const style of asArray(group?.[kind === 'paragraph' ? 'ParagraphStyle' : 'CharacterStyle'])) { + styles.push(buildStyle(style, kind, dpi)); + } + } + // Some files store styles directly under root without a group wrapper. + for (const style of asArray(root?.[kind === 'paragraph' ? 'ParagraphStyle' : 'CharacterStyle'])) { + styles.push(buildStyle(style, kind, dpi)); + } + }; + + collect('RootParagraphStyleGroup', 'paragraph'); + collect('RootCharacterStyleGroup', 'character'); + + // Deduplicate by id (some IDMLs duplicate the root group + a flat list). + const seen = new Set(); + return styles.filter((s) => { + if (seen.has(s.id)) return false; + seen.add(s.id); + return true; + }); +} + +/** + * @param {Record} node + * @param {'paragraph'|'character'} kind + * @param {number} dpi + * @returns {import('../ir.js').StyleIR} + */ +function buildStyle(node, kind, dpi) { + const id = String(node['@Self'] ?? ''); + const name = String(node['@Name'] ?? id); + const properties = {}; + for (const [key, value] of Object.entries(node)) { + // fast-xml-parser stores attributes with our `@` prefix; the bare + // element children are the rest. We keep both in `properties` so + // downstream mappers can pluck anything they need without us having to + // promote every IDML attribute to a typed field. + if (key === '@Self' || key === '@Name') continue; + properties[key] = value; + } + + const pointSize = readNumber(node['@PointSize']); + const leading = readNumber(node['@Leading']); + const tracking = readNumber(node['@Tracking']); + + return { + id, + name, + kind, + fontSize: pointSize !== undefined ? roundPx(lengthToPx(pointSize, dpi)) : undefined, + leading: leading !== undefined ? roundPx(lengthToPx(leading, dpi)) : undefined, + tracking, + fontRef: typeof node['@AppliedFont'] === 'string' ? node['@AppliedFont'] : undefined, + fillColorRef: typeof node['@FillColor'] === 'string' ? node['@FillColor'] : undefined, + properties, + }; +} + +function readNumber(v) { + if (v === undefined || v === null) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} diff --git a/packages/pipeline/src/indesign/parsers/spreads.js b/packages/pipeline/src/indesign/parsers/spreads.js new file mode 100644 index 0000000..59b52c3 --- /dev/null +++ b/packages/pipeline/src/indesign/parsers/spreads.js @@ -0,0 +1,121 @@ +// Spreads/Spread_.xml describes one spread (one or two facing pages) +// with all the frames placed on it. Master spreads have the same shape. +// +// Geometry comes via `ItemTransform` (a 6-component affine matrix: a b c d tx ty) +// and `GeometricBounds` (top left bottom right, in spread coordinates). +// We collapse this to a simple axis-aligned rect — print designers do use +// rotation occasionally, but for FSE conversion we work in screen-space and +// rotation goes in `properties` for the mapper to handle. + +import { parseXml, asArray } from './xml.js'; +import { lengthToPx, roundPx } from '../units.js'; + +/** + * @param {string|Uint8Array} input + * @param {string} sourcePath + * @param {number} dpi + * @returns {{ id: string, pages: Array, frames: Array, appliedMasterRef?: string }} + */ +export function parseSpread(input, sourcePath, dpi) { + const tree = parseXml(input); + const root = tree?.['idPkg:Spread'] ?? tree; + const spread = asArray(root?.['Spread'])[0]; + if (!spread) { + throw new Error(`${sourcePath} has no element`); + } + return { + id: String(spread['@Self']), + pages: asArray(spread['Page']).map((p) => buildPage(p, dpi)), + frames: collectFrames(spread, dpi, /* masterRef */ undefined), + appliedMasterRef: + typeof spread['@AppliedMaster'] === 'string' && spread['@AppliedMaster'] !== 'n' + ? spread['@AppliedMaster'] + : undefined, + }; +} + +/** + * @param {string|Uint8Array} input + * @param {string} sourcePath + * @param {number} dpi + * @returns {import('../ir.js').MasterSpreadIR} + */ +export function parseMasterSpread(input, sourcePath, dpi) { + const tree = parseXml(input); + const root = tree?.['idPkg:MasterSpread'] ?? tree; + const master = asArray(root?.['MasterSpread'])[0]; + if (!master) { + throw new Error(`${sourcePath} has no element`); + } + const id = String(master['@Self']); + return { + id, + source: sourcePath, + name: master['@Name'] ?? id, + pages: asArray(master['Page']).map((p) => buildPage(p, dpi)), + frames: collectFrames(master, dpi, id), + }; +} + +function buildPage(node, dpi) { + return { + id: String(node['@Self']), + bounds: parseBounds(node['@GeometricBounds'], dpi), + }; +} + +function collectFrames(parent, dpi, masterRef) { + const frames = []; + for (const tf of asArray(parent['TextFrame'])) { + frames.push({ + kind: 'text', + id: String(tf['@Self']), + bounds: parseBounds(tf['@GeometricBounds'], dpi), + masterRef, + storyRef: typeof tf['@ParentStory'] === 'string' ? tf['@ParentStory'] : undefined, + }); + } + for (const rect of asArray(parent['Rectangle'])) { + // A with a placed image holds it in or children. + const placed = asArray(rect['Image'])[0] ?? asArray(rect['EPS'])[0] ?? asArray(rect['PDF'])[0]; + if (!placed) continue; + const link = asArray(placed['Link'])[0]; + const href = link?.['@LinkResourceURI'] ?? placed?.['@Href']; + // IDML "embedded state" lives on the Link. A Link with no LinkResourceURI + // (or with a `file:` scheme that points inside the package) is embedded. + const embedded = !href || /^file:/.test(String(href)); + frames.push({ + kind: 'image', + id: String(rect['@Self']), + bounds: parseBounds(rect['@GeometricBounds'], dpi), + masterRef, + href: typeof href === 'string' ? href : undefined, + embedded, + }); + } + return frames; +} + +/** + * IDML GeometricBounds: "top left bottom right" as 4 space-separated numbers + * in points. Returns the rect in normalized px. + * + * @param {unknown} raw + * @param {number} dpi + */ +function parseBounds(raw, dpi) { + if (typeof raw !== 'string') { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const parts = raw.trim().split(/\s+/).map(Number); + if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + const [top, left, bottom, right] = parts; + return { + x: roundPx(lengthToPx(left, dpi)), + y: roundPx(lengthToPx(top, dpi)), + width: roundPx(lengthToPx(right - left, dpi)), + height: roundPx(lengthToPx(bottom - top, dpi)), + }; +} diff --git a/packages/pipeline/src/indesign/parsers/stories.js b/packages/pipeline/src/indesign/parsers/stories.js new file mode 100644 index 0000000..07e8c26 --- /dev/null +++ b/packages/pipeline/src/indesign/parsers/stories.js @@ -0,0 +1,83 @@ +// Stories/Story_.xml holds the text content. The structure is: +// +// +// +// +// +// actual text +//
+//
+//
+//
+//
+// +// A "run" in our IR is a CharacterStyleRange's text, flattened. Line breaks +// (
) become explicit newlines in the run text. + +import { parseXml, asArray } from './xml.js'; + +/** + * @param {string|Uint8Array} input + * @param {string} sourcePath The IDML-relative path, kept for IR `source`. + * @returns {Array} + */ +export function parseStory(input, sourcePath) { + const tree = parseXml(input); + const root = tree?.['idPkg:Story'] ?? tree; + + /** @type {Array} */ + const stories = []; + + for (const story of asArray(root?.['Story'])) { + const id = story?.['@Self']; + if (!id) continue; + const runs = collectRuns(story); + stories.push({ id, source: sourcePath, runs }); + } + return stories; +} + +function collectRuns(story) { + const runs = []; + for (const para of asArray(story['ParagraphStyleRange'])) { + const paraStyleRef = para['@AppliedParagraphStyle']; + for (const char of asArray(para['CharacterStyleRange'])) { + const charStyleRef = char['@AppliedCharacterStyle']; + const text = extractRunText(char); + if (text.length === 0) continue; + runs.push({ + text, + paragraphStyleRef: typeof paraStyleRef === 'string' ? paraStyleRef : undefined, + characterStyleRef: typeof charStyleRef === 'string' ? charStyleRef : undefined, + }); + } + // Close every paragraph with a newline so downstream consumers can + // re-emit prose without losing structure. Skipped when the paragraph + // emitted nothing (e.g. all-image content). + if (runs.length > 0 && !runs[runs.length - 1].text.endsWith('\n')) { + runs[runs.length - 1] = { + ...runs[runs.length - 1], + text: runs[runs.length - 1].text + '\n', + }; + } + } + return runs; +} + +function extractRunText(charRange) { + let buf = ''; + for (const content of asArray(charRange['Content'])) { + // fast-xml-parser sometimes hands back a string, sometimes an object + // when the element has attributes. We only care about the text. + if (typeof content === 'string') { + buf += content; + } else if (content && typeof content === 'object' && '#text' in content) { + buf += String(content['#text']); + } + } + //
tags become hard newlines. + for (const _br of asArray(charRange['Br'])) { + buf += '\n'; + } + return buf; +} diff --git a/packages/pipeline/src/indesign/parsers/xml.js b/packages/pipeline/src/indesign/parsers/xml.js new file mode 100644 index 0000000..45fa9ea --- /dev/null +++ b/packages/pipeline/src/indesign/parsers/xml.js @@ -0,0 +1,44 @@ +// Thin wrapper over fast-xml-parser tuned for IDML's element-heavy XML. +// +// IDML elements rely on attributes (`Self`, `ParentStory`, `AppliedFont`) +// rather than text content for almost every cross-reference, so the parser +// is configured to surface attributes alongside child elements. + +import { XMLParser } from 'fast-xml-parser'; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@', + // Keep arrays even when there's one child — saves the consumer doing + // `Array.isArray(x) ? x : [x]` everywhere. + isArray: () => false, // overridden per-call below + allowBooleanAttributes: true, + parseAttributeValue: false, + trimValues: true, +}); + +/** + * Parse an IDML XML buffer/string into a plain JS tree. + * + * @param {string|Uint8Array} input + * @returns {Record} + */ +export function parseXml(input) { + const text = typeof input === 'string' ? input : new TextDecoder('utf-8').decode(input); + return parser.parse(text); +} + +/** + * Normalize a "this might be one element or an array of them" shape into a + * proper array. fast-xml-parser hands back the raw thing. + * + * @template T + * @param {T | T[] | undefined | null} value + * @returns {T[]} + */ +export function asArray(value) { + if (value === undefined || value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} diff --git a/packages/pipeline/src/indesign/units.js b/packages/pipeline/src/indesign/units.js new file mode 100644 index 0000000..c40a52a --- /dev/null +++ b/packages/pipeline/src/indesign/units.js @@ -0,0 +1,78 @@ +// Unit conversion. IDML stores most measurements as points; some appear with +// a unit suffix (e.g. "12.5pt", "3cm"). We normalize everything to CSS pixels +// at a caller-chosen DPI. +// +// Why DPI is configurable: 96 matches the CSS px (web default). 72 matches +// PostScript points (print default). 150/300 are common print exports. + +const POINTS_PER_INCH = 72; +const MM_PER_INCH = 25.4; +const PICAS_PER_INCH = 6; + +/** + * Convert a value in IDML's native unit (points) to pixels. + * + * @param {number} pt + * @param {number} dpi + * @returns {number} + */ +export function ptToPx(pt, dpi) { + return (pt / POINTS_PER_INCH) * dpi; +} + +/** + * Parse a length string that may carry a unit suffix and return pixels. + * + * Accepted: bare numbers (treated as points — IDML's native unit), "pt", "px", + * "pc" (picas), "mm", "cm", "in". Returns NaN for unrecognized input so + * callers can decide whether to warn or fall back. + * + * @param {string|number} value + * @param {number} dpi + * @returns {number} + */ +export function lengthToPx(value, dpi) { + if (typeof value === 'number') { + return ptToPx(value, dpi); + } + if (typeof value !== 'string') { + return Number.NaN; + } + const match = value.trim().match(/^(-?\d*\.?\d+)\s*([a-z]*)$/i); + if (!match) { + return Number.NaN; + } + const n = Number.parseFloat(match[1]); + const unit = match[2].toLowerCase(); + switch (unit) { + case '': + case 'pt': + return ptToPx(n, dpi); + case 'px': + return n; + case 'pc': + return (n / PICAS_PER_INCH) * dpi; + case 'mm': + return (n / MM_PER_INCH) * dpi; + case 'cm': + return (n * 10 / MM_PER_INCH) * dpi; + case 'in': + return n * dpi; + default: + return Number.NaN; + } +} + +/** + * Round to a sensible precision so downstream JSON stays tidy. 3 decimals is + * sub-pixel enough for any rendering decision but avoids long binary tails. + * + * @param {number} n + * @returns {number} + */ +export function roundPx(n) { + if (!Number.isFinite(n)) { + return n; + } + return Math.round(n * 1000) / 1000; +} diff --git a/packages/pipeline/src/indesign/warnings.js b/packages/pipeline/src/indesign/warnings.js new file mode 100644 index 0000000..888c8f6 --- /dev/null +++ b/packages/pipeline/src/indesign/warnings.js @@ -0,0 +1,24 @@ +// Non-fatal warning collector. The whole point of this object is to let the +// parser carry on through dodgy IDMLs without throwing — designers ship +// surprisingly broken files and we'd rather emit a partial IR with notes than +// halt at the first unknown attribute. + +export class WarningCollector { + constructor() { + /** @type {Array} */ + this.entries = []; + } + + /** + * @param {string} code + * @param {string} message + * @param {{file?: string, id?: string}} [context] + */ + add(code, message, context = {}) { + this.entries.push({ code, message, context }); + } + + list() { + return [...this.entries]; + } +} diff --git a/packages/pipeline/src/index.js b/packages/pipeline/src/index.js new file mode 100644 index 0000000..371011a --- /dev/null +++ b/packages/pipeline/src/index.js @@ -0,0 +1,3 @@ +// Public entry: just re-export the InDesign surface for now. As we add more +// input formats (Figma, Canva future migrations) they'll be siblings here. +export * from './indesign/index.js'; diff --git a/packages/pipeline/tests/indesign/helpers/build-idml.js b/packages/pipeline/tests/indesign/helpers/build-idml.js new file mode 100644 index 0000000..75f33fa --- /dev/null +++ b/packages/pipeline/tests/indesign/helpers/build-idml.js @@ -0,0 +1,258 @@ +// In-memory IDML fixture builder. Tests call buildIdml({...}) and get back a +// Uint8Array they can hand straight to parseIdmlBuffer(). +// +// The real IDML format has dozens of XML files; we only generate the subset +// the parser actually reads. The XMLs are minimal but structurally valid: the +// parser pass for that file should succeed without warnings (unless the test +// deliberately omits something). + +import { zipSync, strToU8 } from 'fflate'; + +/** + * @typedef {Object} BuildOptions + * @property {string} [idmlVersion] designmap @DOMVersion + * @property {string} [name] designmap @Name + * @property {boolean} [omitDesignMap] For testing the missing-manifest path + * @property {boolean} [omitGraphic] Skip Resources/Graphic.xml + * @property {boolean} [omitFonts] Skip Resources/Fonts.xml + * @property {boolean} [omitStyles] Skip Resources/Styles.xml + * @property {Array<{id: string, name: string, space?: 'RGB'|'CMYK', values: number[]}>} [colors] + * @property {Array<{id: string, family: string, style?: string, postScriptName?: string}>} [fonts] + * @property {Array<{id: string, name: string, kind: 'paragraph'|'character', pointSize?: number, leading?: number, tracking?: number, appliedFont?: string, fillColor?: string}>} [styles] + * @property {Array<{id: string, runs: Array<{text: string, paragraphStyle?: string, characterStyle?: string}>}>} [stories] + * @property {Array<{id: string, pages: Array<{id: string, bounds: [number, number, number, number]}>, frames: Array, appliedMaster?: string}>} [spreads] + * @property {Array<{id: string, name: string, pages: Array<{id: string, bounds: [number, number, number, number]}>, frames: Array}>} [masters] + * @property {Record} [extraFiles] Add or override arbitrary files in the package + * + * @typedef {{kind: 'text', id: string, bounds: [number, number, number, number], parentStory?: string}} TextFrameSpec + * @typedef {{kind: 'image', id: string, bounds: [number, number, number, number], href?: string}} ImageFrameSpec + */ + +/** + * @param {BuildOptions} [options] + * @returns {Uint8Array} + */ +export function buildIdml(options = {}) { + const o = withDefaults(options); + const files = { + mimetype: strToU8('application/vnd.adobe.indesign-idml-package'), + 'META-INF/container.xml': strToU8(containerXml()), + }; + + if (!o.omitDesignMap) { + files['designmap.xml'] = strToU8(designMapXml(o)); + } + if (!o.omitGraphic) { + files['Resources/Graphic.xml'] = strToU8(graphicXml(o)); + } + if (!o.omitFonts) { + files['Resources/Fonts.xml'] = strToU8(fontsXml(o)); + } + if (!o.omitStyles) { + files['Resources/Styles.xml'] = strToU8(stylesXml(o)); + } + + for (const story of o.stories) { + files[`Stories/Story_${story.id}.xml`] = strToU8(storyXml(story)); + } + for (const spread of o.spreads) { + files[`Spreads/Spread_${spread.id}.xml`] = strToU8(spreadXml(spread)); + } + for (const master of o.masters) { + files[`MasterSpreads/MasterSpread_${master.id}.xml`] = strToU8(masterSpreadXml(master)); + } + if (o.extraFiles) { + for (const [path, contents] of Object.entries(o.extraFiles)) { + files[path] = strToU8(contents); + } + } + + return zipSync(files); +} + +function withDefaults(o) { + return { + idmlVersion: o.idmlVersion ?? '16.0', + name: o.name ?? 'TestDocument', + omitDesignMap: o.omitDesignMap ?? false, + omitGraphic: o.omitGraphic ?? false, + omitFonts: o.omitFonts ?? false, + omitStyles: o.omitStyles ?? false, + colors: o.colors ?? [], + fonts: o.fonts ?? [], + styles: o.styles ?? [], + stories: o.stories ?? [], + spreads: o.spreads ?? [], + masters: o.masters ?? [], + extraFiles: o.extraFiles, + }; +} + +function containerXml() { + return ` + + + + +`; +} + +function designMapXml(o) { + const stories = o.stories.map((s) => ` `).join('\n'); + const spreads = o.spreads.map((s) => ` `).join('\n'); + const masters = o.masters.map((m) => ` `).join('\n'); + return ` + +${[ + ' ', + ' ', + ' ', + stories, + spreads, + masters, + ] + .filter(Boolean) + .join('\n')} +`; +} + +function graphicXml(o) { + const colors = o.colors + .map((c) => { + const space = c.space ?? 'RGB'; + const values = c.values.join(' '); + return ` `; + }) + .join('\n'); + return ` + +${colors} +`; +} + +function fontsXml(o) { + const families = groupBy(o.fonts, (f) => f.family); + const blocks = [...families.entries()].map(([family, fonts]) => { + const inner = fonts + .map( + (f) => + ` `, + ) + .join('\n'); + return ` \n${inner}\n `; + }); + return ` + +${blocks.join('\n')} +`; +} + +function stylesXml(o) { + const buildStyleNode = (s) => { + const tag = s.kind === 'paragraph' ? 'ParagraphStyle' : 'CharacterStyle'; + const attrs = [ + `Self="${s.id}"`, + `Name="${s.name}"`, + s.pointSize !== undefined ? `PointSize="${s.pointSize}"` : null, + s.leading !== undefined ? `Leading="${s.leading}"` : null, + s.tracking !== undefined ? `Tracking="${s.tracking}"` : null, + s.appliedFont ? `AppliedFont="${s.appliedFont}"` : null, + s.fillColor ? `FillColor="${s.fillColor}"` : null, + ] + .filter(Boolean) + .join(' '); + return ` <${tag} ${attrs}/>`; + }; + const paragraphs = o.styles.filter((s) => s.kind === 'paragraph').map(buildStyleNode).join('\n'); + const characters = o.styles.filter((s) => s.kind === 'character').map(buildStyleNode).join('\n'); + return ` + + +${paragraphs} + + +${characters} + +`; +} + +function storyXml(story) { + const paras = story.runs + .map((run) => { + const paraAttr = run.paragraphStyle ? `AppliedParagraphStyle="${run.paragraphStyle}"` : ''; + const charAttr = run.characterStyle ? `AppliedCharacterStyle="${run.characterStyle}"` : ''; + return ` + + ${escapeXml(run.text)} + + `; + }) + .join('\n'); + return ` + + +${paras} + +`; +} + +function spreadXml(spread) { + const pages = spread.pages + .map((p) => ` `) + .join('\n'); + const frames = spread.frames.map(frameXml).join('\n'); + const appliedMaster = spread.appliedMaster ? ` AppliedMaster="${spread.appliedMaster}"` : ''; + return ` + + +${pages} +${frames} + +`; +} + +function masterSpreadXml(master) { + const pages = master.pages + .map((p) => ` `) + .join('\n'); + const frames = master.frames.map(frameXml).join('\n'); + return ` + + +${pages} +${frames} + +`; +} + +function frameXml(frame) { + if (frame.kind === 'text') { + const parent = frame.parentStory ? ` ParentStory="${frame.parentStory}"` : ''; + return ` `; + } + const href = frame.href ?? 'file:Resources/image.jpg'; + return ` + + + + `; +} + +function escapeXml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function groupBy(arr, keyFn) { + const map = new Map(); + for (const item of arr) { + const key = keyFn(item); + const bucket = map.get(key) ?? []; + bucket.push(item); + map.set(key, bucket); + } + return map; +} diff --git a/packages/pipeline/tests/indesign/malformed.test.mjs b/packages/pipeline/tests/indesign/malformed.test.mjs new file mode 100644 index 0000000..67d7e7c --- /dev/null +++ b/packages/pipeline/tests/indesign/malformed.test.mjs @@ -0,0 +1,121 @@ +// Edge cases. These exercise the "warn-don't-throw" philosophy described in +// parse-idml.js. The three explicitly called out in the issue: +// +// - missing manifest → throws (structural failure) +// - unknown style refs → warns, IR still produced +// - empty stories → warns, IR still produced + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseIdmlBuffer } from '../../src/indesign/parse-idml.js'; +import { buildIdml } from './helpers/build-idml.js'; + +test('throws when designmap.xml is missing', () => { + const bytes = buildIdml({ omitDesignMap: true }); + assert.throws(() => parseIdmlBuffer(bytes), /missing designmap\.xml/i); +}); + +test('throws when the package is not a valid zip', () => { + const garbage = new TextEncoder().encode('not actually a zip file'); + assert.throws(() => parseIdmlBuffer(garbage), /not a valid zip/i); +}); + +test('warns on unknown paragraph style refs without throwing', () => { + const bytes = buildIdml({ + styles: [ + { id: 'pstyle-real', name: 'Body', kind: 'paragraph', pointSize: 12 }, + ], + stories: [ + { + id: 'story-1', + runs: [ + { text: 'Hello', paragraphStyle: 'pstyle-real' }, + { text: 'Dangling', paragraphStyle: 'pstyle-ghost' }, + ], + }, + ], + spreads: [ + { + id: 'spread-1', + pages: [{ id: 'page-1', bounds: [0, 0, 100, 100] }], + frames: [{ kind: 'text', id: 'frame-1', bounds: [0, 0, 100, 50], parentStory: 'story-1' }], + }, + ], + }); + + const ir = parseIdmlBuffer(bytes); + const codes = ir.warnings.map((w) => w.code); + assert.ok( + codes.includes('missing-paragraph-style-ref'), + `expected missing-paragraph-style-ref warning, got: ${codes.join(', ')}`, + ); + // The valid style ref should NOT generate a warning. + const ghostWarning = ir.warnings.find((w) => w.message.includes('pstyle-ghost')); + assert.ok(ghostWarning, 'expected the warning to name the dangling ref'); +}); + +test('warns on text frame pointing at a non-existent story', () => { + const bytes = buildIdml({ + spreads: [ + { + id: 'spread-1', + pages: [{ id: 'page-1', bounds: [0, 0, 100, 100] }], + frames: [{ kind: 'text', id: 'frame-1', bounds: [0, 0, 100, 50], parentStory: 'story-ghost' }], + }, + ], + // No stories array. + }); + + const ir = parseIdmlBuffer(bytes); + assert.ok( + ir.warnings.some((w) => w.code === 'missing-story-ref'), + `warnings: ${JSON.stringify(ir.warnings)}`, + ); +}); + +test('empty stories produce an IR with empty run lists, no throw', () => { + const bytes = buildIdml({ + stories: [{ id: 'story-empty', runs: [] }], + spreads: [ + { + id: 'spread-1', + pages: [{ id: 'page-1', bounds: [0, 0, 100, 100] }], + frames: [{ kind: 'text', id: 'frame-1', bounds: [0, 0, 100, 50], parentStory: 'story-empty' }], + }, + ], + }); + + const ir = parseIdmlBuffer(bytes); + assert.equal(ir.stories.length, 1); + assert.equal(ir.stories[0].runs.length, 0); + // The frame still references it correctly — no missing-story-ref warning. + assert.ok(!ir.warnings.some((w) => w.code === 'missing-story-ref')); +}); + +test('missing optional resource files produce warnings, not errors', () => { + const bytes = buildIdml({ + omitGraphic: true, + omitFonts: true, + omitStyles: true, + }); + + const ir = parseIdmlBuffer(bytes); + const codes = ir.warnings.map((w) => w.code); + assert.ok(codes.includes('missing-graphic')); + assert.ok(codes.includes('missing-fonts')); + assert.ok(codes.includes('missing-styles')); + assert.equal(ir.swatches.length, 0); + assert.equal(ir.fonts.length, 0); + assert.equal(ir.styles.length, 0); +}); + +test('unknown color space falls back to black with a warning', () => { + const bytes = buildIdml({ + colors: [{ id: 'col-weird', name: 'Mystery', space: 'LAB', values: [50, 0, 0] }], + }); + const ir = parseIdmlBuffer(bytes); + const swatch = ir.swatches.find((s) => s.id === 'col-weird'); + assert.equal(swatch.color.hex, '#000000'); + assert.equal(swatch.color.space, 'Unknown'); + assert.ok(ir.warnings.some((w) => w.code === 'color-fallback')); +}); diff --git a/packages/pipeline/tests/indesign/parse-idml.test.mjs b/packages/pipeline/tests/indesign/parse-idml.test.mjs new file mode 100644 index 0000000..7d01a47 --- /dev/null +++ b/packages/pipeline/tests/indesign/parse-idml.test.mjs @@ -0,0 +1,140 @@ +// Happy-path: a small but realistic IDML with swatches, fonts, styles, a +// story, a spread, and a master spread should round-trip into a valid IR. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseIdmlBuffer } from '../../src/indesign/parse-idml.js'; +import { Document } from '../../src/indesign/ir.js'; +import { buildIdml } from './helpers/build-idml.js'; + +function buildHappyPath() { + return buildIdml({ + idmlVersion: '16.0', + name: 'Brochure', + colors: [ + { id: 'col-brand', name: 'Brand Blue', space: 'RGB', values: [0, 102, 204] }, + { id: 'col-ink', name: 'Ink', space: 'CMYK', values: [0, 0, 0, 100] }, + ], + fonts: [ + { id: 'font-helv-reg', family: 'Helvetica', style: 'Regular', postScriptName: 'Helvetica' }, + { id: 'font-helv-bold', family: 'Helvetica', style: 'Bold', postScriptName: 'Helvetica-Bold' }, + ], + styles: [ + { id: 'pstyle-h1', name: 'Heading 1', kind: 'paragraph', pointSize: 36, leading: 42, appliedFont: 'font-helv-bold', fillColor: 'col-brand' }, + { id: 'pstyle-body', name: 'Body', kind: 'paragraph', pointSize: 12, leading: 16, appliedFont: 'font-helv-reg', fillColor: 'col-ink' }, + { id: 'cstyle-emph', name: 'Emphasis', kind: 'character' }, + ], + stories: [ + { + id: 'story-headline', + runs: [ + { text: 'Welcome', paragraphStyle: 'pstyle-h1' }, + { text: 'Print to web in one pass.', paragraphStyle: 'pstyle-body' }, + ], + }, + ], + spreads: [ + { + id: 'spread-1', + appliedMaster: 'master-A', + pages: [{ id: 'page-1', bounds: [0, 0, 792, 612] }], + frames: [ + { kind: 'text', id: 'frame-headline', bounds: [36, 36, 360, 100], parentStory: 'story-headline' }, + { kind: 'image', id: 'frame-hero', bounds: [36, 200, 540, 550], href: 'file:Resources/hero.jpg' }, + ], + }, + ], + masters: [ + { + id: 'master-A', + name: 'A-Master', + pages: [{ id: 'master-page-1', bounds: [0, 0, 792, 612] }], + frames: [{ kind: 'text', id: 'master-folio', bounds: [720, 580, 770, 600] }], + }, + ], + }); +} + +test('happy path: parses a full IDML into a validated IR', () => { + const bytes = buildHappyPath(); + const ir = parseIdmlBuffer(bytes); + + // zod has already validated, but we want an explicit second pass that + // surfaces field-level errors if the IR shape drifts later. + const validated = Document.parse(ir); + assert.equal(validated.irVersion, 1); + assert.equal(validated.dpi, 96); + assert.equal(validated.meta.idmlVersion, '16.0'); + assert.equal(validated.meta.name, 'Brochure'); +}); + +test('happy path: all swatches/fonts/styles enumerated', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + + assert.equal(ir.swatches.length, 2); + assert.equal(ir.swatches.find((s) => s.id === 'col-brand')?.color.hex, '#0066cc'); + assert.equal(ir.swatches.find((s) => s.id === 'col-brand')?.color.space, 'RGB'); + // CMYK 0/0/0/100 should approximate to near-black. + assert.equal(ir.swatches.find((s) => s.id === 'col-ink')?.color.hex, '#000000'); + + assert.equal(ir.fonts.length, 2); + assert.equal(ir.fonts[0].family, 'Helvetica'); + + assert.equal(ir.styles.length, 3); + const h1 = ir.styles.find((s) => s.id === 'pstyle-h1'); + assert.ok(h1); + // 36pt at 96dpi = 48px + assert.equal(h1.fontSize, 48); + // 42pt at 96dpi = 56px + assert.equal(h1.leading, 56); +}); + +test('happy path: stories carry runs with paragraph style refs', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + + assert.equal(ir.stories.length, 1); + const story = ir.stories[0]; + assert.equal(story.id, 'story-headline'); + assert.equal(story.runs.length, 2); + assert.equal(story.runs[0].paragraphStyleRef, 'pstyle-h1'); + assert.ok(story.runs[0].text.includes('Welcome')); +}); + +test('happy path: spread frames + master inheritance survive', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + + assert.equal(ir.spreads.length, 1); + const spread = ir.spreads[0]; + assert.equal(spread.appliedMasterRef, 'master-A'); + assert.equal(spread.frames.length, 2); + + const textFrame = spread.frames.find((f) => f.kind === 'text'); + assert.equal(textFrame.storyRef, 'story-headline'); + + const imageFrame = spread.frames.find((f) => f.kind === 'image'); + assert.equal(imageFrame.href, 'file:Resources/hero.jpg'); + assert.equal(imageFrame.embedded, true); +}); + +test('happy path: image frames have positive normalized bounds', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + const imageFrame = ir.spreads[0].frames.find((f) => f.kind === 'image'); + // 36pt → 48px at 96dpi, etc. We only check sign + positive area. + assert.ok(imageFrame.bounds.width > 0); + assert.ok(imageFrame.bounds.height > 0); +}); + +test('happy path: configurable DPI scales geometry', () => { + const lo = parseIdmlBuffer(buildHappyPath(), { dpi: 72 }); + const hi = parseIdmlBuffer(buildHappyPath(), { dpi: 144 }); + const frameLo = lo.spreads[0].frames.find((f) => f.kind === 'text').bounds; + const frameHi = hi.spreads[0].frames.find((f) => f.kind === 'text').bounds; + // 144 dpi = 2x the px of 72 dpi. + assert.equal(frameHi.width, frameLo.width * 2); + assert.equal(frameHi.height, frameLo.height * 2); +}); + +test('happy path: no parse warnings on clean input', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + assert.deepEqual(ir.warnings, []); +}); diff --git a/packages/pipeline/tests/indesign/units.test.mjs b/packages/pipeline/tests/indesign/units.test.mjs new file mode 100644 index 0000000..1e95b85 --- /dev/null +++ b/packages/pipeline/tests/indesign/units.test.mjs @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ptToPx, lengthToPx, roundPx } from '../../src/indesign/units.js'; + +test('ptToPx converts at 96 dpi (CSS default)', () => { + // 72pt = 1 inch = 96px at 96 dpi + assert.equal(ptToPx(72, 96), 96); + // 12pt body text → 16px + assert.equal(ptToPx(12, 96), 16); +}); + +test('ptToPx scales linearly with dpi', () => { + assert.equal(ptToPx(72, 72), 72); + assert.equal(ptToPx(72, 144), 144); +}); + +test('lengthToPx accepts bare numbers as points', () => { + assert.equal(lengthToPx(72, 96), 96); +}); + +test('lengthToPx accepts unit suffixes', () => { + assert.equal(lengthToPx('1in', 96), 96); + assert.equal(lengthToPx('1pc', 96), 16); // 1 pica = 12pt + assert.equal(lengthToPx('25.4mm', 96), 96); // 25.4mm = 1in + assert.equal(lengthToPx('2.54cm', 96), 96); + assert.equal(lengthToPx('100px', 96), 100); // px passes through +}); + +test('lengthToPx returns NaN for garbage', () => { + assert.ok(Number.isNaN(lengthToPx('not-a-number', 96))); + assert.ok(Number.isNaN(lengthToPx('5furlongs', 96))); +}); + +test('roundPx caps at 3 decimal places', () => { + assert.equal(roundPx(1.2345678), 1.235); + assert.equal(roundPx(96), 96); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7517658..036b939 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,18 @@ importers: specifier: ^7.0.0 version: 7.0.0 + packages/pipeline: + dependencies: + fast-xml-parser: + specifier: ^4.5.0 + version: 4.5.6 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + zod: + specifier: ^3.23.8 + version: 3.23.8 + packages: '@babel/code-frame@7.29.0': @@ -624,9 +636,16 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-xml-parser@4.5.6: + resolution: {integrity: sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==} + hasBin: true + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -1307,6 +1326,9 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -2223,10 +2245,16 @@ snapshots: fast-uri@3.1.2: {} + fast-xml-parser@4.5.6: + dependencies: + strnum: 1.1.2 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 + fflate@0.8.3: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -2937,6 +2965,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strnum@1.1.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*'