Merge branch 'main' into prod
Build and Deploy Verso (prod) / deploy (push) Successful in 14m20s

This commit is contained in:
claude
2026-07-25 12:39:20 +00:00
13 changed files with 346 additions and 90 deletions
+5 -4
View File
@@ -318,8 +318,8 @@ async function doCompile(request, stats, timings) {
'done compile'
)
if (request.compressPdf) {
await _compressOutputPdf(compileDir)
if (request.compilePdfQuality && request.compilePdfQuality !== 'none') {
await _compressOutputPdf(compileDir, request.compilePdfQuality)
}
const { outputFiles, buildId } = await _saveOutputFiles({
@@ -449,7 +449,8 @@ async function clearExpiredProjects(maxCacheAgeMs) {
}
}
async function _compressOutputPdf(compileDir) {
async function _compressOutputPdf(compileDir, quality) {
const preset = quality === 'screen' ? '/screen' : '/ebook'
const src = Path.join(compileDir, 'output.pdf')
const tmp = Path.join(compileDir, 'output-gs-tmp.pdf')
const t0 = Date.now()
@@ -461,7 +462,7 @@ async function _compressOutputPdf(compileDir) {
'-q', '-dNOPAUSE', '-dBATCH',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.7',
'-dPDFSETTINGS=/ebook',
`-dPDFSETTINGS=${preset}`,
`-sOutputFile=${tmp}`,
src,
],
+4 -4
View File
@@ -91,10 +91,10 @@ function parse(body, callback) {
default: false,
type: 'boolean',
})
response.compressPdf = _parseAttribute(
'compressPdf',
compile.options.compressPdf,
{ default: false, type: 'boolean' }
response.compilePdfQuality = _parseAttribute(
'compilePdfQuality',
compile.options.compilePdfQuality,
{ default: 'none', type: 'string' }
)
response.stopOnFirstError = _parseAttribute(
'stopOnFirstError',
@@ -0,0 +1,100 @@
import { pipeline } from 'node:stream/promises'
import { createWriteStream, createReadStream } from 'node:fs'
import { unlink, mkdtemp } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { spawn } from 'node:child_process'
import logger from '@overleaf/logger'
import Settings from '@overleaf/settings'
import { expressify } from '@overleaf/promise-utils'
import SessionManager from '../Authentication/SessionManager.mjs'
import ClsiManager from './ClsiManager.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
const GS_PRESETS = { ebook: '/ebook', screen: '/screen' }
async function _compressStream(inputStream, quality) {
const preset = GS_PRESETS[quality] || '/ebook'
const dir = await mkdtemp(join(tmpdir(), 'verso-pdf-'))
const inPath = join(dir, 'input.pdf')
const outPath = join(dir, 'output.pdf')
try {
await pipeline(inputStream, createWriteStream(inPath))
await new Promise((resolve, reject) => {
const proc = spawn(
'gs',
[
'-q', '-dNOPAUSE', '-dBATCH',
'-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.7',
`-dPDFSETTINGS=${preset}`,
`-sOutputFile=${outPath}`,
inPath,
],
{ stdio: 'ignore' }
)
proc.on('error', reject)
proc.on('close', code => {
if (code === 0) resolve()
else reject(new Error(`gs exited ${code}`))
})
})
return { stream: createReadStream(outPath), cleanup: () => Promise.all([unlink(inPath), unlink(outPath)]).catch(() => {}) }
} catch (err) {
await Promise.all([unlink(inPath).catch(() => {}), unlink(outPath).catch(() => {})])
throw err
}
}
async function downloadCompressedPdf(req, res) {
const projectId = req.params.Project_id
const buildId = req.params.build_id
const quality = req.query.quality
const clsiServerId = req.query.clsiserverid || null
const editorId = req.query.editorId || null
if (!GS_PRESETS[quality]) {
return res.status(400).send('Invalid quality. Use "ebook" or "screen".')
}
const userId = SessionManager.getLoggedInUserId(req.session)
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
let cleanup = null
try {
const inputStream = await ClsiManager.promises.getOutputFileStream(
projectId,
compileAsUser,
clsiServerId,
buildId,
'output.pdf'
)
const { stream, cleanup: _cleanup } = await _compressStream(inputStream, quality)
cleanup = _cleanup
const project = await ProjectGetter.promises.getProject(projectId, { name: 1 })
const safeName = (project?.name || 'document')
.replace(/[^\p{L}\p{Nd}]/gu, '_')
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.pdf"`)
await pipeline(stream, res)
} catch (err) {
logger.error({ err, projectId, quality }, 'compressed pdf download failed')
if (!res.headersSent) {
res.status(500).send('Compression failed. Please try compiling first.')
}
} finally {
if (cleanup) await cleanup()
}
}
export default {
downloadCompressedPdf: expressify(downloadCompressedPdf),
}
+8
View File
@@ -5,6 +5,7 @@ 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 CompressedPdfController from './Features/Compile/CompressedPdfController.mjs'
import PythonRequirementsController from './Features/Compile/PythonRequirementsController.mjs'
import ProjectListController from './Features/Project/ProjectListController.mjs'
import SpellingController from './Features/Spelling/SpellingController.mjs'
@@ -671,6 +672,13 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.downloadPdf
)
// On-demand GS-compressed PDF download (quality=ebook|screen)
webRouter.get(
'/download/project/:Project_id/build/:build_id/output/output.pdf/compressed',
AuthorizationMiddleware.ensureUserCanReadProject,
CompressedPdfController.downloadCompressedPdf
)
// Align with limits defined in CompileController.downloadPdf
const rateLimiterMiddlewareOutputFiles = RateLimiterMiddleware.rateLimit(
rateLimiters.miscOutputDownload,
@@ -2289,9 +2289,17 @@
"typst_preview_mode": "",
"typst_preview_pdf": "",
"typst_preview_wasm": "",
"pdf_size": "",
"pdf_size_normal": "",
"pdf_size_compressed": "",
"pdf_quality": "",
"pdf_quality_normal": "",
"pdf_quality_compressed": "",
"pdf_quality_draft": "",
"pdf_download_quality_label": "",
"pdf_download_normal_desc": "",
"pdf_download_ebook_desc": "",
"pdf_download_screen_desc": "",
"pdf_download_ghostscript_note": "",
"pdf_download_compression_failed": "",
"downloading": "",
"experimental": "",
"typst_wasm_error": "",
"typst_wasm_loading": "",
@@ -38,11 +38,11 @@ function PdfCompileButton() {
animateCompileDropdownArrow,
autoCompile,
compiling,
compressPdf,
compilePdfQuality,
draft,
hasChanges,
setAutoCompile,
setCompressPdf,
setCompilePdfQuality,
setDraft,
setStopOnValidationError,
stopOnFirstError,
@@ -203,27 +203,38 @@ function PdfCompileButton() {
</DropdownItem>
</li>
<DropdownDivider />
<DropdownHeader>{t('pdf_size')}</DropdownHeader>
<DropdownHeader>{t('pdf_quality')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(false, setCompressPdf, 'pdf-size')
sendEventAndSet('none', setCompilePdfQuality, 'pdf-quality')
}
trailingIcon={!compressPdf ? 'check' : null}
trailingIcon={compilePdfQuality === 'none' ? 'check' : null}
>
{t('pdf_size_normal')}
{t('pdf_quality_normal')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet(true, setCompressPdf, 'pdf-size')
sendEventAndSet('ebook', setCompilePdfQuality, 'pdf-quality')
}
trailingIcon={compressPdf ? 'check' : null}
trailingIcon={compilePdfQuality === 'ebook' ? 'check' : null}
>
{t('pdf_size_compressed')}
{t('pdf_quality_compressed')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() =>
sendEventAndSet('screen', setCompilePdfQuality, 'pdf-quality')
}
trailingIcon={compilePdfQuality === 'screen' ? 'check' : null}
>
{t('pdf_quality_draft')}
</DropdownItem>
</li>
</>
@@ -23,6 +23,7 @@ import {
import LoadingSpinner from '@/shared/components/loading-spinner'
type ExportFormat = 'html' | 'pdf'
type PdfQuality = 'normal' | 'ebook' | 'screen'
function filenameFromDisposition(disposition: string | null, ext: string) {
const match = disposition?.match(/filename="?([^"]+)"?/)
@@ -44,6 +45,58 @@ function PdfHybridDownloadButton() {
// after they've closed it doesn't pop the modal back open.
const requestIdRef = useRef(0)
// Compressed PDF download modal (for regular PDF, not presentations)
const [showDownloadModal, setShowDownloadModal] = useState(false)
const [downloadQuality, setDownloadQuality] = useState<PdfQuality>('normal')
const [downloading, setDownloading] = useState(false)
const [downloadError, setDownloadError] = useState<string | null>(null)
const startCompressedDownload = useCallback(async () => {
if (!pdfDownloadUrl) return
if (downloadQuality === 'normal') {
// Direct download — no server-side compression needed
const link = document.createElement('a')
link.href = pdfDownloadUrl
link.download = ''
document.body.appendChild(link)
link.click()
link.remove()
setShowDownloadModal(false)
return
}
setDownloading(true)
setDownloadError(null)
try {
// Derive the compressed URL from the existing pdfDownloadUrl by appending /compressed
const compressedUrl = pdfDownloadUrl.replace(
/\/output\.pdf(\?|$)/,
`/output.pdf/compressed$1`
) + (pdfDownloadUrl.includes('?') ? '&' : '?') + `quality=${downloadQuality}`
const response = await fetch(compressedUrl, { credentials: 'same-origin' })
if (!response.ok) {
setDownloadError(t('pdf_download_compression_failed'))
return
}
const blob = await response.blob()
const disposition = response.headers.get('Content-Disposition')
const match = disposition?.match(/filename="?([^"]+)"?/)
const filename = match ? match[1] : 'document.pdf'
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
setShowDownloadModal(false)
} catch (err) {
setDownloadError(err instanceof Error ? err.message : String(err))
} finally {
setDownloading(false)
}
}, [pdfDownloadUrl, downloadQuality, t])
const dismiss = useCallback(() => {
requestIdRef.current += 1
setExporting(null)
@@ -179,42 +232,103 @@ function PdfHybridDownloadButton() {
? t('download_pdf')
: t('please_compile_pdf_before_download')
function handleOnClick(e: React.MouseEvent) {
const event = e as React.MouseEvent<HTMLAnchorElement>
if (event.currentTarget.dataset.disabled === 'true') {
event.preventDefault()
return
}
sendEvent('download-pdf-button-click', {
projectId,
location: 'pdf-preview',
isSmallDevice,
})
}
return (
<OLTooltip
id="download-pdf"
description={description}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
onClick={handleOnClick}
variant="link"
className="pdf-toolbar-btn"
draggable={false}
data-disabled={!pdfDownloadUrl}
disabled={!pdfDownloadUrl}
download
href={pdfDownloadUrl || '#'}
target="_blank"
style={{ pointerEvents: 'auto' }}
aria-label={t('download_pdf')}
<>
<OLTooltip
id="download-pdf"
description={description}
overlayProps={{ placement: 'bottom' }}
>
<MaterialIcon type="download" />
</OLButton>
</OLTooltip>
<OLButton
onClick={() => {
if (!pdfDownloadUrl) return
sendEvent('download-pdf-button-click', {
projectId,
location: 'pdf-preview',
isSmallDevice,
})
setShowDownloadModal(true)
}}
variant="link"
className="pdf-toolbar-btn"
draggable={false}
data-disabled={!pdfDownloadUrl}
disabled={!pdfDownloadUrl}
style={{ pointerEvents: 'auto' }}
aria-label={t('download_pdf')}
>
<MaterialIcon type="download" />
</OLButton>
</OLTooltip>
<OLModal
show={showDownloadModal}
onHide={() => {
setShowDownloadModal(false)
setDownloadError(null)
}}
>
<OLModalHeader closeButton>
<OLModalTitle>{t('download_pdf')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p className="mb-2">{t('pdf_download_quality_label')}</p>
<div className="d-flex flex-column gap-2 mb-3">
{(
[
{ value: 'normal', label: t('pdf_quality_normal'), desc: t('pdf_download_normal_desc') },
{ value: 'ebook', label: t('pdf_quality_compressed'), desc: t('pdf_download_ebook_desc') },
{ value: 'screen', label: t('pdf_quality_draft'), desc: t('pdf_download_screen_desc') },
] as { value: PdfQuality; label: string; desc: string }[]
).map(opt => (
<label key={opt.value} className="d-flex align-items-start gap-2" style={{ cursor: 'pointer' }}>
<input
type="radio"
name="pdf-quality"
value={opt.value}
checked={downloadQuality === opt.value}
onChange={() => setDownloadQuality(opt.value)}
className="mt-1"
/>
<span>
<strong>{opt.label}</strong>
<br />
<small className="text-muted">{opt.desc}</small>
</span>
</label>
))}
</div>
{downloadQuality !== 'normal' && (
<p className="text-muted mb-0" style={{ fontSize: '0.8em' }}>
{t('pdf_download_ghostscript_note')}
</p>
)}
{downloadError && (
<p className="text-danger mb-0 mt-2">{downloadError}</p>
)}
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => {
setShowDownloadModal(false)
setDownloadError(null)
}}
>
{t('cancel')}
</OLButton>
<OLButton
variant="primary"
onClick={startCompressedDownload}
isLoading={downloading}
loadingLabel={`${t('downloading')}`}
disabled={downloading}
>
{t('download')}
</OLButton>
</OLModalFooter>
</OLModal>
</>
)
}
@@ -147,7 +147,7 @@ export default class DocumentCompiler {
// if there was previously a server error
incrementalCompilesEnabled: !this.error,
stopOnFirstError: options.stopOnFirstError,
compressPdf: options.compressPdf,
compilePdfQuality: options.compilePdfQuality,
editorId: EDITOR_SESSION_ID,
}
@@ -81,8 +81,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt: _changedAt,
typstPreviewMode: _typstPreviewMode,
setTypstPreviewMode: _setTypstPreviewMode,
compressPdf: _compressPdf,
setCompressPdf: _setCompressPdf,
compilePdfQuality: _compilePdfQuality,
setCompilePdfQuality: _setCompilePdfQuality,
} = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher(
@@ -449,15 +449,15 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher'
)
const [compressPdf] = useDetachStateWatcher(
'compressPdf',
_compressPdf,
const [compilePdfQuality] = useDetachStateWatcher(
'compilePdfQuality',
_compilePdfQuality,
'detacher',
'detached'
)
const setCompressPdf = useDetachAction(
'setCompressPdf',
_setCompressPdf,
const setCompilePdfQuality = useDetachAction(
'setCompilePdfQuality',
_setCompilePdfQuality,
'detached',
'detacher'
)
@@ -530,8 +530,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt,
typstPreviewMode,
setTypstPreviewMode,
compressPdf,
setCompressPdf,
compilePdfQuality,
setCompilePdfQuality,
}),
[
animateCompileDropdownArrow,
@@ -595,8 +595,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt,
typstPreviewMode,
setTypstPreviewMode,
compressPdf,
setCompressPdf,
compilePdfQuality,
setCompilePdfQuality,
]
)
@@ -91,8 +91,8 @@ export type CompileContext = {
pdfViewer?: string
position?: PdfScrollPosition
rawLog?: string
compressPdf: boolean
setCompressPdf: (value: boolean) => void
compilePdfQuality: 'none' | 'ebook' | 'screen'
setCompilePdfQuality: (value: 'none' | 'ebook' | 'screen') => void
setAutoCompile: (value: boolean) => void
setDraft: (value: any) => void
smoothPdfTransition: boolean
@@ -280,12 +280,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
listen: true,
})
// whether to compress the PDF with ghostscript (for low-bandwidth connections)
const [compressPdf, setCompressPdf] = usePersistedState(
`compress_pdf:${projectId}`,
false,
{ listen: true }
)
// PDF quality for compile-time GS compression ('none' | 'ebook' | 'screen')
const [compilePdfQuality, setCompilePdfQuality] = usePersistedState<
'none' | 'ebook' | 'screen'
>(`pdf_quality:${projectId}`, 'none', { listen: true })
// whether compiling should stop on first error
const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
@@ -422,10 +420,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
compiler.setOption('draft', draft)
}, [compiler, draft])
// keep compressPdf setting in sync with the compiler
// keep compilePdfQuality setting in sync with the compiler
useEffect(() => {
compiler.setOption('compressPdf', compressPdf)
}, [compiler, compressPdf])
compiler.setOption('compilePdfQuality', compilePdfQuality)
}, [compiler, compilePdfQuality])
// keep stop on first error setting in sync with the compiler
useEffect(() => {
@@ -875,7 +873,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
setAnimateCompileDropdownArrow,
setAutoCompile,
setCompiling,
setCompressPdf,
setCompilePdfQuality,
setDraft,
setError,
setHasLintingError, // only for stories
@@ -910,8 +908,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
changedAt,
typstPreviewMode,
setTypstPreviewMode,
compressPdf,
setCompressPdf,
compilePdfQuality,
setCompilePdfQuality,
}),
[
animateCompileDropdownArrow,
@@ -921,7 +919,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
clsiServerId,
codeCheckFailed,
compiling,
compressPdf,
compilePdfQuality,
deliveryLatencies,
draft,
editedSinceCompileStarted,
@@ -941,7 +939,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
recompileFromScratch,
setAnimateCompileDropdownArrow,
setAutoCompile,
setCompressPdf,
setCompilePdfQuality,
setDraft,
setError,
setHasLintingError, // only for stories
+11 -3
View File
@@ -2923,9 +2923,17 @@
"typst_preview_mode": "Preview mode",
"typst_preview_pdf": "PDF (server)",
"typst_preview_wasm": "Live (browser)",
"pdf_size": "PDF size",
"pdf_size_normal": "Normal",
"pdf_size_compressed": "Compressed (low bandwidth)",
"pdf_quality": "PDF quality",
"pdf_quality_normal": "Normal",
"pdf_quality_compressed": "Compressed",
"pdf_quality_draft": "Draft",
"pdf_download_quality_label": "Choose the quality for this download:",
"pdf_download_normal_desc": "Full quality, original file size.",
"pdf_download_ebook_desc": "Reduced file size, 150 dpi images. Good for sharing.",
"pdf_download_screen_desc": "Smallest file size, 72 dpi images. For low-bandwidth or slow devices.",
"pdf_download_ghostscript_note": "Compression is applied server-side using Ghostscript.",
"pdf_download_compression_failed": "Compression failed. Please try again.",
"downloading": "Downloading",
"experimental": "experimental",
"typst_wasm_error": "Preview error",
"typst_wasm_loading": "Loading Typst compiler…",
+11 -3
View File
@@ -2928,9 +2928,17 @@
"typst_preview_mode": "Mode de prévisualisation",
"typst_preview_pdf": "PDF (serveur)",
"typst_preview_wasm": "Direct (navigateur)",
"pdf_size": "Taille du PDF",
"pdf_size_normal": "Normale",
"pdf_size_compressed": "Compressé (faible débit)",
"pdf_quality": "Qualité du PDF",
"pdf_quality_normal": "Normale",
"pdf_quality_compressed": "Compressé",
"pdf_quality_draft": "Brouillon",
"pdf_download_quality_label": "Choisissez la qualité pour ce téléchargement :",
"pdf_download_normal_desc": "Qualité maximale, taille de fichier originale.",
"pdf_download_ebook_desc": "Taille réduite, images à 150 dpi. Idéal pour le partage.",
"pdf_download_screen_desc": "Taille minimale, images à 72 dpi. Pour connexions lentes ou appareils peu puissants.",
"pdf_download_ghostscript_note": "La compression est appliquée côté serveur via Ghostscript.",
"pdf_download_compression_failed": "La compression a échoué. Veuillez réessayer.",
"downloading": "Téléchargement en cours",
"experimental": "expérimental",
"typst_wasm_error": "Erreur de prévisualisation",
"typst_wasm_loading": "Chargement du compilateur Typst…",
+1 -1
View File
@@ -81,7 +81,7 @@ export type CompileResponseData = {
export type CompileOptions = {
draft?: boolean
stopOnFirstError?: boolean
compressPdf?: boolean
compilePdfQuality?: 'none' | 'ebook' | 'screen'
isAutoCompileOnLoad?: boolean
isAutoCompileOnChange?: boolean
rootResourcePath?: string