Files
StepForge/tests/unit/exporters-binary.test.js
T
TylerandClaude Sonnet 4.6 0b490e2aa9 Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process
Lets users record author/co-authors/organization for a guide via a new
"Guide information…" dialog; this metadata renders below the title on
the PDF cover (title now sits above the accent rule). PDF export also
paginates so each step fits its own page where possible, keeps a
step's title/image/lead-in together, and forces the next step onto a
fresh page after an oversized step overflows. Exports now run in a
forked helper process so large guides no longer freeze the UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 12:54:31 -05:00

349 lines
16 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const zlib = require('node:zlib');
const { execFileSync } = require('node:child_process');
const { GuideStore } = require('../../core/store');
const { buildRenderAst } = require('../../core/renderast');
const { exportPdf } = require('../../exporters/pdf');
const { exportGifGuide } = require('../../exporters/gif');
const { exportImageBundle } = require('../../exporters/image-bundle');
const { exportDocx } = require('../../exporters/docx');
const { exportPptx } = require('../../exporters/pptx');
const { TemplateManager } = require('../../core/templates');
const { runExport, EXPORTERS } = require('../../exporters');
const { unzipSync } = require('../../core/zip');
const { decodePng, encodePng } = require('../../core/png');
const raster = require('../../core/raster');
const { decodeGif } = require('./gifdecode');
const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers');
function hasTool(cmd) {
try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; }
}
/** Inflate each page's content stream, in page order, for op-level assertions. */
function pageContents(buf) {
const text = buf.toString('latin1');
const out = [];
const re = /\d+ 0 obj\n<< \/Filter \/FlateDecode \/Length (\d+) >>\nstream\n/g;
let m;
while ((m = re.exec(text)) !== null) {
const len = Number(m[1]);
const start = m.index + m[0].length;
out.push(zlib.inflateSync(buf.subarray(start, start + len)).toString('latin1'));
}
return out;
}
/** Map each step bookmark's title to the 0-based page index it lands on. */
function bookmarkPages(buf) {
const text = buf.toString('latin1');
const kids = [...text.match(/\/Type \/Pages \/Kids \[([^\]]+)\]/)[1].matchAll(/(\d+) 0 R/g)]
.map((m) => Number(m[1]));
const pageIndexOf = new Map(kids.map((id, i) => [id, i]));
const out = [];
for (const m of text.matchAll(/\/Title \(([^)]*)\)([^>]*)/g)) {
const dest = /\/Dest \[(\d+) 0 R/.exec(m[2]);
if (dest) out.push({ title: m[1], pageIndex: pageIndexOf.get(Number(dest[1])) });
}
return out;
}
/** Tiny XML well-formedness check: balanced tags, single root. */
function assertWellFormedXml(xml, label) {
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim();
const stack = [];
const re = /<(\/?)([A-Za-z][\w:.-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
let m;
let roots = 0;
while ((m = re.exec(body)) !== null) {
const [, closing, tag, , selfClose] = m;
if (closing) {
const open = stack.pop();
assert.equal(open, tag, `${label}: </${tag}> closes <${open}>`);
if (!stack.length) roots++;
} else if (!selfClose) {
stack.push(tag);
} else if (!stack.length) roots++;
}
assert.equal(stack.length, 0, `${label}: unclosed tags ${stack.join(',')}`);
assert.ok(roots >= 1, `${label}: no root element`);
}
function fixtureAst(t, label) {
const root = makeTmpDir(label);
t.after(() => rmrf(root));
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
return { ast: buildRenderAst(store, guide.guideId), root, store, guide };
}
test('PDF export: valid document, bookmarks per step, images embedded', (t) => {
const { ast, root } = fixtureAst(t, 'pdfx');
const out = path.join(root, 'out');
const { file, pageCount, imageCount } = exportPdf(ast, out);
assert.equal(imageCount, 2);
assert.ok(pageCount >= 3, 'cover + toc + content');
const text = fs.readFileSync(file).toString('latin1');
assert.ok(text.startsWith('%PDF-1.4'));
// One outline item per step.
const outlineTitles = [...text.matchAll(/\/Title \(([^)]*)\)/g)].map((m) => m[1]);
assert.deepEqual(outlineTitles, [
'1. Open AcmeSync settings',
'1.1. Verify the gear icon is visible',
'2. Enable nightly backups',
]);
// Two image XObjects.
assert.equal((text.match(/\/Subtype \/Image/g) || []).length, 2);
});
test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) => {
const { ast, root } = fixtureAst(t, 'pdfgs');
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'));
const out = execFileSync('gs', ['-dBATCH', '-dNOPAUSE', '-sDEVICE=nullpage', file], { stdio: 'pipe' }).toString();
assert.match(out, new RegExp(`Processing pages 1 through ${pageCount}`));
});
test('PDF cover: title in big text above the accent rule, guide metadata below it', (t) => {
const root = makeTmpDir('pdfcover');
t.after(() => rmrf(root));
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
// No metadata set: cover has no Author/Co-authors/Organization lines.
const astNoMeta = buildRenderAst(store, bare.guideId);
const { file: fileNoMeta } = exportPdf(astNoMeta, path.join(root, 'out1'));
const coverNoMeta = pageContents(fs.readFileSync(fileNoMeta))[0];
assert.ok(!coverNoMeta.includes('Author:'));
assert.ok(!coverNoMeta.includes('Co-authors:'));
assert.ok(!coverNoMeta.includes('Organization:'));
// Set guide metadata, then re-export.
const guide = store.getGuide(bare.guideId);
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee', organization: 'Acme Corp' };
store.saveGuide(guide);
const ast = buildRenderAst(store, guide.guideId);
const { file } = exportPdf(ast, path.join(root, 'out2'));
const cover = pageContents(fs.readFileSync(file))[0];
assert.ok(cover.includes('Author: Jane Doe'));
assert.ok(cover.includes('Co-authors: Alex Lee'));
assert.ok(cover.includes('Organization: Acme Corp'));
// Title (28pt, F2) sits above the accent rule (a 3pt-tall filled rect),
// which sits above the metadata lines (11pt, F1). PDF y increases
// upward, so items higher on the page have larger y values.
const titleY = Number(/\/F2 28 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Configure AcmeSync backups\) Tj/.exec(cover)[1]);
const ruleY = Number(/[\d.]+ [\d.]+ [\d.]+ rg ([\d.]+) ([\d.]+) [\d.]+ 3\.00 re f/.exec(cover)[2]);
const authorY = Number(/\/F1 11 Tf [\d.]+ [\d.]+ [\d.]+ rg 1 0 0 1 [\d.]+ ([\d.]+) Tm \(Author: Jane Doe\) Tj/.exec(cover)[1]);
assert.ok(titleY > ruleY, 'title sits above the accent rule');
assert.ok(ruleY > authorY, 'metadata sits below the accent rule');
});
test('PDF pagination: short steps pack onto a page; a step that does not fit moves to a fresh page', (t) => {
const root = makeTmpDir('pdfpage');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Doesn't fit in what's left of page 1, but fits comfortably on its own
// page — with enough room left for step D to pack onto that same page.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(95) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 1]);
assert.equal(pageCount, 2);
});
test('PDF pagination: a step longer than one page starts fresh and overflows; the next step starts on a new page', (t) => {
const root = makeTmpDir('pdfpage2');
t.after(() => rmrf(root));
const store = new GuideStore(path.join(root, 'data'));
const guide = store.createGuide({ title: 'Pagination overflow test' });
const filler = (n) => `<p>${'Lorem ipsum dolor sit amet consectetur. '.repeat(n)}</p>`;
store.addStep(guide.guideId, { kind: 'empty', title: 'Step A', descriptionHtml: '<p>Short content A.</p>' });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step B', descriptionHtml: '<p>Short content B.</p>' });
// Longer than a full page: starts on its own page and overflows onto the next.
store.addStep(guide.guideId, { kind: 'empty', title: 'Step C', descriptionHtml: filler(120) });
store.addStep(guide.guideId, { kind: 'empty', title: 'Step D', descriptionHtml: '<p>Short content D.</p>' });
const ast = buildRenderAst(store, guide.guideId);
const { file, pageCount } = exportPdf(ast, path.join(root, 'out'), { includeCover: false, includeToc: false });
const pages = bookmarkPages(fs.readFileSync(file));
// A and B pack onto page 0; C starts fresh on page 1 and overflows onto
// page 2; D is forced onto a fresh page 3 rather than sharing C's spillover.
assert.deepEqual(pages.map((p) => p.pageIndex), [0, 0, 1, 3]);
assert.equal(pageCount, 4);
});
test('GIF export: title card + one frame per image step, valid animation', (t) => {
const { ast, root } = fixtureAst(t, 'gifx');
const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 });
const gif = decodeGif(fs.readFileSync(file));
assert.equal(frameCount, 3, 'title card + 2 image steps');
assert.equal(gif.frames.length, 3);
assert.equal(gif.width, 320);
assert.equal(gif.loops, 0);
for (const frame of gif.frames) {
assert.equal(frame.indices.length, gif.width * gif.height, 'frame fully decodes');
}
});
test('GIF export honors template options (no title card/overlay/progress)', (t) => {
const { ast, root } = fixtureAst(t, 'gifopt');
const { file, frameCount, height } = exportGifGuide(ast, path.join(root, 'out'), {
width: 320, titleCard: false, titleOverlay: false, progressBar: false,
});
assert.equal(frameCount, 2);
// Without header/footer the frame height equals the scaled screenshot height.
assert.equal(height, Math.round(320 * (200 / 320)));
assert.equal(decodeGif(fs.readFileSync(file)).frames.length, 2);
});
test('image bundle: annotated PNGs + metadata that references them', (t) => {
const { ast, root } = fixtureAst(t, 'bundle');
const out = path.join(root, 'out');
const { file, imageCount } = exportImageBundle(ast, out);
assert.equal(imageCount, 2);
const meta = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(meta.steps.length, 3);
for (const step of meta.steps) {
if (step.image) {
const img = decodePng(fs.readFileSync(path.join(out, step.image)));
assert.equal(img.width, 320);
}
}
// The empty substep has no image entry.
assert.equal(meta.steps.filter((s) => s.image).length, 2);
});
test('image bundle watermark is composited into the output pixels', (t) => {
const { ast, root } = fixtureAst(t, 'wm');
const out = path.join(root, 'out');
// Solid magenta watermark; bottom-right corner must turn magenta-ish.
const mark = raster.createImage(24, 24, [255, 0, 255, 255]);
const markFile = path.join(root, 'mark.png');
fs.writeFileSync(markFile, encodePng(mark));
exportImageBundle(ast, out, { watermarkPath: markFile, watermarkOpacity: 1 });
const meta = JSON.parse(fs.readFileSync(path.join(out, 'configure-acmesync-backups-bundle.json'), 'utf8'));
const imgPath = meta.steps.find((s) => s.image).image;
const img = decodePng(fs.readFileSync(path.join(out, imgPath)));
const p = ((img.height - 24) * img.width + (img.width - 24)) * 4;
assert.ok(img.data[p] > 200 && img.data[p + 2] > 200 && img.data[p + 1] < 60,
'watermark pixels present in bottom-right corner');
});
test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', (t) => {
const { ast, root } = fixtureAst(t, 'docx');
const { file, imageCount } = exportDocx(ast, path.join(root, 'out'));
assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels']) {
assert.ok(entries.has(required), `missing ${required}`);
}
assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml');
assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types');
// Every relationship target exists in the package, every embed has a rel.
const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8');
const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]);
assert.equal(relTargets.length, 2);
for (const target of relTargets) {
assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`);
const img = decodePng(entries.get(`word/${target}`));
assert.equal(img.width, 320);
}
const docXml = entries.get('word/document.xml').toString('utf8');
const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]);
const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]);
assert.deepEqual(embeds.sort(), relIds.sort());
// unzip CLI also accepts the package (it is a plain zip).
assert.ok(entries.size >= 6);
});
test('PPTX export: slides per step, master/layout/theme present, rels resolve', (t) => {
const { ast, root } = fixtureAst(t, 'pptx');
const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out'));
assert.equal(slideCount, 4, 'title slide + 3 steps');
assert.equal(imageCount, 2);
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
for (const required of [
'[Content_Types].xml', '_rels/.rels', 'ppt/presentation.xml',
'ppt/_rels/presentation.xml.rels', 'ppt/slideMasters/slideMaster1.xml',
'ppt/slideLayouts/slideLayout1.xml', 'ppt/theme/theme1.xml',
]) {
assert.ok(entries.has(required), `missing ${required}`);
}
for (let i = 1; i <= slideCount; i++) {
const xml = entries.get(`ppt/slides/slide${i}.xml`);
assert.ok(xml, `slide${i}.xml present`);
assertWellFormedXml(xml.toString('utf8'), `slide${i}`);
}
// presentation.xml references each slide and the count matches.
const pres = entries.get('ppt/presentation.xml').toString('utf8');
assert.equal((pres.match(/<p:sldId /g) || []).length, slideCount);
// image rels on slides resolve to media files.
for (let i = 1; i <= slideCount; i++) {
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
for (const m of rels.matchAll(/Target="\.\.\/media\/([^"]+)"/g)) {
assert.ok(entries.has(`ppt/media/${m[1]}`), `media ${m[1]} present`);
}
}
});
test('template manager: save/load/rename/duplicate/delete and .sfglt round-trip', (t) => {
const root = makeTmpDir('tpl');
t.after(() => rmrf(root));
const tm = new TemplateManager(path.join(root, 'templates'));
tm.save('pdf', 'compact', { includeCover: false, margin: 24 });
assert.deepEqual(tm.list('pdf'), ['compact']);
assert.deepEqual(tm.load('pdf', 'compact'), { includeCover: false, margin: 24 });
tm.duplicate('pdf', 'compact');
tm.rename('pdf', 'compact copy', 'tight');
assert.deepEqual(tm.list('pdf'), ['compact', 'tight']);
// Share as .sfglt and import into a fresh manager.
const shared = path.join(root, 'tight.sfglt');
tm.exportTemplate('pdf', 'tight', shared);
const tm2 = new TemplateManager(path.join(root, 'templates2'));
const imported = tm2.importTemplate(shared);
assert.equal(imported.format, 'pdf');
assert.deepEqual(tm2.load('pdf', imported.name), { includeCover: false, margin: 24 });
tm.remove('pdf', 'compact');
assert.deepEqual(tm.list('pdf'), ['tight']);
assert.throws(() => tm.save('pdf', '../evil', {}));
assert.throws(() => tm.list('exe'));
});
test('a saved template changes exporter behavior through runExport', (t) => {
const { ast, root } = fixtureAst(t, 'tplrun');
const tm = new TemplateManager(path.join(root, 'templates'));
tm.save('pdf', 'no-cover', { includeCover: false, includeToc: false });
const withDefaults = runExport('pdf', ast, path.join(root, 'out1'));
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired');
assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
});