Add PDF first-page thumbnails via pdftoppm

Generated at upload time, stored alongside the PDF in data/pdfs/.
Shown in the file picker (48px) and component detail view (80px).
Gracefully skipped if pdftoppm is unavailable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 00:40:43 +00:00
parent 61d52d8076
commit 451b04ad03
3 changed files with 54 additions and 2 deletions

View File

@@ -350,6 +350,14 @@ function render_detail_panel() {
const row = document.createElement('div');
row.className = 'detail-file-row';
if (pdf.thumb_filename) {
const thumb = document.createElement('img');
thumb.className = 'pdf-thumb';
thumb.src = `/pdf/${pdf.thumb_filename}`;
thumb.alt = '';
row.appendChild(thumb);
}
const a = document.createElement('a');
a.className = 'detail-file-link';
a.href = `/pdf/${pdf.filename}`;
@@ -1363,6 +1371,14 @@ function render_file_picker_list() {
const row = document.createElement('div');
row.className = 'fp-row';
if (pdf.thumb_filename) {
const thumb = document.createElement('img');
thumb.className = 'fp-thumb';
thumb.src = `/pdf/${pdf.thumb_filename}`;
thumb.alt = '';
row.appendChild(thumb);
}
const name_el = document.createElement('span');
name_el.className = 'fp-name';
name_el.textContent = pdf.display_name;

View File

@@ -1574,7 +1574,23 @@ nav {
.detail-file-row {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
}
.pdf-thumb {
width: auto;
height: 80px;
border: 1px solid var(--border);
border-radius: 3px;
flex-shrink: 0;
}
.fp-thumb {
width: auto;
height: 48px;
border: 1px solid var(--border);
border-radius: 3px;
flex-shrink: 0;
}
.detail-file-link {

View File

@@ -5,6 +5,7 @@ import express from 'express';
import multer from 'multer';
import { unlinkSync, mkdirSync } from 'node:fs';
import { extname, join } from 'node:path';
import { execFileSync } from 'node:child_process';
import sharp from 'sharp';
import { generate_id } from './lib/ids.mjs';
import { compute_cell_size, process_grid_image } from './lib/grid-image.mjs';
@@ -62,6 +63,20 @@ function remove_image_file(img_id) {
try { unlinkSync(join('./data/images', img_id)); } catch {}
}
function generate_pdf_thumb(pdf_path, thumb_prefix) {
// Returns thumb filename (id-thumb.png) on success, null if pdftoppm unavailable.
try {
execFileSync('pdftoppm', [
'-png', '-singlefile', '-scale-to', '512',
'-f', '1', '-l', '1',
pdf_path, thumb_prefix,
]);
return thumb_prefix.split('/').pop() + '.png';
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// Field definitions
// ---------------------------------------------------------------------------
@@ -491,12 +506,16 @@ app.post('/api/pdfs', pdf_upload.single('file'), (req, res) => {
try { unlinkSync(join('./data/pdfs', req.file.filename)); } catch {}
return fail(res, 'a file with that name already exists');
}
const id = generate_id();
const thumb_prefix = join('./data/pdfs', id + '-thumb');
const thumb_file = generate_pdf_thumb(join('./data/pdfs', req.file.filename), thumb_prefix);
const pdf = {
id: generate_id(),
id,
filename: req.file.filename,
display_name,
original_name: req.file.originalname,
size: req.file.size,
thumb_filename: thumb_file,
uploaded_at: Date.now(),
};
set_pdf(pdf);
@@ -520,6 +539,7 @@ app.delete('/api/pdfs/:id', (req, res) => {
const pdf = get_pdf(req.params.id);
if (!pdf) return fail(res, 'not found', 404);
try { unlinkSync(join('./data/pdfs', pdf.filename)); } catch {}
if (pdf.thumb_filename) { try { unlinkSync(join('./data/pdfs', pdf.thumb_filename)); } catch {} }
delete_pdf(req.params.id);
ok(res);
});