mirror of
https://github.com/jacobinwwey/obsidian-NotEMD.git
synced 2026-07-22 12:40:25 +00:00
528 lines
16 KiB
JavaScript
528 lines
16 KiB
JavaScript
const childProcess = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { pathToFileURL } = require('url');
|
|
|
|
const REQUIRED_TOOLS = [
|
|
{ command: 'libreoffice', args: ['--version'] },
|
|
{ command: 'pdftoppm', args: ['-v'] },
|
|
{ command: 'identify', args: ['-version'] },
|
|
{ command: 'convert', args: ['-version'] },
|
|
{ command: 'compare', args: ['-version'] },
|
|
{ command: 'montage', args: ['-version'] },
|
|
];
|
|
|
|
function ensureDirectory(directory) {
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
}
|
|
|
|
function resetDirectory(directory) {
|
|
fs.rmSync(directory, { recursive: true, force: true });
|
|
ensureDirectory(directory);
|
|
}
|
|
|
|
function prepareVisualDiffOutputDirectory(directory) {
|
|
ensureDirectory(directory);
|
|
for (const child of [
|
|
'work',
|
|
'pptx-rendered',
|
|
'pptx-resized',
|
|
'diff',
|
|
'side-by-side',
|
|
]) {
|
|
fs.rmSync(path.join(directory, child), { recursive: true, force: true });
|
|
}
|
|
for (const file of [
|
|
'all-side-by-side-sheet.png',
|
|
'all-diff-sheet.png',
|
|
'comparison-metrics.csv',
|
|
'pptx-visual-diff.report.json',
|
|
]) {
|
|
fs.rmSync(path.join(directory, file), { force: true });
|
|
}
|
|
}
|
|
|
|
function runCommand(command, args, options = {}) {
|
|
const result = childProcess.spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
...options,
|
|
});
|
|
return {
|
|
command,
|
|
args,
|
|
status: result.status,
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
error: result.error ? result.error.message : null,
|
|
};
|
|
}
|
|
|
|
function collectToolAvailability(tools = REQUIRED_TOOLS) {
|
|
const checked = tools.map(tool => {
|
|
const result = runCommand(tool.command, tool.args);
|
|
return {
|
|
command: tool.command,
|
|
available: !result.error && result.status !== 127 && result.status !== 126,
|
|
status: result.status,
|
|
version: (result.stdout || result.stderr).split(/\r?\n/).find(Boolean) || null,
|
|
error: result.error,
|
|
};
|
|
});
|
|
return {
|
|
checked,
|
|
missing: checked.filter(tool => !tool.available).map(tool => tool.command),
|
|
};
|
|
}
|
|
|
|
function firstNumber(value) {
|
|
const match = path.basename(value).match(/(\d+)/);
|
|
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
function collectPngSequence(directory) {
|
|
if (!directory || !fs.existsSync(directory)) {
|
|
return [];
|
|
}
|
|
return fs.readdirSync(directory)
|
|
.filter(file => /\.png$/i.test(file))
|
|
.map(file => path.join(directory, file))
|
|
.sort((left, right) => firstNumber(left) - firstNumber(right) || left.localeCompare(right));
|
|
}
|
|
|
|
function pairPngSequences(referenceDirectory, renderedDirectory) {
|
|
const references = collectPngSequence(referenceDirectory);
|
|
const rendered = collectPngSequence(renderedDirectory);
|
|
const maxLength = Math.max(references.length, rendered.length);
|
|
const pairs = [];
|
|
for (let index = 0; index < maxLength; index += 1) {
|
|
pairs.push({
|
|
slide: index + 1,
|
|
referencePath: references[index] || null,
|
|
renderedPath: rendered[index] || null,
|
|
});
|
|
}
|
|
return pairs;
|
|
}
|
|
|
|
function formatSlideFileName(slide, suffix = '.png') {
|
|
return `slide-${String(slide).padStart(2, '0')}${suffix}`;
|
|
}
|
|
|
|
function resolvePdfPath(workDirectory, pptxPath) {
|
|
const basename = path.basename(pptxPath, path.extname(pptxPath));
|
|
const candidate = path.join(workDirectory, `${basename}.pdf`);
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
const pdfs = fs.readdirSync(workDirectory)
|
|
.filter(file => /\.pdf$/i.test(file))
|
|
.map(file => path.join(workDirectory, file));
|
|
return pdfs[0] || candidate;
|
|
}
|
|
|
|
function renderPptxToPngSequence(options) {
|
|
const pptxPath = path.resolve(options.pptxPath);
|
|
const outputDirectory = path.resolve(options.outputDirectory);
|
|
const workDirectory = path.resolve(options.workDirectory || path.join(outputDirectory, 'work'));
|
|
const rawDirectory = path.join(workDirectory, 'raw-rendered');
|
|
const renderedDirectory = path.resolve(options.renderedDirectory || path.join(outputDirectory, 'pptx-rendered'));
|
|
const dpi = Number.isFinite(Number(options.dpi)) ? Number(options.dpi) : 150;
|
|
|
|
if (!fs.existsSync(pptxPath)) {
|
|
throw new Error(`PPTX file does not exist: ${pptxPath}`);
|
|
}
|
|
|
|
resetDirectory(workDirectory);
|
|
resetDirectory(rawDirectory);
|
|
resetDirectory(renderedDirectory);
|
|
const profileDirectory = path.join(workDirectory, 'libreoffice-profile');
|
|
ensureDirectory(profileDirectory);
|
|
|
|
const convertResult = runCommand('libreoffice', [
|
|
'--headless',
|
|
'--invisible',
|
|
'--nodefault',
|
|
'--nofirststartwizard',
|
|
'--norestore',
|
|
`-env:UserInstallation=${pathToFileURL(profileDirectory).toString()}`,
|
|
'--convert-to',
|
|
'pdf',
|
|
'--outdir',
|
|
workDirectory,
|
|
pptxPath,
|
|
], { timeout: Number(options.timeoutMs) || 120000 });
|
|
if (convertResult.status !== 0) {
|
|
throw new Error(`LibreOffice PPTX to PDF conversion failed: ${convertResult.stderr || convertResult.stdout || convertResult.error || 'unknown error'}`);
|
|
}
|
|
|
|
const pdfPath = resolvePdfPath(workDirectory, pptxPath);
|
|
if (!fs.existsSync(pdfPath)) {
|
|
throw new Error(`LibreOffice did not produce a PDF in ${workDirectory}`);
|
|
}
|
|
|
|
const ppmPrefix = path.join(rawDirectory, 'slide');
|
|
const ppmResult = runCommand('pdftoppm', [
|
|
'-png',
|
|
'-r',
|
|
String(dpi),
|
|
pdfPath,
|
|
ppmPrefix,
|
|
], { timeout: Number(options.timeoutMs) || 120000 });
|
|
if (ppmResult.status !== 0) {
|
|
throw new Error(`pdftoppm render failed: ${ppmResult.stderr || ppmResult.stdout || ppmResult.error || 'unknown error'}`);
|
|
}
|
|
|
|
const rawImages = collectPngSequence(rawDirectory);
|
|
const renderedImages = rawImages.map((imagePath, index) => {
|
|
const target = path.join(renderedDirectory, formatSlideFileName(index + 1));
|
|
fs.copyFileSync(imagePath, target);
|
|
return target;
|
|
});
|
|
|
|
return {
|
|
pptxPath,
|
|
pdfPath,
|
|
renderedDirectory,
|
|
renderedImages,
|
|
dpi,
|
|
commands: {
|
|
libreOffice: {
|
|
status: convertResult.status,
|
|
stderr: convertResult.stderr.trim(),
|
|
},
|
|
pdftoppm: {
|
|
status: ppmResult.status,
|
|
stderr: ppmResult.stderr.trim(),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function identifyDimensions(imagePath) {
|
|
const result = runCommand('identify', ['-format', '%w %h', imagePath], { timeout: 30000 });
|
|
if (result.status !== 0) {
|
|
throw new Error(`identify failed for ${imagePath}: ${result.stderr || result.stdout || result.error || 'unknown error'}`);
|
|
}
|
|
const [width, height] = result.stdout.trim().split(/\s+/).map(Number);
|
|
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
|
throw new Error(`identify returned invalid dimensions for ${imagePath}: ${result.stdout}`);
|
|
}
|
|
return { width, height };
|
|
}
|
|
|
|
function parseCompareMetric(output) {
|
|
const text = String(output || '').trim();
|
|
const normalizedMatch = text.match(/\(([-+]?\d*\.?\d+(?:e[-+]?\d+)?)\)/i);
|
|
if (normalizedMatch) {
|
|
return {
|
|
raw: text,
|
|
value: Number(normalizedMatch[1]),
|
|
normalized: true,
|
|
};
|
|
}
|
|
const valueMatch = text.match(/[-+]?\d*\.?\d+(?:e[-+]?\d+)?/i);
|
|
return {
|
|
raw: text,
|
|
value: valueMatch ? Number(valueMatch[0]) : null,
|
|
normalized: false,
|
|
};
|
|
}
|
|
|
|
function runCompareMetric(metric, referencePath, renderedPath, outputPath) {
|
|
const args = ['-metric', metric, referencePath, renderedPath, outputPath || 'null:'];
|
|
const result = runCommand('compare', args, { timeout: 60000 });
|
|
if (![0, 1].includes(result.status)) {
|
|
throw new Error(`ImageMagick compare ${metric} failed: ${result.stderr || result.stdout || result.error || 'unknown error'}`);
|
|
}
|
|
return parseCompareMetric(result.stderr || result.stdout);
|
|
}
|
|
|
|
function resizeRenderedImage(renderedPath, referenceDimensions, outputPath) {
|
|
const geometry = `${referenceDimensions.width}x${referenceDimensions.height}!`;
|
|
const result = runCommand('convert', [renderedPath, '-resize', geometry, outputPath], { timeout: 60000 });
|
|
if (result.status !== 0) {
|
|
throw new Error(`ImageMagick resize failed: ${result.stderr || result.stdout || result.error || 'unknown error'}`);
|
|
}
|
|
return outputPath;
|
|
}
|
|
|
|
function buildSideBySide(referencePath, renderedPath, outputPath) {
|
|
const result = runCommand('montage', [
|
|
referencePath,
|
|
renderedPath,
|
|
'-tile',
|
|
'2x1',
|
|
'-geometry',
|
|
'+12+12',
|
|
'-background',
|
|
'white',
|
|
outputPath,
|
|
], { timeout: 60000 });
|
|
if (result.status !== 0) {
|
|
throw new Error(`ImageMagick side-by-side montage failed: ${result.stderr || result.stdout || result.error || 'unknown error'}`);
|
|
}
|
|
return outputPath;
|
|
}
|
|
|
|
function buildContactSheet(inputPaths, outputPath, tile = '3x') {
|
|
if (inputPaths.length === 0) {
|
|
return null;
|
|
}
|
|
const result = runCommand('montage', [
|
|
...inputPaths,
|
|
'-tile',
|
|
tile,
|
|
'-geometry',
|
|
'+8+8',
|
|
'-background',
|
|
'white',
|
|
outputPath,
|
|
], { timeout: 120000 });
|
|
if (result.status !== 0) {
|
|
throw new Error(`ImageMagick contact sheet montage failed: ${result.stderr || result.stdout || result.error || 'unknown error'}`);
|
|
}
|
|
return outputPath;
|
|
}
|
|
|
|
function csvCell(value) {
|
|
if (value === null || value === undefined) {
|
|
return '';
|
|
}
|
|
const text = String(value);
|
|
if (/[",\n\r]/.test(text)) {
|
|
return `"${text.replace(/"/g, '""')}"`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function writeComparisonCsv(pages, outputPath) {
|
|
const headers = [
|
|
'slide',
|
|
'rmse_normalized',
|
|
'absolute_error_pixels',
|
|
'absolute_error_ratio',
|
|
'reference_width',
|
|
'reference_height',
|
|
'reference_path',
|
|
'rendered_path',
|
|
'resized_path',
|
|
'diff_path',
|
|
'side_by_side_path',
|
|
];
|
|
const rows = pages.map(page => [
|
|
page.slide,
|
|
page.rmseNormalized,
|
|
page.absoluteErrorPixels,
|
|
page.absoluteErrorRatio,
|
|
page.referenceDimensions?.width,
|
|
page.referenceDimensions?.height,
|
|
page.referencePath,
|
|
page.renderedPath,
|
|
page.resizedPath,
|
|
page.diffPath,
|
|
page.sideBySidePath,
|
|
]);
|
|
fs.writeFileSync(
|
|
outputPath,
|
|
[headers, ...rows].map(row => row.map(csvCell).join(',')).join('\n'),
|
|
'utf8',
|
|
);
|
|
return outputPath;
|
|
}
|
|
|
|
function summarizePageMetrics(pages) {
|
|
const comparablePages = pages.filter(page => Number.isFinite(page.rmseNormalized));
|
|
const meanRmse = comparablePages.length > 0
|
|
? comparablePages.reduce((sum, page) => sum + page.rmseNormalized, 0) / comparablePages.length
|
|
: null;
|
|
const maxRmse = comparablePages.length > 0
|
|
? Math.max(...comparablePages.map(page => page.rmseNormalized))
|
|
: null;
|
|
const worstSlides = [...comparablePages]
|
|
.sort((left, right) => right.rmseNormalized - left.rmseNormalized)
|
|
.slice(0, 12)
|
|
.map(page => ({
|
|
slide: page.slide,
|
|
rmseNormalized: page.rmseNormalized,
|
|
absoluteErrorRatio: page.absoluteErrorRatio,
|
|
sideBySidePath: page.sideBySidePath,
|
|
diffPath: page.diffPath,
|
|
}));
|
|
|
|
return {
|
|
pageCount: pages.length,
|
|
comparablePageCount: comparablePages.length,
|
|
missingReferenceSlides: pages.filter(page => !page.referencePath).map(page => page.slide),
|
|
missingRenderedSlides: pages.filter(page => !page.renderedPath).map(page => page.slide),
|
|
meanRmse,
|
|
maxRmse,
|
|
worstSlides,
|
|
};
|
|
}
|
|
|
|
function evaluateVisualGate(summary, thresholds) {
|
|
const maxRmse = Number(thresholds?.maxRmse);
|
|
const meanRmse = Number(thresholds?.meanRmse);
|
|
const failures = [];
|
|
if (summary.missingReferenceSlides.length > 0) {
|
|
failures.push(`Missing reference PNG for slides: ${summary.missingReferenceSlides.join(', ')}`);
|
|
}
|
|
if (summary.missingRenderedSlides.length > 0) {
|
|
failures.push(`Missing rendered PPTX PNG for slides: ${summary.missingRenderedSlides.join(', ')}`);
|
|
}
|
|
if (Number.isFinite(maxRmse) && Number.isFinite(summary.maxRmse) && summary.maxRmse > maxRmse) {
|
|
failures.push(`Max RMSE ${summary.maxRmse.toFixed(6)} exceeds ${maxRmse}`);
|
|
}
|
|
if (Number.isFinite(meanRmse) && Number.isFinite(summary.meanRmse) && summary.meanRmse > meanRmse) {
|
|
failures.push(`Mean RMSE ${summary.meanRmse.toFixed(6)} exceeds ${meanRmse}`);
|
|
}
|
|
if (summary.comparablePageCount === 0) {
|
|
failures.push('No comparable PPTX/PNG pages were found');
|
|
}
|
|
return {
|
|
passed: failures.length === 0,
|
|
failures,
|
|
thresholds: {
|
|
maxRmse: Number.isFinite(maxRmse) ? maxRmse : null,
|
|
meanRmse: Number.isFinite(meanRmse) ? meanRmse : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function comparePngSequences(options) {
|
|
const referenceDirectory = path.resolve(options.referenceDirectory);
|
|
const renderedDirectory = path.resolve(options.renderedDirectory);
|
|
const outputDirectory = path.resolve(options.outputDirectory);
|
|
const resizedDirectory = path.join(outputDirectory, 'pptx-resized');
|
|
const diffDirectory = path.join(outputDirectory, 'diff');
|
|
const sideBySideDirectory = path.join(outputDirectory, 'side-by-side');
|
|
|
|
resetDirectory(resizedDirectory);
|
|
resetDirectory(diffDirectory);
|
|
resetDirectory(sideBySideDirectory);
|
|
|
|
const pairs = pairPngSequences(referenceDirectory, renderedDirectory);
|
|
const pages = pairs.map(pair => {
|
|
if (!pair.referencePath || !pair.renderedPath) {
|
|
return {
|
|
slide: pair.slide,
|
|
referencePath: pair.referencePath,
|
|
renderedPath: pair.renderedPath,
|
|
rmseNormalized: null,
|
|
absoluteErrorPixels: null,
|
|
absoluteErrorRatio: null,
|
|
};
|
|
}
|
|
|
|
const referenceDimensions = identifyDimensions(pair.referencePath);
|
|
const resizedPath = path.join(resizedDirectory, formatSlideFileName(pair.slide));
|
|
resizeRenderedImage(pair.renderedPath, referenceDimensions, resizedPath);
|
|
const diffPath = path.join(diffDirectory, formatSlideFileName(pair.slide));
|
|
const sideBySidePath = path.join(sideBySideDirectory, formatSlideFileName(pair.slide));
|
|
const rmse = runCompareMetric('RMSE', pair.referencePath, resizedPath, diffPath);
|
|
const ae = runCompareMetric('AE', pair.referencePath, resizedPath, null);
|
|
const absoluteErrorPixels = Number.isFinite(ae.value) ? ae.value : null;
|
|
const pixelCount = referenceDimensions.width * referenceDimensions.height;
|
|
|
|
buildSideBySide(pair.referencePath, resizedPath, sideBySidePath);
|
|
|
|
return {
|
|
slide: pair.slide,
|
|
referencePath: pair.referencePath,
|
|
renderedPath: pair.renderedPath,
|
|
resizedPath,
|
|
diffPath,
|
|
sideBySidePath,
|
|
referenceDimensions,
|
|
rmseRaw: rmse.raw,
|
|
rmseNormalized: Number.isFinite(rmse.value) ? rmse.value : null,
|
|
absoluteErrorPixels,
|
|
absoluteErrorRatio: absoluteErrorPixels === null ? null : absoluteErrorPixels / pixelCount,
|
|
};
|
|
});
|
|
|
|
const summary = summarizePageMetrics(pages);
|
|
const metricsCsvPath = writeComparisonCsv(pages, path.join(outputDirectory, 'comparison-metrics.csv'));
|
|
const sideBySideSheetPath = buildContactSheet(
|
|
pages.filter(page => page.sideBySidePath).map(page => page.sideBySidePath),
|
|
path.join(outputDirectory, 'all-side-by-side-sheet.png'),
|
|
'3x',
|
|
);
|
|
const diffSheetPath = buildContactSheet(
|
|
pages.filter(page => page.diffPath).map(page => page.diffPath),
|
|
path.join(outputDirectory, 'all-diff-sheet.png'),
|
|
'3x',
|
|
);
|
|
|
|
return {
|
|
referenceDirectory,
|
|
renderedDirectory,
|
|
outputDirectory,
|
|
resizedDirectory,
|
|
diffDirectory,
|
|
sideBySideDirectory,
|
|
sideBySideSheetPath,
|
|
diffSheetPath,
|
|
metricsCsvPath,
|
|
pages,
|
|
summary,
|
|
gate: evaluateVisualGate(summary, options.thresholds || {}),
|
|
};
|
|
}
|
|
|
|
function buildPptxVisualDiff(options) {
|
|
const outputDirectory = path.resolve(options.outputDirectory);
|
|
prepareVisualDiffOutputDirectory(outputDirectory);
|
|
const tools = collectToolAvailability();
|
|
if (tools.missing.length > 0) {
|
|
return {
|
|
available: false,
|
|
outputDirectory,
|
|
tools,
|
|
error: `Missing required tools: ${tools.missing.join(', ')}`,
|
|
gate: {
|
|
passed: false,
|
|
failures: [`Missing required tools: ${tools.missing.join(', ')}`],
|
|
thresholds: options.thresholds || {},
|
|
},
|
|
};
|
|
}
|
|
|
|
const render = renderPptxToPngSequence({
|
|
pptxPath: options.pptxPath,
|
|
outputDirectory,
|
|
dpi: options.dpi,
|
|
timeoutMs: options.timeoutMs,
|
|
});
|
|
const comparison = comparePngSequences({
|
|
referenceDirectory: options.referenceDirectory,
|
|
renderedDirectory: render.renderedDirectory,
|
|
outputDirectory,
|
|
thresholds: options.thresholds,
|
|
});
|
|
const report = {
|
|
available: true,
|
|
outputDirectory,
|
|
tools,
|
|
render,
|
|
comparison,
|
|
gate: comparison.gate,
|
|
};
|
|
fs.writeFileSync(path.join(outputDirectory, 'pptx-visual-diff.report.json'), JSON.stringify(report, null, 2), 'utf8');
|
|
return report;
|
|
}
|
|
|
|
module.exports = {
|
|
REQUIRED_TOOLS,
|
|
buildPptxVisualDiff,
|
|
collectPngSequence,
|
|
collectToolAvailability,
|
|
comparePngSequences,
|
|
evaluateVisualGate,
|
|
pairPngSequences,
|
|
parseCompareMetric,
|
|
renderPptxToPngSequence,
|
|
summarizePageMetrics,
|
|
writeComparisonCsv,
|
|
};
|