diff --git a/bin/flavian.mjs b/bin/flavian.mjs index ba3cbbb..76c5375 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 3fbf818..1a2180b 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/package.json b/packages/pipeline/package.json index f37333f..51eb64f 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -12,11 +12,13 @@ "flavian-ingest": "./bin/ingest.mjs", "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 ba0e963..4eeab9d 100644 --- a/packages/pipeline/src/indesign/index.js +++ b/packages/pipeline/src/indesign/index.js @@ -6,3 +6,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/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); + } +});