This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
+148
-34
@@ -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
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user