PDF preview: thumbnails via Ghostscript, preview in file cards

- GET /pdf/preview/:fileId — renders first page as PNG (72 DPI)
- GET /pdf/preview/:fileId/:page — specific page preview
- GET /pdf/thumbnails/:fileId — list of all page preview URLs
- File cards show actual PDF thumbnail instead of icon
- Cached previews (30 min cleanup)
- Fallback to PDF icon if preview fails
This commit is contained in:
treamz 2026-03-22 01:22:25 +03:00
parent ee49fed415
commit 4bd90b26bc
2 changed files with 63 additions and 1 deletions

View File

@ -147,6 +147,19 @@
.file-card-icon {
flex-shrink: 0;
color: var(--accent);
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-600);
}
.file-card-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-card-info {
flex: 1;
@ -1071,7 +1084,7 @@
card.dataset.id = f.id;
card.innerHTML = `
<div class="file-card-order">${idx + 1}</div>
<div class="file-card-icon">${pdfIcon(20)}</div>
<div class="file-card-icon"><img src="/pdf/preview/${f.id}" alt="" onerror="this.parentNode.innerHTML=pdfIcon(20)"></div>
<div class="file-card-info">
<div class="file-card-name">${escHtml(f.name)}</div>
<div class="file-card-meta">${formatSize(f.size)} · ${f.pages} стр.</div>

View File

@ -101,6 +101,55 @@ router.post('/upload', limiter, upload.array('files', 10), async (req, res) => {
res.json({ files: results });
});
// Preview — render PDF page as PNG thumbnail via Ghostscript
router.get('/preview/:fileId', (req, res) => renderPreview(req, res, 1));
router.get('/preview/:fileId/:page', (req, res) => renderPreview(req, res, parseInt(req.params.page) || 1));
async function renderPreview(req, res, pageNum) {
const f = getFile(req.params.fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const cacheKey = `${f.id}_p${pageNum}`;
const cachePath = path.join(DOWNLOADS_DIR, `preview_${cacheKey}.png`);
// Return cached if exists
if (fs.existsSync(cachePath)) {
return res.type('png').sendFile(cachePath);
}
try {
await new Promise((resolve, reject) => {
const proc = spawn('gs', [
'-sDEVICE=png16m', '-r72', '-dNOPAUSE', '-dBATCH', '-dQUIET',
`-dFirstPage=${pageNum}`, `-dLastPage=${pageNum}`,
'-dTextAlphaBits=4', '-dGraphicsAlphaBits=4',
`-sOutputFile=${cachePath}`, f.path,
], { timeout: 15000 });
proc.on('close', code => code === 0 ? resolve() : reject(new Error('Preview failed')));
proc.on('error', reject);
});
// Cleanup after 30 min
setTimeout(() => { try { fs.unlinkSync(cachePath); } catch {} }, 30 * 60 * 1000);
res.type('png').sendFile(cachePath);
} catch (err) {
res.status(500).json({ error: 'Не удалось создать превью' });
}
}
// Thumbnails — return preview URLs for all pages
router.get('/thumbnails/:fileId', async (req, res) => {
const f = getFile(req.params.fileId);
if (!f) return res.status(404).json({ error: 'Файл не найден' });
const urls = [];
for (let i = 1; i <= Math.min(f.pages, 50); i++) {
urls.push(`/pdf/preview/${f.id}/${i}`);
}
res.json({ pages: f.pages, thumbnails: urls });
});
// Info
router.get('/info/:fileId', async (req, res) => {
const f = getFile(req.params.fileId);