Presentation download menu: standalone HTML + faithful slide PDF (decktape)
Build and Deploy Verso / deploy (push) Failing after 24m2s

In RevealJS mode the download button becomes a 2-choice menu:

- Standalone HTML: a one-off compile with embed-resources (chalkboard and other
  runtime-only plugins are dropped, since they don't survive self-containment),
  yielding a single portable .html.
- Slide PDF: render the deck, then print it with decktape (headless Chromium)
  to a faithful one-slide-per-page PDF.

Implementation:
- Dockerfile-base: install decktape + headless Chromium (open-source; deps via
  playwright install-deps for Ubuntu-Noble correctness). Base-only change.
- QuartoRunner honours options.exportMode ('html-standalone' | 'pdf-slides');
  exportMode is threaded web ClsiManager -> CLSI RequestParser -> CompileManager
  -> runner.
- New GET /project/:id/presentation-export/:format compiles in the matching
  export mode and streams the result as a download (PresentationExportController,
  reusing ClsiManager.getOutputFileStream).
- pdf-hybrid-download-button shows the dropdown when the output is output.html;
  PDF/LaTeX projects keep the single download button.
- i18n: download_as_standalone_html / download_as_pdf_slides (en + fr +
  extracted-translations.json).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-01 21:00:50 +00:00
parent 899879472e
commit c38e2b8b49
11 changed files with 206 additions and 6 deletions
+18
View File
@@ -69,6 +69,24 @@ RUN mkdir -p /opt/quarto-extensions \
\
&& chown -R www-data:www-data /opt/quarto-extensions
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
# -----------------------------------------------------------------------
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
# reveal.js slides to a faithful, one-slide-per-page PDF. Chromium is the
# open-source engine (BSD); decktape is MIT, Puppeteer Apache-2.0 — all
# permissive and AGPL-compatible. They are invoked as a separate process
# (QuartoRunner runs `decktape ...`), never linked into the app.
#
# Puppeteer downloads its Chromium into PUPPETEER_CACHE_DIR during the global
# install; we put it in a world-readable /opt path so the www-data runtime user
# can launch it. Playwright is used only as a robust, distro-aware installer for
# Chromium's system libraries (handles Ubuntu Noble's t64 package renames).
ENV PUPPETEER_CACHE_DIR=/opt/puppeteer
RUN npm install -g decktape \
&& npx --yes playwright@latest install-deps chromium \
&& chmod -R a+rX /opt/puppeteer \
&& rm -rf /root/.npm /root/.cache
# Install TeX Live (for compiling .tex projects with latexmk)
# -----------------------------------------------------------------------
# Verso compiles .qmd with Quarto and .tex with latexmk; both engines live
+1
View File
@@ -247,6 +247,7 @@ async function doCompile(request, stats, timings) {
environment: env,
compileGroup: request.compileGroup,
stopOnFirstError: request.stopOnFirstError,
exportMode: request.exportMode,
stats,
timings,
})
+28 -5
View File
@@ -16,7 +16,7 @@ function runQuarto(compileName, options, callback) {
'starting quarto compile'
)
const command = _buildQuartoCommand(mainFile)
const command = _buildQuartoCommand(mainFile, options.exportMode)
ProcessTable[compileName] = CommandRunner.run(
compileName,
@@ -50,7 +50,7 @@ function runQuarto(compileName, options, callback) {
)
}
function _buildQuartoCommand(mainFile) {
function _buildQuartoCommand(mainFile, exportMode) {
// Run through a POSIX shell so stderr is merged into stdout (2>&1).
// LocalCommandRunner replaces $COMPILE_DIR before the shell sees it.
//
@@ -74,12 +74,35 @@ function _buildQuartoCommand(mainFile) {
// only substitutes the FIRST $COMPILE_DIR and the shell CWD is the dir.
const inputPath = `$COMPILE_DIR/${mainFile}`
const baseName = mainFile.replace(/\.[^/.]+$/, '') // strip extension
// Export modes (on-demand download from the editor), vs the normal preview
// compile:
// - 'html-standalone': a single self-contained .html (embed-resources).
// Runtime plugins like chalkboard won't work self-contained, but the
// 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.
const renderFlags =
exportMode === 'html-standalone'
? ' -M embed-resources=true -M chalkboard=false'
: ''
let tail =
`(mv ${baseName}.pdf output.pdf 2>/dev/null || ` +
`mv ${baseName}.html output.html 2>/dev/null)`
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.
tail +=
` && decktape --chrome-arg=--no-sandbox "$(pwd)/output.html" output-slides.pdf 2>&1`
}
const cmd =
`mkdir -p _extensions && ` +
`cp -rn /opt/quarto-extensions/_extensions/. _extensions/ 2>/dev/null; ` +
`quarto render ${inputPath} 2>&1 && ` +
`(mv ${baseName}.pdf output.pdf 2>/dev/null || ` +
`mv ${baseName}.html output.html 2>/dev/null)`
`quarto render ${inputPath}${renderFlags} 2>&1 && ` +
tail
return ['/bin/sh', '-c', cmd]
}
+7
View File
@@ -102,6 +102,13 @@ function parse(body, callback) {
response.check = _parseAttribute('check', compile.options.check, {
type: 'string',
})
// Verso: on-demand presentation export ('html-standalone' | 'pdf-slides'),
// honoured by QuartoRunner; empty for a normal preview compile.
response.exportMode = _parseAttribute(
'exportMode',
compile.options.exportMode,
{ default: '', type: 'string' }
)
response.flags = _parseAttribute('flags', compile.options.flags, {
default: [],
type: 'object',
@@ -1148,6 +1148,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
imageName: project.imageName,
draft: Boolean(options.draft),
stopOnFirstError: Boolean(options.stopOnFirstError),
exportMode: options.exportMode,
check: options.check,
syncType: options.syncType,
syncState: options.syncState,
@@ -0,0 +1,87 @@
import { pipeline } from 'node:stream/promises'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import CompileManager from './CompileManager.mjs'
import ClsiManager from './ClsiManager.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
// On-demand export of a RevealJS deck from the editor's download menu.
// - html → a single self-contained .html (embed-resources)
// - pdf → a faithful slide-per-page PDF (decktape / headless Chromium)
// Each triggers a one-off compile in the matching export mode, then streams the
// produced file back as a download.
const FORMATS = {
html: {
exportMode: 'html-standalone',
file: 'output.html',
ext: 'html',
contentType: 'text/html',
},
pdf: {
exportMode: 'pdf-slides',
file: 'output-slides.pdf',
ext: 'pdf',
contentType: 'application/pdf',
},
}
async function exportPresentation(req, res) {
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
const format = FORMATS[req.params.format]
if (!format) return res.status(400).send('Unknown export format')
try {
const { status, outputFiles, clsiServerId, buildId } =
await CompileManager.promises.compile(projectId, userId, {
exportMode: format.exportMode,
bypassRecentCompileCheck: true,
})
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.`
)
}
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
const stream = await ClsiManager.promises.getOutputFileStream(
projectId,
compileAsUser,
clsiServerId,
buildId,
format.file
)
const project = await ProjectGetter.promises.getProject(projectId, {
name: 1,
})
const safeName = (project?.name || 'presentation')
.replace(/[^a-zA-Z0-9-_ ]+/g, '')
.trim()
.replace(/\s+/g, '-')
res.setHeader('Content-Type', format.contentType)
res.setHeader(
'Content-Disposition',
`attachment; filename="${safeName || 'presentation'}.${format.ext}"`
)
await pipeline(stream, res)
} catch (err) {
logger.error(
{ err, projectId, format: req.params.format },
'presentation export failed'
)
if (!res.headersSent) {
res.status(500).send('Export failed. Please try compiling first.')
}
}
}
export default {
exportPresentation: expressify(exportPresentation),
}
+8
View File
@@ -4,6 +4,7 @@ import Features from './infrastructure/Features.mjs'
import ProjectController from './Features/Project/ProjectController.mjs'
import ProjectApiController from './Features/Project/ProjectApiController.mjs'
import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs'
import PresentationExportController from './Features/Compile/PresentationExportController.mjs'
import ProjectListController from './Features/Project/ProjectListController.mjs'
import SpellingController from './Features/Spelling/SpellingController.mjs'
import EditorRouter from './Features/Editor/EditorRouter.mjs'
@@ -692,6 +693,13 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
PublishedPresentationController.unpublish
)
// On-demand export of a RevealJS deck (download menu): html | pdf.
webRouter.get(
'/project/:Project_id/presentation-export/:format',
AuthorizationMiddleware.ensureUserCanReadProject,
PresentationExportController.exportPresentation
)
// Standalone viewer for a published presentation (no editor chrome).
// Visibility is enforced inside the handler: 'public' is anonymous,
// 'private' requires any logged-in Verso user.
@@ -530,7 +530,9 @@
"download": "",
"download_all": "",
"download_as_pdf": "",
"download_as_pdf_slides": "",
"download_as_source_zip": "",
"download_as_standalone_html": "",
"download_csv": "",
"download_metadata": "",
"download_pdf": "",
@@ -6,9 +6,15 @@ import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLButton from '@/shared/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
function PdfHybridDownloadButton() {
const { pdfDownloadUrl, showLogs } = useCompileContext()
const { pdfDownloadUrl, pdfFile, showLogs } = useCompileContext()
const { sendEvent } = useEditorAnalytics()
const { projectId } = useProjectContext()
const { t } = useTranslation()
@@ -16,6 +22,49 @@ function PdfHybridDownloadButton() {
if (showLogs) {
return null
}
// A RevealJS deck compiles to output.html. For it we offer two export
// choices instead of a single download: a self-contained HTML file, or a
// faithful slide PDF (each triggers a one-off server-side export render).
const isPresentation = pdfFile?.path === 'output.html'
if (isPresentation) {
return (
<Dropdown align="end">
<DropdownToggle
id="download-presentation"
variant="link"
className="pdf-toolbar-btn"
aria-label={t('download')}
>
<MaterialIcon type="download" />
</DropdownToggle>
<DropdownMenu>
<li role="none">
<DropdownItem
href={`/project/${projectId}/presentation-export/html`}
target="_blank"
rel="noopener"
download
>
{t('download_as_standalone_html')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
href={`/project/${projectId}/presentation-export/pdf`}
target="_blank"
rel="noopener"
download
>
{t('download_as_pdf_slides')}
</DropdownItem>
</li>
</DropdownMenu>
</Dropdown>
)
}
const description = pdfDownloadUrl
? t('download_pdf')
: t('please_compile_pdf_before_download')
+2
View File
@@ -691,7 +691,9 @@
"download": "Download",
"download_all": "Download all",
"download_as_pdf": "Download as PDF",
"download_as_pdf_slides": "Download PDF slides",
"download_as_source_zip": "Download as source (.zip)",
"download_as_standalone_html": "Download standalone HTML",
"download_csv": "Download CSV",
"download_metadata": "Download Overleaf metadata",
"download_pdf": "Download PDF",
+2
View File
@@ -347,6 +347,8 @@
"done": "Terminé",
"dont_have_account": "Vous navez pas de compte ?",
"download": "Télécharger",
"download_as_pdf_slides": "Télécharger les diapositives PDF",
"download_as_standalone_html": "Télécharger le HTML autonome",
"download_pdf": "Télécharger le PDF",
"download_zip_file": "Télécharger le fichier Zip",
"drag_here": "glissez ici",