Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19
@@ -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 { 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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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 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 });
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user