mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 4 - office, ebooks, data, archives (14 tools) (#224)
This commit is contained in:
@@ -14,7 +14,8 @@ export {
|
||||
gsPdfaConvert,
|
||||
type PdfCompressionPreset,
|
||||
} from "./ghostscript.js";
|
||||
export { type ConvertOptions, convertDocument } from "./libreoffice.js";
|
||||
export { type ConvertOptions, convertDocument, parseConvertTarget } from "./libreoffice.js";
|
||||
export { type PandocOptions, pandocAvailable, runPandoc } from "./pandoc.js";
|
||||
export {
|
||||
assertValidRange,
|
||||
qpdfDecrypt,
|
||||
|
||||
@@ -10,23 +10,42 @@ export interface ConvertOptions {
|
||||
timeoutMs?: number; // default 120s, spec 4.7 hard kill
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a target string into the output file extension and the full
|
||||
* --convert-to argument. Bare extensions ("pdf") pass through unchanged.
|
||||
* Qualified targets ("docx:MS Word 2007 XML") split on the FIRST colon:
|
||||
* the part before it is the output extension, the full string is the
|
||||
* --convert-to value.
|
||||
*/
|
||||
export function parseConvertTarget(target: string): { ext: string; convertTo: string } {
|
||||
const idx = target.indexOf(":");
|
||||
if (idx === -1) return { ext: target, convertTo: target };
|
||||
return { ext: target.slice(0, idx), convertTo: target };
|
||||
}
|
||||
|
||||
/**
|
||||
* LibreOffice headless conversion with per-invocation profile isolation
|
||||
* (spec 4.7): each run gets its own UserInstallation dir so concurrent
|
||||
* conversions cannot corrupt a shared profile; the profile is removed in
|
||||
* finally and the process is SIGKILLed at the deadline.
|
||||
*
|
||||
* target may be a bare extension ("pdf") or "ext:Filter Name"
|
||||
* ("docx:MS Word 2007 XML"). The extension controls the output filename;
|
||||
* the full string is passed to --convert-to.
|
||||
*
|
||||
* Returns the produced file path inside outDir.
|
||||
*/
|
||||
export async function convertDocument(
|
||||
inputPath: string,
|
||||
outDir: string,
|
||||
targetExt: string,
|
||||
target: string,
|
||||
opts: ConvertOptions = {},
|
||||
): Promise<string> {
|
||||
const bin = resolveSoffice();
|
||||
if (!bin) throw new Error("soffice binary not found (set SOFFICE_PATH or install LibreOffice)");
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
const profileDir = join(tmpdir(), `snapotter-lo-${randomUUID()}`);
|
||||
const { ext, convertTo } = parseConvertTarget(target);
|
||||
try {
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(
|
||||
@@ -38,7 +57,7 @@ export async function convertDocument(
|
||||
"--nolockcheck",
|
||||
"--nodefault",
|
||||
"--convert-to",
|
||||
targetExt,
|
||||
convertTo,
|
||||
"--outdir",
|
||||
outDir,
|
||||
inputPath,
|
||||
@@ -70,10 +89,10 @@ export async function convertDocument(
|
||||
else reject(new Error(`LibreOffice exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
const expected = `${basename(inputPath, extname(inputPath))}.${targetExt}`;
|
||||
const expected = `${basename(inputPath, extname(inputPath))}.${ext}`;
|
||||
const produced = (await readdir(outDir)).find((f) => f === expected);
|
||||
if (!produced)
|
||||
throw new Error(`LibreOffice produced no ${targetExt} output for ${basename(inputPath)}`);
|
||||
throw new Error(`LibreOffice produced no ${ext} output for ${basename(inputPath)}`);
|
||||
return join(outDir, produced);
|
||||
} finally {
|
||||
await rm(profileDir, { recursive: true, force: true }).catch(() => {});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
|
||||
let cachedAvailable: boolean | null = null;
|
||||
|
||||
/** True when a pandoc binary is on PATH (gates local tests; the image installs it). */
|
||||
export function pandocAvailable(): boolean {
|
||||
if (cachedAvailable !== null) return cachedAvailable;
|
||||
const r = spawnSync("pandoc", ["--version"], { stdio: "ignore" });
|
||||
cachedAvailable = r.status === 0;
|
||||
return cachedAvailable;
|
||||
}
|
||||
|
||||
/** Detect pandoc major version once (2.x vs 3.x) to choose the self-contained flag. */
|
||||
let cachedSelfContainedArgs: string[] | null = null;
|
||||
|
||||
function selfContainedArgs(): string[] {
|
||||
if (cachedSelfContainedArgs !== null) return cachedSelfContainedArgs;
|
||||
const r = spawnSync("pandoc", ["--version"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (r.status !== 0 || !r.stdout) {
|
||||
// Fallback to 2.x flag if we cannot determine the version.
|
||||
cachedSelfContainedArgs = ["--self-contained"];
|
||||
return cachedSelfContainedArgs;
|
||||
}
|
||||
// First line: "pandoc 2.17.1.1" or "pandoc 3.1.2"
|
||||
const match = r.stdout.match(/pandoc\s+(\d+)/);
|
||||
const major = match ? Number(match[1]) : 2;
|
||||
cachedSelfContainedArgs =
|
||||
major >= 3 ? ["--embed-resources", "--standalone"] : ["--self-contained"];
|
||||
return cachedSelfContainedArgs;
|
||||
}
|
||||
|
||||
export interface PandocOptions {
|
||||
timeoutMs?: number;
|
||||
/** Inline images/css as data: URIs (pandoc 2.x: --self-contained; 3.x: --embed-resources). */
|
||||
selfContained?: boolean;
|
||||
/** Extra args appended verbatim (e.g. ["--standalone"]). */
|
||||
extraArgs?: string[];
|
||||
}
|
||||
|
||||
/** Runs pandoc in -> out; rejects with the stderr tail on failure. */
|
||||
export function runPandoc(
|
||||
inPath: string,
|
||||
outPath: string,
|
||||
opts: PandocOptions = {},
|
||||
): Promise<void> {
|
||||
const timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
const args = [inPath, "-o", outPath];
|
||||
if (opts.selfContained) {
|
||||
args.push(...selfContainedArgs());
|
||||
}
|
||||
if (opts.extraArgs) {
|
||||
args.push(...opts.extraArgs);
|
||||
}
|
||||
return new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn("pandoc", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let err = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`pandoc timed out after ${Math.round(timeoutMs / 1000)}s`));
|
||||
}, timeoutMs);
|
||||
child.stderr.on("data", (c: Buffer) => {
|
||||
err = (err + c.toString("utf8")).slice(-4096);
|
||||
});
|
||||
child.on("error", (e) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (code === 0) resolvePromise();
|
||||
else reject(new Error(`pandoc exited ${code ?? signal}: ${err.slice(-1000)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user