f629f6a50c
Build and Deploy Verso / deploy (push) Successful in 10m12s
- CLSI thumbnails: bump to 794px/q90 (matches preview quality) for
crisp display on 2x/3x phone screens
- Lumière filter chips → underline tab bar: single scrollable row with
teal active indicator, no more wrapping alignment issues; zoom buttons
separated by a vertical divider on the right
- Fix editor vertical split default on mobile: disable react-resizable-panels
autoSaveId on mobile to prevent a stale collapsed PDF pane from firing
onCollapse → changeLayout('flat') and overriding the verticalSplit
default set by getInitialLayout()
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
428 lines
10 KiB
JavaScript
428 lines
10 KiB
JavaScript
import logger from '@overleaf/logger'
|
|
import Settings from '@overleaf/settings'
|
|
import fs from 'node:fs/promises'
|
|
import Path from 'node:path'
|
|
import CommandRunner from './CommandRunner.js'
|
|
import LockManager from './LockManager.js'
|
|
import OError from '@overleaf/o-error'
|
|
import { ConversionError } from './Errors.js'
|
|
|
|
const CONVERSION_CONFIGS = {
|
|
docx: {
|
|
inputFilename: 'input.docx',
|
|
pandocArgs: ['--extract-media=.', '--from', 'docx+citations', '--citeproc'],
|
|
},
|
|
markdown: {
|
|
inputFilename: 'input.md',
|
|
pandocArgs: ['--from', 'markdown'],
|
|
},
|
|
typst: {
|
|
inputFilename: 'input.typ',
|
|
pandocArgs: ['--from', 'typst'],
|
|
},
|
|
}
|
|
|
|
const PDF_TO_JPEG_CONFIGS = {
|
|
preview: { width: 794, quality: 90 },
|
|
thumbnail: { width: 794, quality: 90 },
|
|
}
|
|
|
|
const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
|
|
const PDF_TO_JPEG_OUTPUT_FILENAME = 'output.jpg'
|
|
const PDF_TO_JPEG_OUTPUT_BASENAME = Path.basename(
|
|
PDF_TO_JPEG_OUTPUT_FILENAME,
|
|
'.jpg'
|
|
)
|
|
|
|
async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
|
|
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
|
const lock = LockManager.acquire(conversionDir)
|
|
try {
|
|
return await convertToLaTeX(
|
|
conversionId,
|
|
conversionDir,
|
|
inputPath,
|
|
conversionType
|
|
)
|
|
} finally {
|
|
lock.release()
|
|
}
|
|
}
|
|
|
|
async function convertToLaTeX(
|
|
conversionId,
|
|
conversionDir,
|
|
inputPath,
|
|
conversionType
|
|
) {
|
|
const config = CONVERSION_CONFIGS[conversionType]
|
|
if (!config) {
|
|
throw new OError('unsupported conversion type', { conversionType })
|
|
}
|
|
await fs.mkdir(conversionDir, { recursive: true })
|
|
const newSourcePath = Path.join(conversionDir, config.inputFilename)
|
|
await fs.copyFile(inputPath, newSourcePath)
|
|
const outputName = crypto.randomUUID() + '.zip'
|
|
|
|
try {
|
|
const {
|
|
stdout: stdoutPandoc,
|
|
stderr: stderrPandoc,
|
|
exitCode: exitCodePandoc,
|
|
} = await CommandRunner.promises.run(
|
|
conversionId,
|
|
[
|
|
'pandoc',
|
|
config.inputFilename,
|
|
'--output',
|
|
'main.tex',
|
|
'--to',
|
|
'latex',
|
|
'--standalone',
|
|
...config.pandocArgs,
|
|
],
|
|
conversionDir,
|
|
Settings.pandocImage,
|
|
Settings.conversionTimeoutSeconds * 1000,
|
|
{},
|
|
'conversions',
|
|
null
|
|
)
|
|
if (exitCodePandoc !== 0) {
|
|
throw new ConversionError('Non-zero exit code from pandoc', {
|
|
type: conversionType,
|
|
exitCode: exitCodePandoc,
|
|
stderr: stderrPandoc,
|
|
})
|
|
}
|
|
logger.debug(
|
|
{ stdout: stdoutPandoc, stderr: stderrPandoc, exitCode: exitCodePandoc },
|
|
'conversion command completed'
|
|
)
|
|
|
|
// Clean up the source document to leave only the conversion result
|
|
await fs.unlink(newSourcePath).catch(() => {})
|
|
|
|
const {
|
|
stdout: stdoutZip,
|
|
stderr: stderrZip,
|
|
exitCode: exitCodeZip,
|
|
} = await CommandRunner.promises.run(
|
|
conversionId,
|
|
['zip', '-r', outputName, '.'],
|
|
conversionDir,
|
|
Settings.pandocImage,
|
|
Settings.conversionTimeoutSeconds * 1000,
|
|
{},
|
|
'conversions',
|
|
null
|
|
)
|
|
if (exitCodeZip !== 0) {
|
|
throw new OError('Non-zero exit code from pandoc', {
|
|
exitCode: exitCodeZip,
|
|
stderr: stderrZip,
|
|
})
|
|
}
|
|
logger.debug(
|
|
{ stdout: stdoutZip, stderr: stderrZip, exitCode: exitCodeZip },
|
|
'conversion output compressed'
|
|
)
|
|
} catch (error) {
|
|
// Clean up the conversion directory on error to avoid leaving failed conversions around
|
|
await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
|
|
if (error instanceof ConversionError) {
|
|
throw error
|
|
}
|
|
throw new OError('pandoc conversion failed').withCause(error)
|
|
}
|
|
|
|
return Path.join(conversionDir, outputName)
|
|
}
|
|
|
|
const LATEX_EXPORT_CONFIGS = {
|
|
docx: {
|
|
fileExtension: 'docx',
|
|
compressOutput: false,
|
|
getPandocArgs: ({ outputPath }) => [
|
|
'--output',
|
|
outputPath,
|
|
'--from',
|
|
'latex',
|
|
'--to',
|
|
'docx',
|
|
'--citeproc',
|
|
'--number-sections',
|
|
],
|
|
},
|
|
markdown: {
|
|
fileExtension: 'md',
|
|
compressOutput: true,
|
|
getPandocArgs: ({ outputPath }) => [
|
|
'--output',
|
|
outputPath,
|
|
'--from',
|
|
'latex',
|
|
'--to',
|
|
'markdown',
|
|
],
|
|
},
|
|
html: {
|
|
fileExtension: 'html',
|
|
compressOutput: true,
|
|
getPandocArgs: ({ outputPath }) => [
|
|
'--output',
|
|
outputPath,
|
|
'--from',
|
|
'latex',
|
|
'--to',
|
|
'html',
|
|
'--standalone',
|
|
],
|
|
},
|
|
typst: {
|
|
fileExtension: 'typ',
|
|
compressOutput: false,
|
|
getPandocArgs: ({ outputPath }) => [
|
|
'--output',
|
|
outputPath,
|
|
'--from',
|
|
'latex',
|
|
'--to',
|
|
'typst',
|
|
],
|
|
},
|
|
latex: {
|
|
fileExtension: 'tex',
|
|
compressOutput: false,
|
|
getPandocArgs: ({ outputPath }) => [
|
|
'--output',
|
|
outputPath,
|
|
'--from',
|
|
'typst',
|
|
'--to',
|
|
'latex',
|
|
'--standalone',
|
|
],
|
|
},
|
|
}
|
|
|
|
async function convertLaTeXToDocumentInDirWithLock(
|
|
conversionId,
|
|
compileDir,
|
|
rootDocPath,
|
|
type
|
|
) {
|
|
const lock = LockManager.acquire(compileDir)
|
|
try {
|
|
return await convertLaTeXToDocumentInDir(
|
|
conversionId,
|
|
compileDir,
|
|
rootDocPath,
|
|
type
|
|
)
|
|
} finally {
|
|
lock.release()
|
|
}
|
|
}
|
|
|
|
async function convertLaTeXToDocumentInDir(
|
|
conversionId,
|
|
compileDir,
|
|
rootDocPath = 'main.tex',
|
|
type
|
|
) {
|
|
if (!Object.hasOwn(LATEX_EXPORT_CONFIGS, type)) {
|
|
throw new OError('unsupported conversion type', { type })
|
|
}
|
|
const config = LATEX_EXPORT_CONFIGS[type]
|
|
|
|
const timeoutMs = Settings.conversionTimeoutSeconds * 1000
|
|
const outputId = crypto.randomUUID()
|
|
|
|
logger.debug(
|
|
{ compileDir, rootDocPath, type },
|
|
'running pandoc latex-to-document in compile dir'
|
|
)
|
|
|
|
if (!config.compressOutput) {
|
|
const outputName = `${outputId}.${config.fileExtension}`
|
|
const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
|
|
conversionId,
|
|
[
|
|
'pandoc',
|
|
rootDocPath,
|
|
...config.getPandocArgs({ outputPath: outputName }),
|
|
'--resource-path=.',
|
|
],
|
|
compileDir,
|
|
Settings.pandocImage,
|
|
timeoutMs,
|
|
{},
|
|
'conversions',
|
|
null
|
|
)
|
|
|
|
if (exitCode !== 0) {
|
|
throw new ConversionError('pandoc latex-to-document conversion failed', {
|
|
type,
|
|
exitCode,
|
|
stderr,
|
|
})
|
|
}
|
|
|
|
logger.debug(
|
|
{ stdout, stderr, exitCode },
|
|
'pandoc latex-to-document conversion completed'
|
|
)
|
|
|
|
return Path.join(compileDir, outputName)
|
|
}
|
|
|
|
// For compressed outputs we stage everything inside a uuid subdir so
|
|
// the archive root ends up flat:
|
|
// - pandoc runs with cwd=<outputId>, --extract-media=. drops images flat
|
|
// alongside main.<ext>, and --resource-path=.. lets it find originals
|
|
// in the parent compile dir.
|
|
// - zip runs with the same cwd, so `zip -r ../<id>.zip .` produces an
|
|
// archive whose root is main.<ext> + the media files (no uuid leak,
|
|
// no collision with anything already in compileDir).
|
|
await fs.mkdir(Path.join(compileDir, outputId), { recursive: true })
|
|
|
|
const outputName = `main.${config.fileExtension}`
|
|
const finalOutputName = `${outputId}.zip`
|
|
|
|
const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
|
|
conversionId,
|
|
[
|
|
'pandoc',
|
|
Path.join('..', rootDocPath),
|
|
...config.getPandocArgs({ outputPath: outputName }),
|
|
'--resource-path=..',
|
|
'--extract-media=.',
|
|
],
|
|
compileDir,
|
|
Settings.pandocImage,
|
|
timeoutMs,
|
|
{},
|
|
'conversions',
|
|
outputId
|
|
)
|
|
|
|
if (exitCode !== 0) {
|
|
throw new ConversionError('pandoc latex-to-document conversion failed', {
|
|
type,
|
|
exitCode,
|
|
stderr,
|
|
})
|
|
}
|
|
|
|
logger.debug(
|
|
{ stdout, stderr, exitCode },
|
|
'pandoc latex-to-document conversion completed'
|
|
)
|
|
|
|
const {
|
|
exitCode: zipExitCode,
|
|
stdout: zipStdout,
|
|
stderr: zipStderr,
|
|
} = await CommandRunner.promises.run(
|
|
conversionId,
|
|
['zip', '-r', Path.join('..', finalOutputName), '.'],
|
|
compileDir,
|
|
Settings.pandocImage,
|
|
timeoutMs,
|
|
{},
|
|
'conversions',
|
|
outputId
|
|
)
|
|
|
|
if (zipExitCode !== 0) {
|
|
throw new OError('zip compression of export failed', {
|
|
exitCode: zipExitCode,
|
|
stdout: zipStdout,
|
|
stderr: zipStderr,
|
|
})
|
|
}
|
|
|
|
logger.debug(
|
|
{ stdout: zipStdout, stderr: zipStderr, exitCode: zipExitCode },
|
|
'export compressed'
|
|
)
|
|
|
|
return Path.join(compileDir, finalOutputName)
|
|
}
|
|
|
|
async function convertPDFToJPEGWithLock(conversionId, inputPath, mode) {
|
|
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
|
const lock = LockManager.acquire(conversionDir)
|
|
try {
|
|
return await convertPDFToJPEG(conversionId, conversionDir, inputPath, mode)
|
|
} finally {
|
|
lock.release()
|
|
}
|
|
}
|
|
|
|
async function convertPDFToJPEG(conversionId, conversionDir, inputPath, mode) {
|
|
const config = PDF_TO_JPEG_CONFIGS[mode]
|
|
await fs.mkdir(conversionDir, { recursive: true })
|
|
const newSourcePath = Path.join(conversionDir, PDF_TO_JPEG_INPUT_FILENAME)
|
|
await fs.copyFile(inputPath, newSourcePath)
|
|
const dstPath = Path.join(conversionDir, PDF_TO_JPEG_OUTPUT_FILENAME)
|
|
|
|
try {
|
|
const { stdout, stderr, exitCode } = await CommandRunner.promises.run(
|
|
conversionId,
|
|
[
|
|
'pdftocairo',
|
|
'-jpeg',
|
|
'-jpegopt',
|
|
`quality=${config.quality}`,
|
|
'-singlefile',
|
|
'-scale-to-x',
|
|
config.width.toString(),
|
|
'-scale-to-y',
|
|
'-1', // maintain aspect ratio
|
|
PDF_TO_JPEG_INPUT_FILENAME,
|
|
PDF_TO_JPEG_OUTPUT_BASENAME,
|
|
],
|
|
conversionDir,
|
|
Settings.pdftocairoImage,
|
|
Settings.conversionTimeoutSeconds * 1000,
|
|
{},
|
|
'conversions',
|
|
null
|
|
)
|
|
if (exitCode !== 0) {
|
|
throw new OError('Non-zero exit code from pdftocairo', {
|
|
exitCode,
|
|
stderr,
|
|
})
|
|
}
|
|
logger.debug(
|
|
{ stdout, stderr, exitCode },
|
|
'pdf-to-jpeg conversion completed'
|
|
)
|
|
|
|
const stat = await fs.lstat(dstPath)
|
|
if (!stat.isFile()) {
|
|
throw new OError('output.jpg is not a regular file', { stat })
|
|
}
|
|
|
|
// Clean up the source PDF to leave only the conversion result
|
|
await fs.unlink(newSourcePath).catch(() => {})
|
|
} catch (error) {
|
|
await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
|
|
throw new OError('pdf-to-jpeg conversion failed').withCause(error)
|
|
}
|
|
|
|
return dstPath
|
|
}
|
|
|
|
export default {
|
|
promises: {
|
|
convertToLaTeXWithLock,
|
|
convertLaTeXToDocumentInDirWithLock,
|
|
convertPDFToJPEGWithLock,
|
|
},
|
|
}
|