pdfnative-cli
Advanced tools
+1185
-85
@@ -7,2 +7,3 @@ #!/usr/bin/env node | ||
| var promises = require('fs/promises'); | ||
| var crypto = require('crypto'); | ||
| var module$1 = require('module'); | ||
@@ -22,3 +23,9 @@ | ||
| // src/utils/error.ts | ||
| var CliError; | ||
| function deprecate(name, replacement) { | ||
| if (_deprecateSeen.has(name)) return; | ||
| _deprecateSeen.add(name); | ||
| process.stderr.write(`warning: --${name} is deprecated; use ${replacement} instead. | ||
| `); | ||
| } | ||
| var CliError, _deprecateSeen; | ||
| var init_error = __esm({ | ||
@@ -34,2 +41,3 @@ "src/utils/error.ts"() { | ||
| }; | ||
| _deprecateSeen = /* @__PURE__ */ new Set(); | ||
| } | ||
@@ -43,2 +51,17 @@ }); | ||
| let i = 0; | ||
| const setFlag = (key, value) => { | ||
| const existing = flags[key]; | ||
| if (existing === void 0 || typeof existing === "boolean") { | ||
| flags[key] = value; | ||
| return; | ||
| } | ||
| if (typeof value === "boolean") { | ||
| return; | ||
| } | ||
| if (typeof existing === "string") { | ||
| flags[key] = [existing, value]; | ||
| } else { | ||
| existing.push(value); | ||
| } | ||
| }; | ||
| while (i < argv.length) { | ||
@@ -59,3 +82,3 @@ const token = argv[i]; | ||
| const value = token.slice(eqIdx + 1); | ||
| flags[key] = value; | ||
| setFlag(key, value); | ||
| } else { | ||
@@ -65,6 +88,6 @@ const key = token.slice(2); | ||
| if (next !== void 0 && !next.startsWith("-")) { | ||
| flags[key] = next; | ||
| setFlag(key, next); | ||
| i++; | ||
| } else { | ||
| flags[key] = true; | ||
| setFlag(key, true); | ||
| } | ||
@@ -76,6 +99,6 @@ } | ||
| if (next !== void 0 && !next.startsWith("-")) { | ||
| flags[key] = next; | ||
| setFlag(key, next); | ||
| i++; | ||
| } else { | ||
| flags[key] = true; | ||
| setFlag(key, true); | ||
| } | ||
@@ -92,11 +115,27 @@ } else { | ||
| const value = flags[name]; | ||
| if (value !== void 0) { | ||
| if (typeof value !== "string") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| return value; | ||
| if (value === void 0) continue; | ||
| if (typeof value === "boolean") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| if (typeof value === "string") return value; | ||
| return value[0]; | ||
| } | ||
| return void 0; | ||
| } | ||
| function getStringFlagAll(flags, ...names) { | ||
| const out = []; | ||
| for (const name of names) { | ||
| const value = flags[name]; | ||
| if (value === void 0) continue; | ||
| if (typeof value === "boolean") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| if (typeof value === "string") { | ||
| out.push(value); | ||
| } else { | ||
| out.push(...value); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function hasFlag(flags, ...names) { | ||
@@ -135,2 +174,7 @@ return names.some((n) => flags[n] !== void 0); | ||
| } | ||
| async function readBinaryFile(filePath) { | ||
| validatePath(filePath); | ||
| const buf = await promises.readFile(filePath); | ||
| return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); | ||
| } | ||
| function assertJsonSizeLimit(buf) { | ||
@@ -192,2 +236,336 @@ if (buf.length > JSON_SIZE_LIMIT) { | ||
| }); | ||
| async function loadLayoutFile(filePath) { | ||
| if (filePath === void 0) return {}; | ||
| validatePath(filePath); | ||
| let raw; | ||
| try { | ||
| raw = await promises.readFile(filePath, "utf8"); | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to read --layout file: ${msg}`, 1); | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to parse --layout JSON: ${msg}`, 1); | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { | ||
| throw new CliError("--layout file must contain a JSON object.", 1); | ||
| } | ||
| const obj = parsed; | ||
| if (Array.isArray(obj.attachments)) { | ||
| obj.attachments = obj.attachments.map((a) => { | ||
| if (typeof a !== "object" || a === null) return a; | ||
| const rest = { ...a }; | ||
| delete rest.data; | ||
| return rest; | ||
| }); | ||
| } | ||
| return obj; | ||
| } | ||
| function parsePageSizePair(value) { | ||
| const m = /^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i.exec(value.trim()); | ||
| if (m === null) return null; | ||
| return [Number.parseFloat(m[1]), Number.parseFloat(m[2])]; | ||
| } | ||
| function parsePageSize(value) { | ||
| const lower = value.toLowerCase(); | ||
| const named = NAMED_PAGE_SIZES[lower]; | ||
| if (named !== void 0) { | ||
| return { pageWidth: named[0], pageHeight: named[1] }; | ||
| } | ||
| const pair = parsePageSizePair(value); | ||
| if (pair !== null) { | ||
| return { pageWidth: pair[0], pageHeight: pair[1] }; | ||
| } | ||
| const valid = Object.keys(NAMED_PAGE_SIZES).join(", "); | ||
| throw new CliError( | ||
| `Invalid --page-size value "${value}". Expected one of: ${valid}, or WxH (points).`, | ||
| 2 | ||
| ); | ||
| } | ||
| function parseMargin(value) { | ||
| const parts = value.split(",").map((p) => p.trim()); | ||
| const nums = parts.map((p) => { | ||
| const n = Number.parseFloat(p); | ||
| if (!Number.isFinite(n) || n < 0) { | ||
| throw new CliError(`Invalid --margin value "${value}".`, 2); | ||
| } | ||
| return n; | ||
| }); | ||
| if (nums.length === 1) { | ||
| const v = nums[0]; | ||
| return { t: v, r: v, b: v, l: v }; | ||
| } | ||
| if (nums.length === 4) { | ||
| return { | ||
| t: nums[0], | ||
| r: nums[1], | ||
| b: nums[2], | ||
| l: nums[3] | ||
| }; | ||
| } | ||
| throw new CliError(`Invalid --margin "${value}". Expected N or T,R,B,L.`, 2); | ||
| } | ||
| function parseTagged(value) { | ||
| const v = value.toLowerCase(); | ||
| if (v === "none") return false; | ||
| if (VALID_TAGGED.includes(v)) { | ||
| return v; | ||
| } | ||
| throw new CliError( | ||
| `Invalid --tagged value "${value}". Valid: ${VALID_TAGGED.join(", ")}.`, | ||
| 2 | ||
| ); | ||
| } | ||
| function conformanceToTagged(value) { | ||
| const validShort = /* @__PURE__ */ new Set(["1b", "2b", "3b"]); | ||
| if (!validShort.has(value)) { | ||
| throw new CliError( | ||
| `Invalid --conformance value "${value}". Valid: 1b, 2b, 3b.`, | ||
| 2 | ||
| ); | ||
| } | ||
| return "pdfa" + value; | ||
| } | ||
| function buildPageTemplate(parts) { | ||
| if (parts.left === void 0 && parts.center === void 0 && parts.right === void 0) { | ||
| return void 0; | ||
| } | ||
| const tpl = {}; | ||
| if (parts.left !== void 0) tpl.left = parts.left; | ||
| if (parts.center !== void 0) tpl.center = parts.center; | ||
| if (parts.right !== void 0) tpl.right = parts.right; | ||
| return tpl; | ||
| } | ||
| function buildEncryptionFromFlags(args) { | ||
| const ownerFlag = getStringFlag(args.flags, "encrypt-owner-pass"); | ||
| const userFlag = getStringFlag(args.flags, "encrypt-user-pass"); | ||
| const algoFlag = getStringFlag(args.flags, "encrypt-algorithm"); | ||
| const permsFlag = getStringFlag(args.flags, "encrypt-permissions"); | ||
| const owner = process.env.PDFNATIVE_ENCRYPT_OWNER_PASS ?? ownerFlag; | ||
| const user = process.env.PDFNATIVE_ENCRYPT_USER_PASS ?? userFlag; | ||
| if (owner === void 0 && user === void 0 && algoFlag === void 0 && permsFlag === void 0) { | ||
| return void 0; | ||
| } | ||
| if (owner === void 0 || owner.length === 0) { | ||
| throw new CliError( | ||
| "Encryption requires an owner password. Provide --encrypt-owner-pass <pass> or $PDFNATIVE_ENCRYPT_OWNER_PASS.", | ||
| 2 | ||
| ); | ||
| } | ||
| const algo = algoFlag ?? "aes128"; | ||
| if (!VALID_ENCRYPTION_ALGOS.has(algo)) { | ||
| throw new CliError( | ||
| `Invalid --encrypt-algorithm "${algo}". Valid: aes128, aes256.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const opts = { | ||
| ownerPassword: owner, | ||
| algorithm: algo | ||
| }; | ||
| if (user !== void 0) opts.userPassword = user; | ||
| if (permsFlag !== void 0) { | ||
| const perms = {}; | ||
| for (const raw of permsFlag.split(",")) { | ||
| const p = raw.trim(); | ||
| if (p.length === 0) continue; | ||
| const lower = p.toLowerCase(); | ||
| if (!VALID_PERMISSIONS.has(p) && !VALID_PERMISSIONS.has(lower)) { | ||
| throw new CliError( | ||
| `Invalid permission "${p}" in --encrypt-permissions. Valid: print, copy, modify, extractText.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const key = lower === "extracttext" || p === "extractText" ? "extractText" : lower; | ||
| perms[key] = true; | ||
| } | ||
| opts.permissions = perms; | ||
| } | ||
| return opts; | ||
| } | ||
| async function buildWatermarkFromFlags(args) { | ||
| const text = getStringFlag(args.flags, "watermark-text"); | ||
| const opacity = getStringFlag(args.flags, "watermark-opacity"); | ||
| const angle = getStringFlag(args.flags, "watermark-angle"); | ||
| const color = getStringFlag(args.flags, "watermark-color"); | ||
| const fontSize = getStringFlag(args.flags, "watermark-font-size"); | ||
| const imagePath = getStringFlag(args.flags, "watermark-image"); | ||
| const position = getStringFlag(args.flags, "watermark-position"); | ||
| if (text === void 0 && imagePath === void 0 && opacity === void 0 && angle === void 0 && color === void 0 && fontSize === void 0 && position === void 0) { | ||
| return void 0; | ||
| } | ||
| const wm = {}; | ||
| if (text !== void 0) { | ||
| const t = { text }; | ||
| if (opacity !== void 0) t.opacity = parseUnit(opacity, "watermark-opacity", 0, 1); | ||
| if (angle !== void 0) t.angle = parseFloatFlag(angle, "watermark-angle"); | ||
| if (color !== void 0) t.color = color; | ||
| if (fontSize !== void 0) t.fontSize = parseFloatFlag(fontSize, "watermark-font-size"); | ||
| wm.text = t; | ||
| } | ||
| if (imagePath !== void 0) { | ||
| const data = await readBinaryFile(imagePath); | ||
| const img = { data }; | ||
| if (opacity !== void 0 && text === void 0) { | ||
| img.opacity = parseUnit(opacity, "watermark-opacity", 0, 1); | ||
| } | ||
| wm.image = img; | ||
| } | ||
| if (position !== void 0) { | ||
| if (position !== "background" && position !== "foreground") { | ||
| throw new CliError( | ||
| `Invalid --watermark-position "${position}". Valid: background, foreground.`, | ||
| 2 | ||
| ); | ||
| } | ||
| wm.position = position; | ||
| } | ||
| return wm; | ||
| } | ||
| function parseFloatFlag(value, flag) { | ||
| const n = Number.parseFloat(value); | ||
| if (!Number.isFinite(n)) { | ||
| throw new CliError(`Invalid --${flag} value "${value}".`, 2); | ||
| } | ||
| return n; | ||
| } | ||
| function parseUnit(value, flag, min, max) { | ||
| const n = parseFloatFlag(value, flag); | ||
| if (n < min || n > max) { | ||
| throw new CliError(`--${flag} must be between ${min} and ${max} (got ${value}).`, 2); | ||
| } | ||
| return n; | ||
| } | ||
| async function loadAttachmentsFromFlags(args) { | ||
| const paths = getStringFlagAll(args.flags, "attachment"); | ||
| if (paths.length === 0) return void 0; | ||
| const out = []; | ||
| for (const raw of paths) { | ||
| const parts = raw.split(":"); | ||
| let pathPart = parts[0] ?? ""; | ||
| let offset = 1; | ||
| if (pathPart.length === 1 && /^[A-Za-z]$/.test(pathPart) && parts.length > 1) { | ||
| pathPart = `${pathPart}:${parts[1] ?? ""}`; | ||
| offset = 2; | ||
| } | ||
| if (pathPart.length === 0) { | ||
| throw new CliError(`Invalid --attachment value "${raw}".`, 2); | ||
| } | ||
| const mimePart = parts[offset]; | ||
| const relPart = parts[offset + 1]; | ||
| const descPart = parts[offset + 2]; | ||
| const data = await readBinaryFile(pathPart); | ||
| const filename = pathPart.split(/[/\\]/).pop() ?? "attachment"; | ||
| const mime = mimePart && mimePart.length > 0 ? mimePart : "application/octet-stream"; | ||
| const att = { | ||
| filename, | ||
| data, | ||
| mimeType: mime | ||
| }; | ||
| if (relPart !== void 0 && relPart.length > 0) { | ||
| att.relationship = relPart; | ||
| } | ||
| if (descPart !== void 0 && descPart.length > 0) { | ||
| att.description = descPart; | ||
| } | ||
| out.push(att); | ||
| } | ||
| return out; | ||
| } | ||
| async function buildLayoutOptions(args) { | ||
| const layoutPath = getStringFlag(args.flags, "layout"); | ||
| const fromFile = await loadLayoutFile(layoutPath); | ||
| const out = { ...fromFile }; | ||
| const pageSize = getStringFlag(args.flags, "page-size"); | ||
| if (pageSize !== void 0) { | ||
| const { pageWidth, pageHeight } = parsePageSize(pageSize); | ||
| out.pageWidth = pageWidth; | ||
| out.pageHeight = pageHeight; | ||
| } | ||
| const margin = getStringFlag(args.flags, "margin"); | ||
| if (margin !== void 0) { | ||
| out.margins = parseMargin(margin); | ||
| } | ||
| if (hasFlag(args.flags, "compress")) { | ||
| out.compress = true; | ||
| } | ||
| const tagged = getStringFlag(args.flags, "tagged"); | ||
| const conformance = getStringFlag(args.flags, "conformance"); | ||
| if (tagged !== void 0 && conformance !== void 0) { | ||
| throw new CliError( | ||
| "Use either --tagged or --conformance, not both. Prefer --tagged.", | ||
| 2 | ||
| ); | ||
| } | ||
| if (tagged !== void 0) { | ||
| out.tagged = parseTagged(tagged); | ||
| } else if (conformance !== void 0) { | ||
| deprecate("conformance", "--tagged pdfa<level>"); | ||
| out.tagged = conformanceToTagged(conformance); | ||
| } | ||
| const header = buildPageTemplate({ | ||
| left: getStringFlag(args.flags, "header-left"), | ||
| center: getStringFlag(args.flags, "header-center"), | ||
| right: getStringFlag(args.flags, "header-right") | ||
| }); | ||
| if (header !== void 0) out.headerTemplate = header; | ||
| const footer = buildPageTemplate({ | ||
| left: getStringFlag(args.flags, "footer-left"), | ||
| center: getStringFlag(args.flags, "footer-center"), | ||
| right: getStringFlag(args.flags, "footer-right") | ||
| }); | ||
| if (footer !== void 0) out.footerTemplate = footer; | ||
| const watermark = await buildWatermarkFromFlags(args); | ||
| if (watermark !== void 0) out.watermark = watermark; | ||
| const encryption = buildEncryptionFromFlags(args); | ||
| if (encryption !== void 0) out.encryption = encryption; | ||
| const attachments = await loadAttachmentsFromFlags(args); | ||
| if (attachments !== void 0) out.attachments = attachments; | ||
| const tg = out.tagged; | ||
| if (encryption !== void 0 && tg !== void 0 && tg !== false) { | ||
| throw new CliError( | ||
| "Encryption is mutually exclusive with --tagged (PDF/A forbids encryption per ISO 19005-1 \xA76.3.2).", | ||
| 2 | ||
| ); | ||
| } | ||
| return out; | ||
| } | ||
| function assertStreamingCompatible(layout) { | ||
| const check = (tpl, label) => { | ||
| if (tpl === void 0) return; | ||
| for (const part of [tpl.left, tpl.center, tpl.right]) { | ||
| if (part !== void 0 && part.includes("{pages}")) { | ||
| throw new CliError( | ||
| `--stream is incompatible with the {pages} placeholder in --${label}-* (total page count not known until full render).`, | ||
| 2 | ||
| ); | ||
| } | ||
| } | ||
| }; | ||
| check(layout.headerTemplate, "header"); | ||
| check(layout.footerTemplate, "footer"); | ||
| } | ||
| var VALID_TAGGED, NAMED_PAGE_SIZES, VALID_ENCRYPTION_ALGOS, VALID_PERMISSIONS; | ||
| var init_layout = __esm({ | ||
| "src/utils/layout.ts"() { | ||
| init_args(); | ||
| init_io(); | ||
| init_error(); | ||
| VALID_TAGGED = ["none", "pdfa1b", "pdfa2b", "pdfa2u", "pdfa3b"]; | ||
| NAMED_PAGE_SIZES = { | ||
| a4: [595.28, 841.89], | ||
| letter: [612, 792], | ||
| legal: [612, 1008], | ||
| a3: [841.89, 1190.55], | ||
| tabloid: [792, 1224], | ||
| a5: [419.53, 595.28] | ||
| }; | ||
| VALID_ENCRYPTION_ALGOS = /* @__PURE__ */ new Set(["aes128", "aes256"]); | ||
| VALID_PERMISSIONS = /* @__PURE__ */ new Set(["print", "copy", "modify", "extractText", "extracttext"]); | ||
| } | ||
| }); | ||
@@ -199,2 +577,39 @@ // src/commands/render.ts | ||
| }); | ||
| function isPdfParamsLike(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const v = value; | ||
| return typeof v.title === "string" && Array.isArray(v.headers) && Array.isArray(v.rows); | ||
| } | ||
| function isDocumentParamsLike(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const v = value; | ||
| return Array.isArray(v.blocks); | ||
| } | ||
| function hasTocBlock(params) { | ||
| for (const b of params.blocks) { | ||
| const block = b; | ||
| if (block.type === "toc") return true; | ||
| } | ||
| return false; | ||
| } | ||
| async function buildFontEntriesForLangs(langs) { | ||
| if (langs.length === 0) return []; | ||
| const entries = []; | ||
| let nextRef = 3; | ||
| for (const lang of langs) { | ||
| if (!pdfnative.hasFontLoader(lang)) { | ||
| throw new CliError( | ||
| `--lang "${lang}" is not a bundled pdfnative font. Register a loader programmatically before invoking the CLI to use a custom font.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const fontData = await pdfnative.loadFontData(lang); | ||
| if (fontData === null) { | ||
| throw new CliError(`Failed to load font data for --lang "${lang}".`, 1); | ||
| } | ||
| entries.push({ fontData, fontRef: `/F${nextRef}`, lang }); | ||
| nextRef++; | ||
| } | ||
| return entries; | ||
| } | ||
| async function render(args) { | ||
@@ -204,14 +619,20 @@ const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const useStream = hasFlag(args.flags, "stream"); | ||
| const conformance = getStringFlag(args.flags, "conformance"); | ||
| if (conformance !== void 0 && !VALID_CONFORMANCE.has(conformance)) { | ||
| const variant = getStringFlag(args.flags, "variant") ?? "document"; | ||
| const langsRaw = getStringFlag(args.flags, "lang"); | ||
| if (!VALID_VARIANTS.has(variant)) { | ||
| throw new CliError( | ||
| `Invalid --conformance value "${conformance}". Valid values: 1b, 2b, 3b.`, | ||
| `Invalid --variant "${variant}". Valid: document, table.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const layout = await buildLayoutOptions(args); | ||
| if (useStream) assertStreamingCompatible(layout); | ||
| if (layout.compress === true) { | ||
| await pdfnative.initNodeCompression(); | ||
| } | ||
| const inputBuf = await readFileOrStdin(inputPath); | ||
| assertJsonSizeLimit(inputBuf); | ||
| let params; | ||
| let parsedInput; | ||
| try { | ||
| params = JSON.parse(inputBuf.toString("utf8")); | ||
| parsedInput = JSON.parse(inputBuf.toString("utf8")); | ||
| } catch (e) { | ||
@@ -221,17 +642,47 @@ const message = e instanceof Error ? e.message : String(e); | ||
| } | ||
| if (typeof params !== "object" || params === null) { | ||
| throw new CliError("JSON input must be a DocumentParams object.", 1); | ||
| if (variant === "table") { | ||
| if (!isPdfParamsLike(parsedInput)) { | ||
| throw new CliError( | ||
| "JSON input must be a PdfParams object (with title, headers, rows) when --variant table is used.", | ||
| 1 | ||
| ); | ||
| } | ||
| if (useStream) { | ||
| const generator = pdfnative.buildPDFStream(parsedInput, layout); | ||
| await writeStreamingOutput(generator, outputPath); | ||
| } else { | ||
| const pdfBytes = pdfnative.buildPDFBytes(parsedInput, layout); | ||
| await writeOutput(pdfBytes, outputPath); | ||
| } | ||
| return; | ||
| } | ||
| if (conformance !== void 0) { | ||
| params["pdfaConformance"] = conformance; | ||
| if (!isDocumentParamsLike(parsedInput)) { | ||
| throw new CliError( | ||
| 'JSON input must be a DocumentParams object (with a "blocks" array).', | ||
| 1 | ||
| ); | ||
| } | ||
| let params = parsedInput; | ||
| if (langsRaw !== void 0) { | ||
| const langs = langsRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0); | ||
| const fontEntries = await buildFontEntriesForLangs(langs); | ||
| const existing = params.fontEntries ?? []; | ||
| params = { ...params, fontEntries: [...existing, ...fontEntries] }; | ||
| } | ||
| const effectiveLayout = params.layout !== void 0 && params.layout !== null ? { ...params.layout, ...layout } : layout; | ||
| if (useStream && hasTocBlock(params)) { | ||
| throw new CliError( | ||
| "--stream is incompatible with TOC blocks (multi-pass pagination required).", | ||
| 2 | ||
| ); | ||
| } | ||
| if (useStream) { | ||
| const generator = pdfnative.buildDocumentPDFStream(params); | ||
| const generator = pdfnative.buildDocumentPDFStream(params, effectiveLayout); | ||
| await writeStreamingOutput(generator, outputPath); | ||
| } else { | ||
| const pdfBytes = pdfnative.buildDocumentPDFBytes(params); | ||
| const pdfBytes = pdfnative.buildDocumentPDFBytes(params, effectiveLayout); | ||
| await writeOutput(pdfBytes, outputPath); | ||
| } | ||
| } | ||
| var VALID_CONFORMANCE; | ||
| var VALID_VARIANTS; | ||
| var init_render = __esm({ | ||
@@ -243,11 +694,6 @@ "src/commands/render.ts"() { | ||
| init_error(); | ||
| VALID_CONFORMANCE = /* @__PURE__ */ new Set(["1b", "2b", "3b"]); | ||
| init_layout(); | ||
| VALID_VARIANTS = /* @__PURE__ */ new Set(["document", "table"]); | ||
| } | ||
| }); | ||
| // src/commands/sign.ts | ||
| var sign_exports = {}; | ||
| __export(sign_exports, { | ||
| sign: () => sign | ||
| }); | ||
| function pemToDer(pem) { | ||
@@ -262,17 +708,90 @@ const body = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); | ||
| } | ||
| async function loadPem(envVar, filePath, label) { | ||
| function splitPemBlocks(pem) { | ||
| const re = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g; | ||
| const matches = pem.match(re); | ||
| return matches ?? []; | ||
| } | ||
| async function loadPem(envVar, filePath, label, flagName) { | ||
| const fromEnv = process.env[envVar]; | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) { | ||
| return fromEnv; | ||
| } | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) return fromEnv; | ||
| if (filePath !== void 0) { | ||
| validatePath(filePath); | ||
| const buf = await promises.readFile(filePath, "utf8"); | ||
| return buf; | ||
| return promises.readFile(filePath, "utf8"); | ||
| } | ||
| throw new CliError( | ||
| `Missing ${label}. Provide $${envVar} (env) or --${label.replace(/ /g, "-")} <path>.`, | ||
| `Missing ${label}. Provide $${envVar} (env) or --${flagName} <path>.`, | ||
| 2 | ||
| ); | ||
| } | ||
| async function loadPemChain(envVar, filePaths) { | ||
| const blocks = []; | ||
| const fromEnv = process.env[envVar]; | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) { | ||
| blocks.push(...splitPemBlocks(fromEnv)); | ||
| } | ||
| for (const filePath of filePaths) { | ||
| validatePath(filePath); | ||
| const content = await promises.readFile(filePath, "utf8"); | ||
| const split = splitPemBlocks(content); | ||
| if (split.length === 0) { | ||
| blocks.push(content); | ||
| } else { | ||
| blocks.push(...split); | ||
| } | ||
| } | ||
| return blocks; | ||
| } | ||
| async function loadRsaPrivateKey(envVar, filePath, flagName) { | ||
| const pem = await loadPem(envVar, filePath, "private key", flagName); | ||
| try { | ||
| return pdfnative.parseRsaPrivateKey(pemToDer(pem)); | ||
| } catch { | ||
| throw new CliError( | ||
| "Failed to parse RSA private key. Verify the file is a valid PEM-encoded PKCS#8 RSA key.", | ||
| 1 | ||
| ); | ||
| } | ||
| } | ||
| async function loadCertificate(envVar, filePath, flagName) { | ||
| const pem = await loadPem(envVar, filePath, "certificate", flagName); | ||
| try { | ||
| return pdfnative.parseCertificate(pemToDer(pem)); | ||
| } catch { | ||
| throw new CliError( | ||
| "Failed to parse X.509 certificate. Verify the file is valid PEM-encoded.", | ||
| 1 | ||
| ); | ||
| } | ||
| } | ||
| function parseCertificateChain(pemBlocks) { | ||
| const out = []; | ||
| for (const pem of pemBlocks) { | ||
| try { | ||
| out.push(pdfnative.parseCertificate(pemToDer(pem))); | ||
| } catch { | ||
| throw new CliError("Failed to parse certificate in chain.", 1); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| var init_keys = __esm({ | ||
| "src/utils/keys.ts"() { | ||
| init_core_bridge(); | ||
| init_io(); | ||
| init_error(); | ||
| } | ||
| }); | ||
| // src/commands/sign.ts | ||
| var sign_exports = {}; | ||
| __export(sign_exports, { | ||
| sign: () => sign | ||
| }); | ||
| function parseSigningTime(raw) { | ||
| const t = new Date(raw); | ||
| if (Number.isNaN(t.getTime())) { | ||
| throw new CliError(`Invalid --signing-time "${raw}". Expected ISO 8601 (e.g. 2026-04-28T12:00:00Z).`, 2); | ||
| } | ||
| return t; | ||
| } | ||
| async function sign(args) { | ||
@@ -283,19 +802,55 @@ const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const certPath = getStringFlag(args.flags, "cert"); | ||
| const algorithm = getStringFlag(args.flags, "algorithm") ?? "rsa-sha256"; | ||
| const reason = getStringFlag(args.flags, "reason"); | ||
| const name = getStringFlag(args.flags, "name"); | ||
| const location = getStringFlag(args.flags, "location"); | ||
| const contactInfo = getStringFlag(args.flags, "contact"); | ||
| const signingTimeRaw = getStringFlag(args.flags, "signing-time"); | ||
| const chainPaths = getStringFlagAll(args.flags, "cert-chain"); | ||
| if (!VALID_ALGORITHMS.has(algorithm)) { | ||
| throw new CliError( | ||
| `Invalid --algorithm "${algorithm}". Valid: rsa-sha256, ecdsa-sha256.`, | ||
| 2 | ||
| ); | ||
| } | ||
| if (algorithm === "ecdsa-sha256") { | ||
| throw new CliError( | ||
| "ECDSA signing is not yet available via the CLI. It requires a pdfnative release exposing parseEcPrivateKey. Use the pdfnative Node.js API directly to sign with ECDSA.", | ||
| 2 | ||
| ); | ||
| } | ||
| const signingTime = signingTimeRaw !== void 0 ? parseSigningTime(signingTimeRaw) : void 0; | ||
| if (process.env["PDFNATIVE_SIGN_KEY"] === void 0 && keyPath === void 0) { | ||
| throw new CliError("Missing private key. Provide $PDFNATIVE_SIGN_KEY (env) or --key <path>.", 2); | ||
| } | ||
| if (process.env["PDFNATIVE_SIGN_CERT"] === void 0 && certPath === void 0) { | ||
| throw new CliError("Missing certificate. Provide $PDFNATIVE_SIGN_CERT (env) or --cert <path>.", 2); | ||
| } | ||
| const pdfBuf = await readFileOrStdin(inputPath); | ||
| const pdfBytes = new Uint8Array(pdfBuf); | ||
| const privateKeyPem = await loadPem("PDFNATIVE_SIGN_KEY", keyPath, "private key"); | ||
| const certPem = await loadPem("PDFNATIVE_SIGN_CERT", certPath, "certificate"); | ||
| let options; | ||
| const rsaKey = await loadRsaPrivateKey("PDFNATIVE_SIGN_KEY", keyPath, "key"); | ||
| const signerCert = await loadCertificate("PDFNATIVE_SIGN_CERT", certPath, "cert"); | ||
| const chainPemBlocks = await loadPemChain("PDFNATIVE_SIGN_CHAIN", chainPaths); | ||
| const certChain = chainPemBlocks.length > 0 ? parseCertificateChain(chainPemBlocks) : void 0; | ||
| const options = { | ||
| rsaKey, | ||
| signerCert, | ||
| algorithm | ||
| }; | ||
| if (certChain !== void 0) options.certChain = certChain; | ||
| if (reason !== void 0) options.reason = reason; | ||
| if (name !== void 0) options.name = name; | ||
| if (location !== void 0) options.location = location; | ||
| if (contactInfo !== void 0) options.contactInfo = contactInfo; | ||
| if (signingTime !== void 0) options.signingTime = signingTime; | ||
| let signedBytes; | ||
| try { | ||
| const keyDer = pemToDer(privateKeyPem); | ||
| const certDer = pemToDer(certPem); | ||
| const rsaKey = pdfnative.parseRsaPrivateKey(keyDer); | ||
| const signerCert = pdfnative.parseCertificate(certDer); | ||
| options = { rsaKey, signerCert, algorithm: "rsa-sha256" }; | ||
| } catch { | ||
| throw new CliError("Failed to parse signing credentials. Verify key and certificate are valid PEM-encoded files.", 1); | ||
| signedBytes = pdfnative.signPdfBytes(pdfBytes, options); | ||
| } catch (e) { | ||
| const safeMsg = e instanceof Error ? e.message.split("\n")[0] : "unknown error"; | ||
| throw new CliError(`Failed to sign PDF: ${safeMsg ?? "unknown error"}`, 1); | ||
| } | ||
| const signedBytes = pdfnative.signPdfBytes(pdfBytes, options); | ||
| await writeOutput(signedBytes, outputPath); | ||
| } | ||
| var VALID_ALGORITHMS; | ||
| var init_sign = __esm({ | ||
@@ -307,5 +862,353 @@ "src/commands/sign.ts"() { | ||
| init_error(); | ||
| init_keys(); | ||
| VALID_ALGORITHMS = /* @__PURE__ */ new Set(["rsa-sha256", "ecdsa-sha256"]); | ||
| } | ||
| }); | ||
| // src/commands/verify.ts | ||
| var verify_exports = {}; | ||
| __export(verify_exports, { | ||
| verify: () => verify | ||
| }); | ||
| function decodeHexString(hex) { | ||
| const clean = hex.replace(/\s+/g, ""); | ||
| if (clean.length % 2 !== 0) { | ||
| throw new Error("hex string has odd length"); | ||
| } | ||
| const out = new Uint8Array(clean.length / 2); | ||
| for (let i = 0; i < out.length; i++) { | ||
| out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); | ||
| } | ||
| return out; | ||
| } | ||
| function bytesToHex(bytes) { | ||
| let s = ""; | ||
| for (let i = 0; i < bytes.length; i++) { | ||
| s += bytes[i].toString(16).padStart(2, "0"); | ||
| } | ||
| return s; | ||
| } | ||
| function digestByteRange(pdfBytes, byteRange) { | ||
| const [a, b, c, d] = byteRange; | ||
| const hash = crypto.createHash("sha256"); | ||
| hash.update(pdfBytes.subarray(a, a + b)); | ||
| hash.update(pdfBytes.subarray(c, c + d)); | ||
| return hash.digest("hex"); | ||
| } | ||
| function decodeOid(node) { | ||
| if (node.tag !== 6) return null; | ||
| const bytes = node.value; | ||
| if (bytes.length === 0) return null; | ||
| const first = bytes[0]; | ||
| const parts = [Math.floor(first / 40), first % 40]; | ||
| let v = 0; | ||
| for (let i = 1; i < bytes.length; i++) { | ||
| const byte = bytes[i]; | ||
| v = v << 7 | byte & 127; | ||
| if ((byte & 128) === 0) { | ||
| parts.push(v); | ||
| v = 0; | ||
| } | ||
| } | ||
| return parts.join("."); | ||
| } | ||
| function findMessageDigest(node) { | ||
| if (node.children.length >= 2) { | ||
| const oid = decodeOid(node.children[0]); | ||
| if (oid === MESSAGE_DIGEST_OID) { | ||
| const setNode = node.children[1]; | ||
| if (setNode.children.length > 0) { | ||
| const oct = setNode.children[0]; | ||
| if (oct.tag === 4) { | ||
| return oct.value; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const child of node.children) { | ||
| const found = findMessageDigest(child); | ||
| if (found !== null) return found; | ||
| } | ||
| return null; | ||
| } | ||
| function extractCertsFromCms(cmsBytes, root) { | ||
| const certs = []; | ||
| const visit = (node) => { | ||
| if (node.tag === 160 && node.children.length > 0) { | ||
| for (const child of node.children) { | ||
| if (child.tag === 48) { | ||
| certs.push(cmsBytes.subarray(child.offset, child.offset + child.totalLength)); | ||
| } | ||
| } | ||
| } | ||
| for (const child of node.children) visit(child); | ||
| }; | ||
| visit(root); | ||
| return certs; | ||
| } | ||
| function nameToString(name) { | ||
| if (name === void 0) return null; | ||
| const parts = []; | ||
| if (name.cn !== void 0) parts.push(`CN=${name.cn}`); | ||
| if (name.o !== void 0) parts.push(`O=${name.o}`); | ||
| if (name.ou !== void 0) parts.push(`OU=${name.ou}`); | ||
| if (name.c !== void 0) parts.push(`C=${name.c}`); | ||
| return parts.length > 0 ? parts.join(", ") : null; | ||
| } | ||
| function resolveValue(reader, val) { | ||
| if (val === void 0) return null; | ||
| if (pdfnative.isRef(val)) { | ||
| try { | ||
| return reader.resolveValue(val); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| return val; | ||
| } | ||
| function getDictString(dict, key) { | ||
| const v = dict.get(key); | ||
| return typeof v === "string" ? v : null; | ||
| } | ||
| function getDictName(dict, key) { | ||
| const v = dict.get(key); | ||
| return v !== void 0 && pdfnative.isName(v) ? pdfnative.nameValue(v) ?? null : null; | ||
| } | ||
| function findSignatureFields(reader) { | ||
| try { | ||
| const catalog = reader.getCatalog(); | ||
| const acroVal = resolveValue(reader, catalog.get("AcroForm")); | ||
| if (acroVal === null || !pdfnative.isDict(acroVal)) return []; | ||
| const fieldsVal = resolveValue(reader, acroVal.get("Fields")); | ||
| if (fieldsVal === null || !pdfnative.isArray(fieldsVal)) return []; | ||
| const out = []; | ||
| for (const fieldRef of fieldsVal) { | ||
| const field = resolveValue(reader, fieldRef); | ||
| if (field === null || !pdfnative.isDict(field)) continue; | ||
| if (getDictName(field, "FT") !== "Sig") continue; | ||
| const sigVal = resolveValue(reader, field.get("V")); | ||
| if (sigVal === null || !pdfnative.isDict(sigVal)) continue; | ||
| out.push({ | ||
| fieldName: getDictString(field, "T"), | ||
| sigDict: sigVal | ||
| }); | ||
| } | ||
| return out; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function parseSignatureDict(dict) { | ||
| const brVal = dict.get("ByteRange"); | ||
| let byteRange = null; | ||
| if (Array.isArray(brVal) && brVal.length === 4 && brVal.every((n) => typeof n === "number")) { | ||
| byteRange = [ | ||
| brVal[0], | ||
| brVal[1], | ||
| brVal[2], | ||
| brVal[3] | ||
| ]; | ||
| } | ||
| const contentsRaw = dict.get("Contents"); | ||
| let contents = null; | ||
| if (typeof contentsRaw === "string") { | ||
| const stripped = contentsRaw.replace(/^</, "").replace(/>$/, "").trim(); | ||
| if (/^[0-9a-fA-F\s]+$/.test(stripped) && stripped.length > 0) { | ||
| try { | ||
| contents = decodeHexString(stripped); | ||
| } catch { | ||
| contents = null; | ||
| } | ||
| } | ||
| if (contents === null) { | ||
| const raw = new Uint8Array(contentsRaw.length); | ||
| for (let i = 0; i < contentsRaw.length; i++) { | ||
| raw[i] = contentsRaw.charCodeAt(i) & 255; | ||
| } | ||
| contents = raw; | ||
| } | ||
| } | ||
| return { | ||
| byteRange, | ||
| contents, | ||
| subFilter: getDictName(dict, "SubFilter"), | ||
| signingTime: getDictString(dict, "M"), | ||
| reason: getDictString(dict, "Reason"), | ||
| location: getDictString(dict, "Location") | ||
| }; | ||
| } | ||
| function findChainParent(cert, candidates) { | ||
| for (const c of candidates) { | ||
| if (c === cert) continue; | ||
| try { | ||
| if (pdfnative.verifyCertSignature(cert, c)) return c; | ||
| } catch { | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| function buildChain(leaf, pool) { | ||
| const chain = [leaf]; | ||
| let current = leaf; | ||
| let chainValid = true; | ||
| const seen = /* @__PURE__ */ new Set([leaf]); | ||
| while (!pdfnative.isSelfSigned(current)) { | ||
| const parent = findChainParent(current, pool); | ||
| if (parent === void 0 || seen.has(parent)) { | ||
| chainValid = false; | ||
| break; | ||
| } | ||
| chain.push(parent); | ||
| seen.add(parent); | ||
| current = parent; | ||
| } | ||
| return { chain, chainValid, root: current }; | ||
| } | ||
| function certEquals(a, b) { | ||
| if (a.raw.length !== b.raw.length) return false; | ||
| for (let i = 0; i < a.raw.length; i++) { | ||
| if (a.raw[i] !== b.raw[i]) return false; | ||
| } | ||
| return true; | ||
| } | ||
| async function verify(args) { | ||
| const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const format = getStringFlag(args.flags, "format", "f") ?? "json"; | ||
| const strict = hasFlag(args.flags, "strict"); | ||
| const trustPaths = getStringFlagAll(args.flags, "trust"); | ||
| if (format !== "json" && format !== "text") { | ||
| throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); | ||
| } | ||
| const trustPemBlocks = await loadPemChain("PDFNATIVE_VERIFY_TRUST", trustPaths); | ||
| const trustRoots = trustPemBlocks.length > 0 ? parseCertificateChain(trustPemBlocks) : []; | ||
| const inputBuf = await readFileOrStdin(inputPath); | ||
| const pdfBytes = new Uint8Array(inputBuf); | ||
| let reader; | ||
| try { | ||
| reader = pdfnative.openPdf(pdfBytes); | ||
| } catch (e) { | ||
| const message = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to read PDF: ${message}`, 1); | ||
| } | ||
| const fields = findSignatureFields(reader); | ||
| const reports = []; | ||
| fields.forEach((field, idx) => { | ||
| const notes = []; | ||
| const sig = parseSignatureDict(field.sigDict); | ||
| let digest = null; | ||
| let integrity = false; | ||
| let signerSubject = null; | ||
| let signerIssuer = null; | ||
| let chainValid = false; | ||
| let trustedRoot = false; | ||
| if (sig.byteRange !== null) { | ||
| digest = digestByteRange(pdfBytes, sig.byteRange); | ||
| } else { | ||
| notes.push("missing /ByteRange"); | ||
| } | ||
| if (sig.contents !== null) { | ||
| try { | ||
| const root = pdfnative.derDecode(sig.contents); | ||
| const certDers = extractCertsFromCms(sig.contents, root); | ||
| if (certDers.length === 0) { | ||
| notes.push("no certificates embedded in CMS"); | ||
| } else { | ||
| const certs = certDers.map((der) => pdfnative.parseCertificate(der)); | ||
| const leaf = certs[0]; | ||
| signerSubject = nameToString(leaf.subject); | ||
| signerIssuer = nameToString(leaf.issuer); | ||
| const md = findMessageDigest(root); | ||
| if (md !== null && digest !== null) { | ||
| integrity = bytesToHex(md) === digest; | ||
| if (!integrity) { | ||
| notes.push("messageDigest mismatch \u2014 content tampered after signing"); | ||
| } | ||
| } else { | ||
| notes.push("messageDigest attribute not found in CMS"); | ||
| } | ||
| const pool = [...certs, ...trustRoots]; | ||
| const built = buildChain(leaf, pool); | ||
| chainValid = built.chainValid; | ||
| if (!chainValid) { | ||
| notes.push("chain incomplete (no parent for an intermediate cert)"); | ||
| } | ||
| if (trustRoots.length === 0) { | ||
| trustedRoot = pdfnative.isSelfSigned(built.root); | ||
| if (trustedRoot) { | ||
| notes.push("no --trust provided; accepted self-signed root"); | ||
| } | ||
| } else { | ||
| trustedRoot = trustRoots.some((t) => certEquals(t, built.root)); | ||
| if (!trustedRoot) notes.push("chain root not in --trust list"); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| notes.push(`failed to parse CMS: ${msg}`); | ||
| } | ||
| } else { | ||
| notes.push("missing /Contents"); | ||
| } | ||
| reports.push({ | ||
| index: idx, | ||
| fieldName: field.fieldName, | ||
| subFilter: sig.subFilter, | ||
| signerSubject, | ||
| signerIssuer, | ||
| signingTime: sig.signingTime, | ||
| reason: sig.reason, | ||
| location: sig.location, | ||
| digest, | ||
| integrity, | ||
| chainValid, | ||
| trustedRoot, | ||
| notes | ||
| }); | ||
| }); | ||
| const allValid = reports.length > 0 && reports.every((r) => r.integrity && r.chainValid && r.trustedRoot); | ||
| const result = { signatures: reports, allValid }; | ||
| if (format === "json") { | ||
| process.stdout.write(JSON.stringify(result, null, 2) + "\n"); | ||
| } else { | ||
| process.stdout.write(`Signatures: ${reports.length} | ||
| `); | ||
| for (const r of reports) { | ||
| process.stdout.write( | ||
| ` | ||
| [${r.index}] field=${r.fieldName ?? "\u2014"} subFilter=${r.subFilter ?? "\u2014"} | ||
| signer: ${r.signerSubject ?? "\u2014"} | ||
| issuer: ${r.signerIssuer ?? "\u2014"} | ||
| signed at: ${r.signingTime ?? "\u2014"} | ||
| integrity: ${r.integrity ? "OK" : "FAIL"} | ||
| chain: ${r.chainValid ? "valid" : "invalid"} | ||
| trust: ${r.trustedRoot ? "trusted" : "untrusted"} | ||
| ` | ||
| ); | ||
| if (r.notes.length > 0) { | ||
| process.stdout.write(` notes: ${r.notes.join("; ")} | ||
| `); | ||
| } | ||
| } | ||
| process.stdout.write( | ||
| ` | ||
| Result: ${allValid ? "all signatures valid" : "one or more checks failed"} | ||
| ` | ||
| ); | ||
| } | ||
| if (strict && !allValid) { | ||
| throw new CliError("", 1); | ||
| } | ||
| } | ||
| var MESSAGE_DIGEST_OID; | ||
| var init_verify = __esm({ | ||
| "src/commands/verify.ts"() { | ||
| init_core_bridge(); | ||
| init_args(); | ||
| init_io(); | ||
| init_error(); | ||
| init_keys(); | ||
| MESSAGE_DIGEST_OID = "1.2.840.113549.1.9.4"; | ||
| } | ||
| }); | ||
| // src/commands/inspect.ts | ||
@@ -322,6 +1225,4 @@ var inspect_exports = {}; | ||
| } | ||
| if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(trimmed)) { | ||
| return null; | ||
| } | ||
| return trimmed || null; | ||
| if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(trimmed)) return null; | ||
| return trimmed.length > 0 ? trimmed : null; | ||
| } | ||
@@ -336,6 +1237,5 @@ function extractVersion(reader) { | ||
| function extractEncrypted(reader) { | ||
| const trailer = reader.trailer; | ||
| return trailer.get("Encrypt") !== void 0; | ||
| return reader.trailer.get("Encrypt") !== void 0; | ||
| } | ||
| function extractPdfaConformance(reader) { | ||
| function readXmp(reader) { | ||
| try { | ||
@@ -352,10 +1252,15 @@ const catalog = reader.getCatalog(); | ||
| ); | ||
| const xmp = new TextDecoder("utf-8", { fatal: false }).decode(decoded); | ||
| const partMatch = /pdfaid:part[^>]*>(\d+)</.exec(xmp); | ||
| const confMatch = /pdfaid:conformance[^>]*>([A-Za-z]+)</.exec(xmp); | ||
| if (partMatch !== null && confMatch !== null) { | ||
| return `${partMatch[1]}${confMatch[1].toLowerCase()}`; | ||
| } | ||
| return new TextDecoder("utf-8", { fatal: false }).decode(decoded); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function extractPdfaConformance(reader) { | ||
| const xmp = readXmp(reader); | ||
| if (xmp === null) return null; | ||
| const partMatch = /pdfaid:part[^>]*>(\d+)</.exec(xmp); | ||
| const confMatch = /pdfaid:conformance[^>]*>([A-Za-z]+)</.exec(xmp); | ||
| if (partMatch !== null && confMatch !== null) { | ||
| return `${partMatch[1]}${confMatch[1].toLowerCase()}`; | ||
| } | ||
| return null; | ||
@@ -384,7 +1289,79 @@ } | ||
| } | ||
| function inspectPages(reader) { | ||
| const out = []; | ||
| for (let i = 0; i < reader.pageCount; i++) { | ||
| const page = reader.getPage(i); | ||
| const mediaBox = page.get("MediaBox"); | ||
| let width = null; | ||
| let height = null; | ||
| const box = Array.isArray(mediaBox) ? mediaBox : null; | ||
| if (box !== null && box.length === 4) { | ||
| const w = box[2]; | ||
| const h = box[3]; | ||
| if (typeof w === "number") width = w; | ||
| if (typeof h === "number") height = h; | ||
| } | ||
| const rotation = typeof page.get("Rotate") === "number" ? page.get("Rotate") : 0; | ||
| const annots = page.get("Annots"); | ||
| let annotations = 0; | ||
| let formFields = 0; | ||
| if (Array.isArray(annots)) { | ||
| for (const ref of annots) { | ||
| annotations++; | ||
| try { | ||
| const annot = reader.resolveValue(ref); | ||
| if (annot instanceof Map && annot.get("Subtype") === "/Widget") { | ||
| formFields++; | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
| out.push({ index: i, width, height, rotation, annotations, formFields }); | ||
| } | ||
| return out; | ||
| } | ||
| function buildVerbose(reader) { | ||
| const trailerKeys = []; | ||
| for (const k of reader.trailer.keys()) trailerKeys.push(k); | ||
| const catalogKeys = []; | ||
| try { | ||
| for (const k of reader.getCatalog().keys()) catalogKeys.push(k); | ||
| } catch { | ||
| } | ||
| const objectCount = reader.xref?.entries?.size ?? 0; | ||
| const xmp = readXmp(reader); | ||
| return { | ||
| trailerKeys, | ||
| catalogKeys, | ||
| objectCount, | ||
| xmpMetadata: xmp | ||
| }; | ||
| } | ||
| function evaluateChecks(checks, result) { | ||
| const out = []; | ||
| for (const c of checks) { | ||
| if (!VALID_CHECKS.has(c)) { | ||
| throw new CliError( | ||
| `Invalid --check value "${c}". Valid: ${[...VALID_CHECKS].join(", ")}.`, | ||
| 2 | ||
| ); | ||
| } | ||
| if (c === "pdfa") out.push({ name: c, passed: result.pdfaConformance !== null }); | ||
| if (c === "signed") out.push({ name: c, passed: result.signatures > 0 }); | ||
| if (c === "encrypted") out.push({ name: c, passed: result.encrypted }); | ||
| } | ||
| return { | ||
| checks: out.map((x) => `${x.name}=${x.passed ? "pass" : "fail"}`), | ||
| allPassed: out.every((x) => x.passed) | ||
| }; | ||
| } | ||
| async function inspect(args) { | ||
| const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const format = getStringFlag(args.flags, "format", "f") ?? "json"; | ||
| const verbose = hasFlag(args.flags, "verbose"); | ||
| const includePages = hasFlag(args.flags, "pages"); | ||
| const checks = getStringFlagAll(args.flags, "check"); | ||
| if (format !== "json" && format !== "text") { | ||
| throw new CliError(`Invalid --format value "${format}". Valid values: json, text.`, 2); | ||
| throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); | ||
| } | ||
@@ -401,3 +1378,3 @@ const inputBuf = await readFileOrStdin(inputPath); | ||
| const info = reader.getInfo(); | ||
| const result = { | ||
| const baseResult = { | ||
| version: extractVersion(reader), | ||
@@ -416,2 +1393,7 @@ pageCount: reader.pageCount, | ||
| }; | ||
| const result = { | ||
| ...baseResult, | ||
| ...includePages ? { pages: inspectPages(reader) } : {}, | ||
| ...verbose ? { verbose: buildVerbose(reader) } : {} | ||
| }; | ||
| if (format === "json") { | ||
@@ -432,5 +1414,30 @@ process.stdout.write(JSON.stringify(result, null, 2) + "\n"); | ||
| ]; | ||
| if (result.pages !== void 0) { | ||
| lines.push("Pages detail:"); | ||
| for (const p of result.pages) { | ||
| lines.push( | ||
| ` #${p.index + 1}: ${p.width ?? "?"}x${p.height ?? "?"}pt rot=${p.rotation}\xB0 annots=${p.annotations} fields=${p.formFields}` | ||
| ); | ||
| } | ||
| } | ||
| if (result.verbose !== void 0) { | ||
| lines.push(`Trailer keys: ${result.verbose.trailerKeys.join(", ")}`); | ||
| lines.push(`Catalog keys: ${result.verbose.catalogKeys.join(", ")}`); | ||
| lines.push(`Object count: ${result.verbose.objectCount}`); | ||
| if (result.verbose.xmpMetadata !== null) { | ||
| lines.push(`XMP metadata: (${result.verbose.xmpMetadata.length} chars)`); | ||
| } | ||
| } | ||
| process.stdout.write(lines.join("\n") + "\n"); | ||
| } | ||
| if (checks.length > 0) { | ||
| const evaluation = evaluateChecks(checks, result); | ||
| if (!evaluation.allPassed) { | ||
| process.stderr.write(`check failed: ${evaluation.checks.join(", ")} | ||
| `); | ||
| throw new CliError("", 1); | ||
| } | ||
| } | ||
| } | ||
| var VALID_CHECKS; | ||
| var init_inspect = __esm({ | ||
@@ -442,2 +1449,3 @@ "src/commands/inspect.ts"() { | ||
| init_error(); | ||
| VALID_CHECKS = /* @__PURE__ */ new Set(["pdfa", "signed", "encrypted"]); | ||
| } | ||
@@ -456,7 +1464,8 @@ }); | ||
| render Render a JSON document definition to PDF | ||
| sign Apply a digital signature to an existing PDF | ||
| sign Apply a digital signature to a PDF | ||
| verify Verify embedded PDF signatures | ||
| inspect Analyse a PDF and output metadata / conformance info | ||
| Options: | ||
| --help, -h Show this help message | ||
| --help, -h Show this help message | ||
| --version, -V Show version | ||
@@ -466,15 +1475,51 @@ | ||
| `; | ||
| var RENDER_USAGE = `pdfnative render \u2014 Render a JSON DocumentParams to PDF | ||
| var RENDER_USAGE = `pdfnative render \u2014 Render a JSON document definition to PDF | ||
| Usage: | ||
| pdfnative render [--input <file.json>] [--output <out.pdf>] [--stream] [--conformance <level>] | ||
| pdfnative render [--input <file>] [--output <out.pdf>] [options] | ||
| Options: | ||
| --input, -i Path to JSON input file (default: stdin) | ||
| I/O: | ||
| --input, -i Path to JSON input (default: stdin) | ||
| --output, -o Output PDF path (default: stdout) | ||
| --stream Use streaming output for large documents | ||
| --conformance PDF/A conformance level: 1b, 2b, or 3b | ||
| --stream Stream output (large documents). Incompatible with TOC blocks | ||
| and with header/footer templates that contain {pages}. | ||
| Variant: | ||
| --variant document (default) or table | ||
| Layout (flags override values from --layout file): | ||
| --layout Path to JSON layout file (PdfLayoutOptions) | ||
| --page-size Named (a4|letter|legal|a3|tabloid|a5) or WxH in points | ||
| --margin Uniform N or "top,right,bottom,left" in points | ||
| --tagged none|pdfa1b|pdfa2b|pdfa3b (PDF/A flag, sets PDF/A conformance) | ||
| --conformance DEPRECATED \u2014 alias for --tagged pdfa{1b|2b|3b} | ||
| --compress Enable Flate compression (initialises Node compression) | ||
| --lang Comma-separated language packs (e.g. th,ja,ar) | ||
| Header / Footer: | ||
| --header-left, --header-center, --header-right | ||
| --footer-left, --footer-center, --footer-right | ||
| Each accepts a template string. {page}, {pages}, {date} are | ||
| substituted by pdfnative. | ||
| Watermark: | ||
| --watermark-text Text watermark | ||
| --watermark-image Image path (PNG/JPEG) | ||
| --watermark-opacity 0.0\u20131.0 (default 0.2) | ||
| --watermark-rotation degrees (default 45) | ||
| Encryption (mutually exclusive with --tagged pdfa*): | ||
| --encrypt aes-128 | aes-256 | ||
| --owner-password (or env $PDFNATIVE_ENCRYPT_OWNER_PASS \u2014 env wins) | ||
| --user-password (or env $PDFNATIVE_ENCRYPT_USER_PASS \u2014 env wins) | ||
| --permissions Comma-separated: print,copy,modify,annotate,form, | ||
| accessibility,assemble,print-hi-res | ||
| Attachments (PDF/A-3, repeatable): | ||
| --attachment <path>[:mime[:rel[:desc]]] | ||
| rel = Source|Data|Alternative|Supplement|Unspecified | ||
| --help, -h Show this help message | ||
| `; | ||
| var SIGN_USAGE = `pdfnative sign \u2014 Apply a digital signature to an existing PDF | ||
| var SIGN_USAGE = `pdfnative sign \u2014 Apply a digital signature to a PDF | ||
@@ -484,12 +1529,48 @@ Usage: | ||
| Options: | ||
| I/O: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --output, -o Signed PDF output path (default: stdout) | ||
| --key Path to PEM private key file | ||
| (overridden by $PDFNATIVE_SIGN_KEY env var) | ||
| --cert Path to PEM certificate file | ||
| (overridden by $PDFNATIVE_SIGN_CERT env var) | ||
| Credentials (env wins over file flags): | ||
| --key Path to PEM private key (env: PDFNATIVE_SIGN_KEY) | ||
| --cert Path to PEM signer certificate (env: PDFNATIVE_SIGN_CERT) | ||
| --cert-chain Path to PEM intermediate (repeatable; env: PDFNATIVE_SIGN_CHAIN) | ||
| Algorithm: | ||
| --algorithm rsa-sha256 (default). ecdsa-sha256 not yet wired (pdfnative | ||
| does not yet expose parseEcPrivateKey). | ||
| Signature metadata (optional): | ||
| --reason Reason text shown in signature panel | ||
| --name Signer name override | ||
| --location Signing location | ||
| --contact Contact info | ||
| --signing-time ISO 8601 timestamp (default: now) | ||
| Security: key material is never written to logs or error messages. | ||
| --help, -h Show this help message | ||
| `; | ||
| var VERIFY_USAGE = `pdfnative verify \u2014 Verify CMS/PKCS#7 signatures in a PDF | ||
| Security: $PDFNATIVE_SIGN_KEY and $PDFNATIVE_SIGN_CERT env vars take precedence over file flags. | ||
| Usage: | ||
| pdfnative verify [--input <file.pdf>] [--trust <root.pem>]... [--strict] [--format json|text] | ||
| Options: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --trust PEM file with trusted root certs (repeatable; | ||
| env: PDFNATIVE_VERIFY_TRUST). When omitted, self-signed | ||
| roots are accepted. | ||
| --strict Exit code 1 if any signature fails any check. | ||
| --format, -f json (default) or text | ||
| --help, -h Show this help message | ||
| Reported per signature: | ||
| - byte-range integrity (SHA-256 against CMS messageDigest) | ||
| - signer subject / issuer | ||
| - certificate chain validity | ||
| - chain root trust evaluation | ||
| Out of scope (v0.2.0): full CMS-signature-value verification, OCSP/CRL, | ||
| RFC 3161 timestamps, LTV. These require future pdfnative API additions. | ||
| `; | ||
@@ -499,7 +1580,12 @@ var INSPECT_USAGE = `pdfnative inspect \u2014 Analyse a PDF and output metadata | ||
| Usage: | ||
| pdfnative inspect [--input <file.pdf>] [--format <fmt>] | ||
| pdfnative inspect [--input <file.pdf>] [--format <fmt>] [options] | ||
| Options: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --format, -f Output format: json (default) or text | ||
| --format, -f json (default) or text | ||
| --verbose, -v Include trailerKeys, catalogKeys, objectCount, | ||
| XMP metadata length | ||
| --pages Per-page width/height/rotation/annotation/formField counts | ||
| --check Assert a property; repeatable; AND semantics; exits 1 on | ||
| failure. Values: pdfa | signed | encrypted | ||
| --help, -h Show this help message | ||
@@ -522,2 +1608,6 @@ `; | ||
| } | ||
| case "verify": { | ||
| const m = await Promise.resolve().then(() => (init_verify(), verify_exports)); | ||
| return m.verify; | ||
| } | ||
| case "inspect": { | ||
@@ -528,3 +1618,5 @@ const m = await Promise.resolve().then(() => (init_inspect(), inspect_exports)); | ||
| default: | ||
| return Promise.reject(new CliError(`Unknown command: ${name}. Run pdfnative --help for usage.`, 1)); | ||
| return Promise.reject( | ||
| new CliError(`Unknown command: ${name}. Run pdfnative --help for usage.`, 1) | ||
| ); | ||
| } | ||
@@ -556,2 +1648,5 @@ } | ||
| break; | ||
| case "verify": | ||
| process.stdout.write(VERIFY_USAGE); | ||
| break; | ||
| case "inspect": | ||
@@ -567,8 +1662,11 @@ process.stdout.write(INSPECT_USAGE); | ||
| } | ||
| const commandArgs = parseArgs(argv.filter((_t, _i) => { | ||
| if (_t === commandName && args.positionals[0] === commandName) { | ||
| let stripped = false; | ||
| const rest = argv.filter((tok) => { | ||
| if (!stripped && tok === commandName) { | ||
| stripped = true; | ||
| return false; | ||
| } | ||
| return true; | ||
| })); | ||
| }); | ||
| const commandArgs = parseArgs(rest); | ||
| const command = await loadCommand(commandName); | ||
@@ -579,3 +1677,5 @@ await command(commandArgs); | ||
| if (e instanceof CliError) { | ||
| process.stderr.write(e.message + "\n"); | ||
| if (e.message.length > 0) { | ||
| process.stderr.write(e.message + "\n"); | ||
| } | ||
| process.exit(e.exitCode); | ||
@@ -582,0 +1682,0 @@ } |
+1186
-86
| #!/usr/bin/env node | ||
| import { buildDocumentPDFStream, buildDocumentPDFBytes, parseRsaPrivateKey, parseCertificate, signPdfBytes, openPdf } from 'pdfnative'; | ||
| import { initNodeCompression, buildPDFStream, buildPDFBytes, buildDocumentPDFStream, buildDocumentPDFBytes, signPdfBytes, openPdf, derDecode, parseCertificate, isSelfSigned, hasFontLoader, loadFontData, parseRsaPrivateKey, isDict, isArray, isRef, isName, nameValue, verifyCertSignature } from 'pdfnative'; | ||
| import { createWriteStream } from 'fs'; | ||
| import { readFile, writeFile } from 'fs/promises'; | ||
| import { createHash } from 'crypto'; | ||
| import { createRequire } from 'module'; | ||
@@ -18,3 +19,9 @@ | ||
| // src/utils/error.ts | ||
| var CliError; | ||
| function deprecate(name, replacement) { | ||
| if (_deprecateSeen.has(name)) return; | ||
| _deprecateSeen.add(name); | ||
| process.stderr.write(`warning: --${name} is deprecated; use ${replacement} instead. | ||
| `); | ||
| } | ||
| var CliError, _deprecateSeen; | ||
| var init_error = __esm({ | ||
@@ -30,2 +37,3 @@ "src/utils/error.ts"() { | ||
| }; | ||
| _deprecateSeen = /* @__PURE__ */ new Set(); | ||
| } | ||
@@ -39,2 +47,17 @@ }); | ||
| let i = 0; | ||
| const setFlag = (key, value) => { | ||
| const existing = flags[key]; | ||
| if (existing === void 0 || typeof existing === "boolean") { | ||
| flags[key] = value; | ||
| return; | ||
| } | ||
| if (typeof value === "boolean") { | ||
| return; | ||
| } | ||
| if (typeof existing === "string") { | ||
| flags[key] = [existing, value]; | ||
| } else { | ||
| existing.push(value); | ||
| } | ||
| }; | ||
| while (i < argv.length) { | ||
@@ -55,3 +78,3 @@ const token = argv[i]; | ||
| const value = token.slice(eqIdx + 1); | ||
| flags[key] = value; | ||
| setFlag(key, value); | ||
| } else { | ||
@@ -61,6 +84,6 @@ const key = token.slice(2); | ||
| if (next !== void 0 && !next.startsWith("-")) { | ||
| flags[key] = next; | ||
| setFlag(key, next); | ||
| i++; | ||
| } else { | ||
| flags[key] = true; | ||
| setFlag(key, true); | ||
| } | ||
@@ -72,6 +95,6 @@ } | ||
| if (next !== void 0 && !next.startsWith("-")) { | ||
| flags[key] = next; | ||
| setFlag(key, next); | ||
| i++; | ||
| } else { | ||
| flags[key] = true; | ||
| setFlag(key, true); | ||
| } | ||
@@ -88,11 +111,27 @@ } else { | ||
| const value = flags[name]; | ||
| if (value !== void 0) { | ||
| if (typeof value !== "string") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| return value; | ||
| if (value === void 0) continue; | ||
| if (typeof value === "boolean") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| if (typeof value === "string") return value; | ||
| return value[0]; | ||
| } | ||
| return void 0; | ||
| } | ||
| function getStringFlagAll(flags, ...names) { | ||
| const out = []; | ||
| for (const name of names) { | ||
| const value = flags[name]; | ||
| if (value === void 0) continue; | ||
| if (typeof value === "boolean") { | ||
| throw new CliError(`Flag --${name} requires a value.`, 2); | ||
| } | ||
| if (typeof value === "string") { | ||
| out.push(value); | ||
| } else { | ||
| out.push(...value); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| function hasFlag(flags, ...names) { | ||
@@ -131,2 +170,7 @@ return names.some((n) => flags[n] !== void 0); | ||
| } | ||
| async function readBinaryFile(filePath) { | ||
| validatePath(filePath); | ||
| const buf = await readFile(filePath); | ||
| return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); | ||
| } | ||
| function assertJsonSizeLimit(buf) { | ||
@@ -188,2 +232,336 @@ if (buf.length > JSON_SIZE_LIMIT) { | ||
| }); | ||
| async function loadLayoutFile(filePath) { | ||
| if (filePath === void 0) return {}; | ||
| validatePath(filePath); | ||
| let raw; | ||
| try { | ||
| raw = await readFile(filePath, "utf8"); | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to read --layout file: ${msg}`, 1); | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to parse --layout JSON: ${msg}`, 1); | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { | ||
| throw new CliError("--layout file must contain a JSON object.", 1); | ||
| } | ||
| const obj = parsed; | ||
| if (Array.isArray(obj.attachments)) { | ||
| obj.attachments = obj.attachments.map((a) => { | ||
| if (typeof a !== "object" || a === null) return a; | ||
| const rest = { ...a }; | ||
| delete rest.data; | ||
| return rest; | ||
| }); | ||
| } | ||
| return obj; | ||
| } | ||
| function parsePageSizePair(value) { | ||
| const m = /^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/i.exec(value.trim()); | ||
| if (m === null) return null; | ||
| return [Number.parseFloat(m[1]), Number.parseFloat(m[2])]; | ||
| } | ||
| function parsePageSize(value) { | ||
| const lower = value.toLowerCase(); | ||
| const named = NAMED_PAGE_SIZES[lower]; | ||
| if (named !== void 0) { | ||
| return { pageWidth: named[0], pageHeight: named[1] }; | ||
| } | ||
| const pair = parsePageSizePair(value); | ||
| if (pair !== null) { | ||
| return { pageWidth: pair[0], pageHeight: pair[1] }; | ||
| } | ||
| const valid = Object.keys(NAMED_PAGE_SIZES).join(", "); | ||
| throw new CliError( | ||
| `Invalid --page-size value "${value}". Expected one of: ${valid}, or WxH (points).`, | ||
| 2 | ||
| ); | ||
| } | ||
| function parseMargin(value) { | ||
| const parts = value.split(",").map((p) => p.trim()); | ||
| const nums = parts.map((p) => { | ||
| const n = Number.parseFloat(p); | ||
| if (!Number.isFinite(n) || n < 0) { | ||
| throw new CliError(`Invalid --margin value "${value}".`, 2); | ||
| } | ||
| return n; | ||
| }); | ||
| if (nums.length === 1) { | ||
| const v = nums[0]; | ||
| return { t: v, r: v, b: v, l: v }; | ||
| } | ||
| if (nums.length === 4) { | ||
| return { | ||
| t: nums[0], | ||
| r: nums[1], | ||
| b: nums[2], | ||
| l: nums[3] | ||
| }; | ||
| } | ||
| throw new CliError(`Invalid --margin "${value}". Expected N or T,R,B,L.`, 2); | ||
| } | ||
| function parseTagged(value) { | ||
| const v = value.toLowerCase(); | ||
| if (v === "none") return false; | ||
| if (VALID_TAGGED.includes(v)) { | ||
| return v; | ||
| } | ||
| throw new CliError( | ||
| `Invalid --tagged value "${value}". Valid: ${VALID_TAGGED.join(", ")}.`, | ||
| 2 | ||
| ); | ||
| } | ||
| function conformanceToTagged(value) { | ||
| const validShort = /* @__PURE__ */ new Set(["1b", "2b", "3b"]); | ||
| if (!validShort.has(value)) { | ||
| throw new CliError( | ||
| `Invalid --conformance value "${value}". Valid: 1b, 2b, 3b.`, | ||
| 2 | ||
| ); | ||
| } | ||
| return "pdfa" + value; | ||
| } | ||
| function buildPageTemplate(parts) { | ||
| if (parts.left === void 0 && parts.center === void 0 && parts.right === void 0) { | ||
| return void 0; | ||
| } | ||
| const tpl = {}; | ||
| if (parts.left !== void 0) tpl.left = parts.left; | ||
| if (parts.center !== void 0) tpl.center = parts.center; | ||
| if (parts.right !== void 0) tpl.right = parts.right; | ||
| return tpl; | ||
| } | ||
| function buildEncryptionFromFlags(args) { | ||
| const ownerFlag = getStringFlag(args.flags, "encrypt-owner-pass"); | ||
| const userFlag = getStringFlag(args.flags, "encrypt-user-pass"); | ||
| const algoFlag = getStringFlag(args.flags, "encrypt-algorithm"); | ||
| const permsFlag = getStringFlag(args.flags, "encrypt-permissions"); | ||
| const owner = process.env.PDFNATIVE_ENCRYPT_OWNER_PASS ?? ownerFlag; | ||
| const user = process.env.PDFNATIVE_ENCRYPT_USER_PASS ?? userFlag; | ||
| if (owner === void 0 && user === void 0 && algoFlag === void 0 && permsFlag === void 0) { | ||
| return void 0; | ||
| } | ||
| if (owner === void 0 || owner.length === 0) { | ||
| throw new CliError( | ||
| "Encryption requires an owner password. Provide --encrypt-owner-pass <pass> or $PDFNATIVE_ENCRYPT_OWNER_PASS.", | ||
| 2 | ||
| ); | ||
| } | ||
| const algo = algoFlag ?? "aes128"; | ||
| if (!VALID_ENCRYPTION_ALGOS.has(algo)) { | ||
| throw new CliError( | ||
| `Invalid --encrypt-algorithm "${algo}". Valid: aes128, aes256.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const opts = { | ||
| ownerPassword: owner, | ||
| algorithm: algo | ||
| }; | ||
| if (user !== void 0) opts.userPassword = user; | ||
| if (permsFlag !== void 0) { | ||
| const perms = {}; | ||
| for (const raw of permsFlag.split(",")) { | ||
| const p = raw.trim(); | ||
| if (p.length === 0) continue; | ||
| const lower = p.toLowerCase(); | ||
| if (!VALID_PERMISSIONS.has(p) && !VALID_PERMISSIONS.has(lower)) { | ||
| throw new CliError( | ||
| `Invalid permission "${p}" in --encrypt-permissions. Valid: print, copy, modify, extractText.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const key = lower === "extracttext" || p === "extractText" ? "extractText" : lower; | ||
| perms[key] = true; | ||
| } | ||
| opts.permissions = perms; | ||
| } | ||
| return opts; | ||
| } | ||
| async function buildWatermarkFromFlags(args) { | ||
| const text = getStringFlag(args.flags, "watermark-text"); | ||
| const opacity = getStringFlag(args.flags, "watermark-opacity"); | ||
| const angle = getStringFlag(args.flags, "watermark-angle"); | ||
| const color = getStringFlag(args.flags, "watermark-color"); | ||
| const fontSize = getStringFlag(args.flags, "watermark-font-size"); | ||
| const imagePath = getStringFlag(args.flags, "watermark-image"); | ||
| const position = getStringFlag(args.flags, "watermark-position"); | ||
| if (text === void 0 && imagePath === void 0 && opacity === void 0 && angle === void 0 && color === void 0 && fontSize === void 0 && position === void 0) { | ||
| return void 0; | ||
| } | ||
| const wm = {}; | ||
| if (text !== void 0) { | ||
| const t = { text }; | ||
| if (opacity !== void 0) t.opacity = parseUnit(opacity, "watermark-opacity", 0, 1); | ||
| if (angle !== void 0) t.angle = parseFloatFlag(angle, "watermark-angle"); | ||
| if (color !== void 0) t.color = color; | ||
| if (fontSize !== void 0) t.fontSize = parseFloatFlag(fontSize, "watermark-font-size"); | ||
| wm.text = t; | ||
| } | ||
| if (imagePath !== void 0) { | ||
| const data = await readBinaryFile(imagePath); | ||
| const img = { data }; | ||
| if (opacity !== void 0 && text === void 0) { | ||
| img.opacity = parseUnit(opacity, "watermark-opacity", 0, 1); | ||
| } | ||
| wm.image = img; | ||
| } | ||
| if (position !== void 0) { | ||
| if (position !== "background" && position !== "foreground") { | ||
| throw new CliError( | ||
| `Invalid --watermark-position "${position}". Valid: background, foreground.`, | ||
| 2 | ||
| ); | ||
| } | ||
| wm.position = position; | ||
| } | ||
| return wm; | ||
| } | ||
| function parseFloatFlag(value, flag) { | ||
| const n = Number.parseFloat(value); | ||
| if (!Number.isFinite(n)) { | ||
| throw new CliError(`Invalid --${flag} value "${value}".`, 2); | ||
| } | ||
| return n; | ||
| } | ||
| function parseUnit(value, flag, min, max) { | ||
| const n = parseFloatFlag(value, flag); | ||
| if (n < min || n > max) { | ||
| throw new CliError(`--${flag} must be between ${min} and ${max} (got ${value}).`, 2); | ||
| } | ||
| return n; | ||
| } | ||
| async function loadAttachmentsFromFlags(args) { | ||
| const paths = getStringFlagAll(args.flags, "attachment"); | ||
| if (paths.length === 0) return void 0; | ||
| const out = []; | ||
| for (const raw of paths) { | ||
| const parts = raw.split(":"); | ||
| let pathPart = parts[0] ?? ""; | ||
| let offset = 1; | ||
| if (pathPart.length === 1 && /^[A-Za-z]$/.test(pathPart) && parts.length > 1) { | ||
| pathPart = `${pathPart}:${parts[1] ?? ""}`; | ||
| offset = 2; | ||
| } | ||
| if (pathPart.length === 0) { | ||
| throw new CliError(`Invalid --attachment value "${raw}".`, 2); | ||
| } | ||
| const mimePart = parts[offset]; | ||
| const relPart = parts[offset + 1]; | ||
| const descPart = parts[offset + 2]; | ||
| const data = await readBinaryFile(pathPart); | ||
| const filename = pathPart.split(/[/\\]/).pop() ?? "attachment"; | ||
| const mime = mimePart && mimePart.length > 0 ? mimePart : "application/octet-stream"; | ||
| const att = { | ||
| filename, | ||
| data, | ||
| mimeType: mime | ||
| }; | ||
| if (relPart !== void 0 && relPart.length > 0) { | ||
| att.relationship = relPart; | ||
| } | ||
| if (descPart !== void 0 && descPart.length > 0) { | ||
| att.description = descPart; | ||
| } | ||
| out.push(att); | ||
| } | ||
| return out; | ||
| } | ||
| async function buildLayoutOptions(args) { | ||
| const layoutPath = getStringFlag(args.flags, "layout"); | ||
| const fromFile = await loadLayoutFile(layoutPath); | ||
| const out = { ...fromFile }; | ||
| const pageSize = getStringFlag(args.flags, "page-size"); | ||
| if (pageSize !== void 0) { | ||
| const { pageWidth, pageHeight } = parsePageSize(pageSize); | ||
| out.pageWidth = pageWidth; | ||
| out.pageHeight = pageHeight; | ||
| } | ||
| const margin = getStringFlag(args.flags, "margin"); | ||
| if (margin !== void 0) { | ||
| out.margins = parseMargin(margin); | ||
| } | ||
| if (hasFlag(args.flags, "compress")) { | ||
| out.compress = true; | ||
| } | ||
| const tagged = getStringFlag(args.flags, "tagged"); | ||
| const conformance = getStringFlag(args.flags, "conformance"); | ||
| if (tagged !== void 0 && conformance !== void 0) { | ||
| throw new CliError( | ||
| "Use either --tagged or --conformance, not both. Prefer --tagged.", | ||
| 2 | ||
| ); | ||
| } | ||
| if (tagged !== void 0) { | ||
| out.tagged = parseTagged(tagged); | ||
| } else if (conformance !== void 0) { | ||
| deprecate("conformance", "--tagged pdfa<level>"); | ||
| out.tagged = conformanceToTagged(conformance); | ||
| } | ||
| const header = buildPageTemplate({ | ||
| left: getStringFlag(args.flags, "header-left"), | ||
| center: getStringFlag(args.flags, "header-center"), | ||
| right: getStringFlag(args.flags, "header-right") | ||
| }); | ||
| if (header !== void 0) out.headerTemplate = header; | ||
| const footer = buildPageTemplate({ | ||
| left: getStringFlag(args.flags, "footer-left"), | ||
| center: getStringFlag(args.flags, "footer-center"), | ||
| right: getStringFlag(args.flags, "footer-right") | ||
| }); | ||
| if (footer !== void 0) out.footerTemplate = footer; | ||
| const watermark = await buildWatermarkFromFlags(args); | ||
| if (watermark !== void 0) out.watermark = watermark; | ||
| const encryption = buildEncryptionFromFlags(args); | ||
| if (encryption !== void 0) out.encryption = encryption; | ||
| const attachments = await loadAttachmentsFromFlags(args); | ||
| if (attachments !== void 0) out.attachments = attachments; | ||
| const tg = out.tagged; | ||
| if (encryption !== void 0 && tg !== void 0 && tg !== false) { | ||
| throw new CliError( | ||
| "Encryption is mutually exclusive with --tagged (PDF/A forbids encryption per ISO 19005-1 \xA76.3.2).", | ||
| 2 | ||
| ); | ||
| } | ||
| return out; | ||
| } | ||
| function assertStreamingCompatible(layout) { | ||
| const check = (tpl, label) => { | ||
| if (tpl === void 0) return; | ||
| for (const part of [tpl.left, tpl.center, tpl.right]) { | ||
| if (part !== void 0 && part.includes("{pages}")) { | ||
| throw new CliError( | ||
| `--stream is incompatible with the {pages} placeholder in --${label}-* (total page count not known until full render).`, | ||
| 2 | ||
| ); | ||
| } | ||
| } | ||
| }; | ||
| check(layout.headerTemplate, "header"); | ||
| check(layout.footerTemplate, "footer"); | ||
| } | ||
| var VALID_TAGGED, NAMED_PAGE_SIZES, VALID_ENCRYPTION_ALGOS, VALID_PERMISSIONS; | ||
| var init_layout = __esm({ | ||
| "src/utils/layout.ts"() { | ||
| init_args(); | ||
| init_io(); | ||
| init_error(); | ||
| VALID_TAGGED = ["none", "pdfa1b", "pdfa2b", "pdfa2u", "pdfa3b"]; | ||
| NAMED_PAGE_SIZES = { | ||
| a4: [595.28, 841.89], | ||
| letter: [612, 792], | ||
| legal: [612, 1008], | ||
| a3: [841.89, 1190.55], | ||
| tabloid: [792, 1224], | ||
| a5: [419.53, 595.28] | ||
| }; | ||
| VALID_ENCRYPTION_ALGOS = /* @__PURE__ */ new Set(["aes128", "aes256"]); | ||
| VALID_PERMISSIONS = /* @__PURE__ */ new Set(["print", "copy", "modify", "extractText", "extracttext"]); | ||
| } | ||
| }); | ||
@@ -195,2 +573,39 @@ // src/commands/render.ts | ||
| }); | ||
| function isPdfParamsLike(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const v = value; | ||
| return typeof v.title === "string" && Array.isArray(v.headers) && Array.isArray(v.rows); | ||
| } | ||
| function isDocumentParamsLike(value) { | ||
| if (typeof value !== "object" || value === null) return false; | ||
| const v = value; | ||
| return Array.isArray(v.blocks); | ||
| } | ||
| function hasTocBlock(params) { | ||
| for (const b of params.blocks) { | ||
| const block = b; | ||
| if (block.type === "toc") return true; | ||
| } | ||
| return false; | ||
| } | ||
| async function buildFontEntriesForLangs(langs) { | ||
| if (langs.length === 0) return []; | ||
| const entries = []; | ||
| let nextRef = 3; | ||
| for (const lang of langs) { | ||
| if (!hasFontLoader(lang)) { | ||
| throw new CliError( | ||
| `--lang "${lang}" is not a bundled pdfnative font. Register a loader programmatically before invoking the CLI to use a custom font.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const fontData = await loadFontData(lang); | ||
| if (fontData === null) { | ||
| throw new CliError(`Failed to load font data for --lang "${lang}".`, 1); | ||
| } | ||
| entries.push({ fontData, fontRef: `/F${nextRef}`, lang }); | ||
| nextRef++; | ||
| } | ||
| return entries; | ||
| } | ||
| async function render(args) { | ||
@@ -200,14 +615,20 @@ const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const useStream = hasFlag(args.flags, "stream"); | ||
| const conformance = getStringFlag(args.flags, "conformance"); | ||
| if (conformance !== void 0 && !VALID_CONFORMANCE.has(conformance)) { | ||
| const variant = getStringFlag(args.flags, "variant") ?? "document"; | ||
| const langsRaw = getStringFlag(args.flags, "lang"); | ||
| if (!VALID_VARIANTS.has(variant)) { | ||
| throw new CliError( | ||
| `Invalid --conformance value "${conformance}". Valid values: 1b, 2b, 3b.`, | ||
| `Invalid --variant "${variant}". Valid: document, table.`, | ||
| 2 | ||
| ); | ||
| } | ||
| const layout = await buildLayoutOptions(args); | ||
| if (useStream) assertStreamingCompatible(layout); | ||
| if (layout.compress === true) { | ||
| await initNodeCompression(); | ||
| } | ||
| const inputBuf = await readFileOrStdin(inputPath); | ||
| assertJsonSizeLimit(inputBuf); | ||
| let params; | ||
| let parsedInput; | ||
| try { | ||
| params = JSON.parse(inputBuf.toString("utf8")); | ||
| parsedInput = JSON.parse(inputBuf.toString("utf8")); | ||
| } catch (e) { | ||
@@ -217,17 +638,47 @@ const message = e instanceof Error ? e.message : String(e); | ||
| } | ||
| if (typeof params !== "object" || params === null) { | ||
| throw new CliError("JSON input must be a DocumentParams object.", 1); | ||
| if (variant === "table") { | ||
| if (!isPdfParamsLike(parsedInput)) { | ||
| throw new CliError( | ||
| "JSON input must be a PdfParams object (with title, headers, rows) when --variant table is used.", | ||
| 1 | ||
| ); | ||
| } | ||
| if (useStream) { | ||
| const generator = buildPDFStream(parsedInput, layout); | ||
| await writeStreamingOutput(generator, outputPath); | ||
| } else { | ||
| const pdfBytes = buildPDFBytes(parsedInput, layout); | ||
| await writeOutput(pdfBytes, outputPath); | ||
| } | ||
| return; | ||
| } | ||
| if (conformance !== void 0) { | ||
| params["pdfaConformance"] = conformance; | ||
| if (!isDocumentParamsLike(parsedInput)) { | ||
| throw new CliError( | ||
| 'JSON input must be a DocumentParams object (with a "blocks" array).', | ||
| 1 | ||
| ); | ||
| } | ||
| let params = parsedInput; | ||
| if (langsRaw !== void 0) { | ||
| const langs = langsRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0); | ||
| const fontEntries = await buildFontEntriesForLangs(langs); | ||
| const existing = params.fontEntries ?? []; | ||
| params = { ...params, fontEntries: [...existing, ...fontEntries] }; | ||
| } | ||
| const effectiveLayout = params.layout !== void 0 && params.layout !== null ? { ...params.layout, ...layout } : layout; | ||
| if (useStream && hasTocBlock(params)) { | ||
| throw new CliError( | ||
| "--stream is incompatible with TOC blocks (multi-pass pagination required).", | ||
| 2 | ||
| ); | ||
| } | ||
| if (useStream) { | ||
| const generator = buildDocumentPDFStream(params); | ||
| const generator = buildDocumentPDFStream(params, effectiveLayout); | ||
| await writeStreamingOutput(generator, outputPath); | ||
| } else { | ||
| const pdfBytes = buildDocumentPDFBytes(params); | ||
| const pdfBytes = buildDocumentPDFBytes(params, effectiveLayout); | ||
| await writeOutput(pdfBytes, outputPath); | ||
| } | ||
| } | ||
| var VALID_CONFORMANCE; | ||
| var VALID_VARIANTS; | ||
| var init_render = __esm({ | ||
@@ -239,11 +690,6 @@ "src/commands/render.ts"() { | ||
| init_error(); | ||
| VALID_CONFORMANCE = /* @__PURE__ */ new Set(["1b", "2b", "3b"]); | ||
| init_layout(); | ||
| VALID_VARIANTS = /* @__PURE__ */ new Set(["document", "table"]); | ||
| } | ||
| }); | ||
| // src/commands/sign.ts | ||
| var sign_exports = {}; | ||
| __export(sign_exports, { | ||
| sign: () => sign | ||
| }); | ||
| function pemToDer(pem) { | ||
@@ -258,17 +704,90 @@ const body = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, ""); | ||
| } | ||
| async function loadPem(envVar, filePath, label) { | ||
| function splitPemBlocks(pem) { | ||
| const re = /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g; | ||
| const matches = pem.match(re); | ||
| return matches ?? []; | ||
| } | ||
| async function loadPem(envVar, filePath, label, flagName) { | ||
| const fromEnv = process.env[envVar]; | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) { | ||
| return fromEnv; | ||
| } | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) return fromEnv; | ||
| if (filePath !== void 0) { | ||
| validatePath(filePath); | ||
| const buf = await readFile(filePath, "utf8"); | ||
| return buf; | ||
| return readFile(filePath, "utf8"); | ||
| } | ||
| throw new CliError( | ||
| `Missing ${label}. Provide $${envVar} (env) or --${label.replace(/ /g, "-")} <path>.`, | ||
| `Missing ${label}. Provide $${envVar} (env) or --${flagName} <path>.`, | ||
| 2 | ||
| ); | ||
| } | ||
| async function loadPemChain(envVar, filePaths) { | ||
| const blocks = []; | ||
| const fromEnv = process.env[envVar]; | ||
| if (fromEnv !== void 0 && fromEnv.trim().length > 0) { | ||
| blocks.push(...splitPemBlocks(fromEnv)); | ||
| } | ||
| for (const filePath of filePaths) { | ||
| validatePath(filePath); | ||
| const content = await readFile(filePath, "utf8"); | ||
| const split = splitPemBlocks(content); | ||
| if (split.length === 0) { | ||
| blocks.push(content); | ||
| } else { | ||
| blocks.push(...split); | ||
| } | ||
| } | ||
| return blocks; | ||
| } | ||
| async function loadRsaPrivateKey(envVar, filePath, flagName) { | ||
| const pem = await loadPem(envVar, filePath, "private key", flagName); | ||
| try { | ||
| return parseRsaPrivateKey(pemToDer(pem)); | ||
| } catch { | ||
| throw new CliError( | ||
| "Failed to parse RSA private key. Verify the file is a valid PEM-encoded PKCS#8 RSA key.", | ||
| 1 | ||
| ); | ||
| } | ||
| } | ||
| async function loadCertificate(envVar, filePath, flagName) { | ||
| const pem = await loadPem(envVar, filePath, "certificate", flagName); | ||
| try { | ||
| return parseCertificate(pemToDer(pem)); | ||
| } catch { | ||
| throw new CliError( | ||
| "Failed to parse X.509 certificate. Verify the file is valid PEM-encoded.", | ||
| 1 | ||
| ); | ||
| } | ||
| } | ||
| function parseCertificateChain(pemBlocks) { | ||
| const out = []; | ||
| for (const pem of pemBlocks) { | ||
| try { | ||
| out.push(parseCertificate(pemToDer(pem))); | ||
| } catch { | ||
| throw new CliError("Failed to parse certificate in chain.", 1); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| var init_keys = __esm({ | ||
| "src/utils/keys.ts"() { | ||
| init_core_bridge(); | ||
| init_io(); | ||
| init_error(); | ||
| } | ||
| }); | ||
| // src/commands/sign.ts | ||
| var sign_exports = {}; | ||
| __export(sign_exports, { | ||
| sign: () => sign | ||
| }); | ||
| function parseSigningTime(raw) { | ||
| const t = new Date(raw); | ||
| if (Number.isNaN(t.getTime())) { | ||
| throw new CliError(`Invalid --signing-time "${raw}". Expected ISO 8601 (e.g. 2026-04-28T12:00:00Z).`, 2); | ||
| } | ||
| return t; | ||
| } | ||
| async function sign(args) { | ||
@@ -279,19 +798,55 @@ const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const certPath = getStringFlag(args.flags, "cert"); | ||
| const algorithm = getStringFlag(args.flags, "algorithm") ?? "rsa-sha256"; | ||
| const reason = getStringFlag(args.flags, "reason"); | ||
| const name = getStringFlag(args.flags, "name"); | ||
| const location = getStringFlag(args.flags, "location"); | ||
| const contactInfo = getStringFlag(args.flags, "contact"); | ||
| const signingTimeRaw = getStringFlag(args.flags, "signing-time"); | ||
| const chainPaths = getStringFlagAll(args.flags, "cert-chain"); | ||
| if (!VALID_ALGORITHMS.has(algorithm)) { | ||
| throw new CliError( | ||
| `Invalid --algorithm "${algorithm}". Valid: rsa-sha256, ecdsa-sha256.`, | ||
| 2 | ||
| ); | ||
| } | ||
| if (algorithm === "ecdsa-sha256") { | ||
| throw new CliError( | ||
| "ECDSA signing is not yet available via the CLI. It requires a pdfnative release exposing parseEcPrivateKey. Use the pdfnative Node.js API directly to sign with ECDSA.", | ||
| 2 | ||
| ); | ||
| } | ||
| const signingTime = signingTimeRaw !== void 0 ? parseSigningTime(signingTimeRaw) : void 0; | ||
| if (process.env["PDFNATIVE_SIGN_KEY"] === void 0 && keyPath === void 0) { | ||
| throw new CliError("Missing private key. Provide $PDFNATIVE_SIGN_KEY (env) or --key <path>.", 2); | ||
| } | ||
| if (process.env["PDFNATIVE_SIGN_CERT"] === void 0 && certPath === void 0) { | ||
| throw new CliError("Missing certificate. Provide $PDFNATIVE_SIGN_CERT (env) or --cert <path>.", 2); | ||
| } | ||
| const pdfBuf = await readFileOrStdin(inputPath); | ||
| const pdfBytes = new Uint8Array(pdfBuf); | ||
| const privateKeyPem = await loadPem("PDFNATIVE_SIGN_KEY", keyPath, "private key"); | ||
| const certPem = await loadPem("PDFNATIVE_SIGN_CERT", certPath, "certificate"); | ||
| let options; | ||
| const rsaKey = await loadRsaPrivateKey("PDFNATIVE_SIGN_KEY", keyPath, "key"); | ||
| const signerCert = await loadCertificate("PDFNATIVE_SIGN_CERT", certPath, "cert"); | ||
| const chainPemBlocks = await loadPemChain("PDFNATIVE_SIGN_CHAIN", chainPaths); | ||
| const certChain = chainPemBlocks.length > 0 ? parseCertificateChain(chainPemBlocks) : void 0; | ||
| const options = { | ||
| rsaKey, | ||
| signerCert, | ||
| algorithm | ||
| }; | ||
| if (certChain !== void 0) options.certChain = certChain; | ||
| if (reason !== void 0) options.reason = reason; | ||
| if (name !== void 0) options.name = name; | ||
| if (location !== void 0) options.location = location; | ||
| if (contactInfo !== void 0) options.contactInfo = contactInfo; | ||
| if (signingTime !== void 0) options.signingTime = signingTime; | ||
| let signedBytes; | ||
| try { | ||
| const keyDer = pemToDer(privateKeyPem); | ||
| const certDer = pemToDer(certPem); | ||
| const rsaKey = parseRsaPrivateKey(keyDer); | ||
| const signerCert = parseCertificate(certDer); | ||
| options = { rsaKey, signerCert, algorithm: "rsa-sha256" }; | ||
| } catch { | ||
| throw new CliError("Failed to parse signing credentials. Verify key and certificate are valid PEM-encoded files.", 1); | ||
| signedBytes = signPdfBytes(pdfBytes, options); | ||
| } catch (e) { | ||
| const safeMsg = e instanceof Error ? e.message.split("\n")[0] : "unknown error"; | ||
| throw new CliError(`Failed to sign PDF: ${safeMsg ?? "unknown error"}`, 1); | ||
| } | ||
| const signedBytes = signPdfBytes(pdfBytes, options); | ||
| await writeOutput(signedBytes, outputPath); | ||
| } | ||
| var VALID_ALGORITHMS; | ||
| var init_sign = __esm({ | ||
@@ -303,5 +858,353 @@ "src/commands/sign.ts"() { | ||
| init_error(); | ||
| init_keys(); | ||
| VALID_ALGORITHMS = /* @__PURE__ */ new Set(["rsa-sha256", "ecdsa-sha256"]); | ||
| } | ||
| }); | ||
| // src/commands/verify.ts | ||
| var verify_exports = {}; | ||
| __export(verify_exports, { | ||
| verify: () => verify | ||
| }); | ||
| function decodeHexString(hex) { | ||
| const clean = hex.replace(/\s+/g, ""); | ||
| if (clean.length % 2 !== 0) { | ||
| throw new Error("hex string has odd length"); | ||
| } | ||
| const out = new Uint8Array(clean.length / 2); | ||
| for (let i = 0; i < out.length; i++) { | ||
| out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16); | ||
| } | ||
| return out; | ||
| } | ||
| function bytesToHex(bytes) { | ||
| let s = ""; | ||
| for (let i = 0; i < bytes.length; i++) { | ||
| s += bytes[i].toString(16).padStart(2, "0"); | ||
| } | ||
| return s; | ||
| } | ||
| function digestByteRange(pdfBytes, byteRange) { | ||
| const [a, b, c, d] = byteRange; | ||
| const hash = createHash("sha256"); | ||
| hash.update(pdfBytes.subarray(a, a + b)); | ||
| hash.update(pdfBytes.subarray(c, c + d)); | ||
| return hash.digest("hex"); | ||
| } | ||
| function decodeOid(node) { | ||
| if (node.tag !== 6) return null; | ||
| const bytes = node.value; | ||
| if (bytes.length === 0) return null; | ||
| const first = bytes[0]; | ||
| const parts = [Math.floor(first / 40), first % 40]; | ||
| let v = 0; | ||
| for (let i = 1; i < bytes.length; i++) { | ||
| const byte = bytes[i]; | ||
| v = v << 7 | byte & 127; | ||
| if ((byte & 128) === 0) { | ||
| parts.push(v); | ||
| v = 0; | ||
| } | ||
| } | ||
| return parts.join("."); | ||
| } | ||
| function findMessageDigest(node) { | ||
| if (node.children.length >= 2) { | ||
| const oid = decodeOid(node.children[0]); | ||
| if (oid === MESSAGE_DIGEST_OID) { | ||
| const setNode = node.children[1]; | ||
| if (setNode.children.length > 0) { | ||
| const oct = setNode.children[0]; | ||
| if (oct.tag === 4) { | ||
| return oct.value; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| for (const child of node.children) { | ||
| const found = findMessageDigest(child); | ||
| if (found !== null) return found; | ||
| } | ||
| return null; | ||
| } | ||
| function extractCertsFromCms(cmsBytes, root) { | ||
| const certs = []; | ||
| const visit = (node) => { | ||
| if (node.tag === 160 && node.children.length > 0) { | ||
| for (const child of node.children) { | ||
| if (child.tag === 48) { | ||
| certs.push(cmsBytes.subarray(child.offset, child.offset + child.totalLength)); | ||
| } | ||
| } | ||
| } | ||
| for (const child of node.children) visit(child); | ||
| }; | ||
| visit(root); | ||
| return certs; | ||
| } | ||
| function nameToString(name) { | ||
| if (name === void 0) return null; | ||
| const parts = []; | ||
| if (name.cn !== void 0) parts.push(`CN=${name.cn}`); | ||
| if (name.o !== void 0) parts.push(`O=${name.o}`); | ||
| if (name.ou !== void 0) parts.push(`OU=${name.ou}`); | ||
| if (name.c !== void 0) parts.push(`C=${name.c}`); | ||
| return parts.length > 0 ? parts.join(", ") : null; | ||
| } | ||
| function resolveValue(reader, val) { | ||
| if (val === void 0) return null; | ||
| if (isRef(val)) { | ||
| try { | ||
| return reader.resolveValue(val); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| return val; | ||
| } | ||
| function getDictString(dict, key) { | ||
| const v = dict.get(key); | ||
| return typeof v === "string" ? v : null; | ||
| } | ||
| function getDictName(dict, key) { | ||
| const v = dict.get(key); | ||
| return v !== void 0 && isName(v) ? nameValue(v) ?? null : null; | ||
| } | ||
| function findSignatureFields(reader) { | ||
| try { | ||
| const catalog = reader.getCatalog(); | ||
| const acroVal = resolveValue(reader, catalog.get("AcroForm")); | ||
| if (acroVal === null || !isDict(acroVal)) return []; | ||
| const fieldsVal = resolveValue(reader, acroVal.get("Fields")); | ||
| if (fieldsVal === null || !isArray(fieldsVal)) return []; | ||
| const out = []; | ||
| for (const fieldRef of fieldsVal) { | ||
| const field = resolveValue(reader, fieldRef); | ||
| if (field === null || !isDict(field)) continue; | ||
| if (getDictName(field, "FT") !== "Sig") continue; | ||
| const sigVal = resolveValue(reader, field.get("V")); | ||
| if (sigVal === null || !isDict(sigVal)) continue; | ||
| out.push({ | ||
| fieldName: getDictString(field, "T"), | ||
| sigDict: sigVal | ||
| }); | ||
| } | ||
| return out; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function parseSignatureDict(dict) { | ||
| const brVal = dict.get("ByteRange"); | ||
| let byteRange = null; | ||
| if (Array.isArray(brVal) && brVal.length === 4 && brVal.every((n) => typeof n === "number")) { | ||
| byteRange = [ | ||
| brVal[0], | ||
| brVal[1], | ||
| brVal[2], | ||
| brVal[3] | ||
| ]; | ||
| } | ||
| const contentsRaw = dict.get("Contents"); | ||
| let contents = null; | ||
| if (typeof contentsRaw === "string") { | ||
| const stripped = contentsRaw.replace(/^</, "").replace(/>$/, "").trim(); | ||
| if (/^[0-9a-fA-F\s]+$/.test(stripped) && stripped.length > 0) { | ||
| try { | ||
| contents = decodeHexString(stripped); | ||
| } catch { | ||
| contents = null; | ||
| } | ||
| } | ||
| if (contents === null) { | ||
| const raw = new Uint8Array(contentsRaw.length); | ||
| for (let i = 0; i < contentsRaw.length; i++) { | ||
| raw[i] = contentsRaw.charCodeAt(i) & 255; | ||
| } | ||
| contents = raw; | ||
| } | ||
| } | ||
| return { | ||
| byteRange, | ||
| contents, | ||
| subFilter: getDictName(dict, "SubFilter"), | ||
| signingTime: getDictString(dict, "M"), | ||
| reason: getDictString(dict, "Reason"), | ||
| location: getDictString(dict, "Location") | ||
| }; | ||
| } | ||
| function findChainParent(cert, candidates) { | ||
| for (const c of candidates) { | ||
| if (c === cert) continue; | ||
| try { | ||
| if (verifyCertSignature(cert, c)) return c; | ||
| } catch { | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| function buildChain(leaf, pool) { | ||
| const chain = [leaf]; | ||
| let current = leaf; | ||
| let chainValid = true; | ||
| const seen = /* @__PURE__ */ new Set([leaf]); | ||
| while (!isSelfSigned(current)) { | ||
| const parent = findChainParent(current, pool); | ||
| if (parent === void 0 || seen.has(parent)) { | ||
| chainValid = false; | ||
| break; | ||
| } | ||
| chain.push(parent); | ||
| seen.add(parent); | ||
| current = parent; | ||
| } | ||
| return { chain, chainValid, root: current }; | ||
| } | ||
| function certEquals(a, b) { | ||
| if (a.raw.length !== b.raw.length) return false; | ||
| for (let i = 0; i < a.raw.length; i++) { | ||
| if (a.raw[i] !== b.raw[i]) return false; | ||
| } | ||
| return true; | ||
| } | ||
| async function verify(args) { | ||
| const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const format = getStringFlag(args.flags, "format", "f") ?? "json"; | ||
| const strict = hasFlag(args.flags, "strict"); | ||
| const trustPaths = getStringFlagAll(args.flags, "trust"); | ||
| if (format !== "json" && format !== "text") { | ||
| throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); | ||
| } | ||
| const trustPemBlocks = await loadPemChain("PDFNATIVE_VERIFY_TRUST", trustPaths); | ||
| const trustRoots = trustPemBlocks.length > 0 ? parseCertificateChain(trustPemBlocks) : []; | ||
| const inputBuf = await readFileOrStdin(inputPath); | ||
| const pdfBytes = new Uint8Array(inputBuf); | ||
| let reader; | ||
| try { | ||
| reader = openPdf(pdfBytes); | ||
| } catch (e) { | ||
| const message = e instanceof Error ? e.message : String(e); | ||
| throw new CliError(`Failed to read PDF: ${message}`, 1); | ||
| } | ||
| const fields = findSignatureFields(reader); | ||
| const reports = []; | ||
| fields.forEach((field, idx) => { | ||
| const notes = []; | ||
| const sig = parseSignatureDict(field.sigDict); | ||
| let digest = null; | ||
| let integrity = false; | ||
| let signerSubject = null; | ||
| let signerIssuer = null; | ||
| let chainValid = false; | ||
| let trustedRoot = false; | ||
| if (sig.byteRange !== null) { | ||
| digest = digestByteRange(pdfBytes, sig.byteRange); | ||
| } else { | ||
| notes.push("missing /ByteRange"); | ||
| } | ||
| if (sig.contents !== null) { | ||
| try { | ||
| const root = derDecode(sig.contents); | ||
| const certDers = extractCertsFromCms(sig.contents, root); | ||
| if (certDers.length === 0) { | ||
| notes.push("no certificates embedded in CMS"); | ||
| } else { | ||
| const certs = certDers.map((der) => parseCertificate(der)); | ||
| const leaf = certs[0]; | ||
| signerSubject = nameToString(leaf.subject); | ||
| signerIssuer = nameToString(leaf.issuer); | ||
| const md = findMessageDigest(root); | ||
| if (md !== null && digest !== null) { | ||
| integrity = bytesToHex(md) === digest; | ||
| if (!integrity) { | ||
| notes.push("messageDigest mismatch \u2014 content tampered after signing"); | ||
| } | ||
| } else { | ||
| notes.push("messageDigest attribute not found in CMS"); | ||
| } | ||
| const pool = [...certs, ...trustRoots]; | ||
| const built = buildChain(leaf, pool); | ||
| chainValid = built.chainValid; | ||
| if (!chainValid) { | ||
| notes.push("chain incomplete (no parent for an intermediate cert)"); | ||
| } | ||
| if (trustRoots.length === 0) { | ||
| trustedRoot = isSelfSigned(built.root); | ||
| if (trustedRoot) { | ||
| notes.push("no --trust provided; accepted self-signed root"); | ||
| } | ||
| } else { | ||
| trustedRoot = trustRoots.some((t) => certEquals(t, built.root)); | ||
| if (!trustedRoot) notes.push("chain root not in --trust list"); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| notes.push(`failed to parse CMS: ${msg}`); | ||
| } | ||
| } else { | ||
| notes.push("missing /Contents"); | ||
| } | ||
| reports.push({ | ||
| index: idx, | ||
| fieldName: field.fieldName, | ||
| subFilter: sig.subFilter, | ||
| signerSubject, | ||
| signerIssuer, | ||
| signingTime: sig.signingTime, | ||
| reason: sig.reason, | ||
| location: sig.location, | ||
| digest, | ||
| integrity, | ||
| chainValid, | ||
| trustedRoot, | ||
| notes | ||
| }); | ||
| }); | ||
| const allValid = reports.length > 0 && reports.every((r) => r.integrity && r.chainValid && r.trustedRoot); | ||
| const result = { signatures: reports, allValid }; | ||
| if (format === "json") { | ||
| process.stdout.write(JSON.stringify(result, null, 2) + "\n"); | ||
| } else { | ||
| process.stdout.write(`Signatures: ${reports.length} | ||
| `); | ||
| for (const r of reports) { | ||
| process.stdout.write( | ||
| ` | ||
| [${r.index}] field=${r.fieldName ?? "\u2014"} subFilter=${r.subFilter ?? "\u2014"} | ||
| signer: ${r.signerSubject ?? "\u2014"} | ||
| issuer: ${r.signerIssuer ?? "\u2014"} | ||
| signed at: ${r.signingTime ?? "\u2014"} | ||
| integrity: ${r.integrity ? "OK" : "FAIL"} | ||
| chain: ${r.chainValid ? "valid" : "invalid"} | ||
| trust: ${r.trustedRoot ? "trusted" : "untrusted"} | ||
| ` | ||
| ); | ||
| if (r.notes.length > 0) { | ||
| process.stdout.write(` notes: ${r.notes.join("; ")} | ||
| `); | ||
| } | ||
| } | ||
| process.stdout.write( | ||
| ` | ||
| Result: ${allValid ? "all signatures valid" : "one or more checks failed"} | ||
| ` | ||
| ); | ||
| } | ||
| if (strict && !allValid) { | ||
| throw new CliError("", 1); | ||
| } | ||
| } | ||
| var MESSAGE_DIGEST_OID; | ||
| var init_verify = __esm({ | ||
| "src/commands/verify.ts"() { | ||
| init_core_bridge(); | ||
| init_args(); | ||
| init_io(); | ||
| init_error(); | ||
| init_keys(); | ||
| MESSAGE_DIGEST_OID = "1.2.840.113549.1.9.4"; | ||
| } | ||
| }); | ||
| // src/commands/inspect.ts | ||
@@ -318,6 +1221,4 @@ var inspect_exports = {}; | ||
| } | ||
| if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(trimmed)) { | ||
| return null; | ||
| } | ||
| return trimmed || null; | ||
| if (/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/.test(trimmed)) return null; | ||
| return trimmed.length > 0 ? trimmed : null; | ||
| } | ||
@@ -332,6 +1233,5 @@ function extractVersion(reader) { | ||
| function extractEncrypted(reader) { | ||
| const trailer = reader.trailer; | ||
| return trailer.get("Encrypt") !== void 0; | ||
| return reader.trailer.get("Encrypt") !== void 0; | ||
| } | ||
| function extractPdfaConformance(reader) { | ||
| function readXmp(reader) { | ||
| try { | ||
@@ -348,10 +1248,15 @@ const catalog = reader.getCatalog(); | ||
| ); | ||
| const xmp = new TextDecoder("utf-8", { fatal: false }).decode(decoded); | ||
| const partMatch = /pdfaid:part[^>]*>(\d+)</.exec(xmp); | ||
| const confMatch = /pdfaid:conformance[^>]*>([A-Za-z]+)</.exec(xmp); | ||
| if (partMatch !== null && confMatch !== null) { | ||
| return `${partMatch[1]}${confMatch[1].toLowerCase()}`; | ||
| } | ||
| return new TextDecoder("utf-8", { fatal: false }).decode(decoded); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function extractPdfaConformance(reader) { | ||
| const xmp = readXmp(reader); | ||
| if (xmp === null) return null; | ||
| const partMatch = /pdfaid:part[^>]*>(\d+)</.exec(xmp); | ||
| const confMatch = /pdfaid:conformance[^>]*>([A-Za-z]+)</.exec(xmp); | ||
| if (partMatch !== null && confMatch !== null) { | ||
| return `${partMatch[1]}${confMatch[1].toLowerCase()}`; | ||
| } | ||
| return null; | ||
@@ -380,7 +1285,79 @@ } | ||
| } | ||
| function inspectPages(reader) { | ||
| const out = []; | ||
| for (let i = 0; i < reader.pageCount; i++) { | ||
| const page = reader.getPage(i); | ||
| const mediaBox = page.get("MediaBox"); | ||
| let width = null; | ||
| let height = null; | ||
| const box = Array.isArray(mediaBox) ? mediaBox : null; | ||
| if (box !== null && box.length === 4) { | ||
| const w = box[2]; | ||
| const h = box[3]; | ||
| if (typeof w === "number") width = w; | ||
| if (typeof h === "number") height = h; | ||
| } | ||
| const rotation = typeof page.get("Rotate") === "number" ? page.get("Rotate") : 0; | ||
| const annots = page.get("Annots"); | ||
| let annotations = 0; | ||
| let formFields = 0; | ||
| if (Array.isArray(annots)) { | ||
| for (const ref of annots) { | ||
| annotations++; | ||
| try { | ||
| const annot = reader.resolveValue(ref); | ||
| if (annot instanceof Map && annot.get("Subtype") === "/Widget") { | ||
| formFields++; | ||
| } | ||
| } catch { | ||
| } | ||
| } | ||
| } | ||
| out.push({ index: i, width, height, rotation, annotations, formFields }); | ||
| } | ||
| return out; | ||
| } | ||
| function buildVerbose(reader) { | ||
| const trailerKeys = []; | ||
| for (const k of reader.trailer.keys()) trailerKeys.push(k); | ||
| const catalogKeys = []; | ||
| try { | ||
| for (const k of reader.getCatalog().keys()) catalogKeys.push(k); | ||
| } catch { | ||
| } | ||
| const objectCount = reader.xref?.entries?.size ?? 0; | ||
| const xmp = readXmp(reader); | ||
| return { | ||
| trailerKeys, | ||
| catalogKeys, | ||
| objectCount, | ||
| xmpMetadata: xmp | ||
| }; | ||
| } | ||
| function evaluateChecks(checks, result) { | ||
| const out = []; | ||
| for (const c of checks) { | ||
| if (!VALID_CHECKS.has(c)) { | ||
| throw new CliError( | ||
| `Invalid --check value "${c}". Valid: ${[...VALID_CHECKS].join(", ")}.`, | ||
| 2 | ||
| ); | ||
| } | ||
| if (c === "pdfa") out.push({ name: c, passed: result.pdfaConformance !== null }); | ||
| if (c === "signed") out.push({ name: c, passed: result.signatures > 0 }); | ||
| if (c === "encrypted") out.push({ name: c, passed: result.encrypted }); | ||
| } | ||
| return { | ||
| checks: out.map((x) => `${x.name}=${x.passed ? "pass" : "fail"}`), | ||
| allPassed: out.every((x) => x.passed) | ||
| }; | ||
| } | ||
| async function inspect(args) { | ||
| const inputPath = getStringFlag(args.flags, "input", "i"); | ||
| const format = getStringFlag(args.flags, "format", "f") ?? "json"; | ||
| const verbose = hasFlag(args.flags, "verbose"); | ||
| const includePages = hasFlag(args.flags, "pages"); | ||
| const checks = getStringFlagAll(args.flags, "check"); | ||
| if (format !== "json" && format !== "text") { | ||
| throw new CliError(`Invalid --format value "${format}". Valid values: json, text.`, 2); | ||
| throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); | ||
| } | ||
@@ -397,3 +1374,3 @@ const inputBuf = await readFileOrStdin(inputPath); | ||
| const info = reader.getInfo(); | ||
| const result = { | ||
| const baseResult = { | ||
| version: extractVersion(reader), | ||
@@ -412,2 +1389,7 @@ pageCount: reader.pageCount, | ||
| }; | ||
| const result = { | ||
| ...baseResult, | ||
| ...includePages ? { pages: inspectPages(reader) } : {}, | ||
| ...verbose ? { verbose: buildVerbose(reader) } : {} | ||
| }; | ||
| if (format === "json") { | ||
@@ -428,5 +1410,30 @@ process.stdout.write(JSON.stringify(result, null, 2) + "\n"); | ||
| ]; | ||
| if (result.pages !== void 0) { | ||
| lines.push("Pages detail:"); | ||
| for (const p of result.pages) { | ||
| lines.push( | ||
| ` #${p.index + 1}: ${p.width ?? "?"}x${p.height ?? "?"}pt rot=${p.rotation}\xB0 annots=${p.annotations} fields=${p.formFields}` | ||
| ); | ||
| } | ||
| } | ||
| if (result.verbose !== void 0) { | ||
| lines.push(`Trailer keys: ${result.verbose.trailerKeys.join(", ")}`); | ||
| lines.push(`Catalog keys: ${result.verbose.catalogKeys.join(", ")}`); | ||
| lines.push(`Object count: ${result.verbose.objectCount}`); | ||
| if (result.verbose.xmpMetadata !== null) { | ||
| lines.push(`XMP metadata: (${result.verbose.xmpMetadata.length} chars)`); | ||
| } | ||
| } | ||
| process.stdout.write(lines.join("\n") + "\n"); | ||
| } | ||
| if (checks.length > 0) { | ||
| const evaluation = evaluateChecks(checks, result); | ||
| if (!evaluation.allPassed) { | ||
| process.stderr.write(`check failed: ${evaluation.checks.join(", ")} | ||
| `); | ||
| throw new CliError("", 1); | ||
| } | ||
| } | ||
| } | ||
| var VALID_CHECKS; | ||
| var init_inspect = __esm({ | ||
@@ -438,2 +1445,3 @@ "src/commands/inspect.ts"() { | ||
| init_error(); | ||
| VALID_CHECKS = /* @__PURE__ */ new Set(["pdfa", "signed", "encrypted"]); | ||
| } | ||
@@ -452,7 +1460,8 @@ }); | ||
| render Render a JSON document definition to PDF | ||
| sign Apply a digital signature to an existing PDF | ||
| sign Apply a digital signature to a PDF | ||
| verify Verify embedded PDF signatures | ||
| inspect Analyse a PDF and output metadata / conformance info | ||
| Options: | ||
| --help, -h Show this help message | ||
| --help, -h Show this help message | ||
| --version, -V Show version | ||
@@ -462,15 +1471,51 @@ | ||
| `; | ||
| var RENDER_USAGE = `pdfnative render \u2014 Render a JSON DocumentParams to PDF | ||
| var RENDER_USAGE = `pdfnative render \u2014 Render a JSON document definition to PDF | ||
| Usage: | ||
| pdfnative render [--input <file.json>] [--output <out.pdf>] [--stream] [--conformance <level>] | ||
| pdfnative render [--input <file>] [--output <out.pdf>] [options] | ||
| Options: | ||
| --input, -i Path to JSON input file (default: stdin) | ||
| I/O: | ||
| --input, -i Path to JSON input (default: stdin) | ||
| --output, -o Output PDF path (default: stdout) | ||
| --stream Use streaming output for large documents | ||
| --conformance PDF/A conformance level: 1b, 2b, or 3b | ||
| --stream Stream output (large documents). Incompatible with TOC blocks | ||
| and with header/footer templates that contain {pages}. | ||
| Variant: | ||
| --variant document (default) or table | ||
| Layout (flags override values from --layout file): | ||
| --layout Path to JSON layout file (PdfLayoutOptions) | ||
| --page-size Named (a4|letter|legal|a3|tabloid|a5) or WxH in points | ||
| --margin Uniform N or "top,right,bottom,left" in points | ||
| --tagged none|pdfa1b|pdfa2b|pdfa3b (PDF/A flag, sets PDF/A conformance) | ||
| --conformance DEPRECATED \u2014 alias for --tagged pdfa{1b|2b|3b} | ||
| --compress Enable Flate compression (initialises Node compression) | ||
| --lang Comma-separated language packs (e.g. th,ja,ar) | ||
| Header / Footer: | ||
| --header-left, --header-center, --header-right | ||
| --footer-left, --footer-center, --footer-right | ||
| Each accepts a template string. {page}, {pages}, {date} are | ||
| substituted by pdfnative. | ||
| Watermark: | ||
| --watermark-text Text watermark | ||
| --watermark-image Image path (PNG/JPEG) | ||
| --watermark-opacity 0.0\u20131.0 (default 0.2) | ||
| --watermark-rotation degrees (default 45) | ||
| Encryption (mutually exclusive with --tagged pdfa*): | ||
| --encrypt aes-128 | aes-256 | ||
| --owner-password (or env $PDFNATIVE_ENCRYPT_OWNER_PASS \u2014 env wins) | ||
| --user-password (or env $PDFNATIVE_ENCRYPT_USER_PASS \u2014 env wins) | ||
| --permissions Comma-separated: print,copy,modify,annotate,form, | ||
| accessibility,assemble,print-hi-res | ||
| Attachments (PDF/A-3, repeatable): | ||
| --attachment <path>[:mime[:rel[:desc]]] | ||
| rel = Source|Data|Alternative|Supplement|Unspecified | ||
| --help, -h Show this help message | ||
| `; | ||
| var SIGN_USAGE = `pdfnative sign \u2014 Apply a digital signature to an existing PDF | ||
| var SIGN_USAGE = `pdfnative sign \u2014 Apply a digital signature to a PDF | ||
@@ -480,12 +1525,48 @@ Usage: | ||
| Options: | ||
| I/O: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --output, -o Signed PDF output path (default: stdout) | ||
| --key Path to PEM private key file | ||
| (overridden by $PDFNATIVE_SIGN_KEY env var) | ||
| --cert Path to PEM certificate file | ||
| (overridden by $PDFNATIVE_SIGN_CERT env var) | ||
| Credentials (env wins over file flags): | ||
| --key Path to PEM private key (env: PDFNATIVE_SIGN_KEY) | ||
| --cert Path to PEM signer certificate (env: PDFNATIVE_SIGN_CERT) | ||
| --cert-chain Path to PEM intermediate (repeatable; env: PDFNATIVE_SIGN_CHAIN) | ||
| Algorithm: | ||
| --algorithm rsa-sha256 (default). ecdsa-sha256 not yet wired (pdfnative | ||
| does not yet expose parseEcPrivateKey). | ||
| Signature metadata (optional): | ||
| --reason Reason text shown in signature panel | ||
| --name Signer name override | ||
| --location Signing location | ||
| --contact Contact info | ||
| --signing-time ISO 8601 timestamp (default: now) | ||
| Security: key material is never written to logs or error messages. | ||
| --help, -h Show this help message | ||
| `; | ||
| var VERIFY_USAGE = `pdfnative verify \u2014 Verify CMS/PKCS#7 signatures in a PDF | ||
| Security: $PDFNATIVE_SIGN_KEY and $PDFNATIVE_SIGN_CERT env vars take precedence over file flags. | ||
| Usage: | ||
| pdfnative verify [--input <file.pdf>] [--trust <root.pem>]... [--strict] [--format json|text] | ||
| Options: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --trust PEM file with trusted root certs (repeatable; | ||
| env: PDFNATIVE_VERIFY_TRUST). When omitted, self-signed | ||
| roots are accepted. | ||
| --strict Exit code 1 if any signature fails any check. | ||
| --format, -f json (default) or text | ||
| --help, -h Show this help message | ||
| Reported per signature: | ||
| - byte-range integrity (SHA-256 against CMS messageDigest) | ||
| - signer subject / issuer | ||
| - certificate chain validity | ||
| - chain root trust evaluation | ||
| Out of scope (v0.2.0): full CMS-signature-value verification, OCSP/CRL, | ||
| RFC 3161 timestamps, LTV. These require future pdfnative API additions. | ||
| `; | ||
@@ -495,7 +1576,12 @@ var INSPECT_USAGE = `pdfnative inspect \u2014 Analyse a PDF and output metadata | ||
| Usage: | ||
| pdfnative inspect [--input <file.pdf>] [--format <fmt>] | ||
| pdfnative inspect [--input <file.pdf>] [--format <fmt>] [options] | ||
| Options: | ||
| --input, -i Path to input PDF (default: stdin) | ||
| --format, -f Output format: json (default) or text | ||
| --format, -f json (default) or text | ||
| --verbose, -v Include trailerKeys, catalogKeys, objectCount, | ||
| XMP metadata length | ||
| --pages Per-page width/height/rotation/annotation/formField counts | ||
| --check Assert a property; repeatable; AND semantics; exits 1 on | ||
| failure. Values: pdfa | signed | encrypted | ||
| --help, -h Show this help message | ||
@@ -518,2 +1604,6 @@ `; | ||
| } | ||
| case "verify": { | ||
| const m = await Promise.resolve().then(() => (init_verify(), verify_exports)); | ||
| return m.verify; | ||
| } | ||
| case "inspect": { | ||
@@ -524,3 +1614,5 @@ const m = await Promise.resolve().then(() => (init_inspect(), inspect_exports)); | ||
| default: | ||
| return Promise.reject(new CliError(`Unknown command: ${name}. Run pdfnative --help for usage.`, 1)); | ||
| return Promise.reject( | ||
| new CliError(`Unknown command: ${name}. Run pdfnative --help for usage.`, 1) | ||
| ); | ||
| } | ||
@@ -552,2 +1644,5 @@ } | ||
| break; | ||
| case "verify": | ||
| process.stdout.write(VERIFY_USAGE); | ||
| break; | ||
| case "inspect": | ||
@@ -563,8 +1658,11 @@ process.stdout.write(INSPECT_USAGE); | ||
| } | ||
| const commandArgs = parseArgs(argv.filter((_t, _i) => { | ||
| if (_t === commandName && args.positionals[0] === commandName) { | ||
| let stripped = false; | ||
| const rest = argv.filter((tok) => { | ||
| if (!stripped && tok === commandName) { | ||
| stripped = true; | ||
| return false; | ||
| } | ||
| return true; | ||
| })); | ||
| }); | ||
| const commandArgs = parseArgs(rest); | ||
| const command = await loadCommand(commandName); | ||
@@ -575,3 +1673,5 @@ await command(commandArgs); | ||
| if (e instanceof CliError) { | ||
| process.stderr.write(e.message + "\n"); | ||
| if (e.message.length > 0) { | ||
| process.stderr.write(e.message + "\n"); | ||
| } | ||
| process.exit(e.exitCode); | ||
@@ -578,0 +1678,0 @@ } |
+3
-3
| { | ||
| "name": "pdfnative-cli", | ||
| "version": "0.1.0", | ||
| "description": "Official CLI for pdfnative — render JSON to PDF, sign, and inspect. Zero extra runtime dependencies.", | ||
| "version": "0.2.0", | ||
| "description": "Official CLI for pdfnative — render JSON to PDF, sign, inspect, and verify. Zero extra runtime dependencies.", | ||
| "type": "module", | ||
@@ -66,3 +66,3 @@ "bin": { | ||
| "dependencies": { | ||
| "pdfnative": "^1.0.4" | ||
| "pdfnative": "^1.0.5" | ||
| }, | ||
@@ -69,0 +69,0 @@ "devDependencies": { |
+99
-28
@@ -14,15 +14,24 @@ # pdfnative-cli | ||
| Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library — render JSON to PDF, apply digital signatures, and inspect PDF conformance, directly from the terminal. Zero extra runtime dependencies. | ||
| Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library — render JSON to PDF, apply digital signatures, verify them, and inspect PDF conformance, directly from the terminal. Zero extra runtime dependencies. | ||
| > **What's new in v0.2.0** — full coverage of the `pdfnative` v1.0.5 surface: encryption, watermarks, headers/footers with placeholders, PDF/A-3 attachments, multilingual fonts, table-variant rendering, signing metadata + cert chains, `inspect --verbose / --pages / --check`, and a brand-new `verify` command. **100 % backward-compatible** with v0.1.0 — see [release notes](release-notes/v0.2.0.md). | ||
| ## Highlights | ||
| - **`render`** — pipe a JSON document definition into a production-ready PDF (streaming supported) | ||
| - **`sign`** — apply RSA or ECDSA digital signatures (CMS/PKCS#7) using key files or environment variables | ||
| - **`inspect`** — analyse any PDF: version, page count, encryption, PDF/A conformance, signature count, metadata | ||
| - **Zero extra dependencies** — `pdfnative` is the only runtime dependency; all PDF logic lives there | ||
| - **Streaming output** — `--stream` on render emits PDF chunks progressively for large documents | ||
| - **Stdin / stdout** — every command reads from stdin and writes to stdout by default, composable in shell pipelines | ||
| - **Secret-safe** — signing keys are loaded from env vars (`PDFNATIVE_SIGN_KEY`/`PDFNATIVE_SIGN_CERT`) and never logged | ||
| - **ESM-first, TypeScript strict** — built with tsup, typed declarations included | ||
| - **NPM provenance** — signed builds via GitHub Actions OIDC | ||
| - **`render`** — pipe a JSON document into a production-ready PDF. Encryption (AES-128/256), | ||
| watermarks (text + image), page templates, PDF/A archival, multilingual fonts, streaming, | ||
| and a hybrid `flags + --layout file.json` model for the full `PdfLayoutOptions` surface. | ||
| - **`sign`** — CMS/PKCS#7 digital signatures with full metadata (`--reason`, `--name`, | ||
| `--location`, `--contact`, `--signing-time`) and intermediate CA chains via | ||
| `--cert-chain` (repeatable). Keys loaded from env vars or files; never logged. | ||
| - **`inspect`** — PDF version, page count, encryption, PDF/A conformance, signature count, | ||
| metadata. `--verbose`, `--pages`, and `--check pdfa|signed|encrypted` for CI assertions. | ||
| - **`verify`** _(new in v0.2.0)_ — verify integrity, certificate chains, and trust roots | ||
| of every CMS/PKCS#7 signature embedded in a PDF. JSON & text output, `--strict` mode. | ||
| - **Zero extra dependencies** — `pdfnative` is the sole runtime dependency. | ||
| - **Stdin / stdout by default** — every command is shell-pipeline friendly. | ||
| - **Secret-safe** — signing keys, certs, encryption passwords never appear in error | ||
| output or stderr. PEM material redacted; layout-file `attachments[].data` injection blocked. | ||
| - **ESM-first, TypeScript strict** — built with tsup, typed declarations included. | ||
| - **NPM provenance** — signed builds via GitHub Actions OIDC. | ||
@@ -34,5 +43,6 @@ ## Supported Features | ||
| | **Commands** | | | | ||
| | `render` JSON → PDF | ✅ | Streaming, PDF/A conformance, stdin/stdout | | ||
| | `sign` digital signatures | ✅ | RSA + ECDSA, CMS/PKCS#7, env var secrets | | ||
| | `inspect` PDF metadata | ✅ | Version, pages, encryption, signatures, PDFA | | ||
| | `render` JSON → PDF | ✅ | Streaming, hybrid layout model, multilingual fonts | | ||
| | `sign` digital signatures | ✅ | RSA (CMS/PKCS#7), metadata fields, cert chains | | ||
| | `inspect` PDF metadata | ✅ | `--verbose`, `--pages`, `--check pdfa\|signed\|encrypted` | | ||
| | `verify` signature verification (v0.2.0) | ✅ | Integrity + chain + trust; `--strict`, `--trust` | | ||
| | **Document Blocks** | | | | ||
@@ -45,13 +55,31 @@ | Headings, paragraphs, lists | ✅ | Full text styling support | | ||
| | Page breaks, spacers | ✅ | Explicit pagination control | | ||
| | Table of contents | ✅ | Auto-generated with /GoTo links | | ||
| | **Advanced Layouts** | | | | ||
| | PDF/A archival (1b, 2b, 3b) | ✅ | `--conformance` flag | | ||
| | Table of contents | ✅ | Auto-generated with `/GoTo` links | | ||
| | **Advanced Layouts (v0.2.0)** | | | | ||
| | PDF/A archival (1b, 2b, 2u, 3b) | ✅ | `--tagged pdfa<level>` (preferred) or `--conformance` (deprecated) | | ||
| | Streaming output | ✅ | `--stream` for large documents | | ||
| | Compression | ✅ | Via pdfnative API (50–90% reduction) | | ||
| | Encryption (AES-128/256) | ⚠️ | Not exposed via CLI; use Node.js API | | ||
| | Watermarks | ⚠️ | Not exposed via CLI; use Node.js API | | ||
| | Custom headers/footers | ⚠️ | Not exposed via CLI; use `footerText` property | | ||
| | Custom page sizes | ⚠️ | Not exposed via CLI; use Node.js API | | ||
| | Compression | ✅ | `--compress` flag | | ||
| | Encryption (AES-128/256) | ✅ | `--encrypt-*` flags + env-var precedence | | ||
| | Watermarks (text + image) | ✅ | `--watermark-text`, `--watermark-image`, `--watermark-position` | | ||
| | Headers / footers with placeholders | ✅ | `--header-{l,c,r}`, `--footer-{l,c,r}`, `{page}/{pages}/{date}/{title}` | | ||
| | Custom page sizes | ✅ | `--page-size A4\|Letter\|…` or `WxH` in points | | ||
| | Custom margins | ✅ | `--margin <N>` or `--margin <t,r,b,l>` | | ||
| | PDF/A-3 attachments | ✅ | `--attachment <path>:<mime>:<rel>:<desc>` (repeatable) | | ||
| | Multilingual fonts | ✅ | `--lang th,ja,ar` (requires `registerFontLoader()` in wrapper; Latin built-in) | | ||
| | Table-centric variant (`PdfParams`) | ✅ | `--variant table` | | ||
| | Full `PdfLayoutOptions` | ✅ | `--layout <file.json>` | | ||
| | **Signing (v0.2.0)** | | | | ||
| | RSA signatures (rsa-sha256) | ✅ | Default algorithm | | ||
| | ECDSA signatures | ⚠️ | `--algorithm ecdsa-sha256` parsed; stub error pending pdfnative `parseEcPrivateKey` (v0.3.0) | | ||
| | Signature metadata | ✅ | `--reason`, `--name`, `--location`, `--contact`, `--signing-time` | | ||
| | Cert chains (intermediate CAs) | ✅ | `--cert-chain <pem>` (repeatable) or `PDFNATIVE_SIGN_CHAIN` env | | ||
| | **Verification (v0.2.0)** | | | | ||
| | Byte-range integrity (SHA-256) | ✅ | Recomputed and compared with CMS messageDigest attribute | | ||
| | Certificate chain verification | ✅ | Via pdfnative `verifyCertSignature` | | ||
| | Trust roots | ✅ | `--trust <root.pem>` (repeatable) + self-signed acceptance | | ||
| | Full CMS signature-value | ⚠️ | Deferred to v0.3.0 (pending pdfnative API) | | ||
| | OCSP / CRL revocation | ⚠️ | Deferred to v0.3.0+ | | ||
| | RFC 3161 timestamps | ⚠️ | Deferred to v0.3.0+ | | ||
| **Note:** Features marked **⚠️** are supported by `pdfnative` but not yet exposed through the CLI JSON interface. Use the `pdfnative` Node.js library directly for these features. | ||
| **Note:** features marked **⚠️** are tracked in [ROADMAP.md](ROADMAP.md). Everything else | ||
| works today. | ||
@@ -191,7 +219,26 @@ ## Installation | ||
| |------|---------|-------------| | ||
| | `--input <file>` | stdin | Path to a JSON file containing `DocumentParams` | | ||
| | `--input <file>` | stdin | Path to a JSON file (`DocumentParams` or `PdfParams` if `--variant table`) | | ||
| | `--output <file>` | stdout | Output PDF path | | ||
| | `--stream` | false | Use streaming output (AsyncGenerator) | | ||
| | `--conformance <level>` | — | PDF/A conformance level: `1b`, `2b`, or `3b` | | ||
| | `--stream` | false | Use streaming output (`AsyncGenerator`) | | ||
| | `--variant <kind>` | `document` | `document` (default) or `table` (selects `buildPDFBytes`) | | ||
| | `--layout <file.json>` | — | Load a `Partial<PdfLayoutOptions>` (CLI flags override) | | ||
| | `--page-size <size>` | from layout file or pdfnative default | Named (`a4`, `letter`, `legal`, `a3`, `tabloid`, `a5`) or `WxH` in points | | ||
| | `--margin <N>` or `--margin <t,r,b,l>` | from layout / default | Page margins in points | | ||
| | `--compress` | false | Enable FlateDecode compression | | ||
| | `--tagged <level>` | none | PDF/A: `none`, `pdfa1b`, `pdfa2b`, `pdfa2u`, `pdfa3b` | | ||
| | `--conformance <1b\|2b\|3b>` | — | **Deprecated** — use `--tagged pdfa<level>` | | ||
| | `--watermark-text <s>` / `--watermark-image <path>` | — | Text or image watermark | | ||
| | `--watermark-opacity <0-1>` / `--angle <deg>` / `--color <#hex>` / `--font-size <pt>` | — | Watermark styling | | ||
| | `--watermark-position background\|foreground` | `background` | Render order | | ||
| | `--header-{left,center,right} <tpl>` | — | Header template; placeholders `{page}`, `{pages}`, `{date}`, `{title}` | | ||
| | `--footer-{left,center,right} <tpl>` | — | Footer template; same placeholders | | ||
| | `--encrypt-owner-pass <s>` | `$PDFNATIVE_ENCRYPT_OWNER_PASS` | Owner password (required for any `--encrypt-*`) | | ||
| | `--encrypt-user-pass <s>` | `$PDFNATIVE_ENCRYPT_USER_PASS` | Optional user password | | ||
| | `--encrypt-algorithm aes128\|aes256` | `aes128` | Encryption algorithm | | ||
| | `--encrypt-permissions <list>` | _all denied_ | Comma list: `print,copy,modify,extractText` | | ||
| | `--attachment <path>[:mime[:rel[:desc]]]` _(repeatable)_ | — | PDF/A-3 file attachment | | ||
| | `--lang <code,code>` | — | Activate registered font loaders for non-Latin scripts (`th`, `ja`, `ar`, …); Latin is built-in | | ||
| See `samples/render/` for a working example of every category. | ||
| ### `pdfnative sign` | ||
@@ -203,4 +250,11 @@ | ||
| | `--output <file>` | stdout | Output signed PDF path | | ||
| | `--key <file>` | `PDFNATIVE_SIGN_KEY` env | Path to PEM private key (env var takes precedence) | | ||
| | `--cert <file>` | `PDFNATIVE_SIGN_CERT` env | Path to PEM certificate (env var takes precedence) | | ||
| | `--key <file>` | `$PDFNATIVE_SIGN_KEY` | Path to PEM private key (env var takes precedence) | | ||
| | `--cert <file>` | `$PDFNATIVE_SIGN_CERT` | Path to PEM certificate (env var takes precedence) | | ||
| | `--cert-chain <file>` _(repeatable)_ | `$PDFNATIVE_SIGN_CHAIN` | Intermediate CA PEMs | | ||
| | `--algorithm rsa-sha256\|ecdsa-sha256` | `rsa-sha256` | Signature algorithm. _ECDSA stubbed in v0.2.0; tracked for v0.3.0._ | | ||
| | `--reason <s>` | — | Reason for signing (PDF metadata) | | ||
| | `--name <s>` | — | Signer name (PDF metadata) | | ||
| | `--location <s>` | — | Signing location (PDF metadata) | | ||
| | `--contact <s>` | — | Signer contact (PDF metadata) | | ||
| | `--signing-time <ISO 8601>` | now | Explicit signing timestamp | | ||
@@ -212,4 +266,21 @@ ### `pdfnative inspect` | ||
| | `--input <file>` | stdin | Path to the PDF to inspect | | ||
| | `--format <fmt>` | `json` | Output format: `json` or `text` | | ||
| | `--output <file>` | stdout | Output report path | | ||
| | `--format json\|text` | `json` | Output format | | ||
| | `--verbose` | false | Add trailer keys, catalog keys, object count, XMP | | ||
| | `--pages` | false | Add per-page metadata array | | ||
| | `--check pdfa\|signed\|encrypted` _(repeatable)_ | — | CI-friendly assertion; sets exit code (0 = pass, 1 = fail) | | ||
| ### `pdfnative verify` _(new in v0.2.0)_ | ||
| | Flag | Default | Description | | ||
| |------|---------|-------------| | ||
| | `--input <file>` | stdin | Path to the (possibly signed) PDF | | ||
| | `--format json\|text` | `json` | Output format | | ||
| | `--strict` | false | Exit 1 on any failure or zero signatures (CI-friendly) | | ||
| | `--trust <root.pem>` _(repeatable)_ | _self-signed only_ | Trusted root certificates (PEM) | | ||
| **Scope (v0.2.0):** integrity (byte-range SHA-256) + certificate chain signatures + trust | ||
| evaluation. Full CMS-signature-value verification, OCSP/CRL revocation, and RFC 3161 | ||
| timestamp validation are deferred — see [ROADMAP.md](ROADMAP.md). | ||
| ## Security | ||
@@ -216,0 +287,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
417605
205.29%3239
202.71%329
27.52%22
175%Updated