mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools)!: SnapOtter 2.0 phase 4 wave 1: 45 core tools across all modalities (#219)
This commit is contained in:
@@ -31,3 +31,6 @@ export function qpdfAvailable(): boolean {
|
||||
export function sofficeAvailable(): boolean {
|
||||
return resolveSoffice() !== null;
|
||||
}
|
||||
export function gsAvailable(): boolean {
|
||||
return resolveGs() !== null;
|
||||
}
|
||||
|
||||
@@ -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)}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user