thumb
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user