jacobinwwey_obsidian-NotEMD/scripts/verify-slidev-export-workflow.cjs
2026-07-04 13:57:21 +08:00

1649 lines
63 KiB
JavaScript

#!/usr/bin/env node
/**
* Maintainer verification for the real NoteMD Slidev export workflow.
*
* This script exercises the production source-preparation and Slidev export
* modules without starting Obsidian. It intentionally writes the same vault
* artifacts the UI writes so maintainers can inspect the generated deck and
* standalone HTML after the command completes.
*/
const childProcess = require('child_process');
const fs = require('fs');
const path = require('path');
const esbuild = require('esbuild');
const { buildPptxVisualDiff, extractPptxBackgroundImages } = require('./lib/pptx-visual-diff');
const DEFAULT_PPTX_VISUAL_THRESHOLDS = {
maxRmse: 0.12,
meanRmse: 0.08,
};
const VISIBLE_NATIVE_RENDERED_HTML_PPTX_VISUAL_THRESHOLDS = {
maxRmse: 0.25,
meanRmse: 0.145,
};
const LAYOUT_AUDIT_CONFIG = {
overflowTolerancePx: 6,
minReadableScale: 0.28,
maxAutoPatchPasses: 6,
minEffectiveFontPx: 10,
minSvgTextFontPx: 9,
minTableBodyFontPx: 10,
minCodeFontPx: 10,
minQualityMarginPx: 18,
minContentAreaRatio: 0.18,
lowContentUtilizationScaleThreshold: 0.55,
mermaidLowZoomReviewScale: 0.72,
};
function parseArgs(argv) {
const args = {
vault: 'docs',
source: 'architecture.zh-CN.md',
format: 'html',
htmlMode: 'standalone',
outputSubfolder: 'export',
theme: 'default',
timeoutMs: 180000,
requireNativeStandalone: false,
playwright: true,
screenshots: true,
sampleSlides: null,
pptxVisualDiff: false,
requirePptxVisualMatch: false,
pptxVisualMaxRmse: DEFAULT_PPTX_VISUAL_THRESHOLDS.maxRmse,
pptxVisualMeanRmse: DEFAULT_PPTX_VISUAL_THRESHOLDS.meanRmse,
pptxVisualMaxRmseExplicit: false,
pptxVisualMeanRmseExplicit: false,
pptxVisualDpi: 150,
pptxVisualRenderer: 'auto',
pptxVisualDiffDir: null,
pptxVisualReferenceDir: null,
pptxRenderedHtmlReferenceDiff: false,
requirePptxRenderedHtmlReferenceMatch: false,
pptxVisibleNativeExperiment: false,
requirePptxVisibleNativeMatch: false,
json: false,
};
for (let index = 0; index < argv.length; index++) {
const arg = argv[index];
if (arg === '--vault' && argv[index + 1]) {
args.vault = argv[++index];
} else if (arg === '--source' && argv[index + 1]) {
args.source = argv[++index];
} else if (arg === '--format' && argv[index + 1]) {
args.format = argv[++index];
} else if (arg === '--html-mode' && argv[index + 1]) {
args.htmlMode = argv[++index];
} else if (arg === '--output-subfolder' && argv[index + 1]) {
args.outputSubfolder = argv[++index];
} else if (arg === '--theme' && argv[index + 1]) {
args.theme = argv[++index];
} else if (arg === '--timeout-ms' && argv[index + 1]) {
args.timeoutMs = Number(argv[++index]);
} else if (arg === '--require-native-standalone') {
args.requireNativeStandalone = true;
} else if (arg === '--sample-slides' && argv[index + 1]) {
const rawValue = argv[++index].trim().toLowerCase();
args.sampleSlides = rawValue === 'all'
? []
: rawValue.split(',').map(value => Number(value.trim())).filter(Number.isFinite);
} else if (arg === '--no-playwright') {
args.playwright = false;
} else if (arg === '--no-screenshots') {
args.screenshots = false;
} else if (arg === '--pptx-visual-diff') {
args.pptxVisualDiff = true;
} else if (arg === '--require-pptx-visual-match') {
args.requirePptxVisualMatch = true;
args.pptxVisualDiff = true;
} else if (arg === '--pptx-visual-max-rmse' && argv[index + 1]) {
args.pptxVisualMaxRmse = Number(argv[++index]);
args.pptxVisualMaxRmseExplicit = true;
} else if (arg === '--pptx-visual-mean-rmse' && argv[index + 1]) {
args.pptxVisualMeanRmse = Number(argv[++index]);
args.pptxVisualMeanRmseExplicit = true;
} else if (arg === '--pptx-visual-dpi' && argv[index + 1]) {
args.pptxVisualDpi = Number(argv[++index]);
} else if (arg === '--pptx-visual-renderer' && argv[index + 1]) {
args.pptxVisualRenderer = String(argv[++index]).toLowerCase();
} else if (arg === '--pptx-visual-diff-dir' && argv[index + 1]) {
args.pptxVisualDiffDir = argv[++index];
} else if (arg === '--pptx-visual-reference-dir' && argv[index + 1]) {
args.pptxVisualReferenceDir = argv[++index];
args.pptxVisualDiff = true;
} else if (arg === '--pptx-rendered-html-reference-diff') {
args.pptxRenderedHtmlReferenceDiff = true;
} else if (arg === '--require-pptx-rendered-html-reference-match') {
args.requirePptxRenderedHtmlReferenceMatch = true;
args.pptxRenderedHtmlReferenceDiff = true;
} else if (arg === '--pptx-visible-native-experiment') {
args.pptxVisibleNativeExperiment = true;
} else if (arg === '--require-pptx-visible-native-match') {
args.requirePptxVisibleNativeMatch = true;
args.pptxVisibleNativeExperiment = true;
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--help' || arg === '-h') {
printHelp();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!['html', 'pdf', 'png', 'pptx', 'mp4'].includes(args.format)) {
throw new Error(`Unsupported --format ${args.format}`);
}
if (!['standalone', 'server-script'].includes(args.htmlMode)) {
throw new Error(`Unsupported --html-mode ${args.htmlMode}`);
}
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
if (args.requireNativeStandalone && (args.format !== 'html' || args.htmlMode !== 'standalone')) {
throw new Error('--require-native-standalone requires --format html --html-mode standalone');
}
if (args.pptxVisualDiff && args.format !== 'pptx') {
throw new Error('--pptx-visual-diff requires --format pptx');
}
if (args.pptxVisualReferenceDir && args.format !== 'pptx') {
throw new Error('--pptx-visual-reference-dir requires --format pptx');
}
if (args.pptxRenderedHtmlReferenceDiff && args.format !== 'pptx') {
throw new Error('--pptx-rendered-html-reference-diff requires --format pptx');
}
if (args.pptxVisibleNativeExperiment && args.format !== 'pptx') {
throw new Error('--pptx-visible-native-experiment requires --format pptx');
}
if (!Number.isFinite(args.pptxVisualMaxRmse) || args.pptxVisualMaxRmse < 0) {
throw new Error('--pptx-visual-max-rmse must be a non-negative number');
}
if (!Number.isFinite(args.pptxVisualMeanRmse) || args.pptxVisualMeanRmse < 0) {
throw new Error('--pptx-visual-mean-rmse must be a non-negative number');
}
if (!Number.isFinite(args.pptxVisualDpi) || args.pptxVisualDpi <= 0) {
throw new Error('--pptx-visual-dpi must be a positive number');
}
if (!['auto', 'libreoffice', 'powerpoint'].includes(args.pptxVisualRenderer)) {
throw new Error('--pptx-visual-renderer must be one of: auto, libreoffice, powerpoint');
}
return args;
}
function printHelp() {
console.log([
'Usage: node scripts/verify-slidev-export-workflow.cjs [options]',
'',
'Options:',
' --vault <path> Vault root, default: docs',
' --source <path> Vault-relative source Markdown, default: architecture.zh-CN.md',
' --format <html|pdf|png|pptx|mp4> Export format, default: html',
' --html-mode <standalone|server-script> HTML mode, default: standalone',
' --output-subfolder <path> Vault-relative output folder, default: export',
' --theme <name> Slidev theme, default: default',
' --require-native-standalone Fail if HTML export falls back from native standalone',
' --sample-slides <list|all> Comma-separated slide numbers for Playwright, default: all slides',
' --no-playwright Skip browser rendering checks',
' --no-screenshots Do not write Playwright screenshots',
' --pptx-visual-diff Render PPTX back to PNG and compare each page with the PPTX frozen background reference',
' For visible-native PPTX defaults, this is a diagnostic reference, not the hard visual gate',
' --require-pptx-visual-match Fail when PPTX/PNG visual diff exceeds thresholds; visible-native defaults auto-gate against rendered HTML',
' --pptx-visual-max-rmse <n> Max per-slide normalized RMSE, default: 0.12 raster / 0.25 visible-native rendered HTML',
' --pptx-visual-mean-rmse <n> Mean normalized RMSE, default: 0.08 raster / 0.145 visible-native rendered HTML',
' --pptx-visual-dpi <n> PPTX render-back DPI for the LibreOffice/PDF path, default: 150',
' --pptx-visual-renderer <auto|libreoffice|powerpoint> Render-back engine, default: auto',
' --pptx-visual-diff-dir <path> Output directory for visual diff artifacts',
' --pptx-visual-reference-dir <path> External PNG sequence directory for advisory cross-export comparison',
' --pptx-rendered-html-reference-diff Capture PNG references from the same rendered HTML used by PPTX and compare against PPTX render-back',
' --require-pptx-rendered-html-reference-match Fail when rendered-HTML reference diff exceeds PPTX visual thresholds',
' --pptx-visible-native-experiment Also export experimental visible-native PPTX and compare it against the default frozen-background reference',
' --require-pptx-visible-native-match Fail when the visible-native experiment exceeds PPTX visual thresholds',
' --json Print only the final JSON report',
].join('\n'));
}
async function bundleSlideExportModules() {
const result = await esbuild.build({
stdin: {
contents: [
"export { probeEnvironment } from './src/slideExport/environmentProber';",
"export { prepareSlidevExportSource } from './src/slideExport/slidevSourcePreparer';",
"export { exportSlidevHtml, exportSlidevHtmlWithOutcome, exportSlidevPdf, exportSlidevPng } from './src/slideExport/slidevExporter';",
"export { exportSlidevPptxFromHtml, exportSlidevPptxRenderedHtmlReferencePngSequence, exportSlidevVisibleNativePptxExperimentFromHtml } from './src/slideExport/pptxExporter';",
"export { convergeSlidevDeckLayout } from './src/slideExport/slidevLayoutWorkflow';",
"export { exportVideoMp4 } from './src/slideExport/videoExporter';",
"export { analyzeRenderedSlideMeasurement, summarizeLayoutAudits, patchDeckWithLayoutAudit, countSlideDeckSlides } from './src/slideExport/slidevLayoutAudit';",
"export { resolveWorkspaceHomeCandidates } from './src/slideExport/platformUtils';",
"export { startLocalServer, stopLocalServer } from './src/slideExport/localServer';",
].join('\n'),
resolveDir: process.cwd(),
sourcefile: 'notemd-slidev-workflow-entry.ts',
loader: 'ts',
},
bundle: true,
platform: 'node',
format: 'cjs',
write: false,
logLevel: 'silent',
plugins: [obsidianShimPlugin()],
});
const code = result.outputFiles[0].text;
const bundledModule = { exports: {} };
const evaluate = new Function('require', 'module', 'exports', code);
evaluate(require, bundledModule, bundledModule.exports);
return bundledModule.exports;
}
function obsidianShimPlugin() {
return {
name: 'notemd-obsidian-shim',
setup(build) {
build.onResolve({ filter: /^obsidian$/ }, () => ({
path: 'obsidian-shim',
namespace: 'notemd-obsidian-shim',
}));
build.onLoad({ filter: /.*/, namespace: 'notemd-obsidian-shim' }, () => ({
loader: 'js',
contents: [
'export const Platform = { isDesktopApp: true };',
'export class Notice { constructor(message) { this.message = message; } }',
'export class Modal { constructor(app) { this.app = app; this.contentEl = { empty() {}, createEl() { return this; }, createDiv() { return this; }, appendChild() {}, addClass() {}, setText() {} }; } open() {} close() {} }',
'export class Setting { constructor() {} setName() { return this; } setDesc() { return this; } addButton() { return this; } addText() { return this; } addDropdown() { return this; } addToggle() { return this; } }',
'export class Plugin {}',
'export class PluginSettingTab {}',
'export class ItemView {}',
'export class TFile {}',
'export class TFolder {}',
'export class MarkdownView {}',
'export class WorkspaceLeaf {}',
'export class ButtonComponent {}',
'export class TextAreaComponent {}',
'export const requestUrl = async () => { throw new Error("requestUrl unavailable in Slidev export workflow verification"); };',
'export const getLanguage = () => "en";',
'export const loadPdfJs = async () => null;',
].join('\n'),
}));
},
};
}
function createApp(vaultRoot) {
const adapter = {
basePath: vaultRoot,
getBasePath: () => vaultRoot,
exists: async vaultPath => fs.existsSync(path.join(vaultRoot, vaultPath)),
mkdir: async vaultPath => fs.mkdirSync(path.join(vaultRoot, vaultPath), { recursive: true }),
read: async vaultPath => fs.readFileSync(path.join(vaultRoot, vaultPath), 'utf8'),
list: async vaultPath => {
const absolutePath = path.join(vaultRoot, vaultPath);
const entries = fs.existsSync(absolutePath) ? fs.readdirSync(absolutePath, { withFileTypes: true }) : [];
return {
files: entries.filter(entry => entry.isFile()).map(entry => path.join(vaultPath, entry.name).replace(/\\/g, '/')),
folders: entries.filter(entry => entry.isDirectory()).map(entry => path.join(vaultPath, entry.name).replace(/\\/g, '/')),
};
},
stat: async vaultPath => {
const absolutePath = path.join(vaultRoot, vaultPath);
if (!fs.existsSync(absolutePath)) {
return null;
}
return fs.statSync(absolutePath);
},
write: async (vaultPath, content) => {
const absolutePath = path.join(vaultRoot, vaultPath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, content, 'utf8');
},
};
return {
vault: {
adapter,
read: async file => fs.readFileSync(path.join(vaultRoot, file.path), 'utf8'),
},
};
}
function createSourceFile(vaultRoot, sourcePath) {
const normalizedSource = sourcePath.replace(/\\/g, '/').replace(/^\/+/, '');
const absolutePath = path.join(vaultRoot, normalizedSource);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Source file does not exist: ${absolutePath}`);
}
const extension = path.extname(normalizedSource);
return {
path: normalizedSource,
name: path.basename(normalizedSource),
basename: path.basename(normalizedSource, extension),
extension: extension.replace(/^\./, ''),
};
}
function createConfig(args) {
return {
format: args.format,
withClicks: false,
outputSubfolder: args.outputSubfolder,
ffmpegFps: 1,
ffmpegCrf: 23,
slidevTheme: args.theme,
timeoutMs: args.timeoutMs,
htmlMode: args.htmlMode,
imageScale: 3,
};
}
function collectDeckSummary(deckPath) {
if (!deckPath || !fs.existsSync(deckPath)) {
return null;
}
const deck = fs.readFileSync(deckPath, 'utf8');
const mermaidFences = extractMermaidFenceBlocks(deck);
return {
path: deckPath,
bytes: fs.statSync(deckPath).size,
theme: (deck.match(/^theme:\s*(.+)$/m) || [])[1] || null,
mermaidBlocks: mermaidFences.length,
zoomLines: [...deck.matchAll(/^zoom:\s*(.+)$/gm)].map(match => match[1]),
containsKnownStaleText: deck.includes('快速定位'),
containsMissingTheme: deck.includes('seriph'),
};
}
function collectMermaidSourcePreservation(sourcePath, deckPath) {
if (!sourcePath || !deckPath || !fs.existsSync(sourcePath) || !fs.existsSync(deckPath)) {
return null;
}
const sourceFences = extractMermaidFenceBlocks(fs.readFileSync(sourcePath, 'utf8'));
const deckFences = extractMermaidFenceBlocks(fs.readFileSync(deckPath, 'utf8'));
const changedFenceIndexes = [];
const comparedCount = Math.max(sourceFences.length, deckFences.length);
for (let index = 0; index < comparedCount; index++) {
if (sourceFences[index] !== deckFences[index]) {
changedFenceIndexes.push(index + 1);
}
}
return {
required: sourceFences.length > 0,
passed: changedFenceIndexes.length === 0,
sourceFenceCount: sourceFences.length,
deckFenceCount: deckFences.length,
changedFenceIndexes,
};
}
function buildTableBodyLayoutGate(layoutAudits) {
const tableBodyAudits = layoutAudits.filter(audit => hasTableOrBodyTextSurface(audit));
const failures = [];
for (const audit of tableBodyAudits) {
for (const finding of audit.findings || []) {
if (!isTableBodyLayoutFinding(audit, finding)) {
continue;
}
failures.push({
slide: audit.slide,
kind: finding.kind,
target: finding.target,
message: finding.message,
recommendedPatch: finding.recommendedPatch || null,
overflowAxis: finding.overflowAxis || null,
effectiveFontPx: typeof finding.effectiveFontPx === 'number' ? finding.effectiveFontPx : null,
fontThresholdPx: typeof finding.fontThresholdPx === 'number' ? finding.fontThresholdPx : null,
});
}
}
return {
passed: failures.length === 0,
auditedSlideCount: tableBodyAudits.length,
tableSlideCount: tableBodyAudits.filter(audit => (audit.elementKinds || []).includes('table')).length,
bodyTextSlideCount: tableBodyAudits.filter(audit => (audit.elementKinds || []).includes('text')).length,
failureCount: failures.length,
failureSlides: Array.from(new Set(failures.map(failure => failure.slide))).sort((left, right) => left - right),
failures,
};
}
function buildRenderedLayoutGate(options) {
const required = options?.required === true;
const auditedSlides = Array.isArray(options?.auditedSlides) ? options.auditedSlides : [];
const layoutAudits = Array.isArray(options?.layoutAudits) ? options.layoutAudits : [];
const playwrightChecks = Array.isArray(options?.playwrightChecks) ? options.playwrightChecks : [];
const auditSkippedReason = options?.auditSkippedReason || null;
const failures = [];
if (required && auditSkippedReason) {
failures.push({
kind: 'audit-skipped',
message: auditSkippedReason,
});
}
if (required && !auditSkippedReason && (auditedSlides.length === 0 || layoutAudits.length === 0)) {
failures.push({
kind: 'audit-empty',
message: 'Strict standalone verification requires rendered Playwright layout audits.',
});
}
for (const check of playwrightChecks) {
if (!check?.failed) {
continue;
}
failures.push({
kind: 'playwright-check-failed',
slide: check.slide,
message: (check.errors || []).join('; ') || 'Rendered slide check failed.',
});
}
for (const audit of layoutAudits) {
for (const finding of audit.findings || []) {
failures.push({
kind: finding.kind || 'layout-finding',
slide: audit.slide,
target: finding.target || null,
message: finding.message || 'Rendered layout audit reported a finding.',
recommendedPatch: finding.recommendedPatch || null,
});
}
}
return {
required,
passed: failures.length === 0,
auditedSlideCount: layoutAudits.length,
failureCount: failures.length,
failures,
};
}
function hasTableOrBodyTextSurface(audit) {
const elementKinds = audit?.elementKinds || [];
return elementKinds.includes('table') || elementKinds.includes('text');
}
function isTableBodyLayoutFinding(audit, finding) {
if (!finding) {
return false;
}
if (finding.target === 'table' || finding.target === 'text') {
return true;
}
if (finding.target === 'content' || finding.target === 'slide-root') {
return hasTableOrBodyTextSurface(audit);
}
return false;
}
function extractMermaidFenceBlocks(markdown) {
const lines = markdown.split(/\r?\n/);
const fences = [];
let activeFence = null;
for (const line of lines) {
if (!activeFence) {
const openingMatch = line.trim().match(/^(```+|~~~+)\s*mermaid(?:\s+\{[^}]+\})?\s*$/i);
if (openingMatch) {
activeFence = {
marker: openingMatch[1],
lines: [line],
};
}
continue;
}
activeFence.lines.push(line);
if (isClosingFenceLine(line, activeFence.marker)) {
fences.push(activeFence.lines.join('\n'));
activeFence = null;
}
}
return fences;
}
function isClosingFenceLine(line, openingMarker) {
const markerCharacter = openingMarker[0];
const markerCount = openingMarker.length;
const escapedMarker = markerCharacter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escapedMarker}{${markerCount},}\\s*$`).test(line.trim());
}
async function collectRenderedSlideMeasurement(page, slide) {
return page.evaluate(slotZoneAttr => {
const toRect = rect => ({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
});
const toTextPreview = value => value
.replace(/\s+/g, ' ')
.trim()
.slice(0, 160);
const overlaps = (a, b) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
const unionRects = rects => rects.reduce((acc, rect) => {
if (!acc) {
return { ...rect };
}
return {
left: Math.min(acc.left, rect.left),
top: Math.min(acc.top, rect.top),
right: Math.max(acc.right, rect.right),
bottom: Math.max(acc.bottom, rect.bottom),
width: Math.max(acc.right, rect.right) - Math.min(acc.left, rect.left),
height: Math.max(acc.bottom, rect.bottom) - Math.min(acc.top, rect.top),
};
}, null);
const collectTextContentRect = element => {
const textRects = [];
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if ((node.textContent || '').trim().length > 0) {
const range = document.createRange();
range.selectNodeContents(node);
for (const rect of Array.from(range.getClientRects())) {
if (rect.width > 1 && rect.height > 1) {
textRects.push(toRect(rect));
}
}
range.detach();
}
node = walker.nextNode();
}
return unionRects(textRects);
};
const pickVisibleLargest = selector => Array.from(document.querySelectorAll(selector))
.filter(element => element instanceof Element)
.map(element => ({ element, rect: element.getBoundingClientRect(), style: window.getComputedStyle(element) }))
.filter(entry => entry.style.display !== 'none' && entry.style.visibility !== 'hidden' && Number(entry.style.opacity || '1') > 0 && entry.rect.width > 2 && entry.rect.height > 2)
.sort((left, right) => (right.rect.width * right.rect.height) - (left.rect.width * left.rect.height))[0]?.element ?? null;
const slideRoot = pickVisibleLargest('.slidev-page')
|| pickVisibleLargest('.slidev-layout')
|| pickVisibleLargest('.slidev-slide-content')
|| document.querySelector('#app');
if (!slideRoot) {
return {
slide: null,
slideRoot: null,
safeRect: null,
contentBounds: null,
pageScale: null,
elements: [],
slotZones: [],
errors: ['Slide root not found'],
};
}
const slideRootRect = toRect(slideRoot.getBoundingClientRect());
const safeInsetX = slideRootRect.width * 0.04;
const safeInsetY = slideRootRect.height * 0.06;
const safeRect = {
left: slideRootRect.left + safeInsetX,
top: slideRootRect.top + safeInsetY,
right: slideRootRect.right - safeInsetX,
bottom: slideRootRect.bottom - safeInsetY,
width: slideRootRect.width - safeInsetX * 2,
height: slideRootRect.height - safeInsetY * 2,
};
const selectors = [
['mermaid', '.mermaid, [class*="mermaid"]'],
['table', 'table'],
['code', 'pre, .shiki'],
['image', 'img, svg'],
['text', 'h1, h2, h3, h4, p, li, blockquote'],
['other', 'div, section, article, aside, span'],
];
const seen = new Set();
const measuredElements = [];
for (const [kind, selector] of selectors) {
for (const element of slideRoot.querySelectorAll(selector)) {
if (!(element instanceof Element) || seen.has(element)) {
continue;
}
seen.add(element);
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
continue;
}
const elementRect = toRect(element.getBoundingClientRect());
const rect = kind === 'text'
? collectTextContentRect(element) || elementRect
: elementRect;
const ownerElement = element.closest(`[${slotZoneAttr}]`);
if (rect.width < 2 || rect.height < 2 || (!ownerElement && !overlaps(rect, slideRootRect))) {
continue;
}
const textLength = (element.textContent || '').trim().length;
if (kind === 'other') {
const directTextLength = Array.from(element.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent || '')
.join(' ')
.trim()
.length;
if (directTextLength === 0 || textLength === 0) {
continue;
}
}
measuredElements.push({
ownerElement,
measured: {
kind,
selector,
slotZone: ownerElement?.getAttribute(slotZoneAttr) || undefined,
slotOwner: element.hasAttribute(slotZoneAttr),
textLength,
textPreview: textLength > 0 ? toTextPreview(element.textContent || '') : undefined,
scrollWidth: element.scrollWidth || rect.width,
scrollHeight: element.scrollHeight || rect.height,
clientWidth: element.clientWidth || rect.width,
clientHeight: element.clientHeight || rect.height,
rect,
},
});
}
}
const elements = measuredElements.map(entry => entry.measured);
const slotZones = Array.from(slideRoot.querySelectorAll(`[${slotZoneAttr}]`))
.filter(element => element instanceof Element)
.map(ownerElement => {
const ownerRect = toRect(ownerElement.getBoundingClientRect());
if (ownerRect.width < 2 || ownerRect.height < 2 || !overlaps(ownerRect, slideRootRect)) {
return null;
}
const style = window.getComputedStyle(ownerElement);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
return null;
}
const zoneName = ownerElement.getAttribute(slotZoneAttr);
if (!zoneName) {
return null;
}
const zoneElementRects = measuredElements
.filter(entry => entry.ownerElement === ownerElement && !entry.measured.slotOwner)
.map(entry => entry.measured.rect);
const contentBounds = unionRects(zoneElementRects.length > 0 ? zoneElementRects : [ownerRect]);
if (contentBounds) {
contentBounds.width = contentBounds.right - contentBounds.left;
contentBounds.height = contentBounds.bottom - contentBounds.top;
}
const textValue = (ownerElement.textContent || '').trim();
return {
name: zoneName,
textLength: textValue.length,
textPreview: textValue.length > 0 ? toTextPreview(textValue) : undefined,
ownerRect,
contentBounds,
scrollWidth: ownerElement.scrollWidth || ownerRect.width,
scrollHeight: ownerElement.scrollHeight || ownerRect.height,
clientWidth: ownerElement.clientWidth || ownerRect.width,
clientHeight: ownerElement.clientHeight || ownerRect.height,
};
})
.filter(Boolean);
const contentBounds = unionRects(elements.map(element => element.rect));
if (contentBounds) {
contentBounds.width = contentBounds.right - contentBounds.left;
contentBounds.height = contentBounds.bottom - contentBounds.top;
}
const zoomRaw = window.getComputedStyle(slideRoot).getPropertyValue('--slidev-slide-zoom-scale').trim()
|| window.getComputedStyle(slideRoot).scale
|| '1';
const pageScale = Number.parseFloat(zoomRaw);
return {
slideRoot: slideRootRect,
safeRect,
contentBounds,
pageScale: Number.isFinite(pageScale) ? pageScale : null,
elements,
slotZones,
errors: [],
};
}, 'data-notemd-slot-zone');
}
function configurePlaywrightBrowserPath(slideExport) {
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
return;
}
for (const home of slideExport.resolveWorkspaceHomeCandidates()) {
const candidate = path.join(home, '.cache', 'ms-playwright');
try {
if (fs.existsSync(candidate)) {
process.env.PLAYWRIGHT_BROWSERS_PATH = candidate;
return;
}
} catch {
// Try the next candidate.
}
}
}
async function runPlaywrightChecks(htmlPath, sampleSlides, writeScreenshots, slideExport) {
configurePlaywrightBrowserPath(slideExport);
const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
const checks = [];
const layoutAudits = [];
let serverDirectory = null;
let baseUrl = null;
try {
if (isSlidevServerHtmlEntryPath(htmlPath)) {
serverDirectory = path.dirname(htmlPath);
const port = await slideExport.startLocalServer(serverDirectory);
baseUrl = `http://localhost:${port}/index.html`;
}
for (const slide of sampleSlides) {
const errors = [];
page.removeAllListeners('pageerror');
page.removeAllListeners('console');
const keepError = text => !/Wake Lock permission request denied/i.test(text);
page.on('pageerror', error => {
if (keepError(error.message)) errors.push(error.message);
});
page.on('console', message => {
if (message.type() === 'error' && keepError(message.text())) errors.push(message.text());
});
const targetUrl = baseUrl ? `${baseUrl}#/${slide}` : `file://${htmlPath}#/${slide}`;
await openSlideForAudit(page, targetUrl);
await page.waitForTimeout(1000);
const text = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '');
const measurement = await collectRenderedSlideMeasurement(page, slide);
measurement.slide = slide;
const layoutAudit = slideExport.analyzeRenderedSlideMeasurement(measurement, LAYOUT_AUDIT_CONFIG);
let screenshotPath = null;
if (writeScreenshots) {
screenshotPath = path.join(path.dirname(htmlPath), `slide-${String(slide).padStart(2, '0')}-workflow.png`);
await page.screenshot({ path: screenshotPath, fullPage: true });
}
checks.push({
slide,
failed: errors.length > 0 || !text.includes(`${slide} /`),
errors,
screenshotPath,
preview: text.split('\n').filter(Boolean).slice(0, 4).join(' | '),
layoutFindingCount: layoutAudit.findings.length,
});
layoutAudits.push(layoutAudit);
}
} finally {
await browser.close();
if (serverDirectory) {
slideExport.stopLocalServer(serverDirectory);
}
}
return { checks, layoutAudits };
}
async function openSlideForAudit(page, targetUrl) {
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
if (typeof page.waitForLoadState === 'function') {
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => undefined);
}
if (typeof page.waitForFunction === 'function') {
await page.waitForFunction(
() => {
const root = document.querySelector('.slidev-page, .slidev-layout, .slidev-slide-content, #app');
return Boolean(root && (root.textContent || '').trim().length > 0);
},
null,
{ timeout: 15000 }
).catch(() => undefined);
}
}
function resolveSlidesToAudit(sampleSlides, deckMarkdown, slideExport) {
const deckSlideCount = deckMarkdown ? slideExport.countSlideDeckSlides(deckMarkdown) : 0;
const allSlides = Array.from({ length: Math.max(deckSlideCount, 1) }, (_, index) => index + 1);
if (!Array.isArray(sampleSlides) || sampleSlides.length === 0) {
return allSlides;
}
const filteredSlides = sampleSlides.filter(slide => Number.isFinite(slide) && slide >= 1 && slide <= deckSlideCount);
return filteredSlides.length > 0 ? filteredSlides : allSlides;
}
function checkGitIgnoreStatus(pathsToCheck) {
const existingPaths = pathsToCheck.filter(Boolean).filter(filePath => fs.existsSync(filePath));
if (existingPaths.length === 0) {
return {
ignoredOutputs: [],
unignoredOutputs: [],
error: null,
};
}
const relativePaths = existingPaths.map(filePath => normalizeGitRelativePath(path.relative(process.cwd(), filePath)));
const result = childProcess.spawnSync('git', ['check-ignore', '-v', '--stdin'], {
cwd: process.cwd(),
encoding: 'utf8',
input: `${relativePaths.join('\n')}\n`,
});
const ignoredOutputs = result.stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const ignoredRelativePaths = new Set(ignoredOutputs.map(line => {
const parts = line.split('\t');
return normalizeGitCheckIgnoreOutputPath((parts[parts.length - 1] || line).trim());
}));
return {
ignoredOutputs,
unignoredOutputs: relativePaths.filter(filePath => !ignoredRelativePaths.has(filePath)),
error: result.status && result.status > 1 ? (result.stderr || result.stdout || `git check-ignore exited ${result.status}`) : null,
};
}
function normalizeGitRelativePath(relativePath) {
return String(relativePath || '').replace(/\\/g, '/').replace(/\r$/, '');
}
function normalizeGitCheckIgnoreOutputPath(rawPath) {
const trimmed = String(rawPath || '').trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return normalizeGitRelativePath(JSON.parse(trimmed));
} catch (_error) {
return normalizeGitRelativePath(trimmed.slice(1, -1));
}
}
return normalizeGitRelativePath(trimmed);
}
function injectMermaidPostFit(htmlPath, onProgress) {
const html = fs.readFileSync(htmlPath, 'utf8');
if (html.includes('data-notemd-mermaid-post-fit')) {
return;
}
const POST_FIT_SCRIPT = '<script data-notemd-mermaid-post-fit="1">' + fs.readFileSync(path.join(__dirname, '..', 'src', 'slideExport', 'mermaidPostFitScript.txt'), 'utf8').trim() + '</script>';
const lastBodyIdx = html.lastIndexOf("</body>");
const patched = lastBodyIdx >= 0
? html.slice(0, lastBodyIdx) + POST_FIT_SCRIPT + "\n" + html.slice(lastBodyIdx)
: html + "\n" + POST_FIT_SCRIPT + "\n";
fs.writeFileSync(htmlPath, patched, 'utf8');
onProgress?.('mermaid-fit', 'Injected post-fit script into standalone HTML');
}
function isSlidevServerHtmlEntryPath(htmlPath) {
return String(htmlPath || '').replace(/\\/g, '/').endsWith('/index.html');
}
function inspectPptx(pptxPath) {
if (!pptxPath || !fs.existsSync(pptxPath)) {
return null;
}
try {
const { strFromU8, unzipSync } = require('fflate');
const entries = unzipSync(new Uint8Array(fs.readFileSync(pptxPath)));
const names = Object.keys(entries);
const slideXmlPaths = names
.filter(name => /^ppt\/slides\/slide\d+\.xml$/.test(name))
.sort((left, right) => {
const leftNumber = Number(left.match(/slide(\d+)\.xml$/)?.[1] || 0);
const rightNumber = Number(right.match(/slide(\d+)\.xml$/)?.[1] || 0);
return leftNumber - rightNumber;
});
const slideTextRuns = slideXmlPaths.map(name => {
const xml = strFromU8(entries[name]);
const textRunXmlBlocks = xml.match(/<a:r\b[\s\S]*?<\/a:r>/g) || [];
const textRunAlphaValues = textRunXmlBlocks
.filter(runXml => /<a:t(?:\s[^>]*)?>/.test(runXml))
.flatMap(runXml => Array.from(runXml.matchAll(/<a:alpha\s+val="(\d+)"\s*\/>/g), match => Number(match[1])))
.filter(value => Number.isFinite(value));
const nonOpaqueTextRunCount = textRunXmlBlocks.filter(runXml =>
/<a:t(?:\s[^>]*)?>/.test(runXml)
&& Array.from(runXml.matchAll(/<a:alpha\s+val="(\d+)"\s*\/>/g), match => Number(match[1]))
.some(value => Number.isFinite(value) && value < 100000),
).length;
const transparentTextRunCount = textRunXmlBlocks.filter(runXml =>
/<a:t(?:\s[^>]*)?>/.test(runXml)
&& Array.from(runXml.matchAll(/<a:alpha\s+val="(\d+)"\s*\/>/g), match => Number(match[1]))
.some(value => Number.isFinite(value) && value <= 10000),
).length;
const textRunCount = (xml.match(/<a:t(?:\s[^>]*)?>/g) || []).length;
const pictureCount = (xml.match(/<p:pic>/g) || []).length;
const tableCount = (xml.match(/<a:tbl>/g) || []).length;
const shapeNames = Array.from(
xml.matchAll(/<p:cNvPr\b[^>]*\bname="([^"]*)"/g),
match => match[1],
);
const visibleNativeTextShapeCounts = shapeNames.reduce((counts, shapeName) => {
if (/^Visible Native Table Cell Overlay Text\b/.test(shapeName)) {
counts.tableCellOverlay += 1;
} else if (/^Visible Native Code Text\b/.test(shapeName)) {
counts.code += 1;
} else if (/^Visible Native SVG Text\b/.test(shapeName)) {
counts.svg += 1;
} else if (/^Visible Native Mermaid Text\b/.test(shapeName)) {
counts.mermaid += 1;
} else if (/^Visible Native Text\b/.test(shapeName)) {
counts.body += 1;
} else if (/^Visible Native Table\b/.test(shapeName)) {
counts.table += 1;
}
return counts;
}, {
body: 0,
code: 0,
svg: 0,
mermaid: 0,
tableCellOverlay: 0,
table: 0,
});
const nativeTableOverlayLeakCount = tableCount === 0 ? visibleNativeTextShapeCounts.tableCellOverlay : 0;
return {
path: name,
textRunCount,
pictureCount,
tableCount,
visibleNativeTextShapeCounts,
visibleNativeTextShapeCount:
visibleNativeTextShapeCounts.body +
visibleNativeTextShapeCounts.code +
visibleNativeTextShapeCounts.svg +
visibleNativeTextShapeCounts.mermaid +
visibleNativeTextShapeCounts.tableCellOverlay,
visibleNativeTableShapeCount: visibleNativeTextShapeCounts.table,
tableCellOverlayTextShapeCount: visibleNativeTextShapeCounts.tableCellOverlay,
nativeTableOverlayLeakCount,
nonOpaqueTextRunCount,
transparentTextRunCount,
lowOpacityTextRunCount: Math.max(0, nonOpaqueTextRunCount - transparentTextRunCount),
minimumTextRunAlpha: textRunAlphaValues.length > 0 ? Math.min(...textRunAlphaValues) : 100000,
hasEditableText: textRunCount > 0,
};
});
const textRunCount = slideTextRuns.reduce((total, slide) => total + slide.textRunCount, 0);
const nonOpaqueTextRunCount = slideTextRuns.reduce((total, slide) => total + slide.nonOpaqueTextRunCount, 0);
const transparentTextRunCount = slideTextRuns.reduce((total, slide) => total + slide.transparentTextRunCount, 0);
const nativeTableOverlayLeakCount = slideTextRuns.reduce((total, slide) => total + slide.nativeTableOverlayLeakCount, 0);
const slidesWithoutEditableText = slideTextRuns.filter(slide => !slide.hasEditableText).map(slide => slide.path);
const slidesWithNonOpaqueText = slideTextRuns.filter(slide => slide.nonOpaqueTextRunCount > 0).map(slide => slide.path);
const slidesWithNativeTableOverlayLeak = slideTextRuns
.filter(slide => slide.nativeTableOverlayLeakCount > 0)
.map(slide => slide.path);
return {
isZip: true,
entryCount: names.length,
slideCount: slideXmlPaths.length,
mediaCount: names.filter(name => /^ppt\/media\/image\d+\.(png|jpg|jpeg)$/.test(name)).length,
textRunCount,
pictureCount: slideTextRuns.reduce((total, slide) => total + slide.pictureCount, 0),
tableCount: slideTextRuns.reduce((total, slide) => total + slide.tableCount, 0),
visibleNativeTextShapeCount: slideTextRuns.reduce((total, slide) => total + slide.visibleNativeTextShapeCount, 0),
visibleNativeTableShapeCount: slideTextRuns.reduce((total, slide) => total + slide.visibleNativeTableShapeCount, 0),
tableCellOverlayTextShapeCount: slideTextRuns.reduce((total, slide) => total + slide.tableCellOverlayTextShapeCount, 0),
nonOpaqueTextRunCount,
transparentTextRunCount,
lowOpacityTextRunCount: slideTextRuns.reduce((total, slide) => total + slide.lowOpacityTextRunCount, 0),
nativeTableOverlayLeakCount,
minimumTextRunAlpha: slideTextRuns.length > 0
? Math.min(...slideTextRuns.map(slide => slide.minimumTextRunAlpha))
: 100000,
slidesWithoutEditableText,
slidesWithNonOpaqueText,
slidesWithNativeTableOverlayLeak,
editableTextAudit: {
passed: textRunCount > 0
&& slidesWithoutEditableText.length === 0
&& nonOpaqueTextRunCount === 0
&& nativeTableOverlayLeakCount === 0,
failures: [
...(textRunCount > 0 ? [] : ['pptx contains no editable text runs']),
...(slidesWithoutEditableText.length === 0
? []
: [`slides without editable text: ${slidesWithoutEditableText.join(', ')}`]),
...(nonOpaqueTextRunCount === 0
? []
: [`non-opaque text runs: ${nonOpaqueTextRunCount}`]),
...(nativeTableOverlayLeakCount === 0
? []
: [`native table overlay text leaks: ${nativeTableOverlayLeakCount}`]),
],
},
slideTextRuns,
};
} catch (error) {
return {
isZip: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
function resolvePptxVisualReferenceDirectory(vaultRoot, referenceDirectory) {
if (!referenceDirectory) {
return null;
}
if (path.isAbsolute(referenceDirectory)) {
return referenceDirectory;
}
const workspaceRelative = path.resolve(process.cwd(), referenceDirectory);
if (fs.existsSync(workspaceRelative)) {
return workspaceRelative;
}
return path.join(vaultRoot, referenceDirectory);
}
function resolvePptxVisualDiffDirectory(vaultRoot, configuredDirectory, pptxPath) {
if (configuredDirectory) {
return path.isAbsolute(configuredDirectory)
? configuredDirectory
: path.join(vaultRoot, configuredDirectory);
}
return path.join(path.dirname(pptxPath), `${path.basename(pptxPath, path.extname(pptxPath))}-pptx-visual-diff`);
}
function buildUnavailablePptxVisualDiff(outputDirectory, error, thresholds) {
return {
available: false,
outputDirectory,
error,
gate: {
passed: false,
failures: [error],
thresholds,
},
};
}
function usesVisibleNativeDefaultPptxTextLayer(pptxReport) {
return Boolean(
pptxReport
&& pptxReport.visibleTextLayer === 'native-text-and-background-image'
&& pptxReport.editableLayerRenderMode === 'visible-native-text',
);
}
function selectPptxHardGateReferenceSource(args, pptxReport) {
if (!args.requirePptxVisualMatch) {
return 'none';
}
if (args.pptxVisualReferenceDir) {
return 'configured-png-sequence';
}
if (usesVisibleNativeDefaultPptxTextLayer(pptxReport)) {
return 'pptx-rendered-html-reference';
}
return 'pptx-background-images';
}
function shouldRunRenderedHtmlPptxReferenceDiff(args, pptxReport) {
return Boolean(
args.pptxRenderedHtmlReferenceDiff
|| selectPptxHardGateReferenceSource(args, pptxReport) === 'pptx-rendered-html-reference',
);
}
function shouldRunBackgroundPptxVisualDiff(args, pptxReport) {
const hardGateReferenceSource = selectPptxHardGateReferenceSource(args, pptxReport);
if (args.pptxVisibleNativeExperiment || args.pptxVisualReferenceDir) {
return true;
}
return Boolean(args.pptxVisualDiff && hardGateReferenceSource !== 'pptx-rendered-html-reference');
}
function selectPptxVisualThresholdProfile(args, pptxReport, hardGateReferenceSource) {
const visibleNativeRenderedHtmlGate = hardGateReferenceSource === 'pptx-rendered-html-reference'
&& usesVisibleNativeDefaultPptxTextLayer(pptxReport);
const profileDefaults = visibleNativeRenderedHtmlGate
? VISIBLE_NATIVE_RENDERED_HTML_PPTX_VISUAL_THRESHOLDS
: DEFAULT_PPTX_VISUAL_THRESHOLDS;
return {
name: visibleNativeRenderedHtmlGate
? 'visible-native-rendered-html'
: 'default-raster',
explicit: {
maxRmse: args.pptxVisualMaxRmseExplicit,
meanRmse: args.pptxVisualMeanRmseExplicit,
},
thresholds: {
maxRmse: args.pptxVisualMaxRmseExplicit ? args.pptxVisualMaxRmse : profileDefaults.maxRmse,
meanRmse: args.pptxVisualMeanRmseExplicit ? args.pptxVisualMeanRmse : profileDefaults.meanRmse,
},
};
}
function runPptxVisualDiff({ pptxPath, outputDirectory, referenceDirectory, referenceSource, renderer, dpi, timeoutMs, thresholds }) {
try {
return buildPptxVisualDiff({
pptxPath,
outputDirectory,
referenceDirectory,
referenceSource,
renderer,
dpi,
timeoutMs,
thresholds,
});
} catch (error) {
return buildUnavailablePptxVisualDiff(
outputDirectory,
error instanceof Error ? error.message : String(error),
thresholds,
);
}
}
function extractDefaultPptxBackgroundReference(pptxPath, outputDirectory) {
try {
return extractPptxBackgroundImages({
pptxPath,
outputDirectory,
referenceDirectory: path.join(outputDirectory, 'pptx-background-reference'),
});
} catch (error) {
return {
source: 'pptx-background-images',
referenceDirectory: path.join(outputDirectory, 'pptx-background-reference'),
referenceImages: [],
error: error instanceof Error ? error.message : String(error),
};
}
}
function printProgress(enabled, phase, detail) {
if (!enabled) return;
console.log(detail ? `${phase}: ${detail}` : phase);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const vaultRoot = path.resolve(args.vault);
const app = createApp(vaultRoot);
const sourceFile = createSourceFile(vaultRoot, args.source);
const config = createConfig(args);
const slideExport = await bundleSlideExportModules();
const progress = [];
const onProgress = (phase, detail) => {
progress.push({ phase, detail: detail || '' });
printProgress(!args.json, phase, detail);
};
printProgress(!args.json, 'slidev-workflow', `Vault: ${vaultRoot}`);
printProgress(!args.json, 'slidev-workflow', `Source: ${sourceFile.path}`);
const environment = await slideExport.probeEnvironment([vaultRoot]);
const slideSource = await slideExport.prepareSlidevExportSource(app, sourceFile, config, {}, onProgress);
let layoutConvergence = null;
if (args.playwright) {
layoutConvergence = await slideExport.convergeSlidevDeckLayout(app, slideSource, config, onProgress, {
sampleSlides: args.sampleSlides,
writeScreenshots: args.screenshots,
navigationTimeoutMs: args.timeoutMs,
auditConfig: LAYOUT_AUDIT_CONFIG,
});
}
let exportPath = layoutConvergence?.exportPath;
let htmlExport = layoutConvergence?.htmlExport ?? null;
let htmlExportHistory = layoutConvergence?.htmlExportHistory ?? [];
let pptxReportPath = null;
let pptxReport = null;
let htmlExportPathForPptx = null;
let visibleNativeExperimentResult = null;
if (args.format === 'pdf') {
exportPath = await slideExport.exportSlidevPdf(app, slideSource, config, onProgress);
} else if (args.format === 'png') {
exportPath = await slideExport.exportSlidevPng(app, slideSource, config, onProgress);
} else if (args.format === 'pptx') {
if (!exportPath) {
htmlExport = await slideExport.exportSlidevHtmlWithOutcome(app, slideSource, config, onProgress);
htmlExportHistory = [htmlExport];
exportPath = htmlExport.path;
}
htmlExportPathForPptx = exportPath;
const pptxResult = await slideExport.exportSlidevPptxFromHtml(app, slideSource, config, htmlExportPathForPptx, onProgress);
if (args.pptxVisibleNativeExperiment) {
visibleNativeExperimentResult = await slideExport.exportSlidevVisibleNativePptxExperimentFromHtml(
app,
slideSource,
config,
htmlExportPathForPptx,
onProgress,
);
}
exportPath = pptxResult.path;
htmlExport = layoutConvergence?.htmlExport ?? htmlExport;
htmlExportHistory = layoutConvergence?.htmlExportHistory ?? htmlExportHistory;
pptxReportPath = path.join(vaultRoot, pptxResult.reportPath);
pptxReport = pptxResult.report;
} else if (args.format === 'mp4') {
const pngDirectory = await slideExport.exportSlidevPng(app, slideSource, config, onProgress);
exportPath = await slideExport.exportVideoMp4(app, pngDirectory, slideSource.outputBasename, config, onProgress);
} else if (!exportPath) {
htmlExport = await slideExport.exportSlidevHtmlWithOutcome(app, slideSource, config, onProgress);
htmlExportHistory = [htmlExport];
exportPath = htmlExport.path;
}
// Inject mermaid post-fit script into standalone HTML after full layout settle.
if (args.format === 'html' && args.htmlMode === 'standalone') {
const htmlPath = path.join(vaultRoot, exportPath);
injectMermaidPostFit(htmlPath, onProgress);
}
let absoluteExportPath = path.join(vaultRoot, exportPath);
const absoluteDeckPath = slideSource.preparedDeckPath ? path.join(vaultRoot, slideSource.preparedDeckPath) : null;
let deckSummary = collectDeckSummary(absoluteDeckPath);
const mermaidSourcePreservation = collectMermaidSourcePreservation(path.join(vaultRoot, sourceFile.path), absoluteDeckPath);
let playwrightChecks = layoutConvergence?.checks ?? [];
let layoutAudits = layoutConvergence?.layoutAudits ?? [];
let layoutAuditSummary = layoutConvergence?.layoutAuditSummary ?? slideExport.summarizeLayoutAudits([], 0);
let layoutPatchAttempts = layoutConvergence?.layoutPatchAttempts ?? [];
let auditedSlides = layoutConvergence?.auditedSlides ?? [];
const renderedLayoutGate = buildRenderedLayoutGate({
required: args.requireNativeStandalone,
auditedSlides,
layoutAudits,
playwrightChecks,
auditSkippedReason: args.playwright
? (layoutConvergence?.auditSkippedReason || null)
: 'Playwright disabled by --no-playwright.',
});
const pptxInspection = args.format === 'pptx' ? inspectPptx(absoluteExportPath) : null;
const visibleNativeExperimentPptxPath = visibleNativeExperimentResult
? path.join(vaultRoot, visibleNativeExperimentResult.path)
: null;
const visibleNativeExperimentReportPath = visibleNativeExperimentResult
? path.join(vaultRoot, visibleNativeExperimentResult.reportPath)
: null;
const visibleNativeExperimentInspection = visibleNativeExperimentPptxPath
? inspectPptx(visibleNativeExperimentPptxPath)
: null;
let pptxReferencePngDirectory = null;
let pptxVisualDiff = null;
let pptxRenderedHtmlReference = null;
let pptxRenderedHtmlReferenceVisualDiff = null;
let visibleNativeExperimentVisualDiff = null;
let visibleNativeExperimentVisualReference = null;
const pptxHardGateReferenceSource = args.format === 'pptx'
? selectPptxHardGateReferenceSource(args, pptxReport)
: 'none';
const pptxVisualThresholdProfile = selectPptxVisualThresholdProfile(args, pptxReport, pptxHardGateReferenceSource);
const pptxVisualThresholds = pptxVisualThresholdProfile.thresholds;
const needsDefaultPptxVisualDiff = args.format === 'pptx' && shouldRunBackgroundPptxVisualDiff(args, pptxReport);
const needsRenderedHtmlPptxReferenceDiff = args.format === 'pptx'
&& shouldRunRenderedHtmlPptxReferenceDiff(args, pptxReport);
if (pptxHardGateReferenceSource === 'pptx-rendered-html-reference') {
onProgress(
'pptx-visual-diff',
'Visible-native PPTX default detected; using same rendered HTML as the hard visual-match reference.',
);
}
if (needsDefaultPptxVisualDiff) {
const visualDiffDirectory = resolvePptxVisualDiffDirectory(vaultRoot, args.pptxVisualDiffDir, absoluteExportPath);
const visualReferenceDirectory = resolvePptxVisualReferenceDirectory(vaultRoot, args.pptxVisualReferenceDir);
onProgress(
'pptx-visual-diff',
visualReferenceDirectory
? `Rendering PPTX back to PNG and comparing pages with external PNG reference: ${visualReferenceDirectory}`
: 'Rendering PPTX back to PNG and comparing pages with frozen background references...',
);
pptxVisualDiff = runPptxVisualDiff({
pptxPath: absoluteExportPath,
outputDirectory: visualDiffDirectory,
referenceDirectory: visualReferenceDirectory,
renderer: args.pptxVisualRenderer,
dpi: args.pptxVisualDpi,
timeoutMs: args.timeoutMs,
thresholds: pptxVisualThresholds,
});
if (pptxVisualDiff.error) {
onProgress('pptx-visual-diff', `PPTX visual diff failed: ${pptxVisualDiff.error}`);
} else {
onProgress(
'pptx-visual-diff',
pptxVisualDiff.gate?.passed
? 'PPTX visual diff is within thresholds.'
: `PPTX visual diff exceeded thresholds: ${(pptxVisualDiff.gate?.failures || []).join('; ')}`,
);
}
pptxReferencePngDirectory = pptxVisualDiff.reference?.referenceDirectory || null;
}
if (needsRenderedHtmlPptxReferenceDiff) {
if (!htmlExportPathForPptx) {
throw new Error('Rendered-HTML PPTX reference diff requires the HTML export path used for PPTX.');
}
onProgress(
'pptx-rendered-html-reference',
'Capturing PNG reference from the same rendered HTML used by PPTX export...',
);
pptxRenderedHtmlReference = await slideExport.exportSlidevPptxRenderedHtmlReferencePngSequence(
app,
slideSource,
config,
htmlExportPathForPptx,
onProgress,
);
const renderedHtmlReferenceDiffDirectory = path.join(
path.dirname(absoluteExportPath),
`${path.basename(absoluteExportPath, path.extname(absoluteExportPath))}-rendered-html-reference-diff`,
);
onProgress(
'pptx-rendered-html-reference',
`Rendering PPTX back to PNG and comparing against same rendered HTML reference: ${pptxRenderedHtmlReference.absolutePath}`,
);
pptxRenderedHtmlReferenceVisualDiff = runPptxVisualDiff({
pptxPath: absoluteExportPath,
outputDirectory: renderedHtmlReferenceDiffDirectory,
referenceDirectory: pptxRenderedHtmlReference.absolutePath,
referenceSource: 'pptx-rendered-html-reference',
renderer: args.pptxVisualRenderer,
dpi: args.pptxVisualDpi,
timeoutMs: args.timeoutMs,
thresholds: pptxVisualThresholds,
});
if (pptxRenderedHtmlReferenceVisualDiff.error) {
onProgress('pptx-rendered-html-reference', `Rendered-HTML reference diff failed: ${pptxRenderedHtmlReferenceVisualDiff.error}`);
} else {
onProgress(
'pptx-rendered-html-reference',
pptxRenderedHtmlReferenceVisualDiff.gate?.passed
? 'Rendered-HTML reference diff is within thresholds.'
: `Rendered-HTML reference diff exceeded thresholds: ${(pptxRenderedHtmlReferenceVisualDiff.gate?.failures || []).join('; ')}`,
);
}
}
if (visibleNativeExperimentPptxPath) {
const defaultReferenceSource = pptxVisualDiff?.reference?.source === 'pptx-background-images'
? pptxVisualDiff.reference
: extractDefaultPptxBackgroundReference(
absoluteExportPath,
path.join(path.dirname(absoluteExportPath), `${path.basename(absoluteExportPath, path.extname(absoluteExportPath))}-default-frozen-reference`),
);
visibleNativeExperimentVisualReference = {
source: 'default-frozen-background',
referenceDirectory: defaultReferenceSource.referenceDirectory,
referenceImageCount: defaultReferenceSource.referenceImages?.length || 0,
error: defaultReferenceSource.error || null,
};
const visualDiffDirectory = path.join(
path.dirname(visibleNativeExperimentPptxPath),
`${path.basename(visibleNativeExperimentPptxPath, path.extname(visibleNativeExperimentPptxPath))}-pptx-visual-diff`,
);
if (visibleNativeExperimentVisualReference.error || !visibleNativeExperimentVisualReference.referenceImageCount) {
visibleNativeExperimentVisualDiff = buildUnavailablePptxVisualDiff(
visualDiffDirectory,
visibleNativeExperimentVisualReference.error || 'Default PPTX frozen-background reference contains no images.',
pptxVisualThresholds,
);
onProgress('pptx-visible-native-experiment', `Visible-native visual diff failed: ${visibleNativeExperimentVisualDiff.error}`);
} else {
onProgress(
'pptx-visible-native-experiment',
`Rendering visible-native PPTX back to PNG and comparing against default frozen background: ${visibleNativeExperimentVisualReference.referenceDirectory}`,
);
visibleNativeExperimentVisualDiff = runPptxVisualDiff({
pptxPath: visibleNativeExperimentPptxPath,
outputDirectory: visualDiffDirectory,
referenceDirectory: visibleNativeExperimentVisualReference.referenceDirectory,
renderer: args.pptxVisualRenderer,
dpi: args.pptxVisualDpi,
timeoutMs: args.timeoutMs,
thresholds: pptxVisualThresholds,
});
if (visibleNativeExperimentVisualDiff.error) {
onProgress('pptx-visible-native-experiment', `Visible-native visual diff failed: ${visibleNativeExperimentVisualDiff.error}`);
} else {
onProgress(
'pptx-visible-native-experiment',
visibleNativeExperimentVisualDiff.gate?.passed
? 'Visible-native visual diff is within thresholds.'
: `Visible-native visual diff exceeded thresholds: ${(visibleNativeExperimentVisualDiff.gate?.failures || []).join('; ')}`,
);
}
}
}
const gitIgnoreStatus = checkGitIgnoreStatus([
absoluteDeckPath,
absoluteExportPath,
pptxReportPath,
visibleNativeExperimentPptxPath,
visibleNativeExperimentReportPath,
pptxReferencePngDirectory,
pptxVisualDiff?.outputDirectory,
pptxRenderedHtmlReference?.absolutePath,
pptxRenderedHtmlReferenceVisualDiff?.outputDirectory,
visibleNativeExperimentVisualReference?.referenceDirectory,
visibleNativeExperimentVisualDiff?.outputDirectory,
htmlExport?.standaloneAttempt?.preservedFailurePath
? path.join(vaultRoot, htmlExport.standaloneAttempt.preservedFailurePath)
: null,
htmlExport?.standaloneAttempt?.outputPath
? path.join(vaultRoot, htmlExport.standaloneAttempt.outputPath)
: null,
htmlExport?.fallbackPath
? path.join(vaultRoot, htmlExport.fallbackPath)
: null,
...playwrightChecks.map(check => check.screenshotPath),
]);
const ignoredOutputs = gitIgnoreStatus.ignoredOutputs;
const unignoredOutputs = gitIgnoreStatus.unignoredOutputs;
const hasLayoutFailures = layoutAudits.some(audit => audit.findings.length > 0);
const tableBodyLayoutGate = buildTableBodyLayoutGate(layoutAudits);
const nativeStandalonePassed = htmlExport?.actualMode === 'standalone'
&& htmlExport?.standaloneAttempt?.accepted === true
&& fs.existsSync(path.join(vaultRoot, htmlExport.path));
const standaloneGate = {
required: args.requireNativeStandalone,
passed: !args.requireNativeStandalone || nativeStandalonePassed,
reason: args.requireNativeStandalone && !nativeStandalonePassed
? `Expected native standalone HTML but actual mode was ${htmlExport?.actualMode || 'unavailable'}`
: null,
};
const pptxVisualDiffExecutionPassed = !needsDefaultPptxVisualDiff || Boolean(pptxVisualDiff?.available && !pptxVisualDiff?.error);
const pptxHardGateVisualDiff = pptxHardGateReferenceSource === 'pptx-rendered-html-reference'
? pptxRenderedHtmlReferenceVisualDiff
: pptxVisualDiff;
const pptxVisualGate = {
required: args.requirePptxVisualMatch,
referenceSource: pptxHardGateReferenceSource === 'none'
? (pptxVisualDiff?.reference?.source || null)
: (pptxHardGateReferenceSource === 'pptx-rendered-html-reference'
? 'pptx-rendered-html-reference'
: (pptxHardGateVisualDiff?.reference?.source || pptxHardGateReferenceSource)),
observedPassed: Boolean(pptxHardGateVisualDiff?.gate?.passed),
passed: !args.requirePptxVisualMatch || Boolean(pptxHardGateVisualDiff?.gate?.passed),
failures: pptxHardGateVisualDiff?.gate?.failures || [],
thresholds: pptxVisualThresholds,
thresholdProfile: pptxVisualThresholdProfile.name,
thresholdOverrides: pptxVisualThresholdProfile.explicit,
};
const pptxRenderedHtmlReferenceDiffExecutionPassed = !needsRenderedHtmlPptxReferenceDiff
|| Boolean(pptxRenderedHtmlReferenceVisualDiff?.available && !pptxRenderedHtmlReferenceVisualDiff?.error);
const pptxRenderedHtmlReferenceGate = {
required: args.requirePptxRenderedHtmlReferenceMatch,
observedPassed: Boolean(pptxRenderedHtmlReferenceVisualDiff?.gate?.passed),
passed: !args.requirePptxRenderedHtmlReferenceMatch || Boolean(pptxRenderedHtmlReferenceVisualDiff?.gate?.passed),
failures: pptxRenderedHtmlReferenceVisualDiff?.gate?.failures || [],
thresholds: pptxVisualThresholds,
thresholdProfile: pptxVisualThresholdProfile.name,
thresholdOverrides: pptxVisualThresholdProfile.explicit,
};
const visibleNativeExperimentVisualDiffExecutionPassed = !args.pptxVisibleNativeExperiment
|| Boolean(visibleNativeExperimentVisualDiff?.available && !visibleNativeExperimentVisualDiff?.error);
const visibleNativeExperimentGate = {
required: args.requirePptxVisibleNativeMatch,
observedPassed: Boolean(visibleNativeExperimentVisualDiff?.gate?.passed),
passed: !args.requirePptxVisibleNativeMatch || Boolean(visibleNativeExperimentVisualDiff?.gate?.passed),
failures: visibleNativeExperimentVisualDiff?.gate?.failures || [],
thresholds: pptxVisualThresholds,
thresholdProfile: pptxVisualThresholdProfile.name,
thresholdOverrides: pptxVisualThresholdProfile.explicit,
};
const pptxVisibleNativeExperiment = visibleNativeExperimentResult
? {
path: visibleNativeExperimentPptxPath,
reportPath: visibleNativeExperimentReportPath,
bytes: visibleNativeExperimentPptxPath && fs.existsSync(visibleNativeExperimentPptxPath) && fs.statSync(visibleNativeExperimentPptxPath).isFile()
? fs.statSync(visibleNativeExperimentPptxPath).size
: null,
inspection: visibleNativeExperimentInspection,
sidecarReport: visibleNativeExperimentResult.report,
visualReference: visibleNativeExperimentVisualReference,
visualDiff: visibleNativeExperimentVisualDiff,
gate: visibleNativeExperimentGate,
}
: null;
const visibleNativeExperimentPackagePassed = !args.pptxVisibleNativeExperiment
|| Boolean(visibleNativeExperimentInspection?.isZip && visibleNativeExperimentInspection.textRunCount > 0);
const visibleNativeExperimentOutputPassed = !args.pptxVisibleNativeExperiment
|| Boolean(visibleNativeExperimentPptxPath && fs.existsSync(visibleNativeExperimentPptxPath));
const pptxVisualDiffGatePassed = pptxVisualGate.passed;
const pptxRenderedHtmlReferenceGatePassed = pptxRenderedHtmlReferenceGate.passed;
const visibleNativeExperimentGatePassed = visibleNativeExperimentGate.passed;
const report = {
ok: playwrightChecks.every(check => !check.failed)
&& environment.capabilities[args.format] === true
&& fs.existsSync(absoluteExportPath)
&& !gitIgnoreStatus.error
&& unignoredOutputs.length === 0
&& (args.format !== 'pptx' || Boolean(pptxInspection?.isZip && pptxInspection.editableTextAudit?.passed))
&& visibleNativeExperimentOutputPassed
&& visibleNativeExperimentPackagePassed
&& pptxVisualDiffExecutionPassed
&& pptxRenderedHtmlReferenceDiffExecutionPassed
&& visibleNativeExperimentVisualDiffExecutionPassed
&& pptxVisualDiffGatePassed
&& pptxRenderedHtmlReferenceGatePassed
&& visibleNativeExperimentGatePassed
&& renderedLayoutGate.passed
&& tableBodyLayoutGate.passed
&& !hasLayoutFailures
&& (!deckSummary || (!deckSummary.containsKnownStaleText && !deckSummary.containsMissingTheme))
&& (!mermaidSourcePreservation || mermaidSourcePreservation.passed)
&& standaloneGate.passed,
source: {
vaultRoot,
sourcePath: sourceFile.path,
},
environment: {
node: environment.node,
slidev: environment.slidev,
playwright: environment.playwright,
ffmpeg: environment.ffmpeg,
capabilities: environment.capabilities,
},
slideSource: {
inputFilePath: slideSource.inputFilePath,
outputBasename: slideSource.outputBasename,
preparedDeckPath: slideSource.preparedDeckPath || null,
skillRootPath: slideSource.skillRootPath || null,
skillReferenceCount: slideSource.skillReferencePaths?.length || 0,
},
output: {
format: args.format,
path: absoluteExportPath,
bytes: fs.existsSync(absoluteExportPath) && fs.statSync(absoluteExportPath).isFile()
? fs.statSync(absoluteExportPath).size
: null,
pptxReportPath,
pptxReport,
},
pptxInspection,
pptxVisualDiff,
pptxVisualGate,
pptxRenderedHtmlReference,
pptxRenderedHtmlReferenceVisualDiff,
pptxRenderedHtmlReferenceGate,
pptxVisibleNativeExperiment,
htmlExport,
htmlExportHistory,
standaloneGate,
renderedLayoutGate,
deck: deckSummary,
mermaidSourcePreservation,
tableBodyLayoutGate,
playwright: playwrightChecks,
playwrightSlides: auditedSlides,
layoutAudit: layoutAudits,
layoutAuditSummary,
layoutPatchAttempts,
ignoredOutputs,
unignoredOutputs,
gitIgnoreCheckError: gitIgnoreStatus.error,
progress,
};
if (args.json) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log('\n=== Slidev workflow verification report ===');
console.log(JSON.stringify(report, null, 2));
}
if (!report.ok) {
process.exitCode = 1;
}
if (typeof esbuild.stop === 'function') {
await esbuild.stop();
}
process.exit(process.exitCode || 0);
}
if (require.main === module) {
main().catch(error => {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
});
} else {
module.exports = {
buildRenderedLayoutGate,
buildTableBodyLayoutGate,
inspectPptx,
selectPptxVisualThresholdProfile,
selectPptxHardGateReferenceSource,
shouldRunBackgroundPptxVisualDiff,
shouldRunRenderedHtmlPptxReferenceDiff,
usesVisibleNativeDefaultPptxTextLayer,
normalizeGitCheckIgnoreOutputPath,
normalizeGitRelativePath,
};
}