specpress
Version:
Export PDF and/or DOCX files from a subset of Markdown, ASN.1 and JSON files
280 lines (247 loc) • 9.8 kB
JavaScript
const fs = require('fs')
const path = require('path')
const { execFileSync, spawn } = require('child_process')
/**
* Detects the path to winword.exe via the Windows registry.
* @returns {string|null} Path if found, null otherwise.
*/
function findWinword() {
if (process.platform !== 'win32') return null
try {
const result = execFileSync(
'reg', ['query', 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\Winword.exe', '/ve'],
{ encoding: 'utf8', windowsHide: true }
)
const match = result.match(/REG_SZ\s+(.+)/)
const p = match ? match[1].trim() : null
return (p && fs.existsSync(p)) ? p : null
} catch (e) {
return null
}
}
const LIBREOFFICE_PATHS = [
'/usr/bin/soffice',
'/usr/bin/libreoffice',
'/usr/local/bin/soffice',
'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
]
/**
* Detects the path to LibreOffice's soffice executable.
* @returns {string|null} Path if found, null otherwise.
*/
function findLibreOffice() {
return LIBREOFFICE_PATHS.find(p => fs.existsSync(p)) || null
}
/**
* Detects available merge backends on the current system.
* @returns {{word: string|null, libreoffice: string|null}}
*/
function detectBackends() {
return {
word: findWinword(),
libreoffice: findLibreOffice()
}
}
/**
* Merges a baseline DOCX with one or more revisions into a tracked-changes document
* using Microsoft Word via VBScript.
*
* @param {string} baseDocx - Path to the baseline DOCX.
* @param {Array<{docxPath: string, authorName: string}>} revisions - Ordered revisions.
* @param {string} outputPath - Where to save the final merged DOCX.
* @param {object} [options]
* @param {boolean} [options.debug=false] - Keep intermediate temp files.
* @param {function} [options.onProgress] - Progress callback.
* @returns {Promise<void>}
*/
function mergeViaWord(baseDocx, revisions, outputPath, options = {}) {
const vbsPath = path.join(__dirname, '..', 'scripts', 'merge-multi-version.vbs')
const vbsArgs = ['//nologo', vbsPath, outputPath, baseDocx]
for (const rev of revisions) {
vbsArgs.push(rev.docxPath)
vbsArgs.push(rev.authorName)
}
if (options.debug) vbsArgs.push('debug')
return new Promise((resolve, reject) => {
const proc = spawn('cscript', vbsArgs, { windowsHide: true })
let output = ''
let errorOutput = ''
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
proc.kill()
reject(new Error('Word comparison timed out after 5 minutes'))
}
}, 300000)
proc.stdout.on('data', (data) => {
const text = data.toString().trim()
output += text + '\n'
if (options.onProgress && text.includes('Comparing')) {
options.onProgress(text)
}
})
proc.stderr.on('data', (data) => {
errorOutput += data.toString()
})
proc.on('close', (code) => {
clearTimeout(timer)
if (settled) return
settled = true
if (code !== 0) {
reject(new Error(errorOutput || output || `VBScript exited with code ${code}`))
} else if (!output.includes('Success')) {
reject(new Error(output || 'Word comparison failed'))
} else {
resolve()
}
})
proc.on('error', (err) => {
clearTimeout(timer)
if (settled) return
settled = true
reject(new Error(`Failed to start VBScript: ${err.message}`))
})
})
}
/**
* Resolves the path to LibreOffice's bundled Python interpreter.
* @param {string} sofficePath - Path to the soffice executable.
* @returns {string|null}
*/
function findLibreOfficePython(sofficePath) {
const programDir = path.dirname(sofficePath)
const candidates = process.platform === 'win32'
? [path.join(programDir, 'python.exe')]
: [path.join(programDir, 'python'), '/usr/bin/python3']
return candidates.find(p => fs.existsSync(p)) || null
}
/**
* Merges a baseline DOCX with one revision into a tracked-changes document
* using LibreOffice via a Python-UNO script.
*
* @param {string} baseDocx - Path to the baseline DOCX.
* @param {Array<{docxPath: string, authorName: string}>} revisions - Currently supports 1 revision.
* @param {string} outputPath - Where to save the final merged DOCX.
* @param {object} [options]
* @param {string} [options.libreofficePath] - Explicit path to soffice.
* @param {function} [options.onProgress] - Progress callback.
* @returns {Promise<void>}
*/
function mergeViaLibreOffice(baseDocx, revisions, outputPath, options = {}) {
if (revisions.length > 1) {
return Promise.reject(new Error('LibreOffice backend currently supports only 1 revision'))
}
const soffice = options.libreofficePath || findLibreOffice()
if (!soffice) {
return Promise.reject(new Error('LibreOffice (soffice) not found'))
}
const scriptPath = path.join(__dirname, '..', 'scripts', 'merge_tracked_changes.py')
if (!fs.existsSync(scriptPath)) {
return Promise.reject(new Error('LibreOffice merge script not found: ' + scriptPath))
}
const rev = revisions[0]
if (options.onProgress) options.onProgress('Starting LibreOffice comparison...')
// Resolve symlinks (on Linux, /usr/bin/soffice -> /usr/lib/libreoffice/program/soffice)
const resolvedSoffice = fs.realpathSync(soffice)
const programDir = path.dirname(resolvedSoffice)
const pythonPath = findLibreOfficePython(resolvedSoffice)
if (!pythonPath) {
return Promise.reject(new Error(
'LibreOffice bundled Python not found. Expected at: ' +
(process.platform === 'win32'
? path.join(programDir, 'python.exe')
: path.join(programDir, 'python'))
))
}
// Use LibreOffice's bundled Python which has UNO bindings.
// The script starts its own headless LibreOffice listener and connects via pipe.
const cmd = pythonPath
const args = [scriptPath, baseDocx, rev.docxPath, outputPath, rev.authorName, soffice]
const fundamentalFile = process.platform === 'win32' ? 'fundamental.ini' : 'fundamentalrc'
const fundamentalPath = path.join(programDir, fundamentalFile)
const urePrefix = process.platform === 'win32' ? 'file:///' : 'file://'
const env = Object.assign({}, process.env, {
URE_BOOTSTRAP: urePrefix + fundamentalPath.replace(/\\/g, '/'),
UNO_PATH: programDir,
PATH: programDir + (process.platform === 'win32' ? ';' : ':') + (process.env.PATH || '')
})
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { windowsHide: true, env })
let output = ''
let errorOutput = ''
let settled = false
const timer = setTimeout(() => {
if (!settled) {
settled = true
proc.kill()
reject(new Error('LibreOffice comparison timed out after 5 minutes'))
}
}, 300000)
proc.stdout.on('data', (data) => { output += data.toString() })
proc.stderr.on('data', (data) => {
const text = data.toString()
errorOutput += text
if (options.onProgress) options.onProgress(text.trim())
})
proc.on('close', (code) => {
clearTimeout(timer)
if (settled) return
settled = true
if (code !== 0) {
reject(new Error(errorOutput || output || `LibreOffice exited with code ${code}`))
} else if (!output.includes('Success')) {
reject(new Error(output || errorOutput || 'LibreOffice comparison produced no output'))
} else {
resolve()
}
})
proc.on('error', (err) => {
clearTimeout(timer)
if (settled) return
settled = true
reject(new Error(`Failed to start LibreOffice: ${err.message}`))
})
})
}
/**
* Merges a baseline DOCX with one or more revisions into a tracked-changes document.
*
* @param {string} baseDocx - Path to the baseline DOCX (the "original" version).
* @param {Array<{docxPath: string, authorName: string}>} revisions
* Ordered array of revisions. Each entry introduces tracked changes attributed
* to the given author. MS Word supports N revisions; LibreOffice supports 1 initially.
* @param {string} outputPath - Where to save the final merged DOCX.
* @param {object} [options]
* @param {'auto'|'word'|'libreoffice'} [options.backend='auto'] - Merge backend.
* @param {string} [options.wordPath] - Explicit path to winword.exe.
* @param {string} [options.libreofficePath] - Explicit path to soffice executable.
* @param {boolean} [options.debug=false] - Keep intermediate temp files.
* @param {function} [options.onProgress] - Progress callback: (message: string) => void.
* @returns {Promise<void>}
*/
async function mergeDocxVersions(baseDocx, revisions, outputPath, options = {}) {
if (!revisions || revisions.length === 0) {
throw new Error('At least one revision is required')
}
const backend = options.backend || 'auto'
if (backend === 'word') {
const wordPath = options.wordPath || findWinword()
if (!wordPath) throw new Error('Microsoft Word (winword.exe) not found')
return mergeViaWord(baseDocx, revisions, outputPath, options)
}
if (backend === 'libreoffice') {
return mergeViaLibreOffice(baseDocx, revisions, outputPath, options)
}
// auto: try Word first on Windows, then LibreOffice
if (process.platform === 'win32' && findWinword()) {
return mergeViaWord(baseDocx, revisions, outputPath, options)
}
if (findLibreOffice()) {
return mergeViaLibreOffice(baseDocx, revisions, outputPath, options)
}
throw new Error('No merge backend available. Install Microsoft Word (Windows) or LibreOffice.')
}
module.exports = { mergeDocxVersions, detectBackends, findWinword, findLibreOffice, findLibreOfficePython }