feat(runtime): add native office preview and download plugin
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, extname, isAbsolute } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const failure = (code, message) => Object.assign(new Error(message), { code });
|
||||
|
||||
// No shell; terminate the whole group, including soffice.bin, on cancellation.
|
||||
export function runProcess(executable, args, signal, env = process.env) {
|
||||
signal.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, { detached: true, stdio: 'ignore', env });
|
||||
let error;
|
||||
const kill = () => {
|
||||
if (child.pid) {
|
||||
try { process.kill(-child.pid, 'SIGKILL'); }
|
||||
catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; }
|
||||
}
|
||||
};
|
||||
const abort = () => { error = signal.reason; kill(); };
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) abort();
|
||||
child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); });
|
||||
child.once('close', code => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
kill();
|
||||
if (error) reject(error);
|
||||
else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readBounded(path, maximum, kind, signal) {
|
||||
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required');
|
||||
if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit');
|
||||
const bytes = Buffer.alloc(stat.size + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
signal.throwIfAborted();
|
||||
const read = await file.read(bytes, length, bytes.length - length, null);
|
||||
if (!read.bytesRead) break;
|
||||
length += read.bytesRead;
|
||||
}
|
||||
if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed');
|
||||
return bytes.subarray(0, length);
|
||||
} finally { await file.close(); }
|
||||
}
|
||||
|
||||
export async function createConverter(options = {}) {
|
||||
if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux');
|
||||
if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) {
|
||||
throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides');
|
||||
}
|
||||
const lifetime = new AbortController();
|
||||
const active = new Set();
|
||||
const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options };
|
||||
for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) {
|
||||
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key);
|
||||
}
|
||||
async function render({ inputPath, outputPath }, external = new AbortController().signal) {
|
||||
const deadline = new AbortController();
|
||||
const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]);
|
||||
const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs);
|
||||
let scratch;
|
||||
let output;
|
||||
let success = false;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const extension = extname(inputPath).slice(1).toLowerCase();
|
||||
if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension');
|
||||
if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required');
|
||||
output = await open(outputPath, 'wx', 0o600);
|
||||
scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-'));
|
||||
const source = join(scratch, 'source.' + extension);
|
||||
await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal });
|
||||
const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url));
|
||||
const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' };
|
||||
await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env);
|
||||
const profile = join(scratch, 'profile');
|
||||
await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(profile, 'user/registrymodifications.xcu'), '<?xml version="1.0"?><oor:items xmlns:oor="http://openoffice.org/2001/registry"><item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item></oor:items>');
|
||||
const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export';
|
||||
const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } });
|
||||
await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env);
|
||||
let bytes;
|
||||
try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); }
|
||||
catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; }
|
||||
if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF');
|
||||
signal.throwIfAborted();
|
||||
await output.writeFile(bytes);
|
||||
signal.throwIfAborted();
|
||||
success = true;
|
||||
// System LibreOffice has no kit missing-font diagnostic API.
|
||||
return { backend: 'native-system', missingFonts: [] };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try { await output?.close(); }
|
||||
finally {
|
||||
try { if (output && !success) await rm(outputPath, { force: true }); }
|
||||
finally { if (scratch) await rm(scratch, { recursive: true, force: true }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
render(request, signal) {
|
||||
const task = render(request, signal);
|
||||
active.add(task);
|
||||
task.then(() => active.delete(task), () => active.delete(task));
|
||||
return task;
|
||||
},
|
||||
async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); }
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user