Presentation download menu: standalone HTML + faithful slide PDF (decktape)
Build and Deploy Verso / deploy (push) Failing after 24m2s
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:
@@ -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
|
||||
|
||||
@@ -247,6 +247,7 @@ async function doCompile(request, stats, timings) {
|
||||
environment: env,
|
||||
compileGroup: request.compileGroup,
|
||||
stopOnFirstError: request.stopOnFirstError,
|
||||
exportMode: request.exportMode,
|
||||
stats,
|
||||
timings,
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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": "",
|
||||
|
||||
+50
-1
@@ -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')
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -347,6 +347,8 @@
|
||||
"done": "Terminé",
|
||||
"dont_have_account": "Vous n’avez 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",
|
||||
|
||||
Reference in New Issue
Block a user