ConversionManager.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  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. async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
  20. const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
  21. const lock = LockManager.acquire(conversionDir)
  22. try {
  23. return await convertToLaTeX(
  24. conversionId,
  25. conversionDir,
  26. inputPath,
  27. conversionType
  28. )
  29. } finally {
  30. lock.release()
  31. }
  32. }
  33. async function convertToLaTeX(
  34. conversionId,
  35. conversionDir,
  36. inputPath,
  37. conversionType
  38. ) {
  39. const config = CONVERSION_CONFIGS[conversionType]
  40. if (!config) {
  41. throw new OError('unsupported conversion type', { conversionType })
  42. }
  43. await fs.mkdir(conversionDir, { recursive: true })
  44. const newSourcePath = Path.join(conversionDir, config.inputFilename)
  45. await fs.copyFile(inputPath, newSourcePath)
  46. const outputName = crypto.randomUUID() + '.zip'
  47. try {
  48. const {
  49. stdout: stdoutPandoc,
  50. stderr: stderrPandoc,
  51. exitCode: exitCodePandoc,
  52. } = await CommandRunner.promises.run(
  53. conversionId,
  54. [
  55. 'pandoc',
  56. config.inputFilename,
  57. '--output',
  58. 'main.tex',
  59. '--to',
  60. 'latex',
  61. '--standalone',
  62. ...config.pandocArgs,
  63. ],
  64. conversionDir,
  65. Settings.pandocImage,
  66. Settings.conversionTimeoutSeconds * 1000,
  67. {},
  68. 'conversions',
  69. null
  70. )
  71. if (exitCodePandoc !== 0) {
  72. throw new ConversionError('Non-zero exit code from pandoc', {
  73. type: conversionType,
  74. exitCode: exitCodePandoc,
  75. stderr: stderrPandoc,
  76. })
  77. }
  78. logger.debug(
  79. { stdout: stdoutPandoc, stderr: stderrPandoc, exitCode: exitCodePandoc },
  80. 'conversion command completed'
  81. )
  82. // Clean up the source document to leave only the conversion result
  83. await fs.unlink(newSourcePath).catch(() => {})
  84. const {
  85. stdout: stdoutZip,
  86. stderr: stderrZip,
  87. exitCode: exitCodeZip,
  88. } = await CommandRunner.promises.run(
  89. conversionId,
  90. ['zip', '-r', outputName, '.'],
  91. conversionDir,
  92. Settings.pandocImage,
  93. Settings.conversionTimeoutSeconds * 1000,
  94. {},
  95. 'conversions',
  96. null
  97. )
  98. if (exitCodeZip !== 0) {
  99. throw new OError('Non-zero exit code from pandoc', {
  100. exitCode: exitCodeZip,
  101. stderr: stderrZip,
  102. })
  103. }
  104. logger.debug(
  105. { stdout: stdoutZip, stderr: stderrZip, exitCode: exitCodeZip },
  106. 'conversion output compressed'
  107. )
  108. } catch (error) {
  109. // Clean up the conversion directory on error to avoid leaving failed conversions around
  110. await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
  111. if (error instanceof ConversionError) {
  112. throw error
  113. }
  114. throw new OError('pandoc conversion failed').withCause(error)
  115. }
  116. return Path.join(conversionDir, outputName)
  117. }
  118. const LATEX_EXPORT_CONFIGS = {
  119. docx: {
  120. fileExtension: 'docx',
  121. compressOutput: false,
  122. getPandocArgs: ({ outputPath }) => [
  123. '--output',
  124. outputPath,
  125. '--from',
  126. 'latex',
  127. '--to',
  128. 'docx',
  129. '--citeproc',
  130. '--number-sections',
  131. ],
  132. },
  133. markdown: {
  134. fileExtension: 'md',
  135. compressOutput: true,
  136. getPandocArgs: ({ outputPath }) => [
  137. '--output',
  138. outputPath,
  139. '--from',
  140. 'latex',
  141. '--to',
  142. 'markdown',
  143. ],
  144. },
  145. }
  146. async function convertLaTeXToDocumentInDirWithLock(
  147. conversionId,
  148. compileDir,
  149. rootDocPath,
  150. type
  151. ) {
  152. const lock = LockManager.acquire(compileDir)
  153. try {
  154. return await convertLaTeXToDocumentInDir(
  155. conversionId,
  156. compileDir,
  157. rootDocPath,
  158. type
  159. )
  160. } finally {
  161. lock.release()
  162. }
  163. }
  164. async function convertLaTeXToDocumentInDir(
  165. conversionId,
  166. compileDir,
  167. rootDocPath = 'main.tex',
  168. type
  169. ) {
  170. if (!Object.hasOwn(LATEX_EXPORT_CONFIGS, type)) {
  171. throw new OError('unsupported conversion type', { type })
  172. }
  173. const config = LATEX_EXPORT_CONFIGS[type]
  174. const timeoutMs = Settings.conversionTimeoutSeconds * 1000
  175. const outputId = crypto.randomUUID()
  176. logger.debug(
  177. { compileDir, rootDocPath, type },
  178. 'running pandoc latex-to-document in compile dir'
  179. )
  180. if (!config.compressOutput) {
  181. const outputName = `${outputId}.${config.fileExtension}`
  182. const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
  183. conversionId,
  184. [
  185. 'pandoc',
  186. rootDocPath,
  187. ...config.getPandocArgs({ outputPath: outputName }),
  188. '--resource-path=.',
  189. ],
  190. compileDir,
  191. Settings.pandocImage,
  192. timeoutMs,
  193. {},
  194. 'conversions',
  195. null
  196. )
  197. if (exitCode !== 0) {
  198. throw new ConversionError('pandoc latex-to-document conversion failed', {
  199. type,
  200. exitCode,
  201. stderr,
  202. })
  203. }
  204. logger.debug(
  205. { stdout, stderr, exitCode },
  206. 'pandoc latex-to-document conversion completed'
  207. )
  208. return Path.join(compileDir, outputName)
  209. }
  210. // For compressed outputs we stage everything inside a uuid subdir so
  211. // the archive root ends up flat:
  212. // - pandoc runs with cwd=<outputId>, --extract-media=. drops images flat
  213. // alongside main.<ext>, and --resource-path=.. lets it find originals
  214. // in the parent compile dir.
  215. // - zip runs with the same cwd, so `zip -r ../<id>.zip .` produces an
  216. // archive whose root is main.<ext> + the media files (no uuid leak,
  217. // no collision with anything already in compileDir).
  218. await fs.mkdir(Path.join(compileDir, outputId), { recursive: true })
  219. const outputName = `main.${config.fileExtension}`
  220. const finalOutputName = `${outputId}.zip`
  221. const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
  222. conversionId,
  223. [
  224. 'pandoc',
  225. Path.join('..', rootDocPath),
  226. ...config.getPandocArgs({ outputPath: outputName }),
  227. '--resource-path=..',
  228. '--extract-media=.',
  229. ],
  230. compileDir,
  231. Settings.pandocImage,
  232. timeoutMs,
  233. {},
  234. 'conversions',
  235. outputId
  236. )
  237. if (exitCode !== 0) {
  238. throw new ConversionError('pandoc latex-to-document conversion failed', {
  239. type,
  240. exitCode,
  241. stderr,
  242. })
  243. }
  244. logger.debug(
  245. { stdout, stderr, exitCode },
  246. 'pandoc latex-to-document conversion completed'
  247. )
  248. const {
  249. exitCode: zipExitCode,
  250. stdout: zipStdout,
  251. stderr: zipStderr,
  252. } = await CommandRunner.promises.run(
  253. conversionId,
  254. ['zip', '-r', Path.join('..', finalOutputName), '.'],
  255. compileDir,
  256. Settings.pandocImage,
  257. timeoutMs,
  258. {},
  259. 'conversions',
  260. outputId
  261. )
  262. if (zipExitCode !== 0) {
  263. throw new OError('zip compression of export failed', {
  264. exitCode: zipExitCode,
  265. stdout: zipStdout,
  266. stderr: zipStderr,
  267. })
  268. }
  269. logger.debug(
  270. { stdout: zipStdout, stderr: zipStderr, exitCode: zipExitCode },
  271. 'export compressed'
  272. )
  273. return Path.join(compileDir, finalOutputName)
  274. }
  275. export default {
  276. promises: {
  277. convertToLaTeXWithLock,
  278. convertLaTeXToDocumentInDirWithLock,
  279. },
  280. }