feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)

This commit is contained in:
SnapOtter
2026-06-13 10:18:49 +08:00
parent d647d8ed19
commit ae1337901d
115 changed files with 10026 additions and 924 deletions
+3
View File
@@ -31,3 +31,6 @@ export function qpdfAvailable(): boolean {
export function sofficeAvailable(): boolean {
return resolveSoffice() !== null;
}
export function gsAvailable(): boolean {
return resolveGs() !== null;
}
+55
View File
@@ -0,0 +1,55 @@
import { spawn } from "node:child_process";
import { resolveGs } from "./binaries.js";
export type PdfCompressionPreset = "screen" | "ebook" | "printer";
/** Ghostscript re-distillation with a quality preset; 120s hard kill. */
export async function gsCompressPdf(
inputPath: string,
outPath: string,
preset: PdfCompressionPreset,
): Promise<void> {
const bin = resolveGs();
if (!bin) throw new Error("gs binary not found (set GS_PATH or install ghostscript)");
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(
bin,
[
"-dSAFER",
"-dBATCH",
"-dNOPAUSE",
"-dQUIET",
"-sDEVICE=pdfwrite",
`-dPDFSETTINGS=/${preset}`,
"-dCompatibilityLevel=1.6",
`-sOutputFile=${outPath}`,
inputPath,
],
{ stdio: ["ignore", "ignore", "pipe"] },
);
let err = "";
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
reject(new Error("ghostscript timed out after 120s"));
}, 120_000);
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(`gs exited ${code ?? signal}: ${err.slice(-1000)}`));
});
});
}
+3
View File
@@ -1,10 +1,13 @@
export {
gsAvailable,
qpdfAvailable,
resolveGs,
resolveQpdf,
resolveSoffice,
sofficeAvailable,
} from "./binaries.js";
export { gsCompressPdf, type PdfCompressionPreset } from "./ghostscript.js";
export { type ConvertOptions, convertDocument } from "./libreoffice.js";
export { assertValidRange, qpdfMerge, qpdfRotate, qpdfSplitRanges } from "./pdf-ops.js";
export { pdfPageCountPy } from "./python-docs.js";
export { qpdfCheck, qpdfPageCount } from "./qpdf.js";
+37
View File
@@ -0,0 +1,37 @@
import { runQpdf } from "./qpdf.js";
// qpdf page ranges: digits, commas, hyphens, r-prefixed (r1 = last), and z (last page).
const RANGE_RE = /^[0-9rz][0-9rz,-]*$/i;
export function assertValidRange(range: string): void {
if (!RANGE_RE.test(range) || range.length > 200) {
throw new Error(`Invalid page range: ${range.slice(0, 50)}`);
}
}
/** Merge inputs (>= 2) into outPath, full pages, input order. */
export async function qpdfMerge(inputPaths: string[], outPath: string): Promise<void> {
if (inputPaths.length < 2) throw new Error("qpdfMerge needs at least two inputs");
await runQpdf(["--empty", "--pages", ...inputPaths, "--", outPath], 60_000);
}
/** Extract a page range (qpdf syntax, e.g. "1-3", "1,3,5", "2-z") into outPath. */
export async function qpdfSplitRanges(
inputPath: string,
range: string,
outPath: string,
): Promise<void> {
assertValidRange(range);
await runQpdf([inputPath, "--pages", ".", range, "--", outPath], 60_000);
}
/** Rotate by +angle (90|180|270) applied to a page range (default all: "1-z"). */
export async function qpdfRotate(
inputPath: string,
angle: 90 | 180 | 270,
range: string,
outPath: string,
): Promise<void> {
assertValidRange(range);
await runQpdf([`--rotate=+${angle}:${range}`, inputPath, outPath], 60_000);
}
+2 -1
View File
@@ -1,7 +1,8 @@
import { spawn } from "node:child_process";
import { resolveQpdf } from "./binaries.js";
function runQpdf(args: string[], timeoutMs = 30_000): Promise<string> {
/** @internal Shared qpdf CLI runner for doc-engine modules; not part of the public package API. */
export function runQpdf(args: string[], timeoutMs = 30_000): Promise<string> {
const bin = resolveQpdf();
if (!bin) throw new Error("qpdf binary not found (set QPDF_PATH or install qpdf)");
return new Promise<string>((resolvePromise, reject) => {