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]>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { runExportInWorker } = require('../../app/export-runner');
|
||||
const { buildRenderAst } = require('../../core/renderast');
|
||||
const { runExport } = require('../../exporters');
|
||||
const { buildFixtureGuide } = require('./fixture-guide');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
|
||||
test('export helper process produces the same result as an in-process export', async (t) => {
|
||||
const root = makeTmpDir('exportworker');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
const expected = runExport('json', buildRenderAst(store, guide.guideId), path.join(root, 'inproc'));
|
||||
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: guide.guideId,
|
||||
format: 'json',
|
||||
options: {},
|
||||
outDir: path.join(root, 'worker'),
|
||||
globals: {},
|
||||
});
|
||||
|
||||
assert.equal(result.imageCount, expected.imageCount);
|
||||
assert.ok(fs.existsSync(result.file));
|
||||
const fromWorker = JSON.parse(fs.readFileSync(result.file, 'utf8'));
|
||||
const fromInProcess = JSON.parse(fs.readFileSync(expected.file, 'utf8'));
|
||||
// Each build stamps its own generatedAt; everything else must match exactly.
|
||||
delete fromWorker.generatedAt;
|
||||
delete fromInProcess.generatedAt;
|
||||
assert.deepEqual(fromWorker, fromInProcess);
|
||||
});
|
||||
|
||||
test('export helper process rejects on an unknown format', async (t) => {
|
||||
const root = makeTmpDir('exportworkerbadfmt');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
await assert.rejects(
|
||||
runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: guide.guideId,
|
||||
format: 'exe',
|
||||
options: {},
|
||||
outDir: path.join(root, 'out'),
|
||||
globals: {},
|
||||
}),
|
||||
/unknown export format/,
|
||||
);
|
||||
});
|
||||
|
||||
test('export helper process rejects on an unknown guide id', async (t) => {
|
||||
const root = makeTmpDir('exportworkerbadguide');
|
||||
t.after(() => rmrf(root));
|
||||
const { store } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
await assert.rejects(
|
||||
runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId: 'guide_does_not_exist',
|
||||
format: 'json',
|
||||
options: {},
|
||||
outDir: path.join(root, 'out'),
|
||||
globals: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,10 @@ 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');
|
||||
@@ -25,6 +27,34 @@ 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();
|
||||
@@ -80,6 +110,82 @@ test('PDF renders under Ghostscript end-to-end', { skip: !hasTool('gs') }, (t) =
|
||||
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 });
|
||||
|
||||
@@ -44,6 +44,33 @@ test('render AST: numbering, placeholder expansion, hidden/skipped filtering', (
|
||||
assert.deepEqual([img.data[p], img.data[p + 1], img.data[p + 2]], [255, 0, 0]);
|
||||
});
|
||||
|
||||
test('render AST: guide metadata defaults to empty strings and expands placeholders', (t) => {
|
||||
const root = makeTmpDir('astmeta');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide: bare } = buildFixtureGuide(path.join(root, 'data'));
|
||||
|
||||
// Fixture guide has no metadata set: all fields default to ''.
|
||||
const noMeta = buildRenderAst(store, bare.guideId);
|
||||
assert.deepEqual(noMeta.guide.metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
// Set metadata with placeholders and re-check expansion against guide + global scope.
|
||||
const guide = store.getGuide(bare.guideId);
|
||||
guide.metadata = {
|
||||
author: '[[Author]]',
|
||||
coAuthors: 'Alex Lee, [[CoAuthor]]',
|
||||
organization: '[[Org]]',
|
||||
};
|
||||
store.saveGuide(guide);
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId, { globals: { CoAuthor: 'Sam Patel', Org: 'GlobalOrg' } });
|
||||
// Guide-level placeholder (Author -> Casey) wins over global; CoAuthor/Org fall back to globals.
|
||||
assert.deepEqual(ast.guide.metadata, {
|
||||
author: 'Casey',
|
||||
coAuthors: 'Alex Lee, Sam Patel',
|
||||
organization: 'GlobalOrg',
|
||||
});
|
||||
});
|
||||
|
||||
test('JSON export produces a parseable document with real image files', (t) => {
|
||||
const root = makeTmpDir('expjson');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
@@ -213,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
|
||||
assert.throws(() => store.guideDir('a/b'));
|
||||
assert.throws(() => store.stepDir('ok', '..'));
|
||||
});
|
||||
|
||||
test('guide metadata: optional fields default to empty strings and round-trip via save/load', (t) => {
|
||||
const root = makeTmpDir('metadata');
|
||||
t.after(() => rmrf(root));
|
||||
const store = new GuideStore(root);
|
||||
const guide = store.createGuide({ title: 'Metadata test' });
|
||||
assert.deepEqual(guide.metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
guide.metadata = { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' };
|
||||
store.saveGuide(guide);
|
||||
|
||||
const fresh = new GuideStore(root);
|
||||
const loaded = fresh.getGuide(guide.guideId);
|
||||
assert.deepEqual(loaded.metadata, { author: 'Jane Doe', coAuthors: 'Alex Lee, Sam Patel', organization: 'Acme Corp' });
|
||||
|
||||
// A guide saved before metadata existed normalizes to the same defaults.
|
||||
const legacy = fresh.getGuide(guide.guideId);
|
||||
delete legacy.metadata;
|
||||
store.saveGuide(legacy);
|
||||
assert.deepEqual(fresh.getGuide(guide.guideId).metadata, { author: '', coAuthors: '', organization: '' });
|
||||
|
||||
loaded.metadata = 'not an object';
|
||||
assert.throws(() => store.saveGuide(loaded), /metadata must be an object/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user