Files
Verso/services/clsi/app/js/ConversionManager.js
T
claude c79ac23a15
Build and Deploy Verso / deploy (push) Successful in 10m18s
Improve thumbnail quality and fix mobile editor default layout
Thumbnails: increase CLSI thumbnail from 190px/q50 to 400px/q80.
At 190px/50% JPEG quality, images are noticeably blurry on 2x phone
screens (source needs 380px device pixels but source is only 190px).

Editor mobile layout: getInitialLayout() was returning sideBySide for
any stored 'split' preference (set from a desktop session), even on
mobile. sideBySide on mobile renders vertically via the isMobile check
in main-layout, but the stated default was still wrong. Now on mobile,
any stored value other than 'flat' maps to verticalSplit so the
top-bottom split is always the default; flat is preserved so a user
who explicitly chose editor-only keeps that preference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:44:34 +00:00

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: 400, quality: 80 },
}
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,
},
}