This commit is contained in:
seb
2026-09-19 06:33:45 +02:00
parent b69110f1c2
commit 9d88823167

View File

@@ -1,45 +1,15 @@
import sharp from 'sharp';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const THUMB_MAX = 200;
export const DEBUG_DIR = process.env.PDF_DEBUG_DIR || path.resolve('debug/pdf');
/**
* Sanitizes a filename for filesystem storage.
* @param {string} [name]
* @returns {string}
*/
export function sanitizeFilename(name) {
if (!name || typeof name !== 'string') return 'document';
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
}
/**
* Safely writes a buffer or string to disk, ensuring directory exists.
* @param {string} filePath
* @param {Buffer|string} data
*/
export function writeDebugFile(filePath, data) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, data);
return true;
} catch (err) {
console.warn(`[pdfDebug] Failed to write debug file ${filePath}:`, err.message);
return false;
}
}
/**
* Renders page 1 of a PDF into a PNG buffer using pdftocairo.
* @param {Buffer} pdfBuffer
* @returns {Promise<{pngBuffer: Buffer, stderr: string, durationMs: number}>}
* @returns {Promise<Buffer>}
*/
export function renderPdfPageToPng(pdfBuffer) {
function renderPdfPageToPng(pdfBuffer) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const proc = spawn('pdftocairo', ['-png', '-singlefile', '-', '-'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -51,8 +21,7 @@ export function renderPdfPageToPng(pdfBuffer) {
proc.stderr.on('data', (chunk) => errChunks.push(chunk));
proc.stdin.on('error', (err) => {
// Prevent unhandled EPIPE crashes if pdftocairo exits early
console.warn('[pdfDebug][pdftocairo] stdin stream error:', err.message);
console.warn('[pdftocairo] stdin stream error:', err.message);
});
proc.on('error', (err) => {
@@ -60,236 +29,64 @@ export function renderPdfPageToPng(pdfBuffer) {
});
proc.on('close', (code) => {
const durationMs = Date.now() - startTime;
const stderr = Buffer.concat(errChunks).toString('utf8').trim();
if (code !== 0) {
const stderr = Buffer.concat(errChunks).toString('utf8').trim();
reject(new Error(`pdftocairo exited with code ${code}${stderr ? `: ${stderr}` : ''}`));
} else {
const pngBuffer = Buffer.concat(chunks);
resolve({ pngBuffer, stderr, durationMs });
resolve(Buffer.concat(chunks));
}
});
try {
proc.stdin.end(pdfBuffer);
} catch (writeErr) {
console.warn('[pdfDebug][pdftocairo] Failed writing to stdin:', writeErr.message);
console.warn('[pdftocairo] Failed writing to stdin:', writeErr.message);
}
});
}
/**
* Decodes content to a Buffer. If content is a base64 string, converts it to Buffer.
* @param {Buffer|string} rawContent
* @param {string} [mimetype]
* @returns {Buffer}
*/
export function normalizeContentBuffer(rawContent, mimetype) {
if (Buffer.isBuffer(rawContent)) {
return rawContent;
}
if (typeof rawContent === 'string') {
const trimmed = rawContent.trim();
// If it starts with %PDF or text header:
if (trimmed.startsWith('%PDF-')) {
return Buffer.from(rawContent, 'utf8');
}
// If PDF or looks like base64, attempt base64 decode
const isPdf = mimetype === 'application/pdf' || trimmed.startsWith('JVBER');
if (isPdf || /^[A-Za-z0-9+/=]+$/.test(trimmed.slice(0, 100))) {
try {
const b64 = Buffer.from(trimmed, 'base64');
if (b64.length > 0) {
return b64;
}
} catch {
// Fall back to utf8
}
}
return Buffer.from(rawContent, 'utf8');
}
return Buffer.from(rawContent || '');
}
/**
* Debugs PDF upload and thumbnail generation:
* 1. Writes source PDF to disk
* 2. Renders page 1 PNG via pdftocairo and writes to disk
* 3. Converts to AVIF thumbnail via sharp and writes to disk
* 4. Writes complete JSON metadata to disk
* Generates an AVIF thumbnail for a document (PDF or image).
* Returns { thumbavif, thumbx, thumby } or nulls if unsupported or rendering fails.
*
* @param {Buffer|string} rawContent
* @param {object} [options]
* @param {string} [options.filename='upload.pdf']
* @param {string} [options.mimetype='application/pdf']
* @param {string} [options.debugDir]
* @param {boolean} [options.saveToDisk=true]
* @returns {Promise<{thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object}>}
* @param {Buffer} content
* @param {string} mimetype
* @returns {Promise<{thumbavif: Buffer|null, thumbx: number|null, thumby: number|null}>}
*/
export async function debugGeneratePdfThumbnail(rawContent, options = {}) {
const startTime = Date.now();
const filename = options.filename || 'upload.pdf';
const mimetype = options.mimetype || 'application/pdf';
const saveToDisk = options.saveToDisk ?? true;
const targetDir = options.debugDir || DEBUG_DIR;
const buffer = normalizeContentBuffer(rawContent, mimetype);
const magic = buffer.slice(0, 8).toString('utf8').replace(/[^\x20-\x7E]/g, '.');
const isRealPdf = buffer.slice(0, 4).toString('utf8') === '%PDF';
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const safeBase = `${timestamp}_${sanitizeFilename(filename)}`;
const debugBasePath = path.join(targetDir, safeBase);
console.log(`[pdfDebug] ========================================================`);
console.log(`[pdfDebug] Processing PDF thumbnail for "${filename}"`);
console.log(`[pdfDebug] Mimetype: ${mimetype}, Size: ${buffer.length} bytes, Magic: "${magic}", Valid PDF Header: ${isRealPdf}`);
const metaLog = {
timestamp: new Date().toISOString(),
filename,
mimetype,
sizeBytes: buffer.length,
magic,
isRealPdf,
savedFiles: {},
};
// 1. Write source PDF to disk
if (saveToDisk) {
const sourcePdfPath = `${debugBasePath}-source.pdf`;
writeDebugFile(sourcePdfPath, buffer);
metaLog.savedFiles.sourcePdf = sourcePdfPath;
console.log(`[pdfDebug] Saved source PDF: ${sourcePdfPath}`);
export default async function generateDocumentThumb(content, mimetype) {
if (!Buffer.isBuffer(content) || content.length === 0) {
return { thumbavif: null, thumbx: null, thumby: null };
}
// 2. Render page 1 PNG via pdftocairo
const type = typeof mimetype === 'string' ? mimetype.trim().toLowerCase() : '';
let imageBuffer = null;
try {
const pdfRenderInfo = await renderPdfPageToPng(buffer);
imageBuffer = pdfRenderInfo.pngBuffer;
metaLog.pdftocairo = {
pngBytes: imageBuffer.length,
durationMs: pdfRenderInfo.durationMs,
stderr: pdfRenderInfo.stderr || null,
};
console.log(`[pdfDebug] pdftocairo rendered page 1 in ${pdfRenderInfo.durationMs}ms (${imageBuffer.length} bytes PNG)`);
if (pdfRenderInfo.stderr) {
console.warn(`[pdfDebug] pdftocairo stderr: ${pdfRenderInfo.stderr}`);
if (type === 'application/pdf') {
try {
imageBuffer = await renderPdfPageToPng(content);
} catch (err) {
console.warn('generateDocumentThumb: PDF render failed:', err.message);
return { thumbavif: null, thumbx: null, thumby: null };
}
if (saveToDisk) {
const page1PngPath = `${debugBasePath}-page1.png`;
writeDebugFile(page1PngPath, imageBuffer);
metaLog.savedFiles.page1Png = page1PngPath;
console.log(`[pdfDebug] Saved page 1 PNG: ${page1PngPath}`);
}
} catch (err) {
const durationMs = Date.now() - startTime;
console.error(`[pdfDebug] pdftocairo failed for "${filename}" after ${durationMs}ms:`, err.message);
metaLog.error = {
stage: 'pdftocairo',
message: err.message,
stack: err.stack,
};
metaLog.totalDurationMs = durationMs;
metaLog.success = false;
if (saveToDisk) {
writeDebugFile(`${debugBasePath}-meta.json`, JSON.stringify(metaLog, null, 2));
}
console.log(`[pdfDebug] ========================================================`);
return { thumbavif: null, thumbx: null, thumby: null, meta: metaLog };
} else if (type.startsWith('image/')) {
imageBuffer = content;
} else {
return { thumbavif: null, thumbx: null, thumby: null };
}
// 3. Sharp resize & AVIF conversion
try {
const sharpStartTime = Date.now();
const thumbResult = await sharp(imageBuffer)
.resize(THUMB_MAX, THUMB_MAX, { fit: 'inside', withoutEnlargement: true })
.avif({ effort: 4 })
.toBuffer({ resolveWithObject: true });
const sharpDurationMs = Date.now() - sharpStartTime;
const totalDurationMs = Date.now() - startTime;
metaLog.sharp = {
thumbx: thumbResult.info.width,
thumby: thumbResult.info.height,
avifBytes: thumbResult.data.length,
durationMs: sharpDurationMs,
};
metaLog.totalDurationMs = totalDurationMs;
metaLog.success = true;
console.log(`[pdfDebug] sharp generated AVIF thumbnail ${thumbResult.info.width}x${thumbResult.info.height} (${thumbResult.data.length} bytes) in ${sharpDurationMs}ms (total ${totalDurationMs}ms)`);
if (saveToDisk) {
const thumbAvifPath = `${debugBasePath}-thumb.avif`;
writeDebugFile(thumbAvifPath, thumbResult.data);
metaLog.savedFiles.thumbAvif = thumbAvifPath;
console.log(`[pdfDebug] Saved AVIF thumbnail: ${thumbAvifPath}`);
const metaJsonPath = `${debugBasePath}-meta.json`;
writeDebugFile(metaJsonPath, JSON.stringify(metaLog, null, 2));
console.log(`[pdfDebug] Saved metadata log: ${metaJsonPath}`);
}
console.log(`[pdfDebug] Successfully completed thumbnail generation for "${filename}"`);
console.log(`[pdfDebug] ========================================================`);
return {
thumbavif: thumbResult.data,
thumbx: thumbResult.info.width,
thumby: thumbResult.info.height,
meta: metaLog,
};
} catch (err) {
const totalDurationMs = Date.now() - startTime;
console.error(`[pdfDebug] sharp failed for "${filename}" after ${totalDurationMs}ms:`, err.message);
metaLog.error = {
stage: 'sharp',
message: err.message,
stack: err.stack,
};
metaLog.totalDurationMs = totalDurationMs;
metaLog.success = false;
if (saveToDisk) {
writeDebugFile(`${debugBasePath}-meta.json`, JSON.stringify(metaLog, null, 2));
}
console.log(`[pdfDebug] ========================================================`);
return {
thumbavif: null,
thumbx: null,
thumby: null,
meta: metaLog,
};
console.warn('generateDocumentThumb: sharp thumbnail generation failed:', err.message);
return { thumbavif: null, thumbx: null, thumby: null };
}
}
/**
* Default export compatible with libdashstore's generateDocumentThumb.
*/
export default async function generateDocumentThumb(content, mimetype, options = {}) {
const opts = typeof options === 'object' && options !== null ? options : {};
const result = await debugGeneratePdfThumbnail(content, {
mimetype,
filename: opts.filename,
debugDir: opts.debugDir,
saveToDisk: opts.saveToDisk ?? true,
});
return {
thumbavif: result.thumbavif,
thumbx: result.thumbx,
thumby: result.thumby,
};
}