diff --git a/server-ce/Dockerfile-base b/server-ce/Dockerfile-base index ac2b4260ba..d0bd233b17 100644 --- a/server-ce/Dockerfile-base +++ b/server-ce/Dockerfile-base @@ -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 diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js index 2e3d843baf..fe6472a546 100644 --- a/services/clsi/app/js/CompileManager.js +++ b/services/clsi/app/js/CompileManager.js @@ -247,6 +247,7 @@ async function doCompile(request, stats, timings) { environment: env, compileGroup: request.compileGroup, stopOnFirstError: request.stopOnFirstError, + exportMode: request.exportMode, stats, timings, }) diff --git a/services/clsi/app/js/QuartoRunner.js b/services/clsi/app/js/QuartoRunner.js index 268a941872..4ba220594e 100644 --- a/services/clsi/app/js/QuartoRunner.js +++ b/services/clsi/app/js/QuartoRunner.js @@ -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] } diff --git a/services/clsi/app/js/RequestParser.js b/services/clsi/app/js/RequestParser.js index d1516b9bb4..5e77715da6 100644 --- a/services/clsi/app/js/RequestParser.js +++ b/services/clsi/app/js/RequestParser.js @@ -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', diff --git a/services/web/app/src/Features/Compile/ClsiManager.mjs b/services/web/app/src/Features/Compile/ClsiManager.mjs index ab5dd73423..8c950cc34e 100644 --- a/services/web/app/src/Features/Compile/ClsiManager.mjs +++ b/services/web/app/src/Features/Compile/ClsiManager.mjs @@ -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, diff --git a/services/web/app/src/Features/Compile/PresentationExportController.mjs b/services/web/app/src/Features/Compile/PresentationExportController.mjs new file mode 100644 index 0000000000..d13aee8b83 --- /dev/null +++ b/services/web/app/src/Features/Compile/PresentationExportController.mjs @@ -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), +} diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index a0c1873028..3c7a03fece 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -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. diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index adac57a432..140a0c79fe 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx index 259cdad9f0..1019b93e8c 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.tsx @@ -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 ( + + + + + +
  • + + {t('download_as_standalone_html')} + +
  • +
  • + + {t('download_as_pdf_slides')} + +
  • +
    +
    + ) + } + const description = pdfDownloadUrl ? t('download_pdf') : t('please_compile_pdf_before_download') diff --git a/services/web/locales/en.json b/services/web/locales/en.json index be60bf36cf..d33f512f89 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 34479f93df..31f8f5ef52 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -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",