ConversionManager.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404
  1. import logger from '@overleaf/logger'
  2. import Settings from '@overleaf/settings'
  3. import fs from 'node:fs/promises'
  4. import Path from 'node:path'
  5. import CommandRunner from './CommandRunner.js'
  6. import LockManager from './LockManager.js'
  7. import OError from '@overleaf/o-error'
  8. import { ConversionError } from './Errors.js'
  9. const CONVERSION_CONFIGS = {
  10. docx: {
  11. inputFilename: 'input.docx',
  12. pandocArgs: ['--extract-media=.', '--from', 'docx+citations', '--citeproc'],
  13. },
  14. markdown: {
  15. inputFilename: 'input.md',
  16. pandocArgs: ['--from', 'markdown'],
  17. },
  18. }
  19. const PDF_TO_JPEG_CONFIGS = {
  20. preview: { width: 794, quality: 90 },
  21. thumbnail: { width: 190, quality: 50 },
  22. }
  23. const PDF_TO_JPEG_INPUT_FILENAME = 'input.pdf'
  24. const PDF_TO_JPEG_OUTPUT_FILENAME = 'output.jpg'
  25. const PDF_TO_JPEG_OUTPUT_BASENAME = Path.basename(
  26. PDF_TO_JPEG_OUTPUT_FILENAME,
  27. '.jpg'
  28. )
  29. async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
  30. const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
  31. const lock = LockManager.acquire(conversionDir)
  32. try {
  33. return await convertToLaTeX(
  34. conversionId,
  35. conversionDir,
  36. inputPath,
  37. conversionType
  38. )
  39. } finally {
  40. lock.release()
  41. }
  42. }
  43. async function convertToLaTeX(
  44. conversionId,
  45. conversionDir,
  46. inputPath,
  47. conversionType
  48. ) {
  49. const config = CONVERSION_CONFIGS[conversionType]
  50. if (!config) {
  51. throw new OError('unsupported conversion type', { conversionType })
  52. }
  53. await fs.mkdir(conversionDir, { recursive: true })
  54. const newSourcePath = Path.join(conversionDir, config.inputFilename)
  55. await fs.copyFile(inputPath, newSourcePath)
  56. const outputName = crypto.randomUUID() + '.zip'
  57. try {
  58. const {
  59. stdout: stdoutPandoc,
  60. stderr: stderrPandoc,
  61. exitCode: exitCodePandoc,
  62. } = await CommandRunner.promises.run(
  63. conversionId,
  64. [
  65. 'pandoc',
  66. config.inputFilename,
  67. '--output',
  68. 'main.tex',
  69. '--to',
  70. 'latex',
  71. '--standalone',
  72. ...config.pandocArgs,
  73. ],
  74. conversionDir,
  75. Settings.pandocImage,
  76. Settings.conversionTimeoutSeconds * 1000,
  77. {},
  78. 'conversions',
  79. null
  80. )
  81. if (exitCodePandoc !== 0) {
  82. throw new ConversionError('Non-zero exit code from pandoc', {
  83. type: conversionType,
  84. exitCode: exitCodePandoc,
  85. stderr: stderrPandoc,
  86. })
  87. }
  88. logger.debug(
  89. { stdout: stdoutPandoc, stderr: stderrPandoc, exitCode: exitCodePandoc },
  90. 'conversion command completed'
  91. )
  92. // Clean up the source document to leave only the conversion result
  93. await fs.unlink(newSourcePath).catch(() => {})
  94. const {
  95. stdout: stdoutZip,
  96. stderr: stderrZip,
  97. exitCode: exitCodeZip,
  98. } = await CommandRunner.promises.run(
  99. conversionId,
  100. ['zip', '-r', outputName, '.'],
  101. conversionDir,
  102. Settings.pandocImage,
  103. Settings.conversionTimeoutSeconds * 1000,
  104. {},
  105. 'conversions',
  106. null
  107. )
  108. if (exitCodeZip !== 0) {
  109. throw new OError('Non-zero exit code from pandoc', {
  110. exitCode: exitCodeZip,
  111. stderr: stderrZip,
  112. })
  113. }
  114. logger.debug(
  115. { stdout: stdoutZip, stderr: stderrZip, exitCode: exitCodeZip },
  116. 'conversion output compressed'
  117. )
  118. } catch (error) {
  119. // Clean up the conversion directory on error to avoid leaving failed conversions around
  120. await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
  121. if (error instanceof ConversionError) {
  122. throw error
  123. }
  124. throw new OError('pandoc conversion failed').withCause(error)
  125. }
  126. return Path.join(conversionDir, outputName)
  127. }
  128. const LATEX_EXPORT_CONFIGS = {
  129. docx: {
  130. fileExtension: 'docx',
  131. compressOutput: false,
  132. getPandocArgs: ({ outputPath }) => [
  133. '--output',
  134. outputPath,
  135. '--from',
  136. 'latex',
  137. '--to',
  138. 'docx',
  139. '--citeproc',
  140. '--number-sections',
  141. ],
  142. },
  143. markdown: {
  144. fileExtension: 'md',
  145. compressOutput: true,
  146. getPandocArgs: ({ outputPath }) => [
  147. '--output',
  148. outputPath,
  149. '--from',
  150. 'latex',
  151. '--to',
  152. 'markdown',
  153. ],
  154. },
  155. html: {
  156. fileExtension: 'html',
  157. compressOutput: true,
  158. getPandocArgs: ({ outputPath }) => [
  159. '--output',
  160. outputPath,
  161. '--from',
  162. 'latex',
  163. '--to',
  164. 'html',
  165. '--standalone',
  166. '--mathml',
  167. ],
  168. },
  169. }
  170. async function convertLaTeXToDocumentInDirWithLock(
  171. conversionId,
  172. compileDir,
  173. rootDocPath,
  174. type
  175. ) {
  176. const lock = LockManager.acquire(compileDir)
  177. try {
  178. return await convertLaTeXToDocumentInDir(
  179. conversionId,
  180. compileDir,
  181. rootDocPath,
  182. type
  183. )
  184. } finally {
  185. lock.release()
  186. }
  187. }
  188. async function convertLaTeXToDocumentInDir(
  189. conversionId,
  190. compileDir,
  191. rootDocPath = 'main.tex',
  192. type
  193. ) {
  194. if (!Object.hasOwn(LATEX_EXPORT_CONFIGS, type)) {
  195. throw new OError('unsupported conversion type', { type })
  196. }
  197. const config = LATEX_EXPORT_CONFIGS[type]
  198. const timeoutMs = Settings.conversionTimeoutSeconds * 1000
  199. const outputId = crypto.randomUUID()
  200. logger.debug(
  201. { compileDir, rootDocPath, type },
  202. 'running pandoc latex-to-document in compile dir'
  203. )
  204. if (!config.compressOutput) {
  205. const outputName = `${outputId}.${config.fileExtension}`
  206. const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
  207. conversionId,
  208. [
  209. 'pandoc',
  210. rootDocPath,
  211. ...config.getPandocArgs({ outputPath: outputName }),
  212. '--resource-path=.',
  213. ],
  214. compileDir,
  215. Settings.pandocImage,
  216. timeoutMs,
  217. {},
  218. 'conversions',
  219. null
  220. )
  221. if (exitCode !== 0) {
  222. throw new ConversionError('pandoc latex-to-document conversion failed', {
  223. type,
  224. exitCode,
  225. stderr,
  226. })
  227. }
  228. logger.debug(
  229. { stdout, stderr, exitCode },
  230. 'pandoc latex-to-document conversion completed'
  231. )
  232. return Path.join(compileDir, outputName)
  233. }
  234. // For compressed outputs we stage everything inside a uuid subdir so
  235. // the archive root ends up flat:
  236. // - pandoc runs with cwd=<outputId>, --extract-media=. drops images flat
  237. // alongside main.<ext>, and --resource-path=.. lets it find originals
  238. // in the parent compile dir.
  239. // - zip runs with the same cwd, so `zip -r ../<id>.zip .` produces an
  240. // archive whose root is main.<ext> + the media files (no uuid leak,
  241. // no collision with anything already in compileDir).
  242. await fs.mkdir(Path.join(compileDir, outputId), { recursive: true })
  243. const outputName = `main.${config.fileExtension}`
  244. const finalOutputName = `${outputId}.zip`
  245. const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
  246. conversionId,
  247. [
  248. 'pandoc',
  249. Path.join('..', rootDocPath),
  250. ...config.getPandocArgs({ outputPath: outputName }),
  251. '--resource-path=..',
  252. '--extract-media=.',
  253. ],
  254. compileDir,
  255. Settings.pandocImage,
  256. timeoutMs,
  257. {
  258. // By default pandoc uses cwd for resolving \input, \include, etc.
  259. // Setting TEXINPUTS allows us to override that to look in the actual
  260. // compileDir rather than the output directory.
  261. TEXINPUTS: '..:',
  262. },
  263. 'conversions',
  264. outputId
  265. )
  266. if (exitCode !== 0) {
  267. throw new ConversionError('pandoc latex-to-document conversion failed', {
  268. type,
  269. exitCode,
  270. stderr,
  271. })
  272. }
  273. logger.debug(
  274. { stdout, stderr, exitCode },
  275. 'pandoc latex-to-document conversion completed'
  276. )
  277. const {
  278. exitCode: zipExitCode,
  279. stdout: zipStdout,
  280. stderr: zipStderr,
  281. } = await CommandRunner.promises.run(
  282. conversionId,
  283. ['zip', '-r', Path.join('..', finalOutputName), '.'],
  284. compileDir,
  285. Settings.pandocImage,
  286. timeoutMs,
  287. {},
  288. 'conversions',
  289. outputId
  290. )
  291. if (zipExitCode !== 0) {
  292. throw new OError('zip compression of export failed', {
  293. exitCode: zipExitCode,
  294. stdout: zipStdout,
  295. stderr: zipStderr,
  296. })
  297. }
  298. logger.debug(
  299. { stdout: zipStdout, stderr: zipStderr, exitCode: zipExitCode },
  300. 'export compressed'
  301. )
  302. return Path.join(compileDir, finalOutputName)
  303. }
  304. async function convertPDFToJPEGWithLock(conversionId, inputPath, mode) {
  305. const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
  306. const lock = LockManager.acquire(conversionDir)
  307. try {
  308. return await convertPDFToJPEG(conversionId, conversionDir, inputPath, mode)
  309. } finally {
  310. lock.release()
  311. }
  312. }
  313. async function convertPDFToJPEG(conversionId, conversionDir, inputPath, mode) {
  314. const config = PDF_TO_JPEG_CONFIGS[mode]
  315. await fs.mkdir(conversionDir, { recursive: true })
  316. const newSourcePath = Path.join(conversionDir, PDF_TO_JPEG_INPUT_FILENAME)
  317. await fs.copyFile(inputPath, newSourcePath)
  318. const dstPath = Path.join(conversionDir, PDF_TO_JPEG_OUTPUT_FILENAME)
  319. try {
  320. const { stdout, stderr, exitCode } = await CommandRunner.promises.run(
  321. conversionId,
  322. [
  323. 'pdftocairo',
  324. '-jpeg',
  325. '-jpegopt',
  326. `quality=${config.quality}`,
  327. '-singlefile',
  328. '-scale-to-x',
  329. config.width.toString(),
  330. '-scale-to-y',
  331. '-1', // maintain aspect ratio
  332. PDF_TO_JPEG_INPUT_FILENAME,
  333. PDF_TO_JPEG_OUTPUT_BASENAME,
  334. ],
  335. conversionDir,
  336. Settings.pdftocairoImage,
  337. Settings.conversionTimeoutSeconds * 1000,
  338. {},
  339. 'conversions',
  340. null
  341. )
  342. if (exitCode !== 0) {
  343. throw new OError('Non-zero exit code from pdftocairo', {
  344. exitCode,
  345. stderr,
  346. })
  347. }
  348. logger.debug(
  349. { stdout, stderr, exitCode },
  350. 'pdf-to-jpeg conversion completed'
  351. )
  352. const stat = await fs.lstat(dstPath)
  353. if (!stat.isFile()) {
  354. throw new OError('output.jpg is not a regular file', { stat })
  355. }
  356. // Clean up the source PDF to leave only the conversion result
  357. await fs.unlink(newSourcePath).catch(() => {})
  358. } catch (error) {
  359. await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
  360. throw new OError('pdf-to-jpeg conversion failed').withCause(error)
  361. }
  362. return dstPath
  363. }
  364. export default {
  365. promises: {
  366. convertToLaTeXWithLock,
  367. convertLaTeXToDocumentInDirWithLock,
  368. convertPDFToJPEGWithLock,
  369. },
  370. }