Fix presentation export: Quarto -M uses colon syntax, harden decktape
Build and Deploy Verso / deploy (push) Successful in 8m13s

The standalone-HTML export produced a non-self-contained file (no slide
CSS/JS, math or images when opened away from the server) because Quarto's
--metadata/-M flag uses KEY:VALUE (colon), not KEY=VALUE. '-M
embed-resources=true' silently registered a bogus key and left
embed-resources unset. Switch to colon syntax and also embed MathJax
(self-contained-math:true) so equations render offline.

For the slide PDF, add --disable-dev-shm-usage (the usual cause of
Chromium crashing inside a container with a small /dev/shm), and have the
export controller return the compile log as text/plain on failure so a
failed PDF export shows the real decktape/Chromium error instead of an
HTML page the browser saves as 'pdf.htm'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-02 07:11:51 +00:00
parent c38e2b8b49
commit 4d9adb2723
2 changed files with 49 additions and 8 deletions
+14 -2
View File
@@ -82,9 +82,18 @@ function _buildQuartoCommand(mainFile, exportMode) {
// deck compiles and is fully portable/offline.
// - 'pdf-slides': render the deck, then print it to a faithful slide PDF
// with decktape (headless Chromium), one slide per page.
// NB: Quarto's --metadata/-M flag uses COLON syntax (KEY:VALUE), not "=".
// Using "=" makes Quarto register a bogus key literally named
// "embed-resources=true" and silently leave the real option unset, so the
// deck stays non-self-contained (references a sibling "<base>_files/" dir).
// - embed-resources: inline CSS/JS/images into the single .html
// - self-contained-math: also inline MathJax (not embedded by default),
// so equations render in a fully offline/portable file
// - chalkboard: must be off — it is incompatible with embed-resources and
// errors the render if left enabled
const renderFlags =
exportMode === 'html-standalone'
? ' -M embed-resources=true -M chalkboard=false'
? ' -M embed-resources:true -M self-contained-math:true -M chalkboard:false'
: ''
let tail =
@@ -94,8 +103,11 @@ function _buildQuartoCommand(mainFile, exportMode) {
if (exportMode === 'pdf-slides') {
// After producing output.html, print it to output-slides.pdf. --no-sandbox
// is required for Chromium running as a non-root user inside the container.
// --no-sandbox: Chromium can't sandbox as a non-root container user.
// --disable-dev-shm-usage: containers usually have a tiny /dev/shm, which
// makes Chromium crash mid-render; this routes its shared memory to /tmp.
tail +=
` && decktape --chrome-arg=--no-sandbox "$(pwd)/output.html" output-slides.pdf 2>&1`
` && decktape --chrome-arg=--no-sandbox --chrome-arg=--disable-dev-shm-usage "$(pwd)/output.html" output-slides.pdf 2>&1`
}
const cmd =
@@ -27,6 +27,29 @@ const FORMATS = {
},
}
// Best-effort fetch of the export compile's output.log so export failures can
// show the underlying error (e.g. decktape/Chromium output). Never throws.
async function _fetchLog(projectId, userId, clsiServerId, buildId) {
if (!buildId) return ''
try {
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
const stream = await ClsiManager.promises.getOutputFileStream(
projectId,
compileAsUser,
clsiServerId,
buildId,
'output.log'
)
const chunks = []
for await (const chunk of stream) chunks.push(chunk)
const text = Buffer.concat(chunks).toString('utf8')
// Keep it bounded — the tail holds the relevant error.
return text.length > 8000 ? text.slice(-8000) : text
} catch {
return ''
}
}
async function exportPresentation(req, res) {
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
@@ -41,12 +64,18 @@ async function exportPresentation(req, res) {
})
if (!buildId || !outputFiles?.some(f => f.path === format.file)) {
return res
.status(400)
.send(
`Export failed: the project did not produce ${format.file} (compile status: ${status}). ` +
`This export is only available for RevealJS presentations.`
)
// The expected artefact wasn't produced. Surface the compile log (which
// for the PDF path includes decktape/Chromium's own stderr) as plain
// text so the failure is diagnosable, instead of returning an HTML page
// the browser saves as "pdf.htm".
const log = await _fetchLog(projectId, userId, clsiServerId, buildId)
res.status(400).type('text/plain')
return res.send(
`Export failed: the project did not produce ${format.file} ` +
`(compile status: ${status}). This export is only available for ` +
`RevealJS presentations.\n\n` +
(log ? `--- compile log ---\n${log}` : '(no compile log available)')
)
}
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId