fix: use pdftocairo directly for thumbnails, no Docker image needed
Build and Deploy Verso / deploy (push) Successful in 15m11s

The previous implementation delegated to ConversionManager which uses
the Docker-based CommandRunner and is gated behind enablePdfConversions
(ENABLE_PDF_CONVERSIONS env var). Neither is configured in the Verso
deployment, so every thumbnail request 404'd before doing any work.

poppler-utils (which provides pdftocairo) is already installed directly
in the CLSI base image via install_deps.sh. Rewrite thumbnailFromBuild
to call pdftocairo via execFile instead — no feature flag, no Docker
image, no ConversionManager indirection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-12 09:42:35 +00:00
parent b70c8ddd0e
commit 7da02d9e3a
+31 -17
View File
@@ -1,4 +1,7 @@
import crypto from 'node:crypto'
import { execFile } from 'node:child_process'
import os from 'node:os'
import { promisify } from 'node:util'
import logger from '@overleaf/logger'
import { expressify } from '@overleaf/promise-utils'
import fs from 'node:fs/promises'
@@ -16,6 +19,8 @@ import Settings from '@overleaf/settings'
import Path from 'node:path'
import { z } from '@overleaf/validation-tools'
const execFileAsync = promisify(execFile)
const CONVERSION_CONFIGS = {
docx: { extension: 'docx' },
markdown: { extension: 'zip' },
@@ -251,9 +256,10 @@ async function convertProjectToDocument(req, res) {
}
}
// Generates a JPEG thumbnail of page 1 of the compiled output.pdf using
// pdftocairo (poppler-utils, installed in the CLSI base image). Does NOT
// require ENABLE_PDF_CONVERSIONS or the pdftocairo Docker image.
async function thumbnailFromBuild(req, res) {
if (!Settings.enablePdfConversions) return res.sendStatus(404)
const { project_id: projectId, user_id: userId, build_id: buildId } = req.params
if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400)
@@ -272,20 +278,27 @@ async function thumbnailFromBuild(req, res) {
return res.sendStatus(404)
}
const conversionId = crypto.randomUUID()
let jpegPath
try {
jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock(
conversionId,
pdfPath,
'thumbnail'
)
} catch (err) {
logger.warn({ err, projectId, buildId }, 'thumbnail conversion failed')
return res.sendStatus(500)
}
const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-'))
const outputBase = Path.join(tmpDir, 'thumb')
const jpegPath = outputBase + '.jpg'
try {
await execFileAsync(
'pdftocairo',
[
'-jpeg',
'-jpegopt', 'quality=50',
'-singlefile',
'-scale-to-x', '190',
'-scale-to-y', '-1',
'-f', '1',
'-l', '1',
pdfPath,
outputBase,
],
{ timeout: 30000 }
)
const jpegStat = await fs.stat(jpegPath)
res.setHeader('Content-Type', 'image/jpeg')
res.setHeader('Content-Length', jpegStat.size)
@@ -293,10 +306,11 @@ async function thumbnailFromBuild(req, res) {
res.setHeader('X-Content-Type-Options', 'nosniff')
const readStream = fsSync.createReadStream(jpegPath)
await pipeline(readStream, res)
} catch (err) {
logger.warn({ err, projectId, buildId }, 'thumbnail generation failed')
if (!res.headersSent) res.sendStatus(500)
} finally {
await fs
.rm(Path.dirname(jpegPath), { recursive: true, force: true })
.catch(() => {})
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
}
}