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,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
@@ -14,6 +14,7 @@ const { SearchIndex } = require('../core/search');
|
||||
const { TemplateManager, FORMATS } = require('../core/templates');
|
||||
const { buildRenderAst } = require('../core/renderast');
|
||||
const { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
@@ -607,8 +608,14 @@ function setupIpc() {
|
||||
dir = res.filePaths[0];
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
|
||||
const result = runExport(format, ast, dir, options || {});
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId,
|
||||
format,
|
||||
options: options || {},
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
});
|
||||
|
||||
@@ -317,6 +317,7 @@ class StepForgeApp {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
contextMenu(rect.left, rect.bottom + 4, [
|
||||
{ label: 'Rename guide…', action: () => this.renameGuide() },
|
||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||
|
||||
@@ -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 = [
|
||||
['Capture & steps', [
|
||||
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
|
||||
@@ -727,6 +765,7 @@ window.StepForgeDialogs = {
|
||||
showInfoDialog,
|
||||
showBackupsDialog,
|
||||
showPlaceholdersDialog,
|
||||
showGuideInfoDialog,
|
||||
showShortcutsDialog,
|
||||
showTemplateManager,
|
||||
showRecordingReminder,
|
||||
|
||||
@@ -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() {
|
||||
dialogs.showShortcutsDialog();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -125,6 +125,11 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
createdAt: guide.createdAt,
|
||||
updatedAt: guide.updatedAt,
|
||||
flags: guide.flags,
|
||||
metadata: {
|
||||
author: expand(guide.metadata?.author || ''),
|
||||
coAuthors: expand(guide.metadata?.coAuthors || ''),
|
||||
organization: expand(guide.metadata?.organization || ''),
|
||||
},
|
||||
},
|
||||
steps: limited,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
|
||||
title: fields.title || 'Untitled guide',
|
||||
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
|
||||
placeholders: { ...(fields.placeholders || {}) },
|
||||
metadata: {
|
||||
author: '',
|
||||
coAuthors: '',
|
||||
organization: '',
|
||||
...(fields.metadata || {}),
|
||||
},
|
||||
flags: {
|
||||
focusedViewDefault: false,
|
||||
hideSkippedStepsInExports: true,
|
||||
@@ -149,6 +155,7 @@ function validateGuide(guide) {
|
||||
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');
|
||||
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('; ')}`);
|
||||
return guide;
|
||||
}
|
||||
|
||||
+89
-7
@@ -130,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
|
||||
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) => {
|
||||
if (y + needed > size.height - M) {
|
||||
if (y > M && y + needed > size.height - M) {
|
||||
pdf.addPage();
|
||||
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();
|
||||
|
||||
if (tpl.includeCover) {
|
||||
y = M + usableH * 0.18;
|
||||
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor });
|
||||
y += 6;
|
||||
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
||||
y += 8;
|
||||
writeLines(ast.guide.title, { size: 28, font: 'F2' });
|
||||
y += 10;
|
||||
pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
|
||||
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] });
|
||||
y += 14;
|
||||
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 forcedFresh = false;
|
||||
const pageBottom = size.height - M;
|
||||
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;
|
||||
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'}`);
|
||||
const headSize = step.depth > 0 ? 12 : 14;
|
||||
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;
|
||||
forcedFresh = totalHeight > usableH;
|
||||
}
|
||||
|
||||
function emitBlock(tb) {
|
||||
|
||||
@@ -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