ConversionManager.js 7.3 KB

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