feat(pdf): three-level quality (Normal/Compressed/Draft) + download modal
Build and Deploy Verso / deploy (push) Successful in 11m30s
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:
co-authored by
Claude Sonnet 4.6
parent
e5becc560c
commit
080de97632
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+148
-34
@@ -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
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user