| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- 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'],
- },
- }
- const PDF_TO_JPEG_CONFIGS = {
- preview: { width: 794, quality: 90 },
- thumbnail: { width: 190, quality: 50 },
- }
- 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',
- ],
- },
- }
- 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,
- {
- // By default pandoc uses cwd for resolving \input, \include, etc.
- // Setting TEXINPUTS allows us to override that to look in the actual
- // compileDir rather than the output directory.
- TEXINPUTS: '..:',
- },
- '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,
- },
- }
|