From 93a5c9651854f42a9e98cf46c7e622d2194caeb4 Mon Sep 17 00:00:00 2001 From: Paul Mulligan Date: Wed, 24 Jun 2026 16:24:48 -0400 Subject: [PATCH] feat(pipeline): add IDML/PDF ingest stage to the build pipeline Add an `ingest` ETL stage that extracts an InDesign source (.idml or .pdf) into a generator-ready bundle on disk: ir.json (the intermediate IR the theme generator consumes), assets/ (image bytes pulled from the source, named to match the generator's planAssets output), and assets.manifest.json (per-frame provenance plus unresolved-asset warnings). This closes a real gap: embedded IDML image bytes previously had no automatic path into a generated theme. The stage resolves them once and stages them under the exact filenames generateTheme references, so the bundle feeds straight into generation: flavian pipeline ingest brochure.idml --out-dir build/brochure flavian pipeline indesign build/brochure/ir.json --asset-dir build/brochure/assets Details: - Pure, source-agnostic transform (resolveAssets/buildIngestBundle) joins IR image frames to an href->bytes resolver; runIngest wires in IDML zip extraction and the PDF image cache. - Runnable via `pnpm pipeline:ingest`, `flavian pipeline ingest`, and the `flavian-ingest` bin. - Tested on an in-memory IDML fixture: the transform, the manifest shape, and an end-to-end run whose staged paths line up with generateTheme. Redence proof token (Pipelines / Data-ETL): RDNC-NWYQDP Closes #117 Co-Authored-By: Claude Opus 4.8 --- bin/flavian.mjs | 112 +++++++++- package.json | 1 + packages/pipeline/bin/ingest.mjs | 95 ++++++++ packages/pipeline/package.json | 4 +- .../src/indesign/assets/idml-assets.js | 25 +++ packages/pipeline/src/indesign/index.js | 2 + .../pipeline/src/indesign/ingest/index.js | 208 ++++++++++++++++++ .../tests/indesign/assets-idml.test.mjs | 26 +++ .../pipeline/tests/indesign/ingest.test.mjs | 149 +++++++++++++ 9 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 packages/pipeline/bin/ingest.mjs create mode 100644 packages/pipeline/src/indesign/assets/idml-assets.js create mode 100644 packages/pipeline/src/indesign/ingest/index.js create mode 100644 packages/pipeline/tests/indesign/assets-idml.test.mjs create mode 100644 packages/pipeline/tests/indesign/ingest.test.mjs diff --git a/bin/flavian.mjs b/bin/flavian.mjs index ec3bf6f..ed47ab2 100755 --- a/bin/flavian.mjs +++ b/bin/flavian.mjs @@ -2,6 +2,7 @@ // Flavian CLI — the first-class entry point for Flavian's conversion pipelines. // // flavian pipeline indesign [options] +// flavian pipeline ingest --out-dir [options] // // Today it exposes the InDesign pipeline (#62–#65): parse an .idml/.pdf (or a // pre-parsed IR JSON), map design tokens, and generate a complete FSE theme. @@ -36,13 +37,19 @@ async function main() { process.exit(command ? 0 : 2); } - if (command !== 'indesign') { - fail(`Unknown pipeline: ${command}`); - printPipelineUsage(); - process.exit(2); + if (command === 'indesign') { + await runIndesign(rest); + return; + } + + if (command === 'ingest') { + await runIngestCommand(rest); + return; } - await runIndesign(rest); + fail(`Unknown pipeline: ${command}`); + printPipelineUsage(); + process.exit(2); } async function runIndesign(args) { @@ -125,6 +132,71 @@ async function runIndesign(args) { process.exit(result.report.data.valid ? 0 : 1); } +async function runIngestCommand(args) { + const opts = { quiet: false }; + let input; + + let i = 0; + const value = (flag) => { + const next = args[i + 1]; + if (next === undefined || next.startsWith('-')) { + fail(`${flag} requires a value`); + process.exit(2); + } + i += 1; + return next; + }; + + for (; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--out-dir': case '-o': opts.outDir = value(arg); break; + case '--dpi': opts.dpi = Number(value(arg)); break; + case '--name': opts.name = value(arg); break; + case '--quiet': case '-q': opts.quiet = true; break; + case '-h': case '--help': printIngestUsage(); process.exit(0); break; + default: + if (!input && !arg.startsWith('-')) input = arg; + else { fail(`Unknown argument: ${arg}`); printIngestUsage(); process.exit(2); } + } + } + + if (!input) { + fail('an input is required (an .idml/.pdf file or an IR JSON)'); + printIngestUsage(); + process.exit(2); + } + if (!opts.outDir) { + fail('--out-dir is required'); + printIngestUsage(); + process.exit(2); + } + + // Lazy import so `--help` never pays for loading the pipeline. + const { runIngest } = await import(PIPELINE('ingest/index.js')); + + const summary = await runIngest({ + input, + outDir: path.resolve(opts.outDir), + dpi: opts.dpi, + name: opts.name, + }); + + if (!opts.quiet) { + const c = summary.counts; + process.stderr.write( + [ + `ingest: ${summary.format} → ${path.relative(process.cwd(), summary.dir) || '.'}`, + `assets: ${c.assetsResolved}/${c.imageFrames} resolved (staged ${summary.assetsWritten})`, + 'bundle: ir.json, assets/, assets.manifest.json', + summary.warnings.length ? `warnings: ${summary.warnings.length}` : null, + ].filter(Boolean).join('\n') + '\n', + ); + } + + process.exit(0); +} + /** Resolve the theme output directory from --output, config.output, or default. */ function resolveOutDir(outputFlag, configOutput, slug) { if (outputFlag) return path.resolve(outputFlag); // explicit theme dir @@ -193,6 +265,7 @@ function printRootUsage() { '', 'Commands:', ' pipeline indesign Convert an InDesign document to a WordPress FSE theme', + ' pipeline ingest Stage an InDesign source as a generator-ready bundle', '', 'Run `flavian pipeline indesign --help` for pipeline options.', '', @@ -207,8 +280,9 @@ function printPipelineUsage() { '', 'Pipelines:', ' indesign Convert an .idml/.pdf (or IR JSON) to an FSE theme', + ' ingest Stage a source as a generator-ready bundle (ir.json + assets/)', '', - 'Run `flavian pipeline indesign --help` for options.', + 'Run `flavian pipeline --help` for options.', '', ].join('\n'), ); @@ -254,6 +328,32 @@ function printIndesignUsage() { ); } +function printIngestUsage() { + process.stderr.write( + [ + 'Usage: flavian pipeline ingest --out-dir [options]', + '', + 'Extract an InDesign source into a generator-ready bundle: ir.json (the', + 'intermediate IR the generator consumes), assets/ (image bytes pulled from the', + 'source, named to match the generator), and assets.manifest.json.', + '', + 'The bundle feeds straight into generation:', + ' flavian pipeline indesign /ir.json --asset-dir /assets', + '', + 'Arguments:', + ' An .idml or .pdf file (or a pre-parsed IR JSON)', + '', + 'Options:', + ' -o, --out-dir Bundle output directory (required)', + ' --dpi DPI when parsing .idml/.pdf (default 96)', + ' --name Override the document name', + ' -q, --quiet Suppress the stderr summary', + ' -h, --help Show this help', + '', + ].join('\n'), + ); +} + main().catch((err) => { fail(err.message); process.exit(1); diff --git a/package.json b/package.json index 35b5439..0a3b70e 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ }, "scripts": { "flavian": "node bin/flavian.mjs", + "pipeline:ingest": "node bin/flavian.mjs pipeline ingest", "init": "node scripts/init.mjs", "test:init": "node --test \"tests/init/**/*.test.mjs\"", "test:pipeline": "pnpm --filter @flavian/pipeline test", diff --git a/packages/pipeline/bin/ingest.mjs b/packages/pipeline/bin/ingest.mjs new file mode 100644 index 0000000..b8a8a6e --- /dev/null +++ b/packages/pipeline/bin/ingest.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +// CLI: ingest an InDesign source into a generator-ready bundle on disk. +// +// flavian-ingest --out-dir [options] +// +// Parses the source to the IR, extracts its image bytes (embedded in the IDML +// zip, or decoded from the PDF), and writes a bundle the theme generator can +// consume directly: ir.json, assets/, assets.manifest.json. Then: +// +// flavian-generate-theme /ir.json --asset-dir /assets --out-dir theme +// +// Options: +// -o, --out-dir Bundle output directory (required). Created if missing. +// --dpi DPI for unit normalization when parsing (default 96). +// --name Override the document name. +// -q, --quiet Suppress the stderr summary. +// -h, --help Show this help. + +import { runIngest } from '../src/indesign/ingest/index.js'; + +const args = process.argv.slice(2); +const opts = { quiet: false }; +let input; + +let i = 0; +function wantsValue(flag) { + const next = args[i + 1]; + if (next === undefined || next.startsWith('-')) { + process.stderr.write(`${flag} requires a value\n`); + process.exit(2); + } + return next; +} + +for (; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--out-dir': case '-o': opts.outDir = wantsValue(arg); i += 1; break; + case '--dpi': opts.dpi = Number(wantsValue(arg)); i += 1; break; + case '--name': opts.name = wantsValue(arg); i += 1; break; + case '--quiet': case '-q': opts.quiet = true; break; + case '-h': case '--help': printUsage(); process.exit(0); break; + default: + if (!input && !arg.startsWith('-')) input = arg; + else { process.stderr.write(`Unknown argument: ${arg}\n`); printUsage(); process.exit(2); } + } +} + +if (!input) { + process.stderr.write('an input is required (an .idml/.pdf file or an IR JSON)\n'); + printUsage(); + process.exit(2); +} +if (!opts.outDir) { + process.stderr.write('--out-dir is required\n'); + printUsage(); + process.exit(2); +} + +try { + const summary = await runIngest({ input, outDir: opts.outDir, dpi: opts.dpi, name: opts.name }); + if (!opts.quiet) { + const c = summary.counts; + process.stderr.write( + [ + `ingest: ${summary.format} → ${opts.outDir}`, + `assets: ${c.assetsResolved}/${c.imageFrames} resolved (staged ${summary.assetsWritten})`, + `bundle: ir.json, assets/, assets.manifest.json`, + summary.warnings.length ? `warnings: ${summary.warnings.length}` : null, + ] + .filter(Boolean) + .join('\n') + '\n', + ); + } + process.exit(0); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); +} + +function printUsage() { + process.stderr.write( + [ + 'Usage: flavian-ingest --out-dir [options]', + '', + 'Options:', + ' -o, --out-dir Bundle output directory (required)', + ' --dpi DPI for unit normalization when parsing (default 96)', + ' --name Override the document name', + ' -q, --quiet Suppress the stderr summary', + ' -h, --help Show this help', + '', + ].join('\n'), + ); +} diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index 7c59f75..94e520b 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -11,11 +11,13 @@ "bin": { "flavian-parse-idml": "./bin/parse-idml.mjs", "flavian-parse-pdf": "./bin/parse-pdf.mjs", + "flavian-ingest": "./bin/ingest.mjs", "flavian-map-tokens": "./bin/map-tokens.mjs", "flavian-generate-theme": "./bin/generate-theme.mjs" }, "scripts": { - "test": "node --test" + "test": "node --test", + "ingest": "node bin/ingest.mjs" }, "dependencies": { "fast-xml-parser": "^5.7.0", diff --git a/packages/pipeline/src/indesign/assets/idml-assets.js b/packages/pipeline/src/indesign/assets/idml-assets.js new file mode 100644 index 0000000..84823a9 --- /dev/null +++ b/packages/pipeline/src/indesign/assets/idml-assets.js @@ -0,0 +1,25 @@ +// Resolve image-frame assets stored inside an IDML package. Image frames whose +// href uses the `file:` scheme point at a member of the IDML zip; this helper +// unzips once and returns a Map from that href to its bytes + extension, ready +// for the output generator to stage into the theme's assets/images/. +// +// Real-world IDML can also embed images as base64 inside the spread XML; that +// path is out of scope here (v1 resolves package-linked assets only). + +import { unzipSync } from 'fflate'; + +/** + * @param {Uint8Array|ArrayBuffer|Buffer} buffer Raw .idml bytes. + * @returns {Map} href → asset. + */ +export function extractIdmlAssets(buffer) { + const files = unzipSync(buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)); + const map = new Map(); + for (const [name, bytes] of Object.entries(files)) { + const ext = (name.split('.').pop() ?? '').toLowerCase(); + if (/^(jpe?g|png|gif|webp|svg|tiff?)$/.test(ext)) { + map.set(`file:${name}`, { bytes, ext }); + } + } + return map; +} diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js index 8c240aa..ee250fa 100644 --- a/packages/pipeline/src/indesign/index.js +++ b/packages/pipeline/src/indesign/index.js @@ -5,3 +5,5 @@ export { WarningCollector } from './warnings.js'; export { lengthToPx, ptToPx, roundPx } from './units.js'; export { mapTokens } from './map/index.js'; export { generateTheme } from './generate/index.js'; +export { extractIdmlAssets } from './assets/idml-assets.js'; +export { resolveAssets, buildIngestBundle, writeIngestBundle, runIngest } from './ingest/index.js'; diff --git a/packages/pipeline/src/indesign/ingest/index.js b/packages/pipeline/src/indesign/ingest/index.js new file mode 100644 index 0000000..4a47c24 --- /dev/null +++ b/packages/pipeline/src/indesign/ingest/index.js @@ -0,0 +1,208 @@ +// Ingest stage: a source document (.idml/.pdf) → a generator-ready bundle on +// disk. It sits between parsing and generation and does the one thing neither +// end did before: resolve a document's *image bytes* (embedded in the IDML zip, +// or decoded out of a PDF) and stage them under the exact filenames the theme +// generator will reference. +// +// Extract parse the source to the IR; pull image bytes from the package. +// Transform join image frames ↔ bytes by href; compute staged paths via the +// generator's own planAssets() so the names line up byte-for-byte; +// synthesise a manifest with provenance + unresolved warnings. +// Load write ir.json, assets/, assets.manifest.json. +// +// The result is directly consumable downstream: +// flavian pipeline indesign /ir.json --asset-dir /assets +// +// The pure pieces (resolveAssets, buildIngestBundle) take an href→bytes Map so +// they're source-agnostic and trivially testable; runIngest wires in the +// format-specific extraction. + +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { parseIdmlBuffer } from '../parse-idml.js'; +import { parsePdfBuffer } from '../parse-pdf.js'; +import { extractIdmlAssets } from '../assets/idml-assets.js'; +import { planAssets } from '../generate/media.js'; + +const INGEST_VERSION = 1; + +/** + * @typedef {Object} ResolvedAsset + * @property {string} frameId + * @property {string} [href] + * @property {boolean} embedded + * @property {string} name Staged filename, e.g. "spread-1-image-1.jpg". + * @property {string} relPath Theme-relative path, e.g. "assets/spread-1-image-1.jpg". + * @property {boolean} resolved + * @property {Uint8Array} [bytes] Present only when resolved. + */ + +/** + * Join the IR's planned image assets to source bytes from a resolver. + * + * planAssets() is the generator's own naming pass, so the staged filenames here + * are byte-for-byte what generateTheme() will reference — which is what makes + * the emitted bundle directly consumable downstream. + * + * @param {import('../ir.js').DocumentIR} ir + * @param {Map} [resolver] href → bytes + * @returns {{ assets: ResolvedAsset[], counts: { resolved: number, unresolved: number }, warnings: import('../ir.js').ParseWarningIR[] }} + */ +export function resolveAssets(ir, resolver = new Map()) { + const { assets } = planAssets(ir); + const warnings = []; + let resolved = 0; + + const out = assets.map((a) => { + const base = { frameId: a.frameId, href: a.href, embedded: a.embedded, name: a.name, relPath: a.relPath }; + const hit = a.href ? resolver.get(a.href) : undefined; + if (hit) { + resolved += 1; + return { ...base, resolved: true, bytes: hit.bytes }; + } + warnings.push({ + code: 'asset-unresolved', + message: `Image frame ${a.frameId} (${a.href ?? 'no href'}) had no resolvable bytes; staged path ${a.relPath} reserved but no file written`, + context: { id: a.frameId }, + }); + return { ...base, resolved: false }; + }); + + return { assets: out, counts: { resolved, unresolved: out.length - resolved }, warnings }; +} + +/** + * Build the in-memory ingest bundle: the validated IR, the resolved assets + * (with bytes), and a JSON-serialisable manifest (provenance only, no bytes). + * + * @param {{ ir: import('../ir.js').DocumentIR, resolver?: Map, format?: string }} input + * @returns {{ ir: import('../ir.js').DocumentIR, assets: ResolvedAsset[], manifest: object }} + */ +export function buildIngestBundle({ ir, resolver = new Map(), format = 'unknown' }) { + const { assets, counts, warnings } = resolveAssets(ir, resolver); + + const manifest = { + ingestVersion: INGEST_VERSION, + irVersion: ir.irVersion ?? 1, + source: { format, name: ir.meta?.name }, + irPath: 'ir.json', + assetDir: 'assets', + counts: { + spreads: (ir.spreads ?? []).length, + imageFrames: assets.length, + assetsResolved: counts.resolved, + assetsUnresolved: counts.unresolved, + }, + // Provenance only — raw bytes are staged as files, never inlined here. + assets: assets.map(({ frameId, href, embedded, relPath, resolved }) => ({ frameId, href, embedded, relPath, resolved })), + // IR parse warnings + asset-resolution warnings together, so the manifest + // is the single place a caller has to look. + warnings: [...(ir.warnings ?? []), ...warnings], + }; + + return { ir, assets, manifest }; +} + +/** + * Persist a bundle to disk: ir.json, assets/, assets.manifest.json. + * + * @param {{ ir: object, assets: ResolvedAsset[], manifest: object }} bundle + * @param {string} outDir + * @returns {Promise<{ dir: string, irPath: string, manifestPath: string, assetDir: string, assetsWritten: number }>} + */ +export async function writeIngestBundle(bundle, outDir) { + await fs.mkdir(outDir, { recursive: true }); + + const irPath = path.join(outDir, 'ir.json'); + await fs.writeFile(irPath, `${JSON.stringify(bundle.ir, null, 2)}\n`); + + let assetsWritten = 0; + for (const asset of bundle.assets) { + if (!asset.resolved || !asset.bytes) continue; + const dest = path.join(outDir, asset.relPath); + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.writeFile(dest, asset.bytes); + assetsWritten += 1; + } + + const manifestPath = path.join(outDir, 'assets.manifest.json'); + await fs.writeFile(manifestPath, `${JSON.stringify(bundle.manifest, null, 2)}\n`); + + return { dir: outDir, irPath, manifestPath, assetDir: path.join(outDir, 'assets'), assetsWritten }; +} + +/** + * Orchestrate a full ingest from a source file path to a bundle on disk. + * Detects .idml/.pdf by extension; anything else is treated as pre-parsed IR JSON. + * + * @param {{ input: string, outDir: string, dpi?: number, name?: string }} opts + * @returns {Promise<{ format: string, dir: string, irPath: string, manifestPath: string, assetDir: string, assetsWritten: number, counts: object, warnings: object[] }>} + */ +export async function runIngest({ input, outDir, dpi, name } = {}) { + if (!input) throw new Error('runIngest: an input path is required'); + if (!outDir) throw new Error('runIngest: an outDir is required'); + + const parseOpts = {}; + if (dpi !== undefined) parseOpts.dpi = dpi; + if (name !== undefined) parseOpts.name = name; + + let ir; + let resolver = new Map(); + let format; + + if (/\.idml$/i.test(input)) { + format = 'idml'; + const bytes = await fs.readFile(input); + ir = parseIdmlBuffer(bytes, parseOpts); + resolver = extractIdmlAssets(bytes); + } else if (/\.pdf$/i.test(input)) { + format = 'pdf'; + const bytes = await fs.readFile(input); + // parse-pdf only decodes image bytes when given a cache dir; round-trip + // through a temp dir, then read them back keyed by the same href. + const cacheDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flavian-ingest-pdf-')); + try { + ir = await parsePdfBuffer(bytes, { ...parseOpts, assetCacheDir: cacheDir }); + resolver = await resolverFromCacheDir(ir, cacheDir); + } finally { + await fs.rm(cacheDir, { recursive: true, force: true }); + } + } else { + format = 'ir'; + ir = JSON.parse(await fs.readFile(input, 'utf8')); + } + + const bundle = buildIngestBundle({ ir, resolver, format }); + const written = await writeIngestBundle(bundle, outDir); + + return { format, ...written, counts: bundle.manifest.counts, warnings: bundle.manifest.warnings }; +} + +/** + * Build an href→bytes resolver from a directory the parser persisted images + * into. parse-pdf writes each image to /, so we read them + * back keyed by that same href. + * + * @param {import('../ir.js').DocumentIR} ir + * @param {string} cacheDir + * @returns {Promise>} + */ +async function resolverFromCacheDir(ir, cacheDir) { + const map = new Map(); + for (const spread of ir.spreads ?? []) { + for (const frame of spread.frames ?? []) { + if (frame.kind !== 'image' || !frame.href) continue; + try { + const bytes = await fs.readFile(path.join(cacheDir, frame.href)); + const ext = (frame.href.split('.').pop() ?? 'png').toLowerCase(); + map.set(frame.href, { bytes: new Uint8Array(bytes), ext }); + } catch { + // Not every image frame is persisted (e.g. decode failures); skip it + // and let the frame fall through to 'unresolved'. + } + } + } + return map; +} diff --git a/packages/pipeline/tests/indesign/assets-idml.test.mjs b/packages/pipeline/tests/indesign/assets-idml.test.mjs new file mode 100644 index 0000000..bd4ea35 --- /dev/null +++ b/packages/pipeline/tests/indesign/assets-idml.test.mjs @@ -0,0 +1,26 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { extractIdmlAssets } from '../../src/indesign/assets/idml-assets.js'; +import { buildIdml } from './helpers/build-idml.js'; + +test('extracts embedded asset bytes by href', () => { + const idml = buildIdml({ + spreads: [{ id: 'sp', pages: [{ id: 'p', bounds: [0, 0, 600, 400] }], + frames: [{ kind: 'image', id: 'img', bounds: [0, 0, 600, 400], href: 'file:Resources/hero.jpg' }] }], + extraFiles: { 'Resources/hero.jpg': '\x89PNG\x01\x02\x03' }, + }); + const map = extractIdmlAssets(idml); + assert.ok(map.has('file:Resources/hero.jpg')); + assert.equal(map.get('file:Resources/hero.jpg').ext, 'jpg'); + assert.ok(map.get('file:Resources/hero.jpg').bytes instanceof Uint8Array); +}); + +test('only image-typed entries are included (no XML/manifest noise)', () => { + const idml = buildIdml({ + spreads: [{ id: 'sp', pages: [{ id: 'p', bounds: [0, 0, 600, 400] }], frames: [] }], + }); + const map = extractIdmlAssets(idml); + for (const href of map.keys()) { + assert.match(href, /\.(jpe?g|png|gif|webp|svg|tiff?)$/i); + } +}); diff --git a/packages/pipeline/tests/indesign/ingest.test.mjs b/packages/pipeline/tests/indesign/ingest.test.mjs new file mode 100644 index 0000000..142452e --- /dev/null +++ b/packages/pipeline/tests/indesign/ingest.test.mjs @@ -0,0 +1,149 @@ +// The ingest stage: source (.idml/.pdf) → a generator-ready bundle on disk +// (ir.json + assets/ + assets.manifest.json). These tests pin three things: +// 1. the transform that joins IR image frames to extracted bytes by href, +// 2. the manifest shape (provenance, no bytes), and +// 3. an end-to-end IDML ingest whose staged filenames line up byte-for-byte +// with what the generator (planAssets) will reference — i.e. the bundle is +// directly consumable by `generate-theme --asset-dir `. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { Document } from '../../src/indesign/ir.js'; +import { resolveAssets, buildIngestBundle, runIngest } from '../../src/indesign/ingest/index.js'; +import { generateTheme } from '../../src/indesign/generate/index.js'; +import { buildIdml } from './helpers/build-idml.js'; + +/** A one-spread IR with one resolvable image frame and one dangling one. */ +function twoImageIr() { + return Document.parse({ + dpi: 96, + swatches: [], + fonts: [], + styles: [], + stories: [], + masterSpreads: [], + spreads: [ + { + id: 'sp', + source: 'Spreads/Spread_sp.xml', + pages: [{ id: 'p', bounds: { x: 0, y: 0, width: 600, height: 400 } }], + frames: [ + { kind: 'image', id: 'img', bounds: { x: 0, y: 0, width: 600, height: 400 }, href: 'file:Resources/hero.jpg', embedded: true }, + { kind: 'image', id: 'img2', bounds: { x: 0, y: 0, width: 100, height: 100 }, href: 'file:Links/missing.png', embedded: true }, + ], + }, + ], + }); +} + +function resolverWithHero() { + return new Map([ + ['file:Resources/hero.jpg', { bytes: new TextEncoder().encode('JPEGDATA-hero'), ext: 'jpg' }], + ]); +} + +test('resolveAssets joins image frames to extracted bytes by href', () => { + const { assets, counts, warnings } = resolveAssets(twoImageIr(), resolverWithHero()); + + assert.equal(assets.length, 2); + + const hero = assets.find((a) => a.frameId === 'img'); + assert.equal(hero.resolved, true); + assert.equal(hero.relPath, 'assets/spread-1-image-1.jpg'); + assert.ok(hero.bytes instanceof Uint8Array); + assert.deepEqual(hero.bytes, new TextEncoder().encode('JPEGDATA-hero')); + + const missing = assets.find((a) => a.frameId === 'img2'); + assert.equal(missing.resolved, false); + assert.equal(missing.relPath, 'assets/spread-1-image-2.png'); + assert.equal(missing.bytes, undefined); + + assert.deepEqual(counts, { resolved: 1, unresolved: 1 }); + assert.equal(warnings.filter((w) => w.code === 'asset-unresolved').length, 1); +}); + +test('buildIngestBundle records provenance in the manifest without embedding bytes', () => { + const bundle = buildIngestBundle({ ir: twoImageIr(), resolver: resolverWithHero(), format: 'idml' }); + + assert.equal(bundle.manifest.ingestVersion, 1); + assert.equal(bundle.manifest.source.format, 'idml'); + assert.deepEqual(bundle.manifest.counts, { + spreads: 1, + imageFrames: 2, + assetsResolved: 1, + assetsUnresolved: 1, + }); + + // Manifest entries are provenance only — bytes are written separately. + const entry = bundle.manifest.assets.find((a) => a.frameId === 'img'); + assert.deepEqual(entry, { + frameId: 'img', + href: 'file:Resources/hero.jpg', + embedded: true, + relPath: 'assets/spread-1-image-1.jpg', + resolved: true, + }); + assert.ok(!('bytes' in entry), 'manifest must not carry raw bytes'); + + // The in-memory bundle keeps bytes so the writer can stage them. + const live = bundle.assets.find((a) => a.frameId === 'img'); + assert.ok(live.bytes instanceof Uint8Array); + + // The unresolved frame is surfaced as a warning in the manifest. + assert.ok(bundle.manifest.warnings.some((w) => w.code === 'asset-unresolved')); +}); + +test('runIngest writes a generator-ready bundle from an .idml (assets aligned with the generator)', async () => { + const heroBytes = 'JPEGDATA-hero-frame'; + const idml = buildIdml({ + name: 'Ingest Fixture', + spreads: [ + { + id: 'sp1', + pages: [{ id: 'pg1', bounds: [0, 0, 600, 400] }], + frames: [{ kind: 'image', id: 'hero', bounds: [0, 0, 600, 400], href: 'file:Resources/hero.jpg' }], + }, + ], + extraFiles: { 'Resources/hero.jpg': heroBytes }, + }); + + const work = await fs.mkdtemp(path.join(os.tmpdir(), 'flavian-ingest-')); + const idmlPath = path.join(work, 'fixture.idml'); + const outDir = path.join(work, 'bundle'); + + try { + await fs.writeFile(idmlPath, idml); + + const summary = await runIngest({ input: idmlPath, outDir }); + assert.equal(summary.format, 'idml'); + assert.equal(summary.counts.assetsResolved, 1); + + // ir.json round-trips through the schema — it is the intermediate JSON + // the theme generator consumes. + const irJson = JSON.parse(await fs.readFile(path.join(outDir, 'ir.json'), 'utf8')); + const reparsed = Document.parse(irJson); + assert.equal(reparsed.spreads.length, 1); + + // The extracted image landed at the generator's staged filename. + const stagedRel = 'assets/spread-1-image-1.jpg'; + const staged = await fs.readFile(path.join(outDir, stagedRel)); + assert.deepEqual(new Uint8Array(staged), new TextEncoder().encode(heroBytes)); + + // Manifest written and consistent. + const manifest = JSON.parse(await fs.readFile(path.join(outDir, 'assets.manifest.json'), 'utf8')); + assert.equal(manifest.counts.assetsResolved, 1); + assert.equal(manifest.assets[0].relPath, stagedRel); + + // The payoff: feeding ir.json to the generator references the exact path + // the ingest stage staged — so `--asset-dir /assets` resolves it. + const generated = generateTheme(irJson); + const planned = generated.assets.find((a) => a.frameId === 'hero'); + assert.equal(planned.relPath, stagedRel); + } finally { + await fs.rm(work, { recursive: true, force: true }); + } +});