feat(pdf): three-level quality (Normal/Compressed/Draft) + download modal
Build and Deploy Verso / deploy (push) Successful in 11m30s

- compilePdfQuality: 'none' | 'ebook' | 'screen' replaces compressPdf: boolean
  throughout the stack (types, context, compiler, RequestParser, CompileManager)
- Compile dropdown now shows Normal / Compressed / Draft under "PDF quality"
  (ebook=150dpi, screen=72dpi via Ghostscript)
- Download button opens a modal with the same three quality options plus
  per-option descriptions and a Ghostscript attribution note; compressed
  downloads are served by a new /download/.../output.pdf/compressed endpoint
  that fetches the last build from clsi and pipes it through GS on demand

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-07-25 12:36:10 +00:00
co-authored by Claude Sonnet 4.6
parent e5becc560c
commit 080de97632
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' 'done compile'
) )
if (request.compressPdf) { if (request.compilePdfQuality && request.compilePdfQuality !== 'none') {
await _compressOutputPdf(compileDir) await _compressOutputPdf(compileDir, request.compilePdfQuality)
} }
const { outputFiles, buildId } = await _saveOutputFiles({ 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 src = Path.join(compileDir, 'output.pdf')
const tmp = Path.join(compileDir, 'output-gs-tmp.pdf') const tmp = Path.join(compileDir, 'output-gs-tmp.pdf')
const t0 = Date.now() const t0 = Date.now()
@@ -461,7 +462,7 @@ async function _compressOutputPdf(compileDir) {
'-q', '-dNOPAUSE', '-dBATCH', '-q', '-dNOPAUSE', '-dBATCH',
'-sDEVICE=pdfwrite', '-sDEVICE=pdfwrite',
'-dCompatibilityLevel=1.7', '-dCompatibilityLevel=1.7',
'-dPDFSETTINGS=/ebook', `-dPDFSETTINGS=${preset}`,
`-sOutputFile=${tmp}`, `-sOutputFile=${tmp}`,
src, src,
], ],
+4 -4
View File
@@ -91,10 +91,10 @@ function parse(body, callback) {
default: false, default: false,
type: 'boolean', type: 'boolean',
}) })
response.compressPdf = _parseAttribute( response.compilePdfQuality = _parseAttribute(
'compressPdf', 'compilePdfQuality',
compile.options.compressPdf, compile.options.compilePdfQuality,
{ default: false, type: 'boolean' } { default: 'none', type: 'string' }
) )
response.stopOnFirstError = _parseAttribute( response.stopOnFirstError = _parseAttribute(
'stopOnFirstError', '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 ProjectApiController from './Features/Project/ProjectApiController.mjs'
import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs' import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs'
import PresentationExportController from './Features/Compile/PresentationExportController.mjs' import PresentationExportController from './Features/Compile/PresentationExportController.mjs'
import CompressedPdfController from './Features/Compile/CompressedPdfController.mjs'
import PythonRequirementsController from './Features/Compile/PythonRequirementsController.mjs' import PythonRequirementsController from './Features/Compile/PythonRequirementsController.mjs'
import ProjectListController from './Features/Project/ProjectListController.mjs' import ProjectListController from './Features/Project/ProjectListController.mjs'
import SpellingController from './Features/Spelling/SpellingController.mjs' import SpellingController from './Features/Spelling/SpellingController.mjs'
@@ -671,6 +672,13 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
CompileController.downloadPdf 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 // Align with limits defined in CompileController.downloadPdf
const rateLimiterMiddlewareOutputFiles = RateLimiterMiddleware.rateLimit( const rateLimiterMiddlewareOutputFiles = RateLimiterMiddleware.rateLimit(
rateLimiters.miscOutputDownload, rateLimiters.miscOutputDownload,
@@ -2289,9 +2289,17 @@
"typst_preview_mode": "", "typst_preview_mode": "",
"typst_preview_pdf": "", "typst_preview_pdf": "",
"typst_preview_wasm": "", "typst_preview_wasm": "",
"pdf_size": "", "pdf_quality": "",
"pdf_size_normal": "", "pdf_quality_normal": "",
"pdf_size_compressed": "", "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": "", "experimental": "",
"typst_wasm_error": "", "typst_wasm_error": "",
"typst_wasm_loading": "", "typst_wasm_loading": "",
@@ -38,11 +38,11 @@ function PdfCompileButton() {
animateCompileDropdownArrow, animateCompileDropdownArrow,
autoCompile, autoCompile,
compiling, compiling,
compressPdf, compilePdfQuality,
draft, draft,
hasChanges, hasChanges,
setAutoCompile, setAutoCompile,
setCompressPdf, setCompilePdfQuality,
setDraft, setDraft,
setStopOnValidationError, setStopOnValidationError,
stopOnFirstError, stopOnFirstError,
@@ -203,27 +203,38 @@ function PdfCompileButton() {
</DropdownItem> </DropdownItem>
</li> </li>
<DropdownDivider /> <DropdownDivider />
<DropdownHeader>{t('pdf_size')}</DropdownHeader> <DropdownHeader>{t('pdf_quality')}</DropdownHeader>
<li role="none"> <li role="none">
<DropdownItem <DropdownItem
as="button" as="button"
onClick={() => 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> </DropdownItem>
</li> </li>
<li role="none"> <li role="none">
<DropdownItem <DropdownItem
as="button" as="button"
onClick={() => 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> </DropdownItem>
</li> </li>
</> </>
@@ -23,6 +23,7 @@ import {
import LoadingSpinner from '@/shared/components/loading-spinner' import LoadingSpinner from '@/shared/components/loading-spinner'
type ExportFormat = 'html' | 'pdf' type ExportFormat = 'html' | 'pdf'
type PdfQuality = 'normal' | 'ebook' | 'screen'
function filenameFromDisposition(disposition: string | null, ext: string) { function filenameFromDisposition(disposition: string | null, ext: string) {
const match = disposition?.match(/filename="?([^"]+)"?/) const match = disposition?.match(/filename="?([^"]+)"?/)
@@ -44,6 +45,58 @@ function PdfHybridDownloadButton() {
// after they've closed it doesn't pop the modal back open. // after they've closed it doesn't pop the modal back open.
const requestIdRef = useRef(0) 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(() => { const dismiss = useCallback(() => {
requestIdRef.current += 1 requestIdRef.current += 1
setExporting(null) setExporting(null)
@@ -179,42 +232,103 @@ function PdfHybridDownloadButton() {
? t('download_pdf') ? t('download_pdf')
: t('please_compile_pdf_before_download') : 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 ( return (
<OLTooltip <>
id="download-pdf" <OLTooltip
description={description} id="download-pdf"
overlayProps={{ placement: 'bottom' }} 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')}
> >
<MaterialIcon type="download" /> <OLButton
</OLButton> onClick={() => {
</OLTooltip> 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 // if there was previously a server error
incrementalCompilesEnabled: !this.error, incrementalCompilesEnabled: !this.error,
stopOnFirstError: options.stopOnFirstError, stopOnFirstError: options.stopOnFirstError,
compressPdf: options.compressPdf, compilePdfQuality: options.compilePdfQuality,
editorId: EDITOR_SESSION_ID, editorId: EDITOR_SESSION_ID,
} }
@@ -81,8 +81,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt: _changedAt, changedAt: _changedAt,
typstPreviewMode: _typstPreviewMode, typstPreviewMode: _typstPreviewMode,
setTypstPreviewMode: _setTypstPreviewMode, setTypstPreviewMode: _setTypstPreviewMode,
compressPdf: _compressPdf, compilePdfQuality: _compilePdfQuality,
setCompressPdf: _setCompressPdf, setCompilePdfQuality: _setCompilePdfQuality,
} = localCompileContext } = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher( const [animateCompileDropdownArrow] = useDetachStateWatcher(
@@ -449,15 +449,15 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher' 'detacher'
) )
const [compressPdf] = useDetachStateWatcher( const [compilePdfQuality] = useDetachStateWatcher(
'compressPdf', 'compilePdfQuality',
_compressPdf, _compilePdfQuality,
'detacher', 'detacher',
'detached' 'detached'
) )
const setCompressPdf = useDetachAction( const setCompilePdfQuality = useDetachAction(
'setCompressPdf', 'setCompilePdfQuality',
_setCompressPdf, _setCompilePdfQuality,
'detached', 'detached',
'detacher' 'detacher'
) )
@@ -530,8 +530,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt, changedAt,
typstPreviewMode, typstPreviewMode,
setTypstPreviewMode, setTypstPreviewMode,
compressPdf, compilePdfQuality,
setCompressPdf, setCompilePdfQuality,
}), }),
[ [
animateCompileDropdownArrow, animateCompileDropdownArrow,
@@ -595,8 +595,8 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
changedAt, changedAt,
typstPreviewMode, typstPreviewMode,
setTypstPreviewMode, setTypstPreviewMode,
compressPdf, compilePdfQuality,
setCompressPdf, setCompilePdfQuality,
] ]
) )
@@ -91,8 +91,8 @@ export type CompileContext = {
pdfViewer?: string pdfViewer?: string
position?: PdfScrollPosition position?: PdfScrollPosition
rawLog?: string rawLog?: string
compressPdf: boolean compilePdfQuality: 'none' | 'ebook' | 'screen'
setCompressPdf: (value: boolean) => void setCompilePdfQuality: (value: 'none' | 'ebook' | 'screen') => void
setAutoCompile: (value: boolean) => void setAutoCompile: (value: boolean) => void
setDraft: (value: any) => void setDraft: (value: any) => void
smoothPdfTransition: boolean smoothPdfTransition: boolean
@@ -280,12 +280,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
listen: true, listen: true,
}) })
// whether to compress the PDF with ghostscript (for low-bandwidth connections) // PDF quality for compile-time GS compression ('none' | 'ebook' | 'screen')
const [compressPdf, setCompressPdf] = usePersistedState( const [compilePdfQuality, setCompilePdfQuality] = usePersistedState<
`compress_pdf:${projectId}`, 'none' | 'ebook' | 'screen'
false, >(`pdf_quality:${projectId}`, 'none', { listen: true })
{ listen: true }
)
// whether compiling should stop on first error // whether compiling should stop on first error
const [stopOnFirstError, setStopOnFirstError] = usePersistedState( const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
@@ -422,10 +420,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
compiler.setOption('draft', draft) compiler.setOption('draft', draft)
}, [compiler, draft]) }, [compiler, draft])
// keep compressPdf setting in sync with the compiler // keep compilePdfQuality setting in sync with the compiler
useEffect(() => { useEffect(() => {
compiler.setOption('compressPdf', compressPdf) compiler.setOption('compilePdfQuality', compilePdfQuality)
}, [compiler, compressPdf]) }, [compiler, compilePdfQuality])
// keep stop on first error setting in sync with the compiler // keep stop on first error setting in sync with the compiler
useEffect(() => { useEffect(() => {
@@ -875,7 +873,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
setAnimateCompileDropdownArrow, setAnimateCompileDropdownArrow,
setAutoCompile, setAutoCompile,
setCompiling, setCompiling,
setCompressPdf, setCompilePdfQuality,
setDraft, setDraft,
setError, setError,
setHasLintingError, // only for stories setHasLintingError, // only for stories
@@ -910,8 +908,8 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
changedAt, changedAt,
typstPreviewMode, typstPreviewMode,
setTypstPreviewMode, setTypstPreviewMode,
compressPdf, compilePdfQuality,
setCompressPdf, setCompilePdfQuality,
}), }),
[ [
animateCompileDropdownArrow, animateCompileDropdownArrow,
@@ -921,7 +919,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
clsiServerId, clsiServerId,
codeCheckFailed, codeCheckFailed,
compiling, compiling,
compressPdf, compilePdfQuality,
deliveryLatencies, deliveryLatencies,
draft, draft,
editedSinceCompileStarted, editedSinceCompileStarted,
@@ -941,7 +939,7 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
recompileFromScratch, recompileFromScratch,
setAnimateCompileDropdownArrow, setAnimateCompileDropdownArrow,
setAutoCompile, setAutoCompile,
setCompressPdf, setCompilePdfQuality,
setDraft, setDraft,
setError, setError,
setHasLintingError, // only for stories setHasLintingError, // only for stories
+11 -3
View File
@@ -2923,9 +2923,17 @@
"typst_preview_mode": "Preview mode", "typst_preview_mode": "Preview mode",
"typst_preview_pdf": "PDF (server)", "typst_preview_pdf": "PDF (server)",
"typst_preview_wasm": "Live (browser)", "typst_preview_wasm": "Live (browser)",
"pdf_size": "PDF size", "pdf_quality": "PDF quality",
"pdf_size_normal": "Normal", "pdf_quality_normal": "Normal",
"pdf_size_compressed": "Compressed (low bandwidth)", "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", "experimental": "experimental",
"typst_wasm_error": "Preview error", "typst_wasm_error": "Preview error",
"typst_wasm_loading": "Loading Typst compiler…", "typst_wasm_loading": "Loading Typst compiler…",
+11 -3
View File
@@ -2928,9 +2928,17 @@
"typst_preview_mode": "Mode de prévisualisation", "typst_preview_mode": "Mode de prévisualisation",
"typst_preview_pdf": "PDF (serveur)", "typst_preview_pdf": "PDF (serveur)",
"typst_preview_wasm": "Direct (navigateur)", "typst_preview_wasm": "Direct (navigateur)",
"pdf_size": "Taille du PDF", "pdf_quality": "Qualité du PDF",
"pdf_size_normal": "Normale", "pdf_quality_normal": "Normale",
"pdf_size_compressed": "Compressé (faible débit)", "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", "experimental": "expérimental",
"typst_wasm_error": "Erreur de prévisualisation", "typst_wasm_error": "Erreur de prévisualisation",
"typst_wasm_loading": "Chargement du compilateur Typst…", "typst_wasm_loading": "Chargement du compilateur Typst…",
+1 -1
View File
@@ -81,7 +81,7 @@ export type CompileResponseData = {
export type CompileOptions = { export type CompileOptions = {
draft?: boolean draft?: boolean
stopOnFirstError?: boolean stopOnFirstError?: boolean
compressPdf?: boolean compilePdfQuality?: 'none' | 'ebook' | 'screen'
isAutoCompileOnLoad?: boolean isAutoCompileOnLoad?: boolean
isAutoCompileOnChange?: boolean isAutoCompileOnChange?: boolean
rootResourcePath?: string rootResourcePath?: string