Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19

Merged
Tyler merged 16 commits from export_changes into main 2026-06-15 22:10:32 +00:00
13 changed files with 460 additions and 9 deletions
Showing only changes of commit 0b490e2aa9 - Show all commits
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const path = require('node:path');
const { fork } = require('node:child_process');
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
/**
* Run an export job in a forked helper process, so building the render AST
* and rendering the output (e.g. a multi-step PDF) never blocks the main
* process — and therefore never freezes the editor window — no matter how
* large the guide is.
*/
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
return new Promise((resolve, reject) => {
const worker = fork(WORKER_PATH, [], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
worker.removeAllListeners();
worker.kill();
fn(value);
};
worker.once('message', (msg) => {
if (msg && msg.ok) finish(resolve, msg.result);
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
});
worker.once('error', (err) => finish(reject, err));
worker.once('exit', (code) => {
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
});
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
});
}
module.exports = { runExportInWorker };
+9 -2
View File
@@ -14,6 +14,7 @@ const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates'); const { TemplateManager, FORMATS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast'); const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters'); const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { readLock } = require('../core/locks'); const { readLock } = require('../core/locks');
@@ -607,8 +608,14 @@ function setupIpc() {
dir = res.filePaths[0]; dir = res.filePaths[0];
} }
settings.set(`exports.lastOutputDirs.${format}`, dir); settings.set(`exports.lastOutputDirs.${format}`, dir);
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() }); const result = await runExportInWorker({
const result = runExport(format, ast, dir, options || {}); dataDir: store.root,
guideId,
format,
options: options || {},
outDir: dir,
globals: settings.getGlobalPlaceholders(),
});
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
return { ok: true, ...result }; return { ok: true, ...result };
}); });
+1
View File
@@ -317,6 +317,7 @@ class StepForgeApp {
const rect = e.target.getBoundingClientRect(); const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [ contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() }, { label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() }, { label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() }, { label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() }, { label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
+39
View File
@@ -639,6 +639,44 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
}); });
} }
/**
* Optional document metadata (author, co-authors, organization) shown at the
* top of the guide, below the title, and surfaced on the PDF cover page.
*/
function showGuideInfoDialog({ values = {}, onSave } = {}) {
return new Promise((resolve) => {
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
const { close } = openModal({
title: 'Guide information',
body: el('div', {},
el('div.muted', { style: { marginBottom: '10px' } },
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
labeledRow('Author', authorInput),
labeledRow('Co-authors', coAuthorsInput),
labeledRow('Organization', organizationInput),
),
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
onClick: async () => {
await onSave?.({
author: authorInput.value.trim(),
coAuthors: coAuthorsInput.value.trim(),
organization: organizationInput.value.trim(),
});
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
});
}
const SHORTCUTS = [ const SHORTCUTS = [
['Capture & steps', [ ['Capture & steps', [
['Ctrl+S', 'Save (writes linked archive when guide is linked)'], ['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
@@ -727,6 +765,7 @@ window.StepForgeDialogs = {
showInfoDialog, showInfoDialog,
showBackupsDialog, showBackupsDialog,
showPlaceholdersDialog, showPlaceholdersDialog,
showGuideInfoDialog,
showShortcutsDialog, showShortcutsDialog,
showTemplateManager, showTemplateManager,
showRecordingReminder, showRecordingReminder,
+12
View File
@@ -1571,6 +1571,18 @@ class GuideEditor {
}); });
} }
async openGuideInfo() {
if (!this.guide) return;
await dialogs.showGuideInfoDialog({
values: this.guide.metadata || {},
onSave: async (metadata) => {
this.guide.metadata = metadata;
await api.guide.save({ guide: this.guide });
this.onToast('Guide information saved.');
},
});
}
openShortcutsHelp() { openShortcutsHelp() {
dialogs.showShortcutsDialog(); dialogs.showShortcutsDialog();
} }
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const { GuideStore } = require('./store');
const { buildRenderAst } = require('./renderast');
const { runExport } = require('../exporters');
/**
* Entry point for the export helper process (forked via
* app/export-runner.js). Runs one export job building the render AST and
* invoking the exporter off the main process so a large guide's PDF/DOCX/
* etc. rendering never blocks the UI.
*/
process.on('message', (job) => {
let payload;
try {
const { dataDir, guideId, format, options, outDir, globals, maxSteps } = job;
const store = new GuideStore(dataDir);
const ast = buildRenderAst(store, guideId, { globals, maxSteps });
const result = runExport(format, ast, outDir, options || {});
payload = { ok: true, result };
} catch (err) {
payload = { ok: false, error: err && err.message ? err.message : String(err) };
}
process.send(payload, () => process.exit(payload.ok ? 0 : 1));
});
+5
View File
@@ -125,6 +125,11 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
createdAt: guide.createdAt, createdAt: guide.createdAt,
updatedAt: guide.updatedAt, updatedAt: guide.updatedAt,
flags: guide.flags, flags: guide.flags,
metadata: {
author: expand(guide.metadata?.author || ''),
coAuthors: expand(guide.metadata?.coAuthors || ''),
organization: expand(guide.metadata?.organization || ''),
},
}, },
steps: limited, steps: limited,
}; };
+7
View File
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
title: fields.title || 'Untitled guide', title: fields.title || 'Untitled guide',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''), descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
placeholders: { ...(fields.placeholders || {}) }, placeholders: { ...(fields.placeholders || {}) },
metadata: {
author: '',
coAuthors: '',
organization: '',
...(fields.metadata || {}),
},
flags: { flags: {
focusedViewDefault: false, focusedViewDefault: false,
hideSkippedStepsInExports: true, hideSkippedStepsInExports: true,
@@ -149,6 +155,7 @@ function validateGuide(guide) {
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array'); if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates'); else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object'); if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
if (guide.metadata && typeof guide.metadata !== 'object') errors.push('metadata must be an object');
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`); if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
return guide; return guide;
} }
+89 -7
View File
@@ -130,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const images = tpl.includeImages ? renderAllImages(ast) : new Map();
let y = M; let y = M;
// Never start a page break when already at the top of a page — an item
// taller than a full page (e.g. a step's combined head height) must
// still render and overflow naturally rather than push out a blank page.
const ensure = (needed) => { const ensure = (needed) => {
if (y + needed > size.height - M) { if (y > M && y + needed > size.height - M) {
pdf.addPage(); pdf.addPage();
y = M; y = M;
} }
@@ -169,14 +172,79 @@ function exportPdf(ast, outDir, template = {}) {
} }
}; };
/** Height a callout block (emitBlock) will occupy, including its trailing gap. */
const measureBlock = (tb) => {
const { height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { height: 0 };
return 16 + bodyH + 6;
};
/** Height an image will occupy on the page, including its trailing gap. */
const measureImage = (stepId) => {
const img = images.get(stepId);
if (!img) return 0;
const maxH = usableH * tpl.imageMaxHeightRatio;
let h = (img.height / img.width) * usableW;
if (h > maxH) h = maxH;
return h + 10;
};
const measureCode = (block) => {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
return lines.length * lineH + 16;
};
const measureTable = (block) => {
if (!block.rows || !block.rows.length) return 0;
return block.rows.length * 16 + 8;
};
/**
* Compute the vertical space a step will need: `head` is the title, the
* accent rule, any before-description blocks, the description, and the
* image kept together on one page and `total` adds the remaining
* blocks plus the trailing gap, used to decide whether the whole step
* fits on a fresh page.
*/
const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14;
const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`;
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const { before, rest } = stepContentGroups(step);
for (const tb of before) head += measureBlock(tb);
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId);
let restHeight = 0;
for (const block of rest) {
if (block.kind === 'text') restHeight += measureBlock(block);
else if (block.kind === 'code') restHeight += measureCode(block);
else if (block.kind === 'table') restHeight += measureTable(block);
}
return { head, total: head + restHeight + 10 };
};
pdf.addPage(); pdf.addPage();
if (tpl.includeCover) { if (tpl.includeCover) {
y = M + usableH * 0.18; y = M + usableH * 0.18;
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor }); writeLines(ast.guide.title, { size: 28, font: 'F2' });
y += 6; y += 10;
writeLines(ast.guide.title, { size: 26, font: 'F2' }); pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
y += 8; y += 16;
const meta = ast.guide.metadata || {};
const metaLines = [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
for (const line of metaLines) writeLines(line, { size: 11, color: [90, 90, 90] });
if (metaLines.length) y += 8;
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] }); if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14; y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
@@ -197,10 +265,23 @@ function exportPdf(ast, outDir, template = {}) {
} }
let first = true; let first = true;
let forcedFresh = false;
const pageBottom = size.height - M;
for (const step of ast.steps) { for (const step of ast.steps) {
if (step.forceNewPage && !first) { pdf.addPage(); y = M; } const { head: headHeight, total: totalHeight } = measureStep(step);
// Start a fresh page when: the step's "New page" toggle is set; the
// previous step overflowed past one page (so this step shouldn't share
// the spillover page); or this step doesn't fit in what's left of the
// current page. The last check is skipped when already at the top of a
// page, so an overlong step doesn't push out a blank page first.
const needsFreshPage = !first && (
step.forceNewPage || forcedFresh || (y > M && y + totalHeight > pageBottom)
);
if (needsFreshPage) { pdf.addPage(); y = M; }
first = false; first = false;
ensure(40); // Keep the title, accent rule, lead-in blocks, description, and image
// together — never split across a page boundary.
ensure(headHeight);
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
@@ -259,6 +340,7 @@ function exportPdf(ast, outDir, template = {}) {
} }
y += 10; y += 10;
forcedFresh = totalHeight > usableH;
} }
function emitBlock(tb) { function emitBlock(tb) {
+73
View File
@@ -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: {},
}),
);
});
+106
View File
@@ -4,8 +4,10 @@ const test = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const zlib = require('node:zlib');
const { execFileSync } = require('node:child_process'); const { execFileSync } = require('node:child_process');
const { GuideStore } = require('../../core/store');
const { buildRenderAst } = require('../../core/renderast'); const { buildRenderAst } = require('../../core/renderast');
const { exportPdf } = require('../../exporters/pdf'); const { exportPdf } = require('../../exporters/pdf');
const { exportGifGuide } = require('../../exporters/gif'); const { exportGifGuide } = require('../../exporters/gif');
@@ -25,6 +27,34 @@ function hasTool(cmd) {
try { execFileSync('which', [cmd], { stdio: 'pipe' }); return true; } catch { return false; } 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. */ /** Tiny XML well-formedness check: balanced tags, single root. */
function assertWellFormedXml(xml, label) { function assertWellFormedXml(xml, label) {
const body = xml.replace(/<\?xml[^?]*\?>/, '').trim(); 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}`)); 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) => { test('GIF export: title card + one frame per image step, valid animation', (t) => {
const { ast, root } = fixtureAst(t, 'gifx'); const { ast, root } = fixtureAst(t, 'gifx');
const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 }); const { file, frameCount } = exportGifGuide(ast, path.join(root, 'out'), { width: 320 });
+27
View File
@@ -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]); 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) => { test('JSON export produces a parseable document with real image files', (t) => {
const root = makeTmpDir('expjson'); const root = makeTmpDir('expjson');
t.after(() => rmrf(root)); t.after(() => rmrf(root));
+24
View File
@@ -213,3 +213,27 @@ test('guide ids are validated against path traversal', (t) => {
assert.throws(() => store.guideDir('a/b')); assert.throws(() => store.guideDir('a/b'));
assert.throws(() => store.stepDir('ok', '..')); 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/);
});