From 9d88823167b82eff24981ad594ef4d9298062bd1 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 19 Sep 2026 06:33:45 +0200 Subject: [PATCH] thumb --- src/util/generateDocumentThumb.js | 259 ++++-------------------------- 1 file changed, 28 insertions(+), 231 deletions(-) diff --git a/src/util/generateDocumentThumb.js b/src/util/generateDocumentThumb.js index 81f3a22..0dc3d59 100644 --- a/src/util/generateDocumentThumb.js +++ b/src/util/generateDocumentThumb.js @@ -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} */ -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, - }; -}