ConversionManager.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. )
  69. if (exitCodePandoc !== 0) {
  70. throw new OError('Non-zero exit code from pandoc', {
  71. exitCode: exitCodePandoc,
  72. stderr: stderrPandoc,
  73. })
  74. }
  75. logger.debug(
  76. { stdout: stdoutPandoc, stderr: stderrPandoc, exitCode: exitCodePandoc },
  77. 'conversion command completed'
  78. )
  79. // Clean up the source document to leave only the conversion result
  80. await fs.unlink(newSourcePath).catch(() => {})
  81. const {
  82. stdout: stdoutZip,
  83. stderr: stderrZip,
  84. exitCode: exitCodeZip,
  85. } = await CommandRunner.promises.run(
  86. conversionId,
  87. ['zip', '-r', outputName, '.'],
  88. conversionDir,
  89. Settings.pandocImage,
  90. Settings.conversionTimeoutSeconds * 1000,
  91. {},
  92. 'conversions'
  93. )
  94. if (exitCodeZip !== 0) {
  95. throw new OError('Non-zero exit code from pandoc', {
  96. exitCode: exitCodeZip,
  97. stderr: stderrZip,
  98. })
  99. }
  100. logger.debug(
  101. { stdout: stdoutZip, stderr: stderrZip, exitCode: exitCodeZip },
  102. 'conversion output compressed'
  103. )
  104. } catch (error) {
  105. // Clean up the conversion directory on error to avoid leaving failed conversions around
  106. await fs.rm(conversionDir, { force: true, recursive: true }).catch(() => {})
  107. throw new OError('pandoc conversion failed').withCause(error)
  108. }
  109. return Path.join(conversionDir, outputName)
  110. }
  111. const LATEX_EXPORT_CONFIGS = {
  112. docx: {
  113. fileExtension: 'docx',
  114. compressOutput: false,
  115. getPandocArgs: ({ outputPath }) => [
  116. '--output',
  117. outputPath,
  118. '--from',
  119. 'latex',
  120. '--to',
  121. 'docx',
  122. '--citeproc',
  123. '--number-sections',
  124. '--resource-path=.',
  125. ],
  126. },
  127. markdown: {
  128. fileExtension: 'md',
  129. compressOutput: true,
  130. getPandocArgs: ({ outputPath, subdirName }) => [
  131. '--output',
  132. outputPath,
  133. '--from',
  134. 'latex',
  135. '--to',
  136. 'markdown',
  137. '--resource-path=.',
  138. `--extract-media=${subdirName}`,
  139. ],
  140. },
  141. }
  142. async function convertLaTeXToDocumentInDirWithLock(
  143. conversionId,
  144. compileDir,
  145. rootDocPath,
  146. type
  147. ) {
  148. const lock = LockManager.acquire(compileDir)
  149. try {
  150. return await convertLaTeXToDocumentInDir(
  151. conversionId,
  152. compileDir,
  153. rootDocPath,
  154. type
  155. )
  156. } finally {
  157. lock.release()
  158. }
  159. }
  160. async function convertLaTeXToDocumentInDir(
  161. conversionId,
  162. compileDir,
  163. rootDocPath = 'main.tex',
  164. type
  165. ) {
  166. if (!Object.hasOwn(LATEX_EXPORT_CONFIGS, type)) {
  167. throw new OError('unsupported conversion type', { type })
  168. }
  169. const config = LATEX_EXPORT_CONFIGS[type]
  170. const timeoutMs = Settings.conversionTimeoutSeconds * 1000
  171. const outputId = crypto.randomUUID()
  172. let pandocOutputPath, finalOutputName
  173. if (config.compressOutput) {
  174. await fs.mkdir(Path.join(compileDir, outputId), { recursive: true })
  175. pandocOutputPath = Path.join(outputId, `main.${config.fileExtension}`)
  176. finalOutputName = outputId + '.zip'
  177. } else {
  178. pandocOutputPath = outputId + '.' + config.fileExtension
  179. finalOutputName = pandocOutputPath
  180. }
  181. logger.debug(
  182. { compileDir, rootDocPath, type },
  183. 'running pandoc latex-to-document in compile dir'
  184. )
  185. const { exitCode, stdout, stderr } = await CommandRunner.promises.run(
  186. conversionId,
  187. [
  188. 'pandoc',
  189. rootDocPath,
  190. ...config.getPandocArgs({
  191. outputPath: pandocOutputPath,
  192. subdirName: outputId,
  193. }),
  194. ],
  195. compileDir,
  196. Settings.pandocImage,
  197. timeoutMs,
  198. {},
  199. 'conversions'
  200. )
  201. if (exitCode !== 0) {
  202. throw new OError('pandoc latex-to-document conversion failed', {
  203. type,
  204. exitCode,
  205. stdout,
  206. stderr,
  207. })
  208. }
  209. logger.debug(
  210. { stdout, stderr, exitCode },
  211. 'pandoc latex-to-document conversion completed'
  212. )
  213. if (!config.compressOutput) {
  214. return Path.join(compileDir, finalOutputName)
  215. }
  216. const {
  217. exitCode: zipExitCode,
  218. stdout: zipStdout,
  219. stderr: zipStderr,
  220. } = await CommandRunner.promises.run(
  221. conversionId,
  222. ['sh', '-c', `cd ${outputId} && zip -r ../${finalOutputName} .`],
  223. compileDir,
  224. Settings.pandocImage,
  225. timeoutMs,
  226. {},
  227. 'conversions'
  228. )
  229. if (zipExitCode !== 0) {
  230. throw new OError('zip compression of export failed', {
  231. exitCode: zipExitCode,
  232. stdout: zipStdout,
  233. stderr: zipStderr,
  234. })
  235. }
  236. logger.debug(
  237. { stdout: zipStdout, stderr: zipStderr, exitCode: zipExitCode },
  238. 'export compressed'
  239. )
  240. return Path.join(compileDir, finalOutputName)
  241. }
  242. export default {
  243. promises: {
  244. convertToLaTeXWithLock,
  245. convertLaTeXToDocumentInDirWithLock,
  246. },
  247. }