From 080de97632fbb8d43f384f4433f4eec7368e6a45 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 12:36:10 +0000 Subject: [PATCH] feat(pdf): three-level quality (Normal/Compressed/Draft) + download modal - 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 --- services/clsi/app/js/CompileManager.js | 9 +- services/clsi/app/js/RequestParser.js | 8 +- .../Compile/CompressedPdfController.mjs | 100 ++++++++++ services/web/app/src/router.mjs | 8 + .../web/frontend/extracted-translations.json | 14 +- .../components/pdf-compile-button.tsx | 29 ++- .../components/pdf-hybrid-download-button.tsx | 182 ++++++++++++++---- .../js/features/pdf-preview/util/compiler.ts | 2 +- .../shared/context/detach-compile-context.tsx | 24 +-- .../shared/context/local-compile-context.tsx | 30 ++- services/web/locales/en.json | 14 +- services/web/locales/fr.json | 14 +- services/web/types/compile.ts | 2 +- 13 files changed, 346 insertions(+), 90 deletions(-) create mode 100644 services/web/app/src/Features/Compile/CompressedPdfController.mjs diff --git a/services/clsi/app/js/CompileManager.js b/services/clsi/app/js/CompileManager.js index 963fca401b..d6fea14e0a 100644 --- a/services/clsi/app/js/CompileManager.js +++ b/services/clsi/app/js/CompileManager.js @@ -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, ], diff --git a/services/clsi/app/js/RequestParser.js b/services/clsi/app/js/RequestParser.js index 05f2e4bf11..fecf4c7891 100644 --- a/services/clsi/app/js/RequestParser.js +++ b/services/clsi/app/js/RequestParser.js @@ -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', diff --git a/services/web/app/src/Features/Compile/CompressedPdfController.mjs b/services/web/app/src/Features/Compile/CompressedPdfController.mjs new file mode 100644 index 0000000000..e52e934df0 --- /dev/null +++ b/services/web/app/src/Features/Compile/CompressedPdfController.mjs @@ -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), +} diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index 3fed51f239..61ed9c4b3b 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -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, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index d9339ec12d..f880b8e469 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx index 731961215a..2ce4cf6f8c 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.tsx @@ -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() { - {t('pdf_size')} + {t('pdf_quality')}
  • - 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')}
  • - 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')} + +
  • +
  • + + sendEventAndSet('screen', setCompilePdfQuality, 'pdf-quality') + } + trailingIcon={compilePdfQuality === 'screen' ? 'check' : null} + > + {t('pdf_quality_draft')}
  • 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 0c9eb21a40..42b38f3b46 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 @@ -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('normal') + const [downloading, setDownloading] = useState(false) + const [downloadError, setDownloadError] = useState(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 - if (event.currentTarget.dataset.disabled === 'true') { - event.preventDefault() - return - } - - sendEvent('download-pdf-button-click', { - projectId, - location: 'pdf-preview', - isSmallDevice, - }) - } - return ( - - + - - - + { + 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')} + > + + + + + { + setShowDownloadModal(false) + setDownloadError(null) + }} + > + + {t('download_pdf')} + + +

    {t('pdf_download_quality_label')}

    +
    + {( + [ + { 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 => ( + + ))} +
    + {downloadQuality !== 'normal' && ( +

    + {t('pdf_download_ghostscript_note')} +

    + )} + {downloadError && ( +

    {downloadError}

    + )} +
    + + { + setShowDownloadModal(false) + setDownloadError(null) + }} + > + {t('cancel')} + + + {t('download')} + + +
    + ) } diff --git a/services/web/frontend/js/features/pdf-preview/util/compiler.ts b/services/web/frontend/js/features/pdf-preview/util/compiler.ts index 5883506aa9..c89ee98ac0 100644 --- a/services/web/frontend/js/features/pdf-preview/util/compiler.ts +++ b/services/web/frontend/js/features/pdf-preview/util/compiler.ts @@ -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, } diff --git a/services/web/frontend/js/shared/context/detach-compile-context.tsx b/services/web/frontend/js/shared/context/detach-compile-context.tsx index 5ddb8db0a9..814d32fb35 100644 --- a/services/web/frontend/js/shared/context/detach-compile-context.tsx +++ b/services/web/frontend/js/shared/context/detach-compile-context.tsx @@ -81,8 +81,8 @@ export const DetachCompileProvider: FC = ({ 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 = ({ '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 = ({ changedAt, typstPreviewMode, setTypstPreviewMode, - compressPdf, - setCompressPdf, + compilePdfQuality, + setCompilePdfQuality, }), [ animateCompileDropdownArrow, @@ -595,8 +595,8 @@ export const DetachCompileProvider: FC = ({ changedAt, typstPreviewMode, setTypstPreviewMode, - compressPdf, - setCompressPdf, + compilePdfQuality, + setCompilePdfQuality, ] ) diff --git a/services/web/frontend/js/shared/context/local-compile-context.tsx b/services/web/frontend/js/shared/context/local-compile-context.tsx index b3af09bb3d..95869496bf 100644 --- a/services/web/frontend/js/shared/context/local-compile-context.tsx +++ b/services/web/frontend/js/shared/context/local-compile-context.tsx @@ -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 = ({ 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 = ({ 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 = ({ setAnimateCompileDropdownArrow, setAutoCompile, setCompiling, - setCompressPdf, + setCompilePdfQuality, setDraft, setError, setHasLintingError, // only for stories @@ -910,8 +908,8 @@ export const LocalCompileProvider: FC = ({ changedAt, typstPreviewMode, setTypstPreviewMode, - compressPdf, - setCompressPdf, + compilePdfQuality, + setCompilePdfQuality, }), [ animateCompileDropdownArrow, @@ -921,7 +919,7 @@ export const LocalCompileProvider: FC = ({ clsiServerId, codeCheckFailed, compiling, - compressPdf, + compilePdfQuality, deliveryLatencies, draft, editedSinceCompileStarted, @@ -941,7 +939,7 @@ export const LocalCompileProvider: FC = ({ recompileFromScratch, setAnimateCompileDropdownArrow, setAutoCompile, - setCompressPdf, + setCompilePdfQuality, setDraft, setError, setHasLintingError, // only for stories diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 154a550a03..d122147402 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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…", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 8a13996e07..0d52b2b790 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -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…", diff --git a/services/web/types/compile.ts b/services/web/types/compile.ts index 544968ee39..f3797b0c1c 100644 --- a/services/web/types/compile.ts +++ b/services/web/types/compile.ts @@ -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