diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index fc6e079..095f905 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,25 @@ dispatch layer over it. **Targets:** Node.js ≥ 20, Bun, Deno (via `node dist/cli.cjs`). +## Working Modes & Token Economy + +Default to the cheapest mode that fits the request. Do not over-explore. + +- **Plan mode** — for vague, multi-file, or risky requests. Produce a short numbered plan + (files to touch + approach), then stop for confirmation. No edits yet. Keep it to a handful + of bullets; do not dump file contents. +- **Implement mode** — for clear, scoped requests. Edit directly, then validate. Skip the plan. + +Token discipline (this file loads on every request — keep edits to it minimal): + +- Read in **wide ranges**, not many small reads. Batch independent searches/reads in parallel. +- Stop searching once you can act. Don't re-search for facts already in context or in + `/memories/repo/`. +- Don't restate file contents back to the user; summarize in 1–3 sentences. +- Reuse the per-area instruction files (`.github/instructions/*`) instead of re-deriving + conventions; they hold the deltas, this file holds the globals. +- After code changes, run the smallest sufficient check (targeted test) before the full suite. + ## Architecture ``` @@ -31,6 +50,7 @@ src/ │ ├── error.ts # CliError class, die() helper │ ├── config.ts # `.pdfnativerc.json` discovery + flag-default merge │ ├── colors.ts # NO_COLOR/TTY-aware ANSI helper +│ ├── projection.ts # Agent output projection (compact JSON, --summary, --fields) │ ├── keys.ts # PEM/DER loaders for RSA + EC private keys + X.509 certs │ ├── layout.ts # `--layout` flag parsing & `PdfLayoutOptions` assembly │ ├── asn1-walk.ts # ASN.1/DER walker with absolute byte offsets (50 MiB cap) diff --git a/.github/instructions/cli-design.instructions.md b/.github/instructions/cli-design.instructions.md index 26774c7..6307f83 100644 --- a/.github/instructions/cli-design.instructions.md +++ b/.github/instructions/cli-design.instructions.md @@ -2,54 +2,37 @@ description: "Use when working on the CLI entry point, arg parser, or overall dispatch logic. Covers entry point contract, help formatting, and exit code conventions." applyTo: "src/index.ts" --- -# CLI Design Standards +# CLI Design -## Entry Point Rules +> Entry-point and arg-parser contracts live in `.github/copilot-instructions.md`. This file only +> adds deltas — do not restate the global rules. -- `main()` is the sole entry point — called at module bottom with `main().catch(...)`. -- Command dispatch uses a `switch` on `args.positionals[0]`. -- `--help` / `-h` is checked before command dispatch — print help and `process.exit(0)`. -- `--version` / `-V` prints `package.json` `version` and exits 0. -- Unknown commands print `"Unknown command: . Run pdfnative --help for usage."` to stderr, then exit 1. +## Exit codes -## Exit Codes +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | Runtime error (invalid input, I/O failure) | +| 2 | Usage error (missing/invalid required argument) | -| Code | Meaning | -|------|-------------------------------------------| -| 0 | Success | -| 1 | Runtime error (invalid input, I/O error) | -| 2 | Usage error (missing required argument) | +## Dispatch -## Help Text Format +- `switch` on `args.positionals[0]` over the commands: `render`, `sign`, `inspect`, + `verify`, `batch`, `completion`, `schema`. +- `--help`/`-h` and `--version`/`-v` handled before dispatch. +- Unknown command → stderr message + exit 1. -``` -pdfnative-cli — Official CLI for pdfnative +## Agent globals -Usage: - pdfnative [options] +- The global-flag block sets `PDFNATIVE_JSON=1` on `--json` and `PDFNATIVE_DRY_RUN=1` on + `--dry-run` so all commands and `utils/agent.ts` can read them via env. +- Track the active command in a module-level `activeCommand` (set in `main()`); on a thrown + error, when `isJsonMode()` is true, `emitJsonError(activeCommand, e)` writes the failure + envelope to stderr and the process exits with the `CliError.exitCode` (default 1). +- Numeric exit codes (0/1/2) are unchanged in every mode — `--json` only adds the envelope. -Commands: - render Render a JSON document definition to PDF - sign Apply a digital signature to an existing PDF - inspect Analyse a PDF and output metadata / conformance info +## Help text -Options: - --help, -h Show this help message - --version, -V Show version - -Run `pdfnative --help` for per-command options. -``` - -## Arg Parser Rules (`src/utils/args.ts`) - -- Handle: `--flag value`, `--flag=value`, `-f value`, `--flag` (boolean). -- `--` stops flag parsing; remaining tokens become positionals. -- Return type: `ParsedArgs = { flags: Record; positionals: string[] }`. -- Helper: `getFlag(flags, ...names)` returns the first matching flag value or `undefined`. -- Never throw on unknown flags — collect them silently. - -## Naming - -- Command functions: `render`, `sign`, `inspect` (verb, no prefix). -- Flag names: kebab-case (`--input`, `--output`, `--key`, `--cert`). -- Env vars: `PDFNATIVE_SIGN_KEY`, `PDFNATIVE_SIGN_CERT` (screaming snake). +- One global `USAGE` block listing all commands (incl. `schema`) and the `--json` / `--dry-run` + global options, plus one `*_USAGE` block per command. Point agents at `AGENTS.md`. +- Keep `*_USAGE` flag lists in sync with each command's actual flags. diff --git a/.github/instructions/commands.instructions.md b/.github/instructions/commands.instructions.md index e3d574f..79c6077 100644 --- a/.github/instructions/commands.instructions.md +++ b/.github/instructions/commands.instructions.md @@ -2,70 +2,73 @@ description: "Use when implementing or modifying render, sign, or inspect commands. Covers flag conventions, stdin/stdout, streaming, secret handling, and error contracts." applyTo: "src/commands/**" --- -# Command Implementation Standards +# Command Implementation -## Shared Conventions +> Shared conventions and security constraints are in `.github/copilot-instructions.md` +> (Command Conventions + Security Constraints). This file only adds per-command deltas. -- Signature: `export async function (args: ParsedArgs): Promise` -- `--input` = file path; omit → read from stdin -- `--output` = file path; omit → write to stdout (binary: `process.stdout.write(buffer)`) -- Validation/usage errors → `throw new CliError('message', 2)` -- Runtime errors → `throw new CliError('message', 1)` -- Never catch and swallow errors — let `main()` handle exit +## Shared -## `render` Command +- Signature: `export async function (args: ParsedArgs): Promise`. +- `--input` omitted → stdin; `--output` omitted → stdout (binary via `process.stdout.write`). +- Usage error → `CliError(msg, 2)`; runtime error → `CliError(msg, 1)`. Never swallow errors. +- Validate every path arg against `..` traversal before read/write. -``` -pdfnative render [--input ] [--output ] [--stream] [--conformance 1b|2b|3b] -``` +## Agent contract (cross-cutting) -- Reads JSON from `--input` or stdin. -- Parses as `DocumentParams` (full pdfnative API surface). -- Input size cap: **50 MB** before `JSON.parse` — throw `CliError` if exceeded. -- `--stream` flag: use `streamDocumentPdf` (AsyncGenerator) instead of `buildDocumentPDFBytes`. - - When streaming to a file, pipe chunks via `fs.createWriteStream`. - - When streaming to stdout, call `process.stdout.write(chunk)` per chunk. -- `--conformance`: inject `pdfaConformance` into the parsed params. +- **Error codes:** pass a stable `ErrorCode` as the 3rd `CliError` arg + (`E_USAGE`/`E_INPUT`/`E_PARSE`/`E_IO`/`E_SIGN`/`E_VERIFY_FAILED`/`E_CHECK_FAILED`/ + `E_UNSUPPORTED`/`E_RUNTIME`). Omitting it derives `E_USAGE` from exit 2, else `E_RUNTIME`. +- **`--json`:** never write the envelope yourself in the dispatcher path — `index.ts` emits + the failure envelope. Use `emitStatus({...})` (from `utils/agent.ts`) for success status on + `render`/`sign`/`batch`; it is a no-op outside `--json`. stdout stays artifact-only. +- **`--dry-run`:** read `hasFlag(args.flags, 'dry-run') || isDryRun()`; validate fully, then + short-circuit before producing/writing output. +- In `--json` mode, do NOT pre-print a detail to stderr that the envelope already carries + (e.g. `inspect --check` detail rides in the `CliError` message instead). +- **Output projection (`inspect`/`verify`/`batch`):** route the JSON-on-stdout branch through + `utils/projection.ts`. Order: `out = --summary ? toSummary(full) : full`, then + `if (--fields) out = selectFields(out, parseFieldList(raw))`, then + `serializeJson(out, hasFlag('pretty') || !isJsonMode())`. Compact is the default under + `--json`; `--pretty` opts back in; non-`--json` stays pretty. Keep `--summary` shapes minimal + and in lock-step with the `*-summary` `schema` subjects. Strip `summary`/`fields`/`pretty` + from any flags forwarded to a sub-command (see `batch`'s `BATCH_ONLY_FLAGS`). -## `sign` Command +## `render` -``` -pdfnative sign --input [--output ] [--key ] [--cert ] -``` +- JSON → `DocumentParams` (or `PdfParams` when `--variant table`). 50 MB cap before `JSON.parse`. +- Streaming flags are mutually exclusive: `--stream`, `--stream-page-by-page`, `--stream-true` + (1.3.0 `buildDocumentPDFStreamTrue` / `buildPDFStreamTrue`). `--stream` and `--stream-true` + reject TOC blocks and `{pages}`. +- `--font` allow-list: `latin`, `emoji`, `color-emoji`, and 22 script codes + (`ar hy bn ru hi am ka el he ja km ko my pl zh si ta te th bo tr vi`). Name doubles as `--lang`. +- `--max-blocks ` → positive integer → `layout.maxBlocks` (invalid → `CliError` exit 2). -- Secret loading priority (highest first): - 1. `PDFNATIVE_SIGN_KEY` env var (PEM string of private key) - 2. `--key ` flag (file path to PEM) - - Same logic for cert: `PDFNATIVE_SIGN_CERT` → `--cert`. -- **Never log key material** — not on error, not on debug. Truncate or omit from messages. -- If neither env var nor flag is provided for key or cert → `CliError` exit code 2. -- Path traversal: validate `--input`, `--output`, `--key`, `--cert` against `../` sequences. -- Call `signPdfBytes(pdfBytes, { privateKeyPem, certificatePem })` from core-bridge. +## `sign` -## `inspect` Command +- Secret priority: env (`PDFNATIVE_SIGN_KEY` / `PDFNATIVE_SIGN_CERT`) over `--key` / `--cert`. +- **Never log key material.** Replace any `signPdfBytes` error with the fixed string + `'Failed to sign PDF.'`. Missing key or cert → `CliError` exit 2. +- `--timestamp` is reserved and must error clearly (sign-side LTV upstream-blocked). -``` -pdfnative inspect [--input ] [--format json|text] -``` +## `inspect` -- Default output format: `json`. -- `--format text`: human-readable table (key: value lines). -- Output shape (JSON): - ```json - { - "version": "1.7", - "pageCount": 3, - "encrypted": false, - "pdfaConformance": "2b", - "signatures": 1, - "metadata": { "title": "...", "author": "...", "creationDate": "..." } - } - ``` -- No raw binary blobs in output — sanitize all fields. -- Uses `PdfReader` from core-bridge. +- Default `--format json`; `--text` is human-readable. No raw binary blobs in output. +- `--pdfua` adds a `validatePdfUA` report `{ valid, errors, warnings }`. +- `--check` allow-list: `pdfa`, `signed`, `encrypted`, `pdfua` (sets exit code 0/1). -## Security Checklist +## `verify` / `batch` / `completion` -- All file paths: validate no `..` segments before read/write. -- JSON input: size-check buffer before parsing (50 MB cap). -- Signing keys: zero from memory isn't guaranteed in JS, but never persist or log. +- `verify`: offline by default; online revocation only through `utils/fetch-guard.ts`. + Redact CMS parse errors — never leak byte offsets / parser state. Strict fail → + `CliError('', 1, ErrorCode.VERIFY_FAILED)`. +- `batch`: parallel directory render, reuse render logic, per-file summary. Global `--json` + forces the JSON summary. `--dry-run` skips `mkdir` and forwards to each `render`. +- `completion`: emit static bash/zsh/fish scripts only (keep `schema` + `--json`/`--dry-run` + in the flag/command tables). + +## `schema` + +- `pdfnative schema [render|inspect|verify|batch|list]` — print a hand-authored, versioned + JSON Schema (Draft 2020-12). `$id` embeds the CLI version. Pure data, zero deps; the CLI + only PRODUCES schemas (no bundled validator). Unknown subject → `CliError(..., 2, USAGE)`. diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 514c2be..2dd4825 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -2,100 +2,32 @@ description: "Use when writing tests, adding test coverage, or debugging test failures in pdfnative-cli. Covers vitest patterns, CLI testing conventions, and coverage targets." applyTo: "tests/**" --- -# Testing Standards +# Testing ## Framework -- **vitest** — native ESM, fast watch mode, built-in coverage. -- Run: `npm run test` (single run), `npm run test:watch` (watch), `npm run test:coverage`. -- Config: `vitest.config.ts`. +- **vitest** (native ESM). Run: `npm test`, `npm run test:watch`, `npm run test:coverage`. +- Tests mirror `src/`: `tests/commands/*.test.ts`, `tests/utils/*.test.ts`, + `tests/integration/*.test.ts`. -## Test Organization +## Command test pattern -``` -tests/ -├── utils/ -│ └── args.test.ts # arg parser edge cases -└── commands/ - ├── render.test.ts # render command (JSON → PDF bytes) - ├── sign.test.ts # sign command (PDF + key → signed PDF) - └── inspect.test.ts # inspect command (PDF → metadata) -``` +1. Capture stdout via `vi.spyOn(process.stdout, 'write')`. +2. Use `os.tmpdir()` temp files; clean up in `afterEach`. +3. Test error paths with `await expect(fn(...)).rejects.toBeInstanceOf(CliError)` and assert + `.exitCode`. +4. Drive commands through `parseArgs([...])`, e.g. `await render(parseArgs(['--input', tmpIn]))`. +5. Assert PDF output starts with `%PDF` and contains `%%EOF`. -## Command Testing Patterns +## Conventions -Because commands write to `process.stdout` or files, tests should: +- `describe('functionName')` → `it('should ...')`; one concept per assertion; `it.each` for + parameterized flag forms. +- Append new cases before the final `});` of the relevant `describe`. +- `--variant table` tests need COMPLETE `PdfParams` (incl. `infoItems`, `balanceText`, + `countText`) — `assembleTableParts` throws on missing `infoItems`. -1. **Capture stdout**: redirect `process.stdout.write` via `vi.spyOn`. -2. **Use temp files**: write fixtures to OS temp dir (`os.tmpdir()`), clean up in `afterEach`. -3. **Test error paths**: verify `CliError` is thrown with correct `.exitCode`. +## Coverage targets -```typescript -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render } from '../../src/commands/render.js'; -import { CliError } from '../../src/utils/error.js'; -import { parseArgs } from '../../src/utils/args.js'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; - -describe('render', () => { - it('should produce a valid PDF for minimal DocumentParams', async () => { - const tmpOut = path.join(os.tmpdir(), `test-${Date.now()}.pdf`); - const input = JSON.stringify({ title: 'Test' }); - const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.json`); - await fs.writeFile(tmpIn, input, 'utf8'); - - await render(parseArgs([`--input`, tmpIn, `--output`, tmpOut])); - - const bytes = await fs.readFile(tmpOut); - expect(bytes.slice(0, 4).toString()).toBe('%PDF'); - expect(bytes.toString().includes('%%EOF')).toBe(true); - - await fs.unlink(tmpIn); - await fs.unlink(tmpOut); - }); - - it('should throw CliError(2) when JSON is invalid', async () => { - const tmpIn = path.join(os.tmpdir(), `bad-${Date.now()}.json`); - await fs.writeFile(tmpIn, '{bad json}', 'utf8'); - - await expect(render(parseArgs([`--input`, tmpIn]))).rejects.toBeInstanceOf(CliError); - - await fs.unlink(tmpIn); - }); -}); -``` - -## Args Testing Patterns - -```typescript -import { describe, it, expect } from 'vitest'; -import { parseArgs } from '../../src/utils/args.js'; - -describe('parseArgs', () => { - it('handles --flag value', () => { - const result = parseArgs(['--input', 'file.pdf']); - expect(result.flags['input']).toBe('file.pdf'); - }); - - it('handles --flag=value', () => { - const result = parseArgs(['--input=file.pdf']); - expect(result.flags['input']).toBe('file.pdf'); - }); -}); -``` - -## Coverage Targets - -- Statements: ≥ 90% -- Branches: ≥ 80% -- Functions: ≥ 85% -- Lines: ≥ 90% -- `src/index.ts` is excluded from coverage (entry point, tested via smoke test). - -## Vitest Naming Convention - -- `describe('functionName')` → `it('should ...')` -- One assertion per concept. -- Use `it.each` for parameterized cases (e.g., multiple flag forms). +- Statements ≥ 90% · Branches ≥ 80% · Functions ≥ 85% · Lines ≥ 90%. +- `src/index.ts` excluded (entry point, covered by smoke test). diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a5655e..714ee48 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,11 @@ jobs: publish: runs-on: ubuntu-latest timeout-minutes: 20 + # `id-token: write` mints the OIDC token for Trusted Publishing + provenance. + # `contents: write` lets the workflow attach the generated SBOM to the release. + permissions: + contents: write + id-token: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -53,5 +58,24 @@ jobs: - name: Binary smoke test run: node dist/cli.cjs --help + - name: Generate SBOM (CycloneDX) + # Software Bill of Materials for supply-chain transparency. Uses the + # CycloneDX generator via npx (build-time only — adds ZERO runtime + # dependencies to the published package). + run: npx --yes @cyclonedx/cyclonedx-npm@^1 --output-format JSON --output-file sbom.cdx.json + + - name: Upload SBOM artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: sbom-cyclonedx + path: sbom.cdx.json + retention-days: 90 + + - name: Attach SBOM to release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.event.release.tag_name }}" sbom.cdx.json --clobber + - name: Publish run: npm publish --provenance --access public diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..28dea74 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# AGENTS.md — Driving pdfnative-cli from autonomous agents + +`pdfnative-cli` is built so that an autonomous AI agent — or any program — can +drive it inside a larger automated process **deterministically and safely**. + +There is **no separate runtime** for this: agent support is a thin presentation +layer over the normal command dispatch. The official [pdfnative MCP server] is a +different integration; this document is about driving the **CLI** directly (spawn +a process, pass flags, read stdout/stderr, branch on the exit code). + +[pdfnative MCP server]: https://pdfnative.dev + +--- + +## 1. The process contract + +| Channel | Carries | +|---------|---------| +| **stdout** | The primary artifact: a PDF (`render`, `sign`), a JSON report (`inspect`, `verify`, `batch --format json`), a JSON Schema (`schema`), or a completion script (`completion`). | +| **stderr** | All diagnostics: progress, warnings, and the agent JSON envelopes below. | +| **exit code** | `0` success · `1` runtime error · `2` usage error. Unchanged in every mode. | + +Keep stdout binary-clean: write PDFs to `--output ` or redirect stdout, and +read the envelope from stderr. + +--- + +## 2. Agent mode — `--json` + +Pass the global `--json` flag to any command to switch on machine-readable +envelopes (the data on stdout is unchanged; `inspect`/`verify`/`batch` already +default to JSON on stdout). + +**On failure**, a single JSON object is written to stderr: + +```json +{ "ok": false, "command": "inspect", "error": { "code": "E_PARSE", "message": "Failed to read PDF: …" } } +``` + +**On success**, `render` / `sign` / `batch` write a status line to stderr: + +```json +{ "ok": true, "command": "render", "variant": "document", "dryRun": false, "output": "out.pdf", "bytes": 12345 } +``` + +`inspect`, `verify`, and `batch` put their result document on **stdout** as JSON; +`--json` only adds the failure envelope on stderr and (for `batch`) forces the +JSON summary. + +### Stable error codes + +Branch on `error.code`, never on the human message: + +| Code | Meaning | Typical exit | +|------|---------|--------------| +| `E_USAGE` | Missing/invalid flag or argument | 2 | +| `E_INPUT` | Input payload wrong shape / failed validation | 1 | +| `E_PARSE` | Could not parse JSON / PDF / DER input | 1 | +| `E_IO` | Filesystem or stream I/O failure | 1 | +| `E_SIGN` | Signing failed (message is always generic — no key material) | 1 | +| `E_VERIFY_FAILED` | `verify --strict` found an invalid signature | 1 | +| `E_CHECK_FAILED` | `inspect --check` assertion failed | 1 | +| `E_UNSUPPORTED` | Reserved / not-yet-available capability | 2 | +| `E_RUNTIME` | Catch-all runtime error | 1 | + +--- + +## 3. Token economy — compact JSON, `--summary`, `--fields` + +The JSON `inspect` / `verify` / `batch` write to **stdout** is the bulk of what an +agent pays for in tokens. Three composable levers shrink it — typically by ~90 % +— without losing the fields you branch on. They apply to all three JSON-on-stdout +commands. + +**Compact by default under `--json`.** In agent mode the stdout JSON is minified +(no indentation, no padding) instead of the human 2-space form. Pass `--pretty` +to force indentation back on. Outside `--json` the output stays pretty for humans. + +**`--summary` — the canonical minimal verdict.** Collapses the full report to the +handful of fields an orchestrator actually gates on: + +| Command | `--summary` shape | +|---------|-------------------| +| `inspect` | `{ "pages": , "encrypted": , "signatures": , "pdfa": }` | +| `verify` | `{ "valid": , "signatures": , "invalid": }` | +| `batch` | `{ "total": , "succeeded": , "failed": }` (drops the per-file `results` array) | + +**`--fields a,b.c` — dot-path projection.** Keep only the paths you name. A +segment landing on an array maps over every element; unknown paths are silently +omitted (so a conditionally-absent field never crashes the run). Precedence: +`--summary` is applied first, then `--fields` projects the result. + +```bash +# Smallest possible "is this PDF signed and valid?" probe: +pdfnative verify --input doc.pdf --json --summary # → {"valid":false,"signatures":0,"invalid":0} +pdfnative verify --input doc.pdf --json --fields valid # → {"valid":false} +pdfnative inspect --input doc.pdf --json --fields pageCount,signatures +pdfnative batch --input-dir in --output-dir out --json --summary +``` + +The compact shapes are schema-pinned — validate them with +`schema inspect-summary`, `schema verify-summary`, `schema batch-summary`. + +--- + +## 4. Validate first — `--dry-run` + +`render`, `sign`, and `batch` accept `--dry-run`: inputs are fully validated +(JSON parsed, document/table shape checked, layout assembled, signing credentials +loaded and the PDF prepared) but **no output is produced or written**. Combine +with `--json` for a `{ "ok": true, "dryRun": true, … }` envelope. + +```bash +pdfnative render --input doc.json --dry-run --json +``` + +--- + +## 5. Discover shapes — `schema` + +Fetch a versioned JSON Schema (Draft 2020-12) and validate input with your own +tooling before invoking a command. Each schema carries a `$id` embedding the CLI +version so you can detect drift. + +```bash +pdfnative schema list # → { "subjects": ["render","inspect","verify","batch","inspect-summary","verify-summary","batch-summary"] } +pdfnative schema render # input accepted by `render` +pdfnative schema inspect # output of `inspect --format json` +pdfnative schema verify-summary # output of `verify --summary` +``` + +--- + +## 6. Recommended agent loop + +1. `pdfnative --version --json` → confirm the CLI is present and pin the version. +2. `pdfnative schema render` → validate the document you intend to render. +3. `pdfnative render --input doc.json --output out.pdf --dry-run --json` → pre-flight. +4. `pdfnative render --input doc.json --output out.pdf --json` → produce the PDF; + read the status envelope from stderr. +5. On any non-zero exit, parse the stderr envelope and branch on `error.code`. + +For `verify`/`inspect`, read the JSON result on stdout and use `--strict` / +`--check` to turn findings into exit codes for unattended gating. Add +`--summary` (or `--fields`) to keep that stdout JSON token-cheap — see §3. + +--- + +## 7. Safety notes for unattended use + +- **Offline by default.** Only `verify --revocation online` makes network requests, + and only through an SSRF guard. Nothing else touches the network. +- **No secrets in output.** `sign` never emits key material — errors are the fixed + `E_SIGN` / "Failed to sign PDF." Pass keys via `PDFNATIVE_SIGN_KEY` / + `PDFNATIVE_SIGN_CERT` (env wins over `--key` / `--cert`). +- **Bounded input.** JSON input is capped at 50 MB; paths are checked against + traversal. Prefer `--output ` over shell redirection for large PDFs. +- **One process per task.** The CLI is stateless; run it per unit of work and let + the exit code drive your orchestration. + +See [SECURITY.md](SECURITY.md) for the full security model and +[docs/KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md) for the deep reference. diff --git a/CHANGELOG.md b/CHANGELOG.md index a65c82d..47214bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] – 2026-06-30 + +Built on **pdfnative 1.3.0**. Surfaces the new engine capabilities through the CLI: +22 Unicode scripts, COLRv1 colour emoji, true constant-memory streaming, a configurable +document-block cap, and a read-only PDF/UA structural validator. 100% backward-compatible. + +### Added + +#### `render` + +- **22 Unicode scripts + COLRv1 colour emoji.** The `--font` allow-list now covers every + bundled pdfnative font: `latin`, `emoji`, `color-emoji`, and the 22 script codes + (`ar hy bn ru hi am ka el he ja km ko my pl zh si ta te th bo tr vi`), including the six + scripts new in pdfnative 1.3.0 (Telugu `te`, Sinhala `si`, Tibetan `bo`, Khmer `km`, + Myanmar `my`, Amharic/Ethiopic `am`). Each shortcut name doubles as its `--lang` code; + pdfnative routes each code point to the font whose cmap covers it. +- **`--stream-true`** — true constant-memory streaming via pdfnative 1.3.0 + `buildDocumentPDFStreamTrue` / `buildPDFStreamTrue`. PDF parts are emitted and freed as + they go, so the joined binary never materialises. Byte-identical to the buffered builders. + Same constraints as `--stream` (no TOC, no `{pages}`); mutually exclusive with the other + `--stream*` flags. +- **`--max-blocks `** — expose pdfnative 1.3.0 `layout.maxBlocks` (default 100 000) so + very large multi-thousand-page reports no longer hit a spurious ceiling. + +#### `inspect` + +- **PDF/UA (ISO 14289-1) structural validation** via pdfnative 1.3.0 `validatePdfUA`. + `--pdfua` adds a `{ valid, errors, warnings }` report to JSON/text output; `--check pdfua` + turns it into a CI accessibility gate (exit 1 when the structural prerequisites fail). + +#### Agent-native automation contract + +- **Global `--json` envelope.** Any command run with `--json` emits a single + machine-readable object on **stderr**: `{ ok: false, command, error: { code, message } }` + on failure, and a `{ ok: true, … }` status line for `render` / `sign` / `batch` on + success. stdout stays reserved for the primary artifact (PDF, report, schema, script). +- **Stable `E_*` error codes** on every `CliError` (`E_USAGE`, `E_INPUT`, `E_PARSE`, + `E_IO`, `E_SIGN`, `E_VERIFY_FAILED`, `E_CHECK_FAILED`, `E_UNSUPPORTED`, `E_RUNTIME`), + so autonomous callers branch on a failure class without parsing prose. Numeric exit + codes (0/1/2) are unchanged. +- **`--dry-run`** for `render`, `sign`, and `batch` — fully validate inputs (and, for + `sign`, parse credentials and prepare the PDF) without producing or writing output. +- **Token-economy output projection for agents** (`inspect` / `verify` / `batch`): + stdout JSON is **compact by default under `--json`** (`--pretty` opts back into the + human 2-space form), a new **`--summary`** flag emits a canonical minimal verdict + (inspect `{ pages, encrypted, signatures, pdfa }`, verify `{ valid, signatures, invalid }`, + batch `{ total, succeeded, failed }`), and **`--fields a,b.c`** projects the result to + named dot-paths (array segments map over elements; unknown paths are omitted). Composable + — typically ~90 % fewer output tokens with no loss of the fields agents branch on. + Non-`--json` (human) output is unchanged. New `utils/projection.ts` (zero-dep). +- **`schema` command** — print a versioned JSON Schema (Draft 2020-12) for the + `render` input, the `inspect` / `verify` / `batch` JSON output, or the new + `inspect-summary` / `verify-summary` / `batch-summary` compact shapes, with a `$id` + embedding the CLI version. `schema list` enumerates the subjects. +- **[AGENTS.md](AGENTS.md)** documents the full contract for AI agents and CI pipelines. + +#### Supply chain + +- **CycloneDX SBOM** (`sbom.cdx.json`) is generated in CI and attached to every GitHub + release; an **OpenSSF Scorecard** badge is published in the README. No new runtime + dependencies — the SBOM generator runs build-time only. + +### Changed + +- **`pdfnative` bumped** to `^1.3.0` (was `^1.2.0`). +- **npm keywords** expanded for discoverability (`pdf-ua`, `accessibility`, `colr`, + `color-emoji`, `unicode`, `text-shaping`, `opentype`, `bidi`, `streaming`, `ai-agent`, + `agentic`, `automation`, `json-output`, `json-schema`, and the new script names). + ## [1.0.0] – 2026-06-30 First stable release. **Verify-side Long-Term Validation (LTV)** lands in full, the diff --git a/CITATION.cff b/CITATION.cff index 3ef3467..f69eb06 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -5,12 +5,18 @@ message: >- title: "pdfnative-cli — Official CLI for the pdfnative PDF generation library" abstract: >- A zero-dependency command-line interface for pdfnative — a pure-TypeScript, - ISO 32000-1 compliant PDF generation library. pdfnative-cli exposes four - composable commands: render (JSON → PDF, including PDF/A conformance and - streaming output), sign (CMS/PKCS#7 digital signatures via RSA or ECDSA), - inspect (structured PDF metadata analysis), and verify (CMS/PKCS#7 signature - verification with certificate chain and trust evaluation). Designed for shell - pipelines, CI/CD automation, and server-side document generation. + ISO 32000-1 compliant PDF generation library. pdfnative-cli exposes six + composable commands: render (JSON → PDF, including PDF/A conformance, 22 + Unicode scripts, COLRv1 colour emoji, and true constant-memory streaming), + sign (CMS/PKCS#7 digital signatures via RSA or ECDSA), inspect (structured + PDF metadata analysis and PDF/UA structural validation), verify (CMS/PKCS#7 + signature verification with certificate-chain, trust, RFC 3161 timestamp and + OCSP/CRL revocation evaluation), batch (parallel directory rendering), and + completion (shell-completion scripts). An agent-native contract — a global + --json envelope, stable error codes, --dry-run, and a schema command — + lets autonomous AI agents and CI pipelines drive the CLI deterministically. + Designed for shell pipelines, CI/CD automation, and server-side document + generation. type: software authors: - name: "Nizoka" @@ -19,20 +25,24 @@ authors: repository-code: "https://github.com/Nizoka/pdfnative-cli" url: "https://pdfnative.dev" license: MIT -version: 0.3.0 -date-released: "2026-05-05" +version: 1.1.0 +date-released: "2026-06-30" keywords: - pdf - cli - pdf-generation - pdf-signing - pdf-inspection + - pdf-ua + - accessibility - typescript - nodejs - zero-dependencies - document-generation - digital-signature - pdf-a + - ai-agent + - automation - shell - pipeline references: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7e17ec..174a7d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,19 @@ All must pass before opening a PR. - **No `console.log`** — use `process.stdout.write(msg + '\n')` / `process.stderr.write(msg + '\n')` - **`readonly`** on interface props where mutation is unnecessary +## Agent contract + +The CLI is agent-native (see [AGENTS.md](AGENTS.md)). When you add or change a command: + +- Throw `CliError(message, exitCode, ErrorCode.X)` with a stable code from `utils/error.ts`. + Numeric exit codes (0/1/2) must not change. +- Keep **stdout** for the artifact and **stderr** for diagnostics. For success status on + `render`/`sign`/`batch`, call `emitStatus({...})` (no-op outside `--json`). +- Honour `--dry-run` via `hasFlag(args.flags, 'dry-run') || isDryRun()`. +- If a command gains a new input/output shape, update the matching schema in + `commands/schema.ts` (hand-authored Draft 2020-12; bump nothing — the `$id` tracks the + package version automatically) and add a `schema.test.ts` assertion. + ## Project Structure ``` @@ -78,6 +91,8 @@ tests/ # vitest test suite (mirrors src/) - **Never log key material** from the `sign` command — not in error messages, not debug output. - Validate file paths against path traversal before filesystem access. - Cap JSON input at 50 MB before parsing. +- A CycloneDX **SBOM** (`sbom.cdx.json`) is generated in CI and attached to each release; the + generator is build-time only — do not add it as a runtime dependency. ## Commit Convention diff --git a/README.md b/README.md index 9dcd9d0..21a8e38 100644 --- a/README.md +++ b/README.md @@ -8,34 +8,44 @@ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![npm provenance](https://img.shields.io/badge/provenance-signed-blueviolet)](https://docs.npmjs.com/generating-provenance-statements) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/Nizoka/pdfnative-cli/badge)](https://securityscorecards.dev/viewer/?uri=github.com/Nizoka/pdfnative-cli) + [![pdfnative](https://img.shields.io/npm/v/pdfnative?label=pdfnative&color=0066FF)](https://www.npmjs.com/package/pdfnative) [![website](https://img.shields.io/badge/pdfnative.dev-0066FF?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxyZWN0IHg9IjMiIHk9IjIiIHdpZHRoPSIxNCIgaGVpZ2h0PSIxOCIgcng9IjIiIGZpbGw9Im5vbmUiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMS41Ii8+PHBhdGggZD0iTTcgN2g2TTcgMTFoOE03IDE1aDQiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=)](https://pdfnative.dev) 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 v1.0.0** — **Long-Term Validation (LTV) on the verify side**: full -> RFC 3161 timestamp-token validation (PAdES-T), plus OCSP (RFC 6960) and CRL (RFC 5280) -> revocation checking — offline from the embedded `/DSS` by default, with opt-in, -> SSRF-guarded online fetching (`verify --revocation online`). `render` exposes -> pdfnative 1.2.0 **smart tables** (`--table-wrap`, `--repeat-header`, `--zebra`, -> `--cell-padding`, `--min-row-height`) and **page-by-page streaming** -> (`--stream-page-by-page`, TOC-compatible). New **`batch`** and **`completion`** commands, -> a **`.pdfnativerc.json`** config file, and global `--quiet` / `--no-color` / -> `--version --json` flags. Built on **pdfnative 1.2.0**, dropping the last two upstream -> workarounds. See [release notes](release-notes/v1.0.0.md). +> **What's new in v1.1.0** — built on **pdfnative 1.3.0**. `render` now exposes **22 +> Unicode scripts** (Telugu, Sinhala, Tibetan, Khmer, Myanmar, Amharic/Ethiopic + the +> existing 16) and **COLRv1 colour emoji** through expanded `--font` / `--lang` shortcuts, +> plus **true constant-memory streaming** (`--stream-true`) and a `--max-blocks` cap for +> very large documents. `inspect` gains a **PDF/UA (ISO 14289-1) structural validator** +> via `--pdfua` and `--check pdfua` for CI accessibility gates. This release also adds an +> **agent-native contract** — a global `--json` status/error envelope, stable `E_*` error +> codes, a `--dry-run` validation mode, a new **`schema`** command, and a **token-economy +> output projection** (`--summary` / `--fields` + compact JSON) that cuts agent output +> ~90 % — so autonomous AI +> agents and CI pipelines can drive the CLI deterministically (see +> [AGENTS.md](AGENTS.md)). A CycloneDX **SBOM** (`sbom.cdx.json`) is now attached to every +> [GitHub release](https://github.com/Nizoka/pdfnative-cli/releases). +> 100% backward-compatible. See [release notes](release-notes/v1.1.0.md). > > ⭐ Star [`pdfnative`](https://github.com/Nizoka/pdfnative) — the zero-dependency PDF engine that powers this CLI. ## Highlights - **`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. + watermarks (text + image), page templates, PDF/A archival, **22 Unicode scripts + COLRv1 + colour emoji**, streaming (single-pass, page-by-page, or **true constant-memory + `--stream-true`**), 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. + metadata, and **PDF/UA (ISO 14289-1) structural validation**. `--verbose`, `--pages`, + `--pdfua`, and `--check pdfa|signed|encrypted|pdfua` for CI assertions. - **`verify`** — verify every CMS/PKCS#7 signature: byte-range integrity, RSA/ECDSA signature value, certificate chain, trust roots, **RFC 3161 timestamp (PAdES-T)**, and **OCSP + CRL revocation** (embedded `/DSS` offline by default, opt-in SSRF-guarded online). @@ -43,6 +53,13 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library - **`batch`** — render every JSON file in a directory to PDF in parallel, reusing the full `render` pipeline, with a per-file summary and bounded `--concurrency`. - **`completion`** — emit `bash`, `zsh`, or `fish` shell-completion scripts. +- **`schema`** — print a versioned JSON Schema (Draft 2020-12) for any CLI input/output + shape, so agents can self-validate before invoking a command. +- **Agent-native** — a global `--json` status/error envelope, stable `E_*` error codes, and + a `--dry-run` validation mode let autonomous AI agents and CI drive the CLI + deterministically. Token-economy levers — **`--summary`** (minimal verdict), **`--fields`** + (dot-path projection), and compact JSON under `--json` — shrink agent output ~90 %. + See [AGENTS.md](AGENTS.md). - **`.pdfnativerc.json`** — optional config file for default flags (global + per-command); precedence is CLI flags > env > config. - **Zero extra dependencies** — `pdfnative` is the sole runtime dependency. @@ -61,11 +78,16 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | **Commands** | | | | `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` | +| `inspect` PDF metadata | ✅ | `--verbose`, `--pages`, `--pdfua`, `--check pdfa\|signed\|encrypted\|pdfua` | | `verify` signature verification | ✅ | Integrity + chain + trust + timestamp + revocation; `--strict` | | `batch` parallel rendering | ✅ | Directory → PDFs, `--concurrency`, `--fail-fast` | | `completion` shell scripts | ✅ | `bash` / `zsh` / `fish` | +| `schema` JSON Schema export | ✅ | `render` / `inspect` / `verify` / `batch` shapes | | `.pdfnativerc.json` config file | ✅ | Global + per-command defaults; flags > env > config | +| **Agent / automation** | | | +| Global `--json` envelope | ✅ | Status on success, `{ ok, error: { code, message } }` on failure | +| Stable error codes | ✅ | `E_USAGE`, `E_INPUT`, `E_PARSE`, `E_SIGN`, `E_VERIFY_FAILED`, … | +| `--dry-run` validation | ✅ | `render` / `sign` / `batch` — validate without writing | | **Document Blocks** | | | | Headings, paragraphs, lists | ✅ | Full text styling support | | Tables | ✅ | Headers, rows, multi-page | @@ -76,7 +98,7 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | Table of contents | ✅ | Auto-generated with `/GoTo` links | | **Advanced Layouts (v0.2.0)** | | | | PDF/A archival (1b, 2b, 2u, 3b) | ✅ | `--tagged pdfa` (preferred) or `--conformance` (deprecated) | -| Streaming output | ✅ | `--stream` for large documents | +| Streaming output | ✅ | `--stream` (single-pass) for large documents | | Compression | ✅ | `--compress` flag | | Encryption (AES-128/256) | ✅ | `--encrypt-*` flags + env-var precedence | | Watermarks (text + image) | ✅ | `--watermark-text`, `--watermark-image`, `--watermark-position` | @@ -84,7 +106,7 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | Custom page sizes | ✅ | `--page-size A4\|Letter\|…` or `WxH` in points | | Custom margins | ✅ | `--margin ` or `--margin ` | | PDF/A-3 attachments | ✅ | `--attachment :::` (repeatable) | -| Multilingual fonts | ✅ | `--lang th,ja,ar` (requires `registerFontLoader()` in wrapper; Latin built-in) | +| Multilingual fonts | ✅ | 22 Unicode scripts via `--font --lang ` (e.g. `th`, `ja`, `ar`, `te`, `si`, `km`); Latin built-in | | Table-centric variant (`PdfParams`) | ✅ | `--variant table` | | Full `PdfLayoutOptions` | ✅ | `--layout ` | | **Signing (v0.2.0)** | | | @@ -107,9 +129,12 @@ Official CLI for the [`pdfnative`](https://github.com/Nizoka/pdfnative) library | **Render iteration** | | | | Smart tables | ✅ | `--table-wrap`, `--repeat-header`, `--zebra`, `--cell-padding`, `--min-row-height` | | Page-by-page streaming | ✅ | `--stream-page-by-page` (TOC- and `{pages}`-compatible) | +| True constant-memory streaming | ✅ | `--stream-true` (parts freed as emitted; byte-identical output) | +| Configurable block cap | ✅ | `--max-blocks ` (default 100 000) | +| PDF/UA structural validation | ✅ | `inspect --pdfua` / `--check pdfua` (ISO 14289-1) — developer-time gate, not a substitute for veraPDF | | `--watch` re-render on file change | ✅ | 200 ms debounce, requires file `--output` | | `--template ` | ✅ | Deep-merge base under input (caller wins) | -| `--font latin\|emoji` shortcuts | ✅ | Repeatable, allow-list bundled font names | +| `--font` bundled shortcuts | ✅ | Repeatable allow-list: `latin`, `emoji`, `color-emoji`, 22 script codes | **Note:** features marked **⚠️** are tracked in [ROADMAP.md](ROADMAP.md). Everything else works today. @@ -150,6 +175,9 @@ cat document.json | pdfnative render --output report.pdf # Streaming (large documents) pdfnative render --input big-doc.json --output report.pdf --stream +# True constant-memory streaming (lowest peak memory; byte-identical) +pdfnative render --input big-doc.json --output report.pdf --stream-true + # PDF/A conformance pdfnative render --input document.json --output archived.pdf --conformance 2b ``` @@ -191,6 +219,12 @@ pdfnative inspect --input report.pdf # Human-readable pdfnative inspect --input report.pdf --format text +# PDF/UA (ISO 14289-1) structural validation report +pdfnative inspect --input report.pdf --pdfua + +# CI accessibility gate (exit 1 if not PDF/UA-structurally-valid) +pdfnative inspect --input report.pdf --check pdfua + # From stdin cat report.pdf | pdfnative inspect ``` @@ -218,7 +252,7 @@ Ready-to-run examples are in [`samples/`](samples/), organized by feature catego | Category | Examples | Description | |----------|----------|-------------| -| [`render/document/`](samples/render/document/) | 5 files | Minimal, report, all-blocks reference, invoice, technical spec | +| [`render/document/`](samples/render/document/) | 6 files | Minimal, report, all-blocks reference, invoice, technical spec, `--max-blocks` guard | | [`render/table/`](samples/render/table/) | 2 files | Project status, financial summary | | [`render/barcode/`](samples/render/barcode/) | 3 files | QR code, Code 128 shipping label, EAN-13 product | | [`render/form/`](samples/render/form/) | 2 files | Contact form, survey | @@ -249,12 +283,15 @@ See [`samples/README.md`](samples/README.md) for full descriptions, block type r |------|---------|-------------| | `--input ` | stdin | Path to a JSON file (`DocumentParams` or `PdfParams` if `--variant table`) | | `--output ` | stdout | Output PDF path | -| `--stream` | false | Use streaming output (`AsyncGenerator`) | +| `--stream` | false | Single-pass streaming output (`AsyncGenerator`); no TOC, no `{pages}` | +| `--stream-page-by-page` | false | Stream at PDF object boundaries (TOC- and `{pages}`-compatible) | +| `--stream-true` | false | True constant-memory streaming; parts freed as emitted; byte-identical; no TOC, no `{pages}` | | `--variant ` | `document` | `document` (default) or `table` (selects `buildPDFBytes`) | | `--layout ` | — | Load a `Partial` (CLI flags override) | | `--page-size ` | from layout file or pdfnative default | Named (`a4`, `letter`, `legal`, `a3`, `tabloid`, `a5`) or `WxH` in points | | `--margin ` or `--margin ` | from layout / default | Page margins in points | | `--compress` | false | Enable FlateDecode compression | +| `--max-blocks ` | `100000` | Maximum document blocks before pdfnative aborts (large-report guard) | | `--tagged ` | none | PDF/A: `none`, `pdfa1b`, `pdfa2b`, `pdfa2u`, `pdfa3b` | | `--conformance <1b\|2b\|3b>` | — | **Deprecated** — use `--tagged pdfa` | | `--watermark-text ` / `--watermark-image ` | — | Text or image watermark | @@ -267,7 +304,8 @@ See [`samples/README.md`](samples/README.md) for full descriptions, block type r | `--encrypt-algorithm aes128\|aes256` | `aes128` | Encryption algorithm | | `--encrypt-permissions ` | _all denied_ | Comma list: `print,copy,modify,extractText` | | `--attachment [:mime[:rel[:desc]]]` _(repeatable)_ | — | PDF/A-3 file attachment | -| `--lang ` | — | Activate registered font loaders for non-Latin scripts (`th`, `ja`, `ar`, …); Latin is built-in | +| `--lang ` | — | Activate registered font loaders for non-Latin scripts (`th`, `ja`, `ar`, `te`, `si`, `km`, …); Latin is built-in | +| `--font ` _(repeatable)_ | — | Register a bundled font shortcut. Allow-list: `latin`, `emoji`, `color-emoji`, and the 22 script codes `ar hy bn ru hi am ka el he ja km ko my pl zh si ta te th bo tr vi`. The name doubles as the `--lang` code. | See `samples/render/` for a working example of every category. @@ -296,7 +334,8 @@ See `samples/render/` for a working example of every category. | `--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) | +| `--pdfua` | false | Add a PDF/UA (ISO 14289-1) structural validation report (`valid` + `errors` + `warnings`) | +| `--check pdfa\|signed\|encrypted\|pdfua` _(repeatable)_ | — | CI-friendly assertion; sets exit code (0 = pass, 1 = fail) | ### `pdfnative verify` @@ -337,6 +376,20 @@ pdfnative completion zsh > "${fpath[1]}/_pdfnative" pdfnative completion fish > ~/.config/fish/completions/pdfnative.fish ``` +### `pdfnative schema` + +Print a versioned JSON Schema (Draft 2020-12) for a CLI input/output shape, so an +agent can self-validate before invoking a command. + +```bash +pdfnative schema # render input schema (default) +pdfnative schema render # render input (document | table variant) +pdfnative schema inspect # inspect --format json output +pdfnative schema verify # verify --format json output +pdfnative schema batch # batch --format json output +pdfnative schema list # list the available subjects +``` + ### Global options | Flag | Description | @@ -345,8 +398,28 @@ pdfnative completion fish > ~/.config/fish/completions/pdfnative.fish | `--no-config` | Ignore any `.pdfnativerc.json` | | `--quiet`, `-q` | Suppress progress output on stderr | | `--no-color` | Disable ANSI colour (also respects the `NO_COLOR` env var) | +| `--json` | Agent mode: emit a JSON status/error envelope on stderr (data stays on stdout) | +| `--dry-run` | Validate inputs and exit without writing output (`render` / `sign` / `batch`) | | `--version --json` | Machine-readable version output | +## Driving from AI agents + +`pdfnative-cli` is designed so an autonomous agent (or any program) can drive it +deterministically — no MCP server, no daemon, just the process contract: + +- **stdout = the artifact** (PDF, JSON report, schema, completion script); + **stderr = diagnostics.** +- Pass **`--json`** to get a single machine-readable envelope on stderr. On failure: + `{ "ok": false, "command": "...", "error": { "code": "E_*", "message": "..." } }`. + On success for `render` / `sign` / `batch`: a `{ "ok": true, ... }` status line. +- Branch on the **stable error code** (`E_USAGE`, `E_INPUT`, `E_PARSE`, `E_IO`, `E_SIGN`, + `E_VERIFY_FAILED`, `E_CHECK_FAILED`, `E_UNSUPPORTED`, `E_RUNTIME`) rather than the + message text. Numeric **exit codes** stay `0` (success), `1` (runtime), `2` (usage). +- Use **`--dry-run`** to validate input without producing output. +- Fetch a **`schema`** to validate input before calling. + +See [AGENTS.md](AGENTS.md) and the [`samples/agent/`](samples/agent) scripts. + ## Security - **Offline by default** — no network access unless you pass `verify --revocation online`. @@ -362,7 +435,7 @@ See [SECURITY.md](SECURITY.md) for the full security policy and vulnerability di ## Getting Help **Have a question?** -- 📖 Check the [FAQ](docs/KNOWLEDGE_BASE.md#11-frequently-asked-questions) first +- 📖 Check the [FAQ](docs/KNOWLEDGE_BASE.md#12-frequently-asked-questions) first - 🔍 Search the samples: `grep -r "your-keyword" samples/` - 📚 Read [KNOWLEDGE_BASE.md](docs/KNOWLEDGE_BASE.md) for technical details - 💬 Open a discussion: [GitHub Discussions](https://github.com/Nizoka/pdfnative-cli/discussions) diff --git a/ROADMAP.md b/ROADMAP.md index 343dbbb..b071ba2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,6 +70,24 @@ This document outlines the planned development direction for pdfnative-cli. Prio ## In Progress +### v1.1.0 — pdfnative 1.3.0 coverage _(released 2026-06-30)_ + +- [x] **`pdfnative` bumped** to `^1.3.0` (was `^1.2.0`). +- [x] **22 Unicode scripts + COLRv1 colour emoji** — `render --font` allow-list expanded to + every bundled pdfnative font, including the six new 1.3.0 scripts (Telugu `te`, Sinhala + `si`, Tibetan `bo`, Khmer `km`, Myanmar `my`, Amharic/Ethiopic `am`) and `color-emoji`. +- [x] **`render --stream-true`** — true constant-memory streaming + (`buildDocumentPDFStreamTrue` / `buildPDFStreamTrue`); byte-identical to the buffered + builders, lowest peak memory. +- [x] **`render --max-blocks `** — expose `layout.maxBlocks` (default 100 000). +- [x] **`inspect --pdfua` / `--check pdfua`** — read-only PDF/UA (ISO 14289-1) structural + validation via `validatePdfUA`, for CI accessibility gates. +- [x] **Agent-native contract** — global `--json` status/error envelopes, stable `E_*` + error codes, a `--dry-run` validation mode for `render` / `sign` / `batch`, and a new + `schema` command exporting versioned JSON Schemas. Documented in `AGENTS.md`. +- [x] **Supply-chain transparency** — CycloneDX SBOM attached to each release; OpenSSF + Scorecard badge published. + ### Next — Sign-side LTV (PAdES-T / LT / LTA), upstream-coordinated Sign-side LTV is **PDF-writing logic that belongs in pdfnative**; the CLI exposes the diff --git a/SECURITY.md b/SECURITY.md index 8b7ea97..d95f8d4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,16 +14,24 @@ We will acknowledge receipt within 48 hours and aim to provide a fix within 7 da | Version | Supported | |---------|-----------| -| 0.3.x | ✅ | -| 0.2.x | ✅ | -| 0.1.x | ✅ | -| < 0.1 | ❌ | +| 1.1.x | ✅ | +| 1.0.x | ✅ | +| < 1.0 | ❌ | ## Security Model pdfnative-cli is a thin dispatch layer over the [`pdfnative`](https://github.com/Nizoka/pdfnative) library. It introduces zero additional runtime dependencies. All PDF cryptographic operations are performed inside `pdfnative` — see the [pdfnative security policy](https://github.com/Nizoka/pdfnative/blob/main/SECURITY.md) for the full cryptographic implementation notes (RSA, ECDSA, AES). -The CLI exposes four commands (`render`, `sign`, `inspect`, `verify`). The `sign` and `verify` commands handle key material and certificate chain loading; security invariants for each are described below. +The CLI exposes six commands (`render`, `sign`, `inspect`, `verify`, `batch`, `completion`), plus a `schema` helper. The `sign` and `verify` commands handle key material and certificate chain loading; security invariants for each are described below. + +### Agent Mode (`--json`, `--dry-run`) + +The agent-native contract is a **pure local presentation/validation layer** and adds **no network surface**: + +- `--json` only changes how diagnostics are formatted on **stderr** (a machine-readable envelope). It never opens sockets, never alters what is written to stdout, and never relaxes any security check. +- `--dry-run` validates inputs and short-circuits **before** producing or writing output. For `sign` it stops after credentials are parsed and the PDF is prepared, before any signature value is computed — and still never logs key material. +- Stable `E_*` error codes carry only a failure class and a redacted message; internal byte offsets, parser state, and key bytes are never exposed (the `sign` failure message stays the fixed `Failed to sign PDF.`). +- The CLI remains **offline by default** in every mode; only `verify --revocation online` performs network requests, and only through the SSRF guard. ### Signing Key Handling diff --git a/docs/KNOWLEDGE_BASE.md b/docs/KNOWLEDGE_BASE.md index 7a96f91..b11fde2 100644 --- a/docs/KNOWLEDGE_BASE.md +++ b/docs/KNOWLEDGE_BASE.md @@ -175,7 +175,7 @@ function validatePath(p: string): void // throws CliError if ../ found **Purpose:** Convert a `DocumentParams` JSON file to a PDF. ```bash -pdfnative render [--input ] [--output ] [--stream] [--conformance 1b|2b|3b] +pdfnative render [--input ] [--output ] [--stream|--stream-page-by-page|--stream-true] [--tagged pdfa2b] [--font ] [--lang ] [--max-blocks ] ``` **Flags:** @@ -184,8 +184,13 @@ pdfnative render [--input ] [--output ] [--stream] [--confor |------|------|---------|-------------| | `--input` | string | stdin | Path to JSON file | | `--output` | string | stdout | Output PDF path | -| `--stream` | boolean | false | Use `streamDocumentPdf` (AsyncGenerator) | -| `--conformance` | `1b`\|`2b`\|`3b` | — | Inject `pdfaConformance` into params | +| `--stream` | boolean | false | Single-pass streaming (`buildDocumentPDFStream`); no TOC, no `{pages}` | +| `--stream-page-by-page` | boolean | false | Object-boundary streaming; TOC- and `{pages}`-compatible | +| `--stream-true` | boolean | false | True constant-memory streaming (`buildDocumentPDFStreamTrue`); parts freed as emitted; byte-identical | +| `--max-blocks` | integer | 100000 | Maximum document blocks (`layout.maxBlocks`) before pdfnative aborts | +| `--font` | string (repeatable) | — | Register a bundled font shortcut (see Multilingual rendering below) | +| `--lang` | string (comma list) | — | Preferred font code per script (`th`, `ja`, `ar`, …) | +| `--conformance` | `1b`\|`2b`\|`3b` | — | **Deprecated** — use `--tagged pdfa` | **JSON schema:** Full [`DocumentParams`](https://github.com/Nizoka/pdfnative) — same object passed to `buildDocumentPDFBytes()`. @@ -240,11 +245,33 @@ See [`samples/`](../samples/) for complete working examples of every supported b **Security:** JSON buffer size is checked before parse. If > 50 MB → `CliError(exit 1)`. -**Streaming behaviour:** When `--stream` is set, the command uses `streamDocumentPdf()` and writes each `Uint8Array` chunk immediately to the output. Compatible with piping to file compression tools. +**Streaming behaviour:** Three mutually-exclusive modes. `--stream` uses a single-pass +AsyncGenerator; `--stream-page-by-page` streams at PDF object boundaries (TOC- and +`{pages}`-compatible); `--stream-true` (pdfnative 1.3.0) emits and frees parts as it goes for +the lowest peak memory and is byte-identical to the buffered builders. Each writes every +`Uint8Array` chunk immediately to the output and is compatible with piping to compression tools. -**Multilingual rendering (`--lang` flag):** +**Multilingual rendering (`--font` / `--lang` flags):** -The `--lang ` flag tells pdfnative which font loaders to activate. Because the CLI process starts fresh for every invocation, font loaders must be registered programmatically **before** the render call. The recommended pattern is a thin Node.js wrapper script: +The `--font ` flag (repeatable) registers a **bundled** pdfnative font for the duration of +the render — no wrapper script required. The allow-list is `latin`, `emoji`, `color-emoji`, and +the 22 script codes `ar hy bn ru hi am ka el he ja km ko my pl zh si ta te th bo tr vi`. Each +name doubles as its `--lang` code; pdfnative routes each code point to the font whose cmap +covers it, so mixed-script and colour-emoji text renders automatically. + +```bash +# Telugu (one of the six scripts new in pdfnative 1.3.0) +pdfnative render --input te.json --font te --lang te --output te.pdf + +# COLRv1 colour emoji +pdfnative render --input party.json --font color-emoji --lang color-emoji --output party.pdf + +# Mixed: English (built-in) + Japanese + Arabic in one document +pdfnative render --input multi.json --font ja --font ar --output multi.pdf +``` + +**Advanced (custom / non-bundled fonts):** to supply your own `fontData`, use the pdfnative +Node.js API directly from a thin wrapper script: ```js // myscript.js (Node.js >= 20, ESM) @@ -357,6 +384,10 @@ pdfnative inspect [--input ] [--format json|text] |------|------|---------|-------------| | `--input` | string | stdin | Input PDF path | | `--format` | `json`\|`text` | `json` | Output format | +| `--verbose` | boolean | false | Add trailer keys, catalog keys, object count, XMP | +| `--pages` | boolean | false | Add per-page metadata array | +| `--pdfua` | boolean | false | Add a PDF/UA (ISO 14289-1) structural validation report | +| `--check` | `pdfa`\|`signed`\|`encrypted`\|`pdfua` (repeatable) | — | CI assertion; sets exit code (0 = pass, 1 = fail) | **JSON output shape:** ```json @@ -374,7 +405,9 @@ pdfnative inspect [--input ] [--format json|text] } ``` -**pdfnative API used:** `openPdf(bytes: Uint8Array): PdfReader` +**pdfnative API used:** `openPdf(bytes: Uint8Array): PdfReader`, and (for `--pdfua` / `--check pdfua`) `validatePdfUA(bytes: Uint8Array): { valid: boolean; errors: readonly string[]; warnings: readonly string[] }`. + +**PDF/UA validation (`--pdfua`):** a fast, read-only structural check (`/MarkInfo /Marked`, `/StructTreeRoot` + `/ParentTree`, `/Metadata`, `/Lang`, per-page `/MCID` uniqueness). It is a developer-time gate, not a substitute for a full reference validator such as veraPDF. With `--check pdfua` the command exits 1 when the structural prerequisites fail. `PdfReader` interface (relevant methods): ```typescript @@ -396,7 +429,115 @@ pdfnative also exports typed accessors: `dictGet`, `dictGetName`, `dictGetNum`, --- -## 5. Security Model +## 5. Agent Automation Contract + +The CLI is designed so an autonomous AI agent — or any program — can drive it +deterministically. There is **no separate runtime**: agent support is a thin +presentation layer over the normal dispatch (the official pdfnative MCP server +is a different integration; this is about driving the CLI process directly). + +### Channels + +| Channel | Carries | +|---------|---------| +| **stdout** | The primary artifact: PDF (`render`, `sign`), JSON report (`inspect`, `verify`, `batch --format json`), JSON Schema (`schema`), or completion script. | +| **stderr** | All diagnostics: progress, warnings, and the agent JSON envelopes. | +| **exit code** | `0` success · `1` runtime · `2` usage. Unchanged in every mode. | + +### `--json` envelope + +Global `--json` sets `PDFNATIVE_JSON=1` (in `index.ts`). In that mode: + +- On **failure**, a single object is written to stderr: + `{ "ok": false, "command": , "error": { "code": "E_*", "message": "…" } }`. +- On **success**, `render` / `sign` / `batch` emit a status line: + `{ "ok": true, "command": "render", "variant": "document", "dryRun": false, "output": "out.pdf", "bytes": 12345 }`. +- `inspect` / `verify` / `batch` already put their result document on stdout as + JSON; `--json` only adds the stderr failure envelope (and forces `batch`'s + JSON summary). + +The helpers live in [`src/utils/agent.ts`](../src/utils/agent.ts): +`isJsonMode()`, `isDryRun()`, `buildErrorEnvelope()`, `emitJsonError()`, +`emitStatus()` (a no-op outside `--json`, so commands call it unconditionally). + +### Stable error codes + +Defined in [`src/utils/error.ts`](../src/utils/error.ts) as `ErrorCode` and +carried on every `CliError.code`: + +| Code | Meaning | +|------|---------| +| `E_USAGE` | Missing/invalid flag or argument (exit 2) | +| `E_INPUT` | Input payload wrong shape / failed validation | +| `E_PARSE` | Could not parse JSON / PDF / DER | +| `E_IO` | Filesystem or stream I/O failure | +| `E_SIGN` | Signing failed (generic message — never leaks key material) | +| `E_VERIFY_FAILED` | `verify --strict` found an invalid signature | +| `E_CHECK_FAILED` | `inspect --check` assertion failed | +| `E_UNSUPPORTED` | Reserved / not-yet-available capability | +| `E_RUNTIME` | Catch-all runtime error | + +When no code is passed, `CliError` derives one from the exit code +(`2 → E_USAGE`, otherwise `E_RUNTIME`), so legacy call sites get a sensible +code for free. + +### `--dry-run` + +`render`, `sign`, and `batch` accept `--dry-run` (sets `PDFNATIVE_DRY_RUN=1`). +Inputs are fully validated — and for `sign`, credentials are parsed and the PDF +is placeholder-prepared — but **no output is produced or written**. Commands +read `hasFlag(args.flags, 'dry-run') || isDryRun()` so a direct command call and +the global flag both work. + +### Token economy — output projection + +The JSON `inspect` / `verify` / `batch` write to stdout is the bulk of an agent's +token cost. The projection layer in +[`src/utils/projection.ts`](../src/utils/projection.ts) shrinks it ~90 % through +three composable levers (`selectFields`, `serializeJson`, `parseFieldList` — all +pure, zero-dep): + +| Lever | Flag | Effect | +|-------|------|--------| +| Compact serialization | *(auto under `--json`)* | Minified JSON (no indentation); `--pretty` opts back into 2-space output. Non-`--json` runs stay pretty for humans. | +| Canonical summary | `--summary` | Collapses the report to a minimal verdict (see below). | +| Dot-path projection | `--fields a,b.c` | Keeps only the named paths; an array segment maps over its elements; unknown paths are silently omitted. | + +Precedence: `--summary` is applied first, then `--fields` projects the result. + +| Command | `--summary` shape | +|---------|-------------------| +| `inspect` | `{ pages, encrypted, signatures, pdfa }` | +| `verify` | `{ valid, signatures, invalid }` | +| `batch` | `{ total, succeeded, failed }` (drops the per-file `results` array) | + +```bash +pdfnative verify --input doc.pdf --json --summary # {"valid":false,"signatures":0,"invalid":0} +pdfnative inspect --input doc.pdf --json --fields pageCount,signatures +pdfnative batch --input-dir in --output-dir out --json --summary +``` + +The summary shapes are schema-pinned: `schema inspect-summary`, +`schema verify-summary`, `schema batch-summary`. + +Why compact-under-`--json` is not a breaking change: agent mode (`--json`) is new +in this release, so no prior consumer relied on its stdout being pretty-printed. +Human invocations (no `--json`) are unchanged. + +### `schema` command + +[`src/commands/schema.ts`](../src/commands/schema.ts) prints a hand-authored, +versioned JSON Schema (Draft 2020-12) for `render` input, `inspect` / `verify` +/ `batch` output, or the `inspect-summary` / `verify-summary` / `batch-summary` +compact shapes. The `$id` embeds the CLI version +(`https://pdfnative.dev/schema/cli//.schema.json`) so callers +can detect drift. `schema list` enumerates the subjects. + +See [AGENTS.md](../AGENTS.md) for the agent-facing summary. + +--- + +## 6. Security Model | Threat | Mitigation | |--------|-----------| @@ -404,13 +545,13 @@ pdfnative also exports typed accessors: `dictGet`, `dictGetName`, `dictGetNum`, | Memory exhaustion via large JSON | 50 MB size check before `JSON.parse` | | Key material leakage via logs | Keys never included in error messages; `sign` command silences all key-related debug output | | Binary injection via inspect output | All metadata fields are string-coerced; no raw binary blobs emitted | -| Supply-chain risk | Zero extra runtime dependencies; OIDC-signed npm provenance; CodeQL + Scorecard CI | +| Supply-chain risk | Zero extra runtime dependencies; OIDC-signed npm provenance; CodeQL + Scorecard CI; CycloneDX SBOM attached to each release | See [SECURITY.md](../SECURITY.md) for the full policy. --- -## 6. Troubleshooting +## 7. Troubleshooting ### `Error: PDFNATIVE_SIGN_KEY is not set` @@ -449,12 +590,14 @@ With `--stream`, the entire PDF must be consumed before the process exits. Use ` --- -## 7. pdfnative API Mapping +## 8. pdfnative API Mapping | CLI action | pdfnative function | Return type | Notes | |------------|--------------------|-------------|-------| | `render` (default) | `buildDocumentPDFBytes(params)` | `Uint8Array` | Synchronous | -| `render --stream` | `buildDocumentPDFStream(params)` | `AsyncGenerator` | Streams chunks | +| `render --stream` | `buildDocumentPDFStream(params)` | `AsyncGenerator` | Single-pass streaming | +| `render --stream-page-by-page` | `buildDocumentPDFPageStream(params)` | `AsyncGenerator` | Object-boundary streaming | +| `render --stream-true` | `buildDocumentPDFStreamTrue(params)` | `AsyncGenerator` | True constant-memory streaming | | `sign` | `signPdfBytes(bytes, options)` | `Uint8Array` | Synchronous; PEM parsed via `parseRsaPrivateKey` + `parseCertificate` | | `inspect` (open) | `openPdf(bytes)` | `PdfReader` | Returns reader with `.getCatalog()`, `.getInfo()`, `.pageCount` etc. | @@ -470,7 +613,7 @@ dictGetArray(dict, key) // PdfArray | undefined --- -## 8. Development Quick Reference +## 9. Development Quick Reference ```bash # Install @@ -498,7 +641,7 @@ echo '{"blocks":[{"type":"paragraph","text":"Hello"}]}' | node dist/cli.cjs rend --- -## 9. Samples +## 10. Samples Complete, runnable examples live in [`samples/`](../samples/), organized by feature category: @@ -527,7 +670,7 @@ See [`samples/README.md`](../samples/README.md) for the full block type referenc --- -## 10. Integration Patterns +## 11. Integration Patterns ### Shell pipeline ```bash @@ -574,9 +717,30 @@ function renderToFile(params: object, outputPath: string): Promise { } ``` +### Autonomous agent (JSON envelope + error codes) +```typescript +import { spawnSync } from 'node:child_process'; + +const r = spawnSync('pdfnative', ['inspect', '--input', 'doc.pdf', '--json'], { + encoding: 'utf8', +}); +if (r.status !== 0) { + // Diagnostics (including the failure envelope) are on stderr. + const env = JSON.parse(r.stderr.trim().split('\n').at(-1)!); + // Branch on the stable class, not the message text. + if (env.error.code === 'E_PARSE') { + // … the input was not a readable PDF + } +} else { + const report = JSON.parse(r.stdout); // primary artifact on stdout +} +``` + +See [AGENTS.md](../AGENTS.md) for the full agent contract. + --- -## 11. Frequently Asked Questions +## 12. Frequently Asked Questions ### Why are watermarks not visible in `render/watermark/` samples? diff --git a/package-lock.json b/package-lock.json index 428f4df..a9ddc77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "pdfnative-cli", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdfnative-cli", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { - "pdfnative": "^1.2.0" + "pdfnative": "^1.3.0" }, "bin": { "pdfnative": "dist/cli.cjs" @@ -126,9 +126,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -143,9 +143,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -160,9 +160,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -177,9 +177,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -194,9 +194,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -211,9 +211,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -228,9 +228,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -245,9 +245,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -262,9 +262,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -279,9 +279,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -296,9 +296,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -330,9 +330,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -347,9 +347,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -364,9 +364,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -398,9 +398,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -415,9 +415,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -432,9 +432,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -449,9 +449,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -466,9 +466,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -483,9 +483,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -500,9 +500,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -517,9 +517,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -534,9 +534,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -551,9 +551,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2253,9 +2253,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2266,32 +2266,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-string-regexp": { @@ -3357,9 +3357,9 @@ "license": "MIT" }, "node_modules/pdfnative": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.2.0.tgz", - "integrity": "sha512-atRSM6rEPAoWwn85tk3JIskVF+OVOrqBxkN7yX5JvZvLcnwhacZlNNo/xkI40kc05cQhI1T3SHMzSdxnYkymjA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pdfnative/-/pdfnative-1.3.0.tgz", + "integrity": "sha512-nAueG9BBVm2GS/yNhWrC4DDSsnrvnvzeSU5VcP/GlYihRSXDD4NRCHOlgHivRlgxah9edfoS45uKFSAOMff+ug==", "license": "MIT", "bin": { "pdfnative-build-font": "tools/build-font-data.cjs" diff --git a/package.json b/package.json index 6899961..bf0bec4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "pdfnative-cli", - "version": "1.0.0", - "description": "Official CLI for pdfnative — render JSON to PDF, sign (RSA + ECDSA), inspect, and verify CMS signatures with LTV (RFC 3161 timestamps, OCSP, CRL). Zero extra runtime dependencies.", + "version": "1.1.0", + "description": "Official CLI for pdfnative — render JSON to PDF (22 Unicode scripts, COLRv1 colour emoji, true constant-memory streaming), sign (RSA + ECDSA), inspect, validate PDF/UA, and verify CMS signatures with LTV (RFC 3161 timestamps, OCSP, CRL). Zero extra runtime dependencies.", "type": "module", "bin": { "pdfnative": "./dist/cli.cjs" @@ -51,7 +51,29 @@ "pdf-watch", "batch", "shell-completions", - "command-line" + "command-line", + "pdf-ua", + "accessibility", + "colr", + "color-emoji", + "unicode", + "text-shaping", + "opentype", + "bidi", + "streaming", + "telugu", + "sinhala", + "khmer", + "myanmar", + "tibetan", + "amharic", + "ai-agent", + "agentic", + "automation", + "json-output", + "json-schema", + "sbom", + "supply-chain" ], "author": "Nizoka (https://pdfnative.dev)", "license": "MIT", @@ -75,7 +97,7 @@ "provenance": true }, "dependencies": { - "pdfnative": "^1.2.0" + "pdfnative": "^1.3.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -85,5 +107,8 @@ "typescript": "^5.4.0", "typescript-eslint": "^8.59.2", "vitest": "^4.1.7" + }, + "overrides": { + "esbuild": "^0.28.1" } } diff --git a/release-notes/draft/PR-v1.1.0.md b/release-notes/draft/PR-v1.1.0.md new file mode 100644 index 0000000..4d17a1c --- /dev/null +++ b/release-notes/draft/PR-v1.1.0.md @@ -0,0 +1,157 @@ +# v1.1.0 — pdfnative 1.3.0 coverage + an agent-native CLI + +> **Branch:** `release/v1.1.0` → `main` +> **Type:** Minor release (additive, 100% backward-compatible with v1.0.0) +> **pdfnative bump:** `^1.2.0` → `^1.3.0` + +## Summary + +Two themes, one release, zero breaking changes: + +1. **Full pdfnative 1.3.0 coverage** — `render` reaches all **22 Unicode scripts** + and **COLRv1 colour emoji**, adds **true constant-memory streaming** + (`--stream-true`) and a **`--max-blocks`** cap; `inspect` gains a read-only + **PDF/UA (ISO 14289-1) structural validator** (`--pdfua`, `--check pdfua`). +2. **Agent-native automation** — a thin presentation layer over the existing + dispatch lets autonomous AI agents and CI drive the CLI deterministically: + a global **`--json`** status/error envelope, stable **`E_*` error codes**, a + **`--dry-run`** validation mode, a new **`schema`** command, and a + **token-economy output projection** (`--summary` / `--fields` + compact JSON + under `--json`) that cuts agent output ~90 %. No MCP server, no daemon, no new + runtime dependency — just the process contract. + +Plus supply-chain transparency: a **CycloneDX SBOM** attached to every release +and an **OpenSSF Scorecard** badge. + +## Changes + +### `src/utils/error.ts` +- New `ErrorCode` const map + `ErrorCodeValue` type (`E_USAGE`, `E_INPUT`, + `E_PARSE`, `E_IO`, `E_SIGN`, `E_VERIFY_FAILED`, `E_CHECK_FAILED`, + `E_UNSUPPORTED`, `E_RUNTIME`). +- `CliError` gains a `code` field; constructor `(message, exitCode = 1, code?)` + derives the code from the exit code when omitted (`2 → E_USAGE`, else + `E_RUNTIME`), so every existing call site keeps a sensible code for free. + +### `src/commands/render.ts`, `sign.ts`, `batch.ts` +- `--dry-run` (`hasFlag(...) || isDryRun()`): full validation, then short-circuit + before producing/writing output. `sign` stops after credentials are parsed and + the PDF is placeholder-prepared, before any signature value is computed. +- `emitStatus({...})` success envelopes on stderr in `--json` mode; INPUT/SIGN/ + IO/UNSUPPORTED error codes attached to the relevant `CliError`s. +- `batch`: global `--json` forces the JSON summary; `--dry-run` skips `mkdir` + and forwards to each `render`. + +### `src/commands/inspect.ts`, `verify.ts` +- `E_PARSE` on unreadable PDFs; `inspect --check` failures carry + `E_CHECK_FAILED`, `verify --strict` failures carry `E_VERIFY_FAILED`. In + `--json` mode the check detail rides in the error message instead of being + pre-printed to stderr (avoids a double-print through the dispatcher). +- **Output projection.** The JSON-on-stdout branch now routes through + `utils/projection.ts`: `--summary` emits a canonical minimal verdict + (inspect `{ pages, encrypted, signatures, pdfa }`, verify + `{ valid, signatures, invalid }`), `--fields a,b.c` projects to named dot-paths, + and output is **compact by default under `--json`** (`--pretty` opts back into + 2-space). Non-`--json` human output is unchanged. + +### `src/commands/batch.ts` (projection) +- `--summary` emits `{ total, succeeded, failed }` (drops the per-file `results` + array — the largest token sink); `--fields` / compact / `--pretty` behave as + above. `summary`/`fields`/`pretty` are added to `BATCH_ONLY_FLAGS` so they are + not forwarded to per-file `render`. + +### `src/commands/completion.ts` +- Added the `schema` command and the `--json` / `--dry-run` global flags to the + bash/zsh/fish flag and command tables; corrected the `batch` flag list. + +### `src/index.ts` +- Global-flag block sets `PDFNATIVE_JSON=1` / `PDFNATIVE_DRY_RUN=1`; tracks the + active command; on a thrown error in `--json` mode, `emitJsonError()` writes + the failure envelope to stderr and exits with the `CliError.exitCode`. +- Registers `schema` with usage text; help/usage list `schema`, `--json`, + `--dry-run`, and point agents at `AGENTS.md`. + +### New files +- `src/utils/agent.ts` — `isJsonMode`, `isDryRun`, `buildErrorEnvelope`, + `emitJsonError`, `emitStatus` (no-op outside `--json`). +- `src/utils/projection.ts` — `selectFields` (dot-path projection, array map, + lenient on unknown paths), `serializeJson` (compact/pretty), `parseFieldList`. + Pure data, zero deps. +- `src/commands/schema.ts` — `pdfnative schema [render|inspect|verify|batch| + inspect-summary|verify-summary|batch-summary|list]`, hand-authored versioned + JSON Schemas (Draft 2020-12); `$id` embeds the CLI version. Pure data, zero deps. +- `AGENTS.md` — agent-facing contract (channels, `--json`, error codes, + `--dry-run`, `schema`, recommended loop, safety notes). +- `tests/utils/error.test.ts`, `tests/utils/agent.test.ts`, + `tests/utils/projection.test.ts`, `tests/commands/schema.test.ts`; agent-mode + and output-projection cases appended to + `tests/commands/{render,sign,inspect,verify,batch,completion}.test.ts`. +- `samples/agent/{01-json-and-dry-run,02-schema,03-error-envelope}.{sh,ps1}`, + `samples/render/font/02-new-scripts.{json,sh,ps1}`, + `samples/render/document/06-max-blocks.{json,sh,ps1}` (the `--max-blocks` + large-report guard), + `samples/inspect/05-pdfua.{sh,ps1}`. + +### Docs & governance +- `README.md` — OpenSSF Scorecard badge, refreshed "What's new", `schema` in the + command tables, an **"Driving from AI agents"** section, agent globals. +- `docs/KNOWLEDGE_BASE.md` — new **§5 Agent Automation Contract** (channels, + envelope, error codes, `--dry-run`, `schema`) + an agent integration snippet. +- `CHANGELOG.md`, `release-notes/v1.1.0.md`, `ROADMAP.md` — agent-native + + SBOM entries. `SECURITY.md` — supported versions to 1.1.x/1.0.x; note that the + agent contract adds no network surface. `CITATION.cff` — version/abstract/ + keywords. `CONTRIBUTING.md` — error-code + schema-authoring conventions, SBOM. +- `.github/instructions/{cli-design,commands}.instructions.md` — agent contract + deltas. `.github/workflows/publish.yml` — CycloneDX SBOM generation + upload + + release attachment (`contents: write`). +- `package.json` — keywords (`ai-agent`, `agentic`, `automation`, `json-output`, + `json-schema`, `sbom`, `supply-chain`). +- Docs polish — README names the SBOM artifact (`sbom.cdx.json`) and links the + releases page, completes the agent error-code list (`E_IO`), and notes the + PDF/UA validator is a developer-time gate (not a veraPDF substitute); + `samples/README.md` version tags aligned to the CLI release line. + +## Validation + +- `npm run typecheck:all` → clean (src + tests). +- `npm run lint` → clean. +- `npm run test:coverage` → **276 / 276 passing** (was 226 in v1.0.0); thresholds + met — statements **81.78 %**, branches **72.01 %**, functions **85.9 %**, + lines **83.59 %**. +- `npm run build` → CJS **142.07 KB**, ESM **141.16 KB**, types emitted. +- Smoke: + - `node dist/cli.cjs --help` → ok; `schema list` → `{ subjects: [...] }`. + - `render … --dry-run --json` → `{ ok: true, dryRun: true, … }` on stderr, **no + file written**. + - bad input piped to `inspect --json` → + `{ ok: false, command: "inspect", error: { code: "E_PARSE", … } }`, exit 1. + +## Backward compatibility + +- **No flag removed or renamed; no exit-code semantics changed** (0/1/2). +- `--json` only adds a stderr envelope; stdout artifacts are byte-unchanged. +- `CliError.code` is additive; existing call sites get a derived code for free. +- Every v1.0.0 invocation continues to work unchanged. + +## Out of scope (unchanged) + +- **MCP / daemon / HTTP / socket interfaces** — the official pdfnative MCP server + is a separate integration; this release keeps the CLI a stateless process. +- **Sign-side LTV** (PAdES-T/LT/LTA) — upstream-blocked in pdfnative; + `sign --timestamp` stays reserved and errors with `E_UNSUPPORTED` (exit 2). +- No new runtime dependency (SBOM generator is CI-only). + +## Self-review checklist + +- [x] No `console.log`; all output via `process.stdout.write` / + `process.stderr.write`. +- [x] stdout = artifact, stderr = diagnostics; `--json` never touches stdout. +- [x] No key material in output — `sign` failure stays the fixed + `Failed to sign PDF.` (`E_SIGN`); `--dry-run` `sign` never logs PEM bytes. +- [x] Numeric exit codes (0/1/2) unchanged; `E_*` codes are additive. +- [x] `--dry-run` writes no output for `render` / `sign` / `batch` (verified). +- [x] Path-traversal validation + 50 MB JSON / 50 MiB ASN.1 caps preserved. +- [x] TypeScript strict; no `any`; new types `readonly` where applicable; + ESM-first `.js` imports. +- [x] `pdfnative` is still the **only** runtime dependency. +- [x] Coverage thresholds green; 276/276 tests pass. diff --git a/release-notes/v1.1.0.md b/release-notes/v1.1.0.md new file mode 100644 index 0000000..b7abc2d --- /dev/null +++ b/release-notes/v1.1.0.md @@ -0,0 +1,157 @@ +# pdfnative-cli v1.1.0 + + + +_Released 2026-06-30_ + +**v1.1.0** surfaces the new **pdfnative 1.3.0** engine capabilities through the +CLI **and** makes the tool agent-native. `render` now reaches all **22 Unicode +scripts** and **COLRv1 colour emoji** through expanded `--font` / `--lang` +shortcuts, adds **true constant-memory streaming** (`--stream-true`), and a +`--max-blocks` cap for very large documents. `inspect` gains a read-only +**PDF/UA (ISO 14289-1) structural validator** (`--pdfua`, `--check pdfua`) for +CI accessibility gates. A new **agent-native contract** — global `--json` +envelopes, stable `E_*` error codes, a `--dry-run` validation mode, and a +`schema` command — lets autonomous AI agents and CI pipelines drive the CLI +deterministically. It also adds a **token-economy output projection** +(`--summary` / `--fields` + compact JSON under `--json`) that cuts agent output +~90% while preserving the fields orchestration code branches on. A CycloneDX +**SBOM** is now attached to every release. + +100% backward-compatible with v1.0.0 — every existing invocation keeps working. + +> ⭐ Star [`pdfnative`](https://github.com/Nizoka/pdfnative) — the +> zero-dependency PDF engine that powers this CLI. Every star helps the +> long-term project. + +## Highlights + +- **22 Unicode scripts + COLRv1 colour emoji.** The six scripts new in + pdfnative 1.3.0 — Telugu (`te`), Sinhala (`si`), Tibetan (`bo`), Khmer + (`km`), Myanmar (`my`), Amharic/Ethiopic (`am`) — plus the existing 16 and + native colour emoji are now selectable via `--font`. +- **True constant-memory streaming.** `--stream-true` emits and frees PDF parts + as it goes, so the fully-joined binary never materialises in memory. + Byte-identical to the buffered builders. +- **Configurable block cap.** `--max-blocks ` exposes pdfnative's + `layout.maxBlocks` (default 100 000) for multi-thousand-page reports. +- **PDF/UA structural validation.** `inspect --pdfua` reports + `{ valid, errors, warnings }`; `inspect --check pdfua` is a CI gate. It is a + developer-time structural check, not a substitute for a full reference + validator such as veraPDF. +- **Agent-native contract.** Global `--json` status/error envelopes, stable + `E_*` error codes, a `--dry-run` validation mode, and a `schema` command. + Token economy is built in via `--summary` (minimal verdict), `--fields` + (dot-path projection), and compact JSON under `--json` (`--pretty` opt-out) + to reduce agent output by ~90% without changing human-readable defaults. + See [AGENTS.md](../AGENTS.md). +- **Supply-chain transparency.** A CycloneDX SBOM (`sbom.cdx.json`) is attached + to every GitHub release and an OpenSSF Scorecard badge is published. + +## What's new + +### `render` + +- **`--font` allow-list expanded to all 22 bundled scripts + `color-emoji`.** + Each shortcut name doubles as its `--lang` code; pdfnative routes each code + point to the font whose cmap covers it, so mixed-script and emoji text render + automatically once the relevant fonts are registered. + + ```bash + # Telugu document + pdfnative render --input te.json --font te --lang te --output te.pdf + + # COLRv1 colour emoji + pdfnative render --input party.json --font color-emoji --lang color-emoji --output party.pdf + ``` + +- **`--stream-true`** — true constant-memory streaming via + `buildDocumentPDFStreamTrue` / `buildPDFStreamTrue`. Same constraints as + `--stream` (no TOC blocks, no `{pages}` placeholder), byte-identical output, + and mutually exclusive with the other `--stream*` flags. + + ```bash + pdfnative render --input big-doc.json --output report.pdf --stream-true + ``` + +- **`--max-blocks `** — raise or lower the document-block ceiling + (default 100 000). + + ```bash + # Cap a potentially huge payload as a fail-fast guard + pdfnative render --input big-doc.json --output report.pdf --max-blocks 50000 + ``` + + See [`samples/render/document/06-max-blocks.*`](../samples/render/document). + +### `inspect` + +- **PDF/UA (ISO 14289-1) structural validation** via `validatePdfUA`. The + validator checks `/MarkInfo /Marked`, `/StructTreeRoot` + `/ParentTree`, + `/Metadata`, `/Lang`, and per-page `/MCID` uniqueness — a fast developer-time + gate that complements a full reference validator such as veraPDF. + + ```bash + # Add a report to the inspection output + pdfnative inspect --input report.pdf --pdfua + + # CI accessibility gate (exit 1 if structural prerequisites fail) + pdfnative inspect --input report.pdf --check pdfua + ``` + +### Agent-native automation + +The CLI is now designed so an autonomous AI agent — or any program — can drive +it deterministically (no MCP server, no daemon, just the process contract). + +- **`--json` envelope.** Pass `--json` to any command to get a single + machine-readable object on **stderr**. On failure: + `{ "ok": false, "command": "…", "error": { "code": "E_*", "message": "…" } }`. + On success, `render` / `sign` / `batch` emit a `{ "ok": true, … }` status + line. stdout stays reserved for the artifact (PDF, report, schema, script). +- **Stable error codes.** Every failure carries a code (`E_USAGE`, `E_INPUT`, + `E_PARSE`, `E_IO`, `E_SIGN`, `E_VERIFY_FAILED`, `E_CHECK_FAILED`, + `E_UNSUPPORTED`, `E_RUNTIME`) so callers branch on a class, not on prose. + Numeric exit codes (0/1/2) are unchanged. +- **`--dry-run`** validates inputs for `render` / `sign` / `batch` without + producing output. +- **`schema` command** prints a versioned JSON Schema (Draft 2020-12) for the + `render` input, the `inspect` / `verify` / `batch` output, and the compact + `inspect-summary` / `verify-summary` / `batch-summary` shapes. +- **Token economy output projection.** For JSON-on-stdout commands + (`inspect` / `verify` / `batch`): + - compact JSON is automatic under `--json` (`--pretty` forces indentation); + - `--summary` emits a canonical minimal verdict: + - `inspect`: `{ pages, encrypted, signatures, pdfa }` + - `verify`: `{ valid, signatures, invalid }` + - `batch`: `{ total, succeeded, failed }` (drops per-file `results`) + - `--fields a,b.c` keeps only named dot-paths (array segments map over items; + unknown paths are omitted). + This keeps stdout token-cheap for agents while preserving deterministic + machine-readable behavior. + + ```bash + pdfnative schema list + pdfnative schema verify-summary + pdfnative schema render + pdfnative inspect --input doc.pdf --json --summary + pdfnative verify --input doc.pdf --json --fields valid + pdfnative render --input doc.json --output out.pdf --dry-run --json + ``` + + See [AGENTS.md](../AGENTS.md) and [`samples/agent/`](../samples/agent). + +## Compatibility + +- Built on **pdfnative `^1.3.0`** (was `^1.2.0`). +- **Node.js ≥ 20**, Bun, Deno (`node dist/cli.cjs`). +- Zero extra runtime dependencies — `pdfnative` remains the sole dependency. +- 100% backward-compatible with v1.0.0. + +## Notes + +- Sign-side LTV (embedding timestamps / `/DSS` at signing time) remains + upstream-blocked in pdfnative; `sign --timestamp` stays reserved and errors + clearly. See [ROADMAP.md](../ROADMAP.md) and [SECURITY.md](../SECURITY.md). +- The agent contract adds **no network surface**: `--json` and `--dry-run` are + pure local presentation/validation. The CLI stays offline by default. diff --git a/samples/README.md b/samples/README.md index fa82d4d..82e3af3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -53,7 +53,7 @@ pdfnative render ` samples/ ├── run-all.js Cross-platform batch renderer (Node.js ≥ 20) ├── render/ JSON payloads for pdfnative render -│ ├── document/ General-purpose documents +│ ├── document/ General-purpose documents (06-max-blocks.* = --max-blocks guard, v1.1.0) │ ├── table/ Table-heavy layouts │ ├── barcode/ QR codes, Code 128, EAN-13 │ ├── form/ Interactive PDF form fields @@ -74,24 +74,41 @@ samples/ │ │ └── 04-multilingual.js Node.js driver: registerFonts(th,ja,ar,ru) → render 04-multilingual.json │ ├── table-variant/ (v0.2.0) Table-centric PdfParams (--variant table) │ ├── font/ (v0.3.0) `--font` / `--lang` flag demo (latin preset) +│ │ ├── 01-latin.* Latin preset shortcut +│ │ ├── 02-new-scripts.* (v1.1.0) Six new 1.3.0 scripts + COLRv1 colour emoji +│ │ └── 03-emoji.* (v1.1.0) Monochrome emoji preset (`--font emoji`) │ ├── template/ (v0.3.0) `--template` deep-merge demo (base + override) │ ├── watch/ (v0.3.0) `--watch` interactive auto-rebuild demo │ └── table-smart/ (v1.0.0) Smart tables: zebra, caption, repeat-header, wrap ├── batch/ (v1.0.0) Parallel directory render (pdfnative batch) +├── agent/ (v1.1.0) Agent-native contract: --json envelope, --dry-run, schema +│ ├── 01-json-and-dry-run.* --json status envelope + --dry-run validation +│ ├── 02-schema.* `schema` command — versioned JSON Schemas +│ ├── 03-error-envelope.* Deterministic failures (stable E_* error codes) +│ └── 04-token-economy.* ~90% smaller output via --summary / --fields / compact JSON ├── completion/ (v1.0.0) Shell-completion script generation ├── config/ (v1.0.0) `.pdfnativerc.json` default-flags demo ├── sign/ Digital signature shell / PowerShell scripts │ ├── 01-basic.* Self-signed RSA-SHA256 sign │ ├── 02-with-metadata.* (v0.2.0) Sign with reason / location / signing-time │ ├── 03-ecdsa.* (v0.3.0) P-256 ECDSA-SHA256 sign -│ └── 04-roundtrip.* (v0.3.0) render → sign → verify pipeline +│ ├── 04-roundtrip.* (v0.3.0) render → sign → verify pipeline +│ ├── 05-cert-chain.* (v1.1.0) Root-CA → signer chain via --cert-chain + verify --trust +│ └── 06-timestamp-reserved.* (v1.1.0) --timestamp is reserved → exit 2 (E_UNSUPPORTED) ├── inspect/ PDF inspection shell / PowerShell scripts +│ ├── 01-json.* JSON metadata report +│ ├── 02-text.* Human-readable text report +│ ├── 03-verbose-pages.* Per-page detail + verbose trailer/catalog keys +│ ├── 04-check-pdfa.* CI gate: assert PDF/A conformance +│ ├── 05-pdfua.* (v1.1.0) PDF/UA (ISO 14289-1) structural validation gate +│ └── 06-check-signed-encrypted.* (v1.1.0) CI gates for --check signed / --check encrypted ├── verify/ Signature verification shell / PowerShell scripts │ ├── 01-self-signed.* (v0.2.0) Verify a self-signed PDF │ ├── 02-strict-mode.* (v0.2.0) `--strict` exits non-zero on failure │ ├── 03-cms-rsa.* (v0.3.0) Verify CMS RSA-SHA256 signature value │ ├── 04-cms-ecdsa.* (v0.3.0) Verify CMS ECDSA-SHA256 signature value -│ └── 05-revocation.* (v1.0.0) OCSP/CRL revocation + timestamp (PAdES-T) +│ ├── 05-revocation.* (v1.0.0) OCSP/CRL revocation + timestamp (PAdES-T) +│ └── 06-online-revocation.* (v1.1.0) Offline default + commented SSRF-guarded online variant └── streaming/ Streaming render Node.js scripts ``` @@ -108,6 +125,9 @@ samples/ | [03-all-blocks.json](render/document/03-all-blocks.json) | **Reference** — every block type in one document (heading, paragraph, list, table, barcode, link, form fields, page break, spacer) | | [04-invoice.json](render/document/04-invoice.json) | Invoice with line-item table, totals, and company branding | | [05-technical-spec.json](render/document/05-technical-spec.json) | Technical specification with multi-level headings and code-style paragraphs | +| [06-max-blocks.json](render/document/06-max-blocks.json) | (v1.1.0) Document body for the `--max-blocks` large-report guard demo | +| [06-max-blocks.sh](render/document/06-max-blocks.sh) | (v1.1.0) Render with a generous `--max-blocks 10000`, then trip the guard with `--max-blocks 3` | +| [06-max-blocks.ps1](render/document/06-max-blocks.ps1) | (v1.1.0) PowerShell equivalent | ### `render/table/` — Tables @@ -149,8 +169,11 @@ samples/ |------|-------------| | [01-draft.json](render/watermark/01-draft.json) | Document with DRAFT status indicator (heading + footer) | | [02-confidential.json](render/watermark/02-confidential.json) | Document with CONFIDENTIAL status indicator (heading + footer) | +| [03-cli-flags.json](render/watermark/03-cli-flags.json) | (v1.1.0) Document body for the CLI watermark-flag demo | +| [03-cli-flags.sh](render/watermark/03-cli-flags.sh) | (v1.1.0) Overlay a real watermark via `--watermark-text/-opacity/-angle/-color/-font-size/-position` | +| [03-cli-flags.ps1](render/watermark/03-cli-flags.ps1) | (v1.1.0) PowerShell equivalent | -**Note:** Visual watermark overlays are supported by `pdfnative` but not yet exposed through the CLI JSON interface. These samples demonstrate using heading and footer text to indicate document status. For programmatic watermarks, use the `pdfnative` Node.js API directly: +**Note:** Visual watermark overlays are exposed through the CLI via the `--watermark-text` / `--watermark-image` flags (plus `--watermark-opacity`, `--watermark-angle`, `--watermark-color`, `--watermark-font-size`, `--watermark-position`) — see `03-cli-flags.*`. The `01`/`02` samples instead show the heading/footer-text approach for status indication. Watermarks can also be set programmatically: ```typescript import { buildDocumentPDFBytes } from 'pdfnative'; const pdf = buildDocumentPDFBytes(params, { @@ -173,6 +196,7 @@ const pdf = buildDocumentPDFBytes(params, { | [01-pdfa-1b.json](render/pdfa/01-pdfa-1b.json) | PDF/A-1b | ISO 19005-1 — baseline archival | | [02-pdfa-2b.json](render/pdfa/02-pdfa-2b.json) | PDF/A-2b | ISO 19005-2 — transparency, JPEG 2000 | | [03-pdfa-3b.json](render/pdfa/03-pdfa-3b.json) | PDF/A-3b | ISO 19005-3 — embedded file attachments | +| [04-pdfa-2u.json](render/pdfa/04-pdfa-2u.json) | PDF/A-2u | ISO 19005-2 — every glyph mapped to Unicode | PDF/A conformance can also be set from the CLI via the `--tagged` flag (or the deprecated `--conformance` alias) instead of the JSON `layout.tagged` field: @@ -191,6 +215,9 @@ pdfnative render --input doc.json --output doc.pdf --conformance 2b | [01-aes128-protected.json](render/encryption/01-aes128-protected.json) | Document body for an AES-128 encrypted PDF | | [01-aes128-protected.sh](render/encryption/01-aes128-protected.sh) | Bash driver — sets `$PDFNATIVE_ENCRYPT_OWNER_PASS` / `$PDFNATIVE_ENCRYPT_USER_PASS` and calls `--encrypt-algorithm aes128 --encrypt-permissions print` | | [01-aes128-protected.ps1](render/encryption/01-aes128-protected.ps1) | PowerShell equivalent | +| [02-aes256-protected.json](render/encryption/02-aes256-protected.json) | (v1.1.0) Document body for an AES-256 encrypted PDF | +| [02-aes256-protected.sh](render/encryption/02-aes256-protected.sh) | (v1.1.0) Bash driver — `--encrypt-algorithm aes256 --encrypt-permissions print` | +| [02-aes256-protected.ps1](render/encryption/02-aes256-protected.ps1) | (v1.1.0) PowerShell equivalent | **Security:** owner / user passwords are read from environment variables first, then `--encrypt-owner-pass` / `--encrypt-user-pass` flags. Encryption is mutually exclusive with `--tagged pdfa*` (ISO 19005 forbids encrypted PDF/A). @@ -322,8 +349,14 @@ const pdf = buildDocumentPDFBytes({ | [01-latin.json](render/font/01-latin.json) | Plain Latin-1 document body | | [01-latin.sh](render/font/01-latin.sh) | Renders with `--font latin --lang latin` | | [01-latin.ps1](render/font/01-latin.ps1) | PowerShell equivalent | +| [02-new-scripts.json](render/font/02-new-scripts.json) | (v1.1.0) Six 1.3.0 scripts (Telugu, Sinhala, Khmer, Burmese, Tibetan, Amharic) + COLRv1 colour emoji | +| [02-new-scripts.sh](render/font/02-new-scripts.sh) | (v1.1.0) Renders the new scripts with per-script `--font`/`--lang` flags | +| [02-new-scripts.ps1](render/font/02-new-scripts.ps1) | (v1.1.0) PowerShell equivalent | +| [03-emoji.json](render/font/03-emoji.json) | (v1.1.0) Monochrome emoji document body | +| [03-emoji.sh](render/font/03-emoji.sh) | (v1.1.0) Renders with `--font emoji --lang emoji` | +| [03-emoji.ps1](render/font/03-emoji.ps1) | (v1.1.0) PowerShell equivalent | -The `--font` and `--lang` flags select a preset from pdfnative's bundled font registry without requiring a `registerFonts` driver script. `latin` is the safe baseline; non-Latin presets still need a driver (see `render/multilang/`). +The `--font` and `--lang` flags select a preset (or, repeated, multiple scripts) from pdfnative's bundled font registry without requiring a `registerFonts` driver script. `latin` is the safe baseline; non-Latin presets can be selected directly by code (`te`, `si`, `km`, `my`, `bo`, `am`, `emoji`, `color-emoji`, …). ### `render/template/` — `--template` Deep Merge (v0.3.0) @@ -371,6 +404,10 @@ Demonstrate the `pdfnative sign` command. Both Unix shell and PowerShell scripts | [sign/03-ecdsa.ps1](sign/03-ecdsa.ps1) | (v0.3.0) PowerShell equivalent | | [sign/04-roundtrip.sh](sign/04-roundtrip.sh) | (v0.3.0) Full **render → sign → verify** pipeline; asserts `signatureValid: true` via `jq` | | [sign/04-roundtrip.ps1](sign/04-roundtrip.ps1) | (v0.3.0) PowerShell equivalent | +| [sign/05-cert-chain.sh](sign/05-cert-chain.sh) | (v1.1.0) Build a root-CA → signer chain, sign with `--cert-chain`, then `verify --trust ` | +| [sign/05-cert-chain.ps1](sign/05-cert-chain.ps1) | (v1.1.0) PowerShell equivalent | +| [sign/06-timestamp-reserved.sh](sign/06-timestamp-reserved.sh) | (v1.1.0) Shows `--timestamp` is reserved and exits 2 (`E_UNSUPPORTED`) — sign-side LTV is upstream-blocked | +| [sign/06-timestamp-reserved.ps1](sign/06-timestamp-reserved.ps1) | (v1.1.0) PowerShell equivalent | **Prerequisites:** `openssl` on your PATH (ships with Git for Windows). @@ -400,6 +437,10 @@ Demonstrate the `pdfnative inspect` command. | [inspect/03-verbose-pages.ps1](inspect/03-verbose-pages.ps1) | (v0.2.0) PowerShell equivalent | | [inspect/04-check-pdfa.sh](inspect/04-check-pdfa.sh) | (v0.2.0) Assert PDF/A conformance via `--check pdfa` (CI-friendly exit code) | | [inspect/04-check-pdfa.ps1](inspect/04-check-pdfa.ps1) | (v0.2.0) PowerShell equivalent | +| [inspect/05-pdfua.sh](inspect/05-pdfua.sh) | (v1.1.0) PDF/UA (ISO 14289-1) structural validation gate via `--check pdfua` | +| [inspect/05-pdfua.ps1](inspect/05-pdfua.ps1) | (v1.1.0) PowerShell equivalent | +| [inspect/06-check-signed-encrypted.sh](inspect/06-check-signed-encrypted.sh) | (v1.1.0) CI gates for `--check encrypted` (PASS) and `--check signed` (FAIL on unsigned) | +| [inspect/06-check-signed-encrypted.ps1](inspect/06-check-signed-encrypted.ps1) | (v1.1.0) PowerShell equivalent | --- @@ -419,6 +460,8 @@ Demonstrate the `pdfnative verify` command — verifies CMS/PKCS#7 signatures em | [verify/04-cms-ecdsa.ps1](verify/04-cms-ecdsa.ps1) | (v0.3.0) PowerShell equivalent | | [verify/05-revocation.sh](verify/05-revocation.sh) | (v1.0.0) Revocation checking — `--revocation offline\|online` + `--revocation-policy strict\|soft-fail` (OCSP/CRL + PAdES-T timestamp) | | [verify/05-revocation.ps1](verify/05-revocation.ps1) | (v1.0.0) PowerShell equivalent | +| [verify/06-online-revocation.sh](verify/06-online-revocation.sh) | (v1.1.0) Offline-by-default verify, with a commented SSRF-guarded `--revocation online` variant | +| [verify/06-online-revocation.ps1](verify/06-online-revocation.ps1) | (v1.1.0) PowerShell equivalent | **Scope (v1.0.0):** verify checks **integrity** (byte-range SHA-256), **CMS signature value** (RSA-PKCS#1 v1.5 SHA-256 and ECDSA-SHA256 over P-256), **certificate chain signatures**, **trust** (against `--trust ` PEM roots, or self-signed acceptance), **RFC 3161 timestamp validation (PAdES-T)**, and **OCSP (RFC 6960) + CRL (RFC 5280) revocation** — embedded from the PDF `/DSS` offline by default, with opt-in SSRF-guarded online fetching via `--revocation online`. Sign-side LTV (embedding timestamps/DSS at signing time) is upstream-blocked in pdfnative — see [ROADMAP.md](../ROADMAP.md) and [SECURITY.md](../SECURITY.md#network-access--revocation-checking). @@ -432,6 +475,8 @@ Demonstrate the `pdfnative batch` command — renders every `*.json` in a direct |--------|-------------| | [batch/01-batch.sh](batch/01-batch.sh) | Batch-render `render/document/*.json` with `--concurrency 4 --compress` (Bash) | | [batch/01-batch.ps1](batch/01-batch.ps1) | PowerShell equivalent | +| [batch/02-fail-fast.sh](batch/02-fail-fast.sh) | (v1.1.0) `--fail-fast` aborts on the first failure (one valid + one invalid input); asserts non-zero exit | +| [batch/02-fail-fast.ps1](batch/02-fail-fast.ps1) | (v1.1.0) PowerShell equivalent | Render flags other than `--input-dir` / `--output-dir` / `--concurrency` / `--fail-fast` / `--format` are forwarded to every file. @@ -477,10 +522,16 @@ Demonstrates piping a large JSON payload directly to `pdfnative render --stream` | Script | Description | |--------|-------------| -| [streaming/01-large-document.js](streaming/01-large-document.js) | Generates a 200-section document via the streaming render path | +| [streaming/01-large-document.js](streaming/01-large-document.js) | Generates a 200-section document via the `--stream` render path | +| [streaming/02-page-by-page.sh](streaming/02-page-by-page.sh) | (v1.0.0) `--stream-page-by-page` — emit one page at a time (bounded memory; no TOC) | +| [streaming/02-page-by-page.ps1](streaming/02-page-by-page.ps1) | PowerShell equivalent | +| [streaming/03-true-streaming.sh](streaming/03-true-streaming.sh) | (v1.1.0) `--stream-true` — fully incremental streaming render | +| [streaming/03-true-streaming.ps1](streaming/03-true-streaming.ps1) | PowerShell equivalent | ```bash node samples/streaming/01-large-document.js +bash samples/streaming/02-page-by-page.sh +bash samples/streaming/03-true-streaming.sh ``` --- diff --git a/samples/agent/01-json-and-dry-run.ps1 b/samples/agent/01-json-and-dry-run.ps1 new file mode 100644 index 0000000..a6f7431 --- /dev/null +++ b/samples/agent/01-json-and-dry-run.ps1 @@ -0,0 +1,17 @@ +# agent/01-json-and-dry-run.ps1 — agent mode: --json status envelope + --dry-run + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent $ScriptDir +$Input = Join-Path $RootDir 'render\document\01-minimal.json' + +Write-Host '→ Dry-run validation (no file written); status envelope on stderr:' +& pdfnative render --input $Input --dry-run --json | Out-Null + +Write-Host '' +Write-Host '→ Real render to a file; success envelope on stderr:' +$OutDir = Join-Path $RootDir 'output\agent' +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +& pdfnative render --input $Input --output (Join-Path $OutDir '01-minimal.pdf') --json +Write-Host ' (envelope above carries { ok, command, output, bytes })' diff --git a/samples/agent/01-json-and-dry-run.sh b/samples/agent/01-json-and-dry-run.sh new file mode 100644 index 0000000..f3e5d50 --- /dev/null +++ b/samples/agent/01-json-and-dry-run.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# agent/01-json-and-dry-run.sh — agent mode: --json status envelope + --dry-run +# +# In agent mode (--json) the CLI keeps the primary artifact on stdout and emits +# a single JSON status/error envelope on stderr. --dry-run validates the input +# and exits 0 WITHOUT writing any output. Both are designed for autonomous AI +# agents and CI pipelines that branch on machine-readable results. +# +# Usage: bash samples/agent/01-json-and-dry-run.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INPUT="$ROOT_DIR/render/document/01-minimal.json" + +echo "→ Dry-run validation (no file written); status envelope on stderr:" +# stdout (the PDF) is discarded; the JSON envelope is on stderr. +pdfnative render --input "$INPUT" --dry-run --json >/dev/null + +echo +echo "→ Real render to a file; success envelope on stderr:" +OUT_DIR="$ROOT_DIR/output/agent" +mkdir -p "$OUT_DIR" +pdfnative render --input "$INPUT" --output "$OUT_DIR/01-minimal.pdf" --json +echo " (envelope above carries { ok, command, output, bytes })" diff --git a/samples/agent/02-schema.ps1 b/samples/agent/02-schema.ps1 new file mode 100644 index 0000000..8ce9ef8 --- /dev/null +++ b/samples/agent/02-schema.ps1 @@ -0,0 +1,14 @@ +# agent/02-schema.ps1 — discover input/output shapes via the schema command + +$ErrorActionPreference = 'Stop' + +Write-Host '→ Available schema subjects:' +& pdfnative schema list + +Write-Host '' +Write-Host '→ render input schema (default subject):' +& pdfnative schema render + +Write-Host '' +Write-Host '→ inspect output schema:' +& pdfnative schema inspect diff --git a/samples/agent/02-schema.sh b/samples/agent/02-schema.sh new file mode 100644 index 0000000..87d81f8 --- /dev/null +++ b/samples/agent/02-schema.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# agent/02-schema.sh — discover input/output shapes via the schema command +# +# Agents can fetch a versioned JSON Schema (Draft 2020-12) for any CLI +# input/output shape and self-validate BEFORE invoking a command. Schemas carry +# a $id that embeds the CLI version so drift is detectable. +# +# Usage: bash samples/agent/02-schema.sh + +set -euo pipefail + +echo "→ Available schema subjects:" +pdfnative schema list + +echo +echo "→ render input schema (default subject):" +pdfnative schema render + +echo +echo "→ inspect output schema:" +pdfnative schema inspect diff --git a/samples/agent/03-error-envelope.ps1 b/samples/agent/03-error-envelope.ps1 new file mode 100644 index 0000000..3800592 --- /dev/null +++ b/samples/agent/03-error-envelope.ps1 @@ -0,0 +1,12 @@ +# agent/03-error-envelope.ps1 — deterministic failures via the JSON error envelope + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path + +Write-Host '→ Feeding non-PDF bytes to inspect --json (expect E_PARSE on stderr):' +'not a pdf' | & pdfnative inspect --json +Write-Host " (exit code: $LASTEXITCODE)" + +Write-Host '' +Write-Host '→ Unknown schema subject (expect E_USAGE, exit 2):' +& pdfnative schema bogus --json +Write-Host " (exit code: $LASTEXITCODE)" diff --git a/samples/agent/03-error-envelope.sh b/samples/agent/03-error-envelope.sh new file mode 100644 index 0000000..b6a0f35 --- /dev/null +++ b/samples/agent/03-error-envelope.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# agent/03-error-envelope.sh — deterministic failures via the JSON error envelope +# +# In agent mode (--json) every failure produces a single JSON object on stderr: +# { "ok": false, "command": "...", "error": { "code": "E_*", "message": "..." } } +# The stable `code` lets an agent branch on the failure class without parsing +# the human-readable message. Numeric exit codes (0/1/2) are unchanged. +# +# Usage: bash samples/agent/03-error-envelope.sh + +set -uo pipefail + +echo "→ Feeding non-PDF bytes to inspect --json (expect E_PARSE on stderr):" +printf 'not a pdf' | pdfnative inspect --json || echo " (exit code: $?)" + +echo +echo "→ Unknown schema subject (expect E_USAGE, exit 2):" +pdfnative schema bogus --json || echo " (exit code: $?)" diff --git a/samples/agent/04-token-economy.ps1 b/samples/agent/04-token-economy.ps1 new file mode 100644 index 0000000..dc9ca57 --- /dev/null +++ b/samples/agent/04-token-economy.ps1 @@ -0,0 +1,33 @@ +# agent/04-token-economy.ps1 — shrink agent output ~90% with --summary / --fields +# +# The JSON that inspect/verify/batch write to stdout is the bulk of an agent's +# token cost. Three composable levers cut it dramatically: +# 1. compact JSON — automatic under --json (--pretty opts back in) +# 2. --summary — a canonical minimal verdict +# 3. --fields a,b — keep only named dot-paths + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent $ScriptDir +$Input = Join-Path $RootDir 'render/document/01-minimal.json' + +$OutDir = Join-Path $RootDir 'output/agent' +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$Pdf = Join-Path $OutDir '04-token-economy.pdf' +& pdfnative render --input $Input --output $Pdf | Out-Null + +Write-Host '→ Full inspect report (pretty, human form):' +(& pdfnative inspect --input $Pdf | Out-String).Substring(0, 400) + '…' + +Write-Host '' +Write-Host '→ Same probe, agent summary (compact, minimal verdict):' +& pdfnative inspect --input $Pdf --json --summary + +Write-Host '' +Write-Host '→ Just the two fields an agent needs:' +& pdfnative inspect --input $Pdf --json --fields pageCount,signatures + +Write-Host '' +Write-Host '→ verify minimal verdict:' +& pdfnative verify --input $Pdf --json --summary diff --git a/samples/agent/04-token-economy.sh b/samples/agent/04-token-economy.sh new file mode 100644 index 0000000..df57fb7 --- /dev/null +++ b/samples/agent/04-token-economy.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# agent/04-token-economy.sh — shrink agent output ~90% with --summary / --fields +# +# The JSON that inspect/verify/batch write to stdout is the bulk of an agent's +# token cost. Three composable levers cut it dramatically without losing the +# fields an orchestrator branches on: +# 1. compact JSON — automatic under --json (--pretty opts back in) +# 2. --summary — a canonical minimal verdict +# 3. --fields a,b — keep only named dot-paths +# +# Usage: bash samples/agent/04-token-economy.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +INPUT="$ROOT_DIR/render/document/01-minimal.json" + +OUT_DIR="$ROOT_DIR/output/agent" +mkdir -p "$OUT_DIR" +PDF="$OUT_DIR/04-token-economy.pdf" +pdfnative render --input "$INPUT" --output "$PDF" >/dev/null + +echo "→ Full inspect report (pretty, human form):" +pdfnative inspect --input "$PDF" | head -c 400; echo '…' + +echo +echo "→ Same probe, agent summary (compact, minimal verdict):" +pdfnative inspect --input "$PDF" --json --summary + +echo +echo "→ Just the two fields an agent needs:" +pdfnative inspect --input "$PDF" --json --fields pageCount,signatures + +echo +echo "→ verify minimal verdict:" +pdfnative verify --input "$PDF" --json --summary diff --git a/samples/batch/02-fail-fast.ps1 b/samples/batch/02-fail-fast.ps1 new file mode 100644 index 0000000..a41c798 --- /dev/null +++ b/samples/batch/02-fail-fast.ps1 @@ -0,0 +1,37 @@ +# batch/02-fail-fast.ps1 — stop a batch at the first failure (v1.0.0) +# +# With --fail-fast the batch aborts as soon as one render fails — what you want +# in CI. Builds a scratch input dir with one valid + one invalid document, runs +# the batch with --fail-fast, and asserts a non-zero exit. +# +# Usage: +# pwsh -File samples\batch\02-fail-fast.ps1 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent $ScriptDir +$Base = Join-Path $RootDir 'output\batch-failfast' +$InDir = Join-Path $Base 'in' +$OutDir = Join-Path $Base 'out' + +if (Test-Path $Base) { Remove-Item -Recurse -Force $Base } +New-Item -ItemType Directory -Force -Path $InDir, $OutDir | Out-Null + +Copy-Item (Join-Path $RootDir 'render\document\01-minimal.json') (Join-Path $InDir '01-ok.json') +Set-Content -Path (Join-Path $InDir '02-broken.json') -Value '{ this is not valid json' -NoNewline + +Write-Host '→ Batch with --fail-fast over one valid + one invalid input:' +& pdfnative batch ` + --input-dir $InDir ` + --output-dir $OutDir ` + --fail-fast ` + --format json +$status = $LASTEXITCODE + +Write-Host '' +Write-Host " exit code: $status (expected non-zero)" +if ($status -ne 0) { + Write-Host ' ✓ PASS — batch aborted on the first failure.' +} else { + Write-Host ' ✗ UNEXPECTED — batch did not fail.' + exit 1 +} diff --git a/samples/batch/02-fail-fast.sh b/samples/batch/02-fail-fast.sh new file mode 100644 index 0000000..0e069d2 --- /dev/null +++ b/samples/batch/02-fail-fast.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# batch/02-fail-fast.sh — stop a batch at the first failure (v1.0.0) +# +# By default `batch` renders every file then reports failures. With --fail-fast +# it aborts as soon as one render fails, which is what you want in CI. This +# sample builds a scratch input directory containing one valid and one invalid +# document, runs the batch with --fail-fast, and asserts a non-zero exit. +# +# Usage: +# bash samples/batch/02-fail-fast.sh + +set -uo pipefail # not -e: we expect the batch to exit non-zero + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +IN_DIR="$ROOT_DIR/output/batch-failfast/in" +OUT_DIR="$ROOT_DIR/output/batch-failfast/out" + +rm -rf "$ROOT_DIR/output/batch-failfast" +mkdir -p "$IN_DIR" "$OUT_DIR" + +# A valid document… +cp "$ROOT_DIR/render/document/01-minimal.json" "$IN_DIR/01-ok.json" +# …and a deliberately invalid one (not a JSON document definition). +printf '{ this is not valid json' > "$IN_DIR/02-broken.json" + +echo "→ Batch with --fail-fast over one valid + one invalid input:" +pdfnative batch \ + --input-dir "$IN_DIR" \ + --output-dir "$OUT_DIR" \ + --fail-fast \ + --format json +STATUS=$? + +echo "" +echo " exit code: $STATUS (expected non-zero)" +if [ "$STATUS" -ne 0 ]; then + echo " ✓ PASS — batch aborted on the first failure." +else + echo " ✗ UNEXPECTED — batch did not fail." + exit 1 +fi diff --git a/samples/inspect/05-pdfua.ps1 b/samples/inspect/05-pdfua.ps1 new file mode 100644 index 0000000..3b6e4cd --- /dev/null +++ b/samples/inspect/05-pdfua.ps1 @@ -0,0 +1,25 @@ +# inspect/05-pdfua.ps1 — PDF/UA (ISO 14289-1) structural validation (pdfnative 1.3.0) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output' +$UaPdf = Join-Path $OutputDir 'inspect\05-tagged.pdf' + +New-Item -ItemType Directory -Force -Path (Split-Path $UaPdf) | Out-Null +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\01-minimal.json') ` + --output $UaPdf ` + --tagged pdfa2b + +Write-Host '→ PDF/UA structural report (JSON):' +& pdfnative inspect --input $UaPdf --pdfua --format json + +Write-Host '→ PDF/UA accessibility gate (--check pdfua):' +& pdfnative inspect --input $UaPdf --check pdfua --format text +if ($LASTEXITCODE -eq 0) { + Write-Host ' ✓ PASS — structural prerequisites satisfied.' +} else { + Write-Host ' ✗ FAIL — PDF/UA structural prerequisites not met (exit 1).' +} diff --git a/samples/inspect/05-pdfua.sh b/samples/inspect/05-pdfua.sh new file mode 100644 index 0000000..450f1a2 --- /dev/null +++ b/samples/inspect/05-pdfua.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# inspect/05-pdfua.sh — PDF/UA (ISO 14289-1) structural validation (pdfnative 1.3.0) +# +# Renders a tagged (PDF/UA-oriented) document, then runs the read-only PDF/UA +# structural validator. `--pdfua` adds a { valid, errors, warnings } report to +# the output; `--check pdfua` turns it into a CI accessibility gate (exit 1 when +# the structural prerequisites fail). +# +# Usage: bash samples/inspect/05-pdfua.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output" +UA_PDF="$OUTPUT_DIR/inspect/05-tagged.pdf" + +mkdir -p "$OUTPUT_DIR/inspect" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --output "$UA_PDF" \ + --tagged pdfa2b + +echo "→ PDF/UA structural report (JSON):" +pdfnative inspect --input "$UA_PDF" --pdfua --format json + +echo "→ PDF/UA accessibility gate (--check pdfua):" +if pdfnative inspect --input "$UA_PDF" --check pdfua --format text; then + echo " ✓ PASS — structural prerequisites satisfied." +else + echo " ✗ FAIL — PDF/UA structural prerequisites not met (exit 1)." +fi diff --git a/samples/inspect/06-check-signed-encrypted.ps1 b/samples/inspect/06-check-signed-encrypted.ps1 new file mode 100644 index 0000000..67ae9fe --- /dev/null +++ b/samples/inspect/06-check-signed-encrypted.ps1 @@ -0,0 +1,43 @@ +# inspect/06-check-signed-encrypted.ps1 — CI gates for --check signed / encrypted +# +# `inspect --check ` exits non-zero when the assertion fails, so it +# can gate a pipeline. Exercises --check encrypted (PASS), --check signed on an +# unsigned PDF (FAIL → exit 1), and --check signed on a signed PDF if present. +# +# Usage: +# pwsh -File samples\inspect\06-check-signed-encrypted.ps1 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\inspect' +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +$PlainPdf = Join-Path $OutputDir '06-plain.pdf' +$EncPdf = Join-Path $OutputDir '06-encrypted.pdf' +$SignedPdf = Join-Path $RootDir 'samples\output\sign\01-basic-signed.pdf' +$Minimal = Join-Path $RootDir 'samples\render\document\01-minimal.json' + +& pdfnative render --input $Minimal --output $PlainPdf + +if (-not $env:PDFNATIVE_ENCRYPT_OWNER_PASS) { $env:PDFNATIVE_ENCRYPT_OWNER_PASS = 'owner-secret' } +if (-not $env:PDFNATIVE_ENCRYPT_USER_PASS) { $env:PDFNATIVE_ENCRYPT_USER_PASS = 'open-sesame' } +& pdfnative render --input $Minimal --output $EncPdf --encrypt-algorithm aes128 + +Write-Host '→ --check encrypted on the encrypted PDF:' +& pdfnative inspect --input $EncPdf --check encrypted --format text | Out-Null +if ($LASTEXITCODE -eq 0) { Write-Host ' ✓ PASS — document is encrypted.' } +else { Write-Host ' ✗ FAIL — expected encrypted.'; exit 1 } + +Write-Host '→ --check signed on the unsigned PDF (expected to fail):' +& pdfnative inspect --input $PlainPdf --check signed --format text | Out-Null +if ($LASTEXITCODE -ne 0) { Write-Host ' ✓ PASS — gate correctly rejected the unsigned document (exit 1).' } +else { Write-Host ' ✗ UNEXPECTED — unsigned document passed --check signed.'; exit 1 } + +if (Test-Path $SignedPdf) { + Write-Host "→ --check signed on $SignedPdf :" + & pdfnative inspect --input $SignedPdf --check signed --format text | Out-Null + if ($LASTEXITCODE -eq 0) { Write-Host ' ✓ PASS — document is signed.' } + else { Write-Host ' ✗ FAIL — expected signed.'; exit 1 } +} else { + Write-Host '→ (skip) run samples\sign\01-basic.ps1 to also exercise --check signed PASS.' +} diff --git a/samples/inspect/06-check-signed-encrypted.sh b/samples/inspect/06-check-signed-encrypted.sh new file mode 100644 index 0000000..9f3d539 --- /dev/null +++ b/samples/inspect/06-check-signed-encrypted.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# inspect/06-check-signed-encrypted.sh — CI gates for --check signed / encrypted +# +# `inspect --check ` exits non-zero when the assertion fails, so it +# can gate a pipeline. This sample exercises two checks: +# - --check encrypted on a freshly encrypted PDF (expected PASS) +# - --check signed on an unsigned PDF (expected FAIL → exit 1) +# - --check signed on a signed PDF if present (expected PASS) +# +# Usage: +# bash samples/inspect/06-check-signed-encrypted.sh + +set -uo pipefail # not -e: we deliberately observe a failing check below + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/inspect" +mkdir -p "$OUTPUT_DIR" + +PLAIN_PDF="$OUTPUT_DIR/06-plain.pdf" +ENC_PDF="$OUTPUT_DIR/06-encrypted.pdf" +SIGNED_PDF="$ROOT_DIR/samples/output/sign/01-basic-signed.pdf" + +# ── Render a plain and an encrypted PDF ──────────────────────────────────── +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --output "$PLAIN_PDF" + +PDFNATIVE_ENCRYPT_OWNER_PASS="${PDFNATIVE_ENCRYPT_OWNER_PASS:-owner-secret}" \ +PDFNATIVE_ENCRYPT_USER_PASS="${PDFNATIVE_ENCRYPT_USER_PASS:-open-sesame}" \ +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --output "$ENC_PDF" \ + --encrypt-algorithm aes128 + +# ── Gate 1: encrypted PDF should pass --check encrypted ──────────────────── +echo "→ --check encrypted on the encrypted PDF:" +if pdfnative inspect --input "$ENC_PDF" --check encrypted --format text >/dev/null; then + echo " ✓ PASS — document is encrypted." +else + echo " ✗ FAIL — expected the document to be encrypted."; exit 1 +fi + +# ── Gate 2: unsigned PDF should FAIL --check signed (exit 1) ──────────────── +echo "→ --check signed on the unsigned PDF (expected to fail):" +if pdfnative inspect --input "$PLAIN_PDF" --check signed --format text >/dev/null; then + echo " ✗ UNEXPECTED — unsigned document passed --check signed."; exit 1 +else + echo " ✓ PASS — gate correctly rejected the unsigned document (exit 1)." +fi + +# ── Gate 3: signed PDF (if available) should pass --check signed ──────────── +if [ -f "$SIGNED_PDF" ]; then + echo "→ --check signed on $SIGNED_PDF:" + if pdfnative inspect --input "$SIGNED_PDF" --check signed --format text >/dev/null; then + echo " ✓ PASS — document is signed." + else + echo " ✗ FAIL — expected the document to be signed."; exit 1 + fi +else + echo "→ (skip) run samples/sign/01-basic.sh to also exercise --check signed PASS." +fi diff --git a/samples/render/document/06-max-blocks.json b/samples/render/document/06-max-blocks.json new file mode 100644 index 0000000..4b47ea5 --- /dev/null +++ b/samples/render/document/06-max-blocks.json @@ -0,0 +1,9 @@ +{ + "blocks": [ + { "type": "heading", "level": 1, "text": "Large-Report Guard Demo" }, + { "type": "paragraph", "text": "This document has five top-level blocks. The --max-blocks flag caps how many blocks pdfnative will lay out before it aborts, so a runaway or accidentally huge payload fails fast instead of exhausting memory." }, + { "type": "heading", "level": 2, "text": "Why cap blocks?" }, + { "type": "list", "items": ["Bound worst-case memory on untrusted input", "Fail fast in CI when a generator misbehaves", "Raise the ceiling for legitimate multi-thousand-page reports"] }, + { "type": "paragraph", "text": "Render this with a generous cap to succeed, or with a cap below five to watch the guard trip." } + ] +} diff --git a/samples/render/document/06-max-blocks.ps1 b/samples/render/document/06-max-blocks.ps1 new file mode 100644 index 0000000..7aa15a5 --- /dev/null +++ b/samples/render/document/06-max-blocks.ps1 @@ -0,0 +1,33 @@ +# render/document/06-max-blocks.ps1 — cap document blocks with --max-blocks +# +# --max-blocks exposes pdfnative's layout.maxBlocks ceiling (default 100000) +# so a very large or runaway document fails fast instead of exhausting memory. +# This sample renders the same 5-block input twice: once with a generous cap +# (succeeds) and once with a cap below the block count (pdfnative aborts). +# +# Usage: +# pwsh -File samples\render\document\06-max-blocks.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $ScriptDir)) +$Input = Join-Path $RootDir 'samples\render\document\06-max-blocks.json' +$OutputDir = Join-Path $RootDir 'samples\output\document' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ Rendering with a generous cap (--max-blocks 10000)…' +& pdfnative render ` + --input $Input ` + --output (Join-Path $OutputDir '06-max-blocks.pdf') ` + --max-blocks 10000 +Write-Host " ✓ Output: $OutputDir\06-max-blocks.pdf" + +Write-Host '→ Re-rendering with a deliberately low cap (--max-blocks 3) — expected to fail…' +& pdfnative render --input $Input --output (Join-Path $env:TEMP 'max-blocks-overflow.pdf') --max-blocks 3 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Error ' ✗ Unexpected success — the guard should have tripped.' + exit 1 +} +Write-Host ' ✓ Guard tripped as expected (non-zero exit).' diff --git a/samples/render/document/06-max-blocks.sh b/samples/render/document/06-max-blocks.sh new file mode 100644 index 0000000..5046c53 --- /dev/null +++ b/samples/render/document/06-max-blocks.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# render/document/06-max-blocks.sh — cap document blocks with --max-blocks +# +# --max-blocks exposes pdfnative's layout.maxBlocks ceiling (default 100000) +# so a very large or runaway document fails fast instead of exhausting memory. +# This sample renders the same 5-block input twice: +# 1. with a generous cap (10000) → succeeds +# 2. with a cap below the block count (3) → pdfnative aborts (non-zero exit) +# +# Pattern: pin a sane upper bound in CI so an accidentally huge payload is +# rejected deterministically rather than running the host out of memory. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/render/document/06-max-blocks.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +INPUT="$ROOT_DIR/samples/render/document/06-max-blocks.json" +OUTPUT_DIR="$ROOT_DIR/samples/output/document" + +mkdir -p "$OUTPUT_DIR" + +echo "→ Rendering with a generous cap (--max-blocks 10000)…" +pdfnative render \ + --input "$INPUT" \ + --output "$OUTPUT_DIR/06-max-blocks.pdf" \ + --max-blocks 10000 +echo " ✓ Output: $OUTPUT_DIR/06-max-blocks.pdf" + +echo "→ Re-rendering with a deliberately low cap (--max-blocks 3) — expected to fail…" +if pdfnative render --input "$INPUT" --output /dev/null --max-blocks 3 2>/dev/null; then + echo " ✗ Unexpected success — the guard should have tripped." >&2 + exit 1 +else + echo " ✓ Guard tripped as expected (non-zero exit)." +fi diff --git a/samples/render/encryption/02-aes256-protected.json b/samples/render/encryption/02-aes256-protected.json new file mode 100644 index 0000000..e0147ee --- /dev/null +++ b/samples/render/encryption/02-aes256-protected.json @@ -0,0 +1,27 @@ +{ + "title": "AES-256 Encrypted Memo", + "blocks": [ + { "type": "heading", "text": "Strongly Encrypted Internal Memo", "level": 1 }, + { "type": "paragraph", "text": "This document is encrypted with AES-256 — the strongest cipher the CLI exposes. As with AES-128, opening it requires the user password, and the owner password grants unrestricted access." }, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "How encryption was applied", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "Algorithm: AES-256 (--encrypt-algorithm aes256)", + "Owner password sourced from $PDFNATIVE_ENCRYPT_OWNER_PASS", + "User password sourced from $PDFNATIVE_ENCRYPT_USER_PASS", + "Permissions: print allowed; copy/modify/extractText denied" + ]}, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "Choosing 128 vs 256", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "AES-128: broad reader compatibility, lower CPU cost", + "AES-256: maximum confidentiality for sensitive archives", + "Both rely on the same owner/user password model and permissions" + ]} + ], + "footerText": "CONFIDENTIAL — AES-256 Encrypted", + "metadata": { + "subject": "Encrypted document demonstrating --encrypt-algorithm aes256", + "keywords": "encryption, aes256, confidential, owner-password" + } +} diff --git a/samples/render/encryption/02-aes256-protected.ps1 b/samples/render/encryption/02-aes256-protected.ps1 new file mode 100644 index 0000000..9a4c699 --- /dev/null +++ b/samples/render/encryption/02-aes256-protected.ps1 @@ -0,0 +1,27 @@ +# render/encryption/02-aes256-protected.ps1 — render an AES-256 encrypted PDF +# +# Usage: +# pwsh -File samples\render\encryption\02-aes256-protected.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $ScriptDir)) +$OutputDir = Join-Path $RootDir 'samples\output\encryption' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +if (-not $env:PDFNATIVE_ENCRYPT_OWNER_PASS) { $env:PDFNATIVE_ENCRYPT_OWNER_PASS = 'owner-secret' } +if (-not $env:PDFNATIVE_ENCRYPT_USER_PASS) { $env:PDFNATIVE_ENCRYPT_USER_PASS = 'open-sesame' } + +Write-Host '→ Rendering AES-256 encrypted PDF…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\encryption\02-aes256-protected.json') ` + --output (Join-Path $OutputDir '02-aes256-protected.pdf') ` + --encrypt-algorithm aes256 ` + --encrypt-permissions 'print' + +Write-Host " ✓ Output: $OutputDir\02-aes256-protected.pdf" +Write-Host '' +Write-Host 'Verify it is encrypted:' +Write-Host " pdfnative inspect --input `"$OutputDir\02-aes256-protected.pdf`" --check encrypted" diff --git a/samples/render/encryption/02-aes256-protected.sh b/samples/render/encryption/02-aes256-protected.sh new file mode 100644 index 0000000..dee31fd --- /dev/null +++ b/samples/render/encryption/02-aes256-protected.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# render/encryption/02-aes256-protected.sh — render an AES-256 encrypted PDF +# +# Demonstrates: +# - --encrypt-algorithm aes256 (strongest cipher the CLI exposes) +# - --encrypt-owner-pass / --encrypt-user-pass (env-var precedence) +# - --encrypt-permissions print +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/render/encryption/02-aes256-protected.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/encryption" + +mkdir -p "$OUTPUT_DIR" + +# Secrets via environment (recommended) — fall back to demo values for the sample. +export PDFNATIVE_ENCRYPT_OWNER_PASS="${PDFNATIVE_ENCRYPT_OWNER_PASS:-owner-secret}" +export PDFNATIVE_ENCRYPT_USER_PASS="${PDFNATIVE_ENCRYPT_USER_PASS:-open-sesame}" + +echo "→ Rendering AES-256 encrypted PDF…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/encryption/02-aes256-protected.json" \ + --output "$OUTPUT_DIR/02-aes256-protected.pdf" \ + --encrypt-algorithm aes256 \ + --encrypt-permissions "print" + +echo " ✓ Output: $OUTPUT_DIR/02-aes256-protected.pdf" +echo "" +echo "Verify it is encrypted:" +echo " pdfnative inspect --input \"$OUTPUT_DIR/02-aes256-protected.pdf\" --check encrypted" diff --git a/samples/render/font/02-new-scripts.json b/samples/render/font/02-new-scripts.json new file mode 100644 index 0000000..b94b6a8 --- /dev/null +++ b/samples/render/font/02-new-scripts.json @@ -0,0 +1,26 @@ +{ + "title": "pdfnative 1.3.0 — new Unicode scripts + colour emoji", + "blocks": [ + { + "type": "heading", + "text": "Six new scripts in pdfnative 1.3.0", + "level": 1 + }, + { + "type": "paragraph", + "text": "Each line below is shaped with the bundled Noto font registered via the --font shortcut. pdfnative routes every code point to the font whose cmap covers it." + }, + { "type": "paragraph", "text": "Telugu (te): తెలుగు లిపి" }, + { "type": "paragraph", "text": "Sinhala (si): සිංහල අකුරු" }, + { "type": "paragraph", "text": "Khmer (km): អក្សរខ្មែរ" }, + { "type": "paragraph", "text": "Myanmar (my): မြန်မာအက္ခရာ" }, + { "type": "paragraph", "text": "Tibetan (bo): བོད་ཡིག" }, + { "type": "paragraph", "text": "Amharic / Ethiopic (am): የአማርኛ ፊደል" }, + { + "type": "heading", + "text": "COLRv1 colour emoji", + "level": 2 + }, + { "type": "paragraph", "text": "Colour emoji: 🎨 😀 🌍 🚀 ✅" } + ] +} diff --git a/samples/render/font/02-new-scripts.ps1 b/samples/render/font/02-new-scripts.ps1 new file mode 100644 index 0000000..edcd419 --- /dev/null +++ b/samples/render/font/02-new-scripts.ps1 @@ -0,0 +1,18 @@ +# render/font/02-new-scripts.ps1 — six new scripts + colour emoji (pdfnative 1.3.0) + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $ScriptDir)) +$OutDir = Join-Path $RootDir 'samples\output\font' + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +& pdfnative render ` + --input (Join-Path $ScriptDir '02-new-scripts.json') ` + --output (Join-Path $OutDir '02-new-scripts.pdf') ` + --font te --font si --font km --font my --font bo --font am ` + --font color-emoji ` + --lang te,si,km,my,bo,am,color-emoji + +Write-Host "✓ Rendered: $(Join-Path $OutDir '02-new-scripts.pdf')" diff --git a/samples/render/font/02-new-scripts.sh b/samples/render/font/02-new-scripts.sh new file mode 100644 index 0000000..3451dc7 --- /dev/null +++ b/samples/render/font/02-new-scripts.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# render/font/02-new-scripts.sh — six new scripts + colour emoji (pdfnative 1.3.0) +# +# Registers the six Unicode scripts added in pdfnative 1.3.0 (Telugu, Sinhala, +# Khmer, Myanmar, Tibetan, Amharic/Ethiopic) plus COLRv1 colour emoji via the +# --font shortcut. Each shortcut name doubles as its --lang code. +# +# Usage: bash samples/render/font/02-new-scripts.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUT_DIR="$ROOT_DIR/samples/output/font" + +mkdir -p "$OUT_DIR" + +pdfnative render \ + --input "$SCRIPT_DIR/02-new-scripts.json" \ + --output "$OUT_DIR/02-new-scripts.pdf" \ + --font te --font si --font km --font my --font bo --font am \ + --font color-emoji \ + --lang te,si,km,my,bo,am,color-emoji + +echo "✓ Rendered: $OUT_DIR/02-new-scripts.pdf" diff --git a/samples/render/font/03-emoji.json b/samples/render/font/03-emoji.json new file mode 100644 index 0000000..9efe53c --- /dev/null +++ b/samples/render/font/03-emoji.json @@ -0,0 +1,22 @@ +{ + "title": "Monochrome Emoji — the --font emoji shortcut", + "blocks": [ + { "type": "heading", "text": "Monochrome Emoji", "level": 1 }, + { "type": "paragraph", "text": "The --font emoji shortcut registers the bundled Noto Emoji (monochrome) font. Unlike --font color-emoji (COLRv1 vector colour), this renders emoji as single-colour glyphs that follow the surrounding text colour — ideal for print, faxable documents, and archives that must stay grayscale." }, + { "type": "spacer", "height": 12 }, + { "type": "paragraph", "text": "Status icons: ✅ ❌ ⚠️ ℹ️ ⭐" }, + { "type": "paragraph", "text": "Objects: 📄 📌 🔒 🔑 📎" }, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "Colour vs monochrome", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "--font emoji — monochrome Noto Emoji, inherits text colour", + "--font color-emoji — full-colour COLRv1 vector glyphs", + "Both register through the same --font / --lang shortcut mechanism" + ]} + ], + "footerText": "Monochrome emoji — --font emoji", + "metadata": { + "subject": "Monochrome emoji demonstrating the --font emoji shortcut", + "keywords": "emoji, monochrome, noto, font" + } +} diff --git a/samples/render/font/03-emoji.ps1 b/samples/render/font/03-emoji.ps1 new file mode 100644 index 0000000..ace89a4 --- /dev/null +++ b/samples/render/font/03-emoji.ps1 @@ -0,0 +1,22 @@ +# render/font/03-emoji.ps1 — monochrome emoji via the --font emoji shortcut +# +# Registers the bundled Noto Emoji (monochrome) font. Emoji render as single- +# colour glyphs that inherit the surrounding text colour. +# +# Usage: pwsh -File samples\render\font\03-emoji.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $ScriptDir)) +$OutDir = Join-Path $RootDir 'samples\output\font' + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +& pdfnative render ` + --input (Join-Path $ScriptDir '03-emoji.json') ` + --output (Join-Path $OutDir '03-emoji.pdf') ` + --font emoji ` + --lang emoji + +Write-Host "✓ Rendered: $OutDir\03-emoji.pdf" diff --git a/samples/render/font/03-emoji.sh b/samples/render/font/03-emoji.sh new file mode 100644 index 0000000..cfe10ec --- /dev/null +++ b/samples/render/font/03-emoji.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# render/font/03-emoji.sh — monochrome emoji via the --font emoji shortcut +# +# Registers the bundled Noto Emoji (monochrome) font. Emoji render as single- +# colour glyphs that inherit the surrounding text colour — the complement to +# 02-new-scripts.sh, which uses --font color-emoji for full-colour COLRv1. +# +# Usage: bash samples/render/font/03-emoji.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUT_DIR="$ROOT_DIR/samples/output/font" + +mkdir -p "$OUT_DIR" + +pdfnative render \ + --input "$SCRIPT_DIR/03-emoji.json" \ + --output "$OUT_DIR/03-emoji.pdf" \ + --font emoji \ + --lang emoji + +echo "✓ Rendered: $OUT_DIR/03-emoji.pdf" diff --git a/samples/render/pdfa/04-pdfa-2u.json b/samples/render/pdfa/04-pdfa-2u.json new file mode 100644 index 0000000..740b9ef --- /dev/null +++ b/samples/render/pdfa/04-pdfa-2u.json @@ -0,0 +1,31 @@ +{ + "title": "Unicode-Preserving Archive — PDF/A-2u", + "blocks": [ + { "type": "heading", "text": "Unicode-Preserving Archive — PDF/A-2u", "level": 1 }, + { "type": "paragraph", "text": "PDF/A-2u (ISO 19005-2, level U) adds one guarantee on top of PDF/A-2b: every glyph in the document maps back to a Unicode code point. This makes the archive reliably searchable and extractable — text can always be copied as the original characters, not as orphaned glyph IDs." }, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "b vs u — what changes", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "PDF/A-2b: visual reproduction is guaranteed (Basic conformance).", + "PDF/A-2u: visual reproduction AND Unicode mapping are guaranteed.", + "Both share ISO 19005-2 (PDF 1.7, transparency, layers, JPEG 2000).", + "Choose u when full-text search and reliable text extraction matter." + ]}, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "When to Use", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "Records that must remain searchable for a decade or more", + "Legal and regulatory archives where text extraction is audited", + "Accessibility workflows that depend on reliable character mapping" + ]} + ], + "footerText": "Archived — PDF/A-2u — ISO 19005-2 (level U)", + "layout": { + "tagged": "pdfa2u" + }, + "metadata": { + "author": "pdfnative-cli", + "subject": "PDF/A-2u Unicode-preserving archival demonstration", + "keywords": "pdfa, archival, iso19005, pdfa2u, unicode" + } +} diff --git a/samples/render/watermark/03-cli-flags.json b/samples/render/watermark/03-cli-flags.json new file mode 100644 index 0000000..c86761b --- /dev/null +++ b/samples/render/watermark/03-cli-flags.json @@ -0,0 +1,24 @@ +{ + "title": "Watermark via CLI Flags", + "blocks": [ + { "type": "heading", "text": "Applying a Watermark Without Touching the JSON", "level": 1 }, + { "type": "paragraph", "text": "This document body carries NO watermark of its own. The diagonal stamp you see is layered on entirely from the command line, so the same JSON can be reused unstamped, stamped DRAFT, or stamped CONFIDENTIAL just by changing flags." }, + { "type": "spacer", "height": 12 }, + { "type": "heading", "text": "Flags used by the accompanying script", "level": 2 }, + { "type": "list", "style": "bullet", "items": [ + "--watermark-text — the stamp string", + "--watermark-opacity — 0.0 (invisible) to 1.0 (opaque)", + "--watermark-angle — rotation in degrees", + "--watermark-color — any CSS-style colour, e.g. #FF3B30", + "--watermark-font-size — glyph size in points", + "--watermark-position — background (behind text) or foreground" + ]}, + { "type": "spacer", "height": 12 }, + { "type": "paragraph", "text": "CLI flags override any layout.watermark embedded in the JSON, which makes them ideal for CI pipelines that stamp build metadata onto an otherwise clean document." } + ], + "footerText": "Watermark applied via CLI flags", + "metadata": { + "subject": "Watermark applied through --watermark-* CLI flags", + "keywords": "watermark, cli, draft, confidential" + } +} diff --git a/samples/render/watermark/03-cli-flags.ps1 b/samples/render/watermark/03-cli-flags.ps1 new file mode 100644 index 0000000..fd21426 --- /dev/null +++ b/samples/render/watermark/03-cli-flags.ps1 @@ -0,0 +1,28 @@ +# render/watermark/03-cli-flags.ps1 — apply a watermark purely from the CLI +# +# Demonstrates the full --watermark-* flag surface. The input JSON contains no +# watermark of its own; the diagonal stamp is layered on from the command line. +# +# Usage: +# pwsh -File samples\render\watermark\03-cli-flags.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $ScriptDir)) +$OutputDir = Join-Path $RootDir 'samples\output\watermark' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ Rendering with a CLI-driven watermark…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\watermark\03-cli-flags.json') ` + --output (Join-Path $OutputDir '03-cli-flags.pdf') ` + --watermark-text 'CONFIDENTIAL' ` + --watermark-opacity 0.15 ` + --watermark-angle 45 ` + --watermark-color '#FF3B30' ` + --watermark-font-size 64 ` + --watermark-position background + +Write-Host " ✓ Output: $OutputDir\03-cli-flags.pdf" diff --git a/samples/render/watermark/03-cli-flags.sh b/samples/render/watermark/03-cli-flags.sh new file mode 100644 index 0000000..16a8d43 --- /dev/null +++ b/samples/render/watermark/03-cli-flags.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# render/watermark/03-cli-flags.sh — apply a watermark purely from the CLI +# +# Demonstrates the full --watermark-* flag surface. The input JSON contains no +# watermark of its own; the diagonal stamp is layered on from the command line, +# overriding any layout.watermark in the JSON. Handy for CI pipelines that stamp +# build status onto an otherwise clean document. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/render/watermark/03-cli-flags.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/watermark" + +mkdir -p "$OUTPUT_DIR" + +echo "→ Rendering with a CLI-driven watermark…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/watermark/03-cli-flags.json" \ + --output "$OUTPUT_DIR/03-cli-flags.pdf" \ + --watermark-text "CONFIDENTIAL" \ + --watermark-opacity 0.15 \ + --watermark-angle 45 \ + --watermark-color "#FF3B30" \ + --watermark-font-size 64 \ + --watermark-position background + +echo " ✓ Output: $OUTPUT_DIR/03-cli-flags.pdf" diff --git a/samples/run-all.js b/samples/run-all.js index c2b4f5d..9efb48f 100644 --- a/samples/run-all.js +++ b/samples/run-all.js @@ -33,18 +33,18 @@ const CATEGORY_FLAGS = { '--header-right', '{date}', '--footer-center', 'Page {page} of {pages}', ], - encryption: [ - '--encrypt-algorithm', 'aes128', - '--encrypt-permissions', 'print', - ], + encryption: [], // file-name-driven (see FILE_FLAGS): aes128 vs aes256 per file. attachments: [ '--tagged', 'pdfa3b', '--attachment', join(__dirname, 'render', 'attachments', 'invoice.xml') + ':application/xml:Source:Structured invoice payload', ], multilang: [], // file-name-driven; resolved below - // v0.3.0 additions - font: ['--font', 'latin', '--lang', 'latin'], + // v0.3.0+ font samples are file-name-driven (see FILE_FLAGS): each JSON in + // font/ registers a different set of bundled fonts, so no category-wide flag + // applies — e.g. 02-new-scripts.json must NOT inherit the Latin-only flags, + // otherwise its non-Latin scripts render as .notdef tofu. + font: [], }; // Categories whose JSON samples are intentionally skipped by run-all because @@ -52,10 +52,34 @@ const CATEGORY_FLAGS = { const SKIP_CATEGORIES = new Set(['watch', 'template']); /** Per-file overrides (within a category). */ -// Note: --lang requires a programmatic font loader registered before the -// render call (see samples/render/multilang/ for guidance). The built-in samples -// use Latin-only content so they work out of the box with no extra loaders. -const FILE_FLAGS = {}; +// Note: --lang for non-Latin scripts requires the matching bundled font +// to be registered first via --font (see samples/render/font/ and +// samples/render/multilang/ for guidance). Each font/ sample registers exactly +// the fonts its content needs so it renders real glyphs out of the box. +const FILE_FLAGS = { + // document/ — 06 caps the block ceiling as a large-report guard. + '06-max-blocks.json': ['--max-blocks', '10000'], + // font/ — each sample registers a different bundled font set. + '01-latin.json': ['--font', 'latin', '--lang', 'latin'], + '02-new-scripts.json': [ + '--font', 'te', '--font', 'si', '--font', 'km', '--font', 'my', + '--font', 'bo', '--font', 'am', '--font', 'color-emoji', + '--lang', 'te,si,km,my,bo,am,color-emoji', + ], + '03-emoji.json': ['--font', 'emoji', '--lang', 'emoji'], + // encryption/ — algorithm differs per file (passwords come from CATEGORY_ENV). + '01-aes128-protected.json': ['--encrypt-algorithm', 'aes128', '--encrypt-permissions', 'print'], + '02-aes256-protected.json': ['--encrypt-algorithm', 'aes256', '--encrypt-permissions', 'print'], + // watermark/ — 03 layers the stamp on entirely from the CLI. + '03-cli-flags.json': [ + '--watermark-text', 'CONFIDENTIAL', + '--watermark-opacity', '0.15', + '--watermark-angle', '45', + '--watermark-color', '#FF3B30', + '--watermark-font-size', '64', + '--watermark-position', 'background', + ], +}; // Per-category env-var bootstrap (e.g. encryption passwords). const CATEGORY_ENV = { diff --git a/samples/sign/05-cert-chain.ps1 b/samples/sign/05-cert-chain.ps1 new file mode 100644 index 0000000..f3750d4 --- /dev/null +++ b/samples/sign/05-cert-chain.ps1 @@ -0,0 +1,68 @@ +# sign/05-cert-chain.ps1 — sign with an intermediate certificate chain +# +# Builds a tiny two-level PKI (root CA → signer), signs a PDF with the signer +# certificate, embeds the CA via --cert-chain, and verifies against the root. +# +# Prerequisites: openssl on PATH. +# +# Usage: +# pwsh -File samples\sign\05-cert-chain.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output' +$SignOut = Join-Path $OutputDir 'sign' +$KeysDir = Join-Path $SignOut 'keys\chain' + +New-Item -ItemType Directory -Force -Path $SignOut, $KeysDir | Out-Null + +$UnsignedPdf = Join-Path $OutputDir 'document\04-invoice.pdf' +$SignedPdf = Join-Path $SignOut '05-cert-chain-signed.pdf' +$CaKey = Join-Path $KeysDir 'ca.key' +$CaCert = Join-Path $KeysDir 'ca.crt' +$LeafKey = Join-Path $KeysDir 'signer.key' +$LeafCsr = Join-Path $KeysDir 'signer.csr' +$LeafCert= Join-Path $KeysDir 'signer.crt' + +if (-not (Test-Path $UnsignedPdf)) { + Write-Host '→ Rendering source document…' + New-Item -ItemType Directory -Force -Path (Join-Path $OutputDir 'document') | Out-Null + & pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\04-invoice.json') ` + --output $UnsignedPdf +} + +if (-not (Test-Path $LeafCert)) { + Write-Host '→ Building demo PKI (root CA → signer)…' + & openssl req -x509 -newkey rsa:2048 -keyout $CaKey -out $CaCert ` + -days 3650 -nodes -subj '/CN=pdfnative Demo Root CA/O=pdfnative/C=US' ` + -addext 'basicConstraints=critical,CA:TRUE' 2>$null + & openssl req -newkey rsa:2048 -keyout $LeafKey -out $LeafCsr ` + -nodes -subj '/CN=pdfnative Demo Signer/O=pdfnative/C=US' 2>$null + & openssl x509 -req -in $LeafCsr -CA $CaCert -CAkey $CaKey ` + -CAcreateserial -out $LeafCert -days 825 -sha256 2>$null + Write-Host " ✓ Root CA: $CaCert" + Write-Host " ✓ Signer: $LeafCert" +} + +Write-Host '→ Signing with --cert-chain…' +& pdfnative sign ` + --input $UnsignedPdf ` + --output $SignedPdf ` + --key $LeafKey ` + --cert $LeafCert ` + --cert-chain $CaCert ` + --reason 'Issued under demo root CA' + +Write-Host " ✓ Signed: $SignedPdf" + +Write-Host '→ Verifying against the root CA…' +& pdfnative verify ` + --input $SignedPdf ` + --trust $CaCert ` + --format text + +Write-Host '' +Write-Host 'Expect: chain builds to the trusted root and the signature is valid.' diff --git a/samples/sign/05-cert-chain.sh b/samples/sign/05-cert-chain.sh new file mode 100644 index 0000000..097ab8b --- /dev/null +++ b/samples/sign/05-cert-chain.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# sign/05-cert-chain.sh — sign with an intermediate certificate chain +# +# Builds a tiny two-level PKI (root CA → signer), signs a PDF with the signer +# certificate, and embeds the CA via --cert-chain so a verifier can build the +# path to a trusted root. Verifies with --trust pointing at the root CA. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# - openssl available on your PATH +# +# Usage: +# bash samples/sign/05-cert-chain.sh +# +# Output: samples/output/sign/05-cert-chain-signed.pdf + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output" +SIGN_OUT="$OUTPUT_DIR/sign" +KEYS_DIR="$SIGN_OUT/keys/chain" + +mkdir -p "$SIGN_OUT" "$KEYS_DIR" + +UNSIGNED_PDF="$OUTPUT_DIR/document/04-invoice.pdf" +SIGNED_PDF="$SIGN_OUT/05-cert-chain-signed.pdf" +CA_KEY="$KEYS_DIR/ca.key" +CA_CERT="$KEYS_DIR/ca.crt" +LEAF_KEY="$KEYS_DIR/signer.key" +LEAF_CSR="$KEYS_DIR/signer.csr" +LEAF_CERT="$KEYS_DIR/signer.crt" + +# ── Step 1: render the source document ───────────────────────────────────── +if [ ! -f "$UNSIGNED_PDF" ]; then + echo "→ Rendering source document…" + mkdir -p "$OUTPUT_DIR/document" + pdfnative render \ + --input "$ROOT_DIR/samples/render/document/04-invoice.json" \ + --output "$UNSIGNED_PDF" +fi + +# ── Step 2: build a root CA and a signer certificate signed by it ────────── +if [ ! -f "$LEAF_CERT" ]; then + echo "→ Building demo PKI (root CA → signer)…" + openssl req -x509 -newkey rsa:2048 -keyout "$CA_KEY" -out "$CA_CERT" \ + -days 3650 -nodes -subj "/CN=pdfnative Demo Root CA/O=pdfnative/C=US" \ + -addext "basicConstraints=critical,CA:TRUE" 2>/dev/null + openssl req -newkey rsa:2048 -keyout "$LEAF_KEY" -out "$LEAF_CSR" \ + -nodes -subj "/CN=pdfnative Demo Signer/O=pdfnative/C=US" 2>/dev/null + openssl x509 -req -in "$LEAF_CSR" -CA "$CA_CERT" -CAkey "$CA_KEY" \ + -CAcreateserial -out "$LEAF_CERT" -days 825 -sha256 2>/dev/null + echo " ✓ Root CA: $CA_CERT" + echo " ✓ Signer: $LEAF_CERT" +fi + +# ── Step 3: sign with the signer cert + CA chain ─────────────────────────── +echo "→ Signing with --cert-chain…" +pdfnative sign \ + --input "$UNSIGNED_PDF" \ + --output "$SIGNED_PDF" \ + --key "$LEAF_KEY" \ + --cert "$LEAF_CERT" \ + --cert-chain "$CA_CERT" \ + --reason "Issued under demo root CA" + +echo " ✓ Signed: $SIGNED_PDF" + +# ── Step 4: verify, trusting the root CA ─────────────────────────────────── +echo "→ Verifying against the root CA…" +pdfnative verify \ + --input "$SIGNED_PDF" \ + --trust "$CA_CERT" \ + --format text + +echo "" +echo "Expect: chain builds to the trusted root and the signature is valid." diff --git a/samples/sign/06-timestamp-reserved.ps1 b/samples/sign/06-timestamp-reserved.ps1 new file mode 100644 index 0000000..2daa2c6 --- /dev/null +++ b/samples/sign/06-timestamp-reserved.ps1 @@ -0,0 +1,27 @@ +# sign/06-timestamp-reserved.ps1 — the reserved --timestamp flag (PAdES-T) +# +# Sign-side RFC 3161 timestamping is intentionally NOT yet available. The CLI +# surfaces the flag so the contract is discoverable, but it fails fast with a +# clear message and exit code 2 rather than silently dropping the timestamp. +# This sample asserts that contract — it expects the command to FAIL. +# +# Usage: +# pwsh -File samples\sign\06-timestamp-reserved.ps1 + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) + +Write-Host '→ Attempting sign --timestamp (expected to fail)…' +& pdfnative sign ` + --input (Join-Path $RootDir 'samples\render\document\01-minimal.json') ` + --timestamp 'http://timestamp.example/tsa' ` + --json +$status = $LASTEXITCODE + +Write-Host " exit code: $status (expected 2)" +if ($status -eq 2) { + Write-Host ' ✓ PASS — reserved flag rejected as documented.' +} else { + Write-Host ' ✗ UNEXPECTED — flag did not fail with exit 2.' + exit 1 +} diff --git a/samples/sign/06-timestamp-reserved.sh b/samples/sign/06-timestamp-reserved.sh new file mode 100644 index 0000000..f9f139a --- /dev/null +++ b/samples/sign/06-timestamp-reserved.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# sign/06-timestamp-reserved.sh — the reserved --timestamp flag (PAdES-T) +# +# Sign-side RFC 3161 timestamping is intentionally NOT yet available: embedding +# a timestamp token at signing time needs upstream support in pdfnative. The +# CLI surfaces the flag so the contract is discoverable, but it fails fast with +# a clear message and exit code 2 (E_UNSUPPORTED) rather than silently dropping +# the timestamp. Timestamp VALIDATION is already supported by `pdfnative verify`. +# +# This sample asserts that contract — it expects the command to FAIL. +# +# Usage: +# bash samples/sign/06-timestamp-reserved.sh + +set -uo pipefail # note: not -e; we expect a non-zero exit below + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "→ Attempting sign --timestamp (expected to fail)…" +set +e +pdfnative sign \ + --input "$ROOT_DIR/samples/render/document/01-minimal.json" \ + --timestamp "http://timestamp.example/tsa" \ + --json 2>/tmp/pdfnative-ts.err +STATUS=$? +set -e + +echo " exit code: $STATUS (expected 2)" +echo " stderr envelope:" +sed 's/^/ /' /tmp/pdfnative-ts.err + +if [ "$STATUS" -eq 2 ]; then + echo " ✓ PASS — reserved flag rejected as documented." +else + echo " ✗ UNEXPECTED — flag did not fail with exit 2." + exit 1 +fi diff --git a/samples/streaming/02-page-by-page.ps1 b/samples/streaming/02-page-by-page.ps1 new file mode 100644 index 0000000..46ea956 --- /dev/null +++ b/samples/streaming/02-page-by-page.ps1 @@ -0,0 +1,26 @@ +# streaming/02-page-by-page.ps1 — page-by-page streaming (pdfnative 1.2.0+) +# +# Demonstrates `--stream-page-by-page`: the PDF is emitted one page at a time, +# keeping peak memory bounded by a single page rather than the whole document. +# Unlike `--stream`, this mode reflows across page boundaries (it does NOT +# support TOC blocks, which require multi-pass pagination). +# +# Usage: +# pwsh -File samples\streaming\02-page-by-page.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\streaming' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ Page-by-page streaming render…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\05-technical-spec.json') ` + --output (Join-Path $OutputDir '02-page-by-page.pdf') ` + --stream-page-by-page ` + --compress + +Write-Host " ✓ Output: $OutputDir\02-page-by-page.pdf" diff --git a/samples/streaming/02-page-by-page.sh b/samples/streaming/02-page-by-page.sh new file mode 100644 index 0000000..3b1a956 --- /dev/null +++ b/samples/streaming/02-page-by-page.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# streaming/02-page-by-page.sh — page-by-page streaming (pdfnative 1.2.0+) +# +# Demonstrates `--stream-page-by-page`: the PDF is emitted one page at a time, +# keeping peak memory bounded by a single page rather than the whole document. +# Unlike `--stream`, this mode reflows across page boundaries, so it is the +# right choice for long, paginated documents (it does NOT support TOC blocks, +# which require multi-pass pagination — use buildDocumentPDFBytes for those). +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/streaming/02-page-by-page.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/streaming" + +mkdir -p "$OUTPUT_DIR" + +echo "→ Page-by-page streaming render…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/05-technical-spec.json" \ + --output "$OUTPUT_DIR/02-page-by-page.pdf" \ + --stream-page-by-page \ + --compress + +echo " ✓ Output: $OUTPUT_DIR/02-page-by-page.pdf" diff --git a/samples/streaming/03-true-streaming.ps1 b/samples/streaming/03-true-streaming.ps1 new file mode 100644 index 0000000..5cd4afa --- /dev/null +++ b/samples/streaming/03-true-streaming.ps1 @@ -0,0 +1,25 @@ +# streaming/03-true-streaming.ps1 — true constant-memory streaming (pdfnative 1.3.0+) +# +# Demonstrates `--stream-true`: a true constant-memory generator that produces +# byte-identical output to the buffered renderer while never holding the whole +# PDF in memory. Same constraints as `--stream` (no TOC blocks, no `{pages}`). +# +# Usage: +# pwsh -File samples\streaming\03-true-streaming.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$OutputDir = Join-Path $RootDir 'samples\output\streaming' + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +Write-Host '→ True constant-memory streaming render…' +& pdfnative render ` + --input (Join-Path $RootDir 'samples\render\document\05-technical-spec.json') ` + --output (Join-Path $OutputDir '03-true-streaming.pdf') ` + --stream-true ` + --compress + +Write-Host " ✓ Output: $OutputDir\03-true-streaming.pdf" diff --git a/samples/streaming/03-true-streaming.sh b/samples/streaming/03-true-streaming.sh new file mode 100644 index 0000000..86a6766 --- /dev/null +++ b/samples/streaming/03-true-streaming.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# streaming/03-true-streaming.sh — true constant-memory streaming (pdfnative 1.3.0+) +# +# Demonstrates `--stream-true`: a true constant-memory generator that produces +# byte-identical output to the buffered renderer while never holding the whole +# PDF in memory. Same constraints as `--stream` (no TOC blocks, no `{pages}` +# placeholder), but the lowest peak memory of all modes — ideal for very large +# documents or memory-constrained environments. +# +# Prerequisites: +# - pdfnative-cli installed globally: npm install -g pdfnative-cli +# +# Usage: +# bash samples/streaming/03-true-streaming.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +OUTPUT_DIR="$ROOT_DIR/samples/output/streaming" + +mkdir -p "$OUTPUT_DIR" + +echo "→ True constant-memory streaming render…" +pdfnative render \ + --input "$ROOT_DIR/samples/render/document/05-technical-spec.json" \ + --output "$OUTPUT_DIR/03-true-streaming.pdf" \ + --stream-true \ + --compress + +echo " ✓ Output: $OUTPUT_DIR/03-true-streaming.pdf" diff --git a/samples/verify/06-online-revocation.ps1 b/samples/verify/06-online-revocation.ps1 new file mode 100644 index 0000000..cbceb28 --- /dev/null +++ b/samples/verify/06-online-revocation.ps1 @@ -0,0 +1,49 @@ +# verify/06-online-revocation.ps1 — opt-in online revocation (OCSP/CRL) +# +# `verify` is OFFLINE by default. With --revocation online it additionally +# fetches OCSP (AIA) and CRL (CDP) endpoints — but only through the SSRF-guarded +# client (scheme allow-list, private/loopback/link-local/CGNAT/multicast +# blocking, no redirects, timeout + size caps). A self-signed demo cert has no +# revocation authority, so this runs OFFLINE and shows the online form as a hint. +# +# Prerequisites: samples\sign\01-basic.ps1 has produced a signed PDF. +# +# Usage: +# pwsh -File samples\verify\06-online-revocation.ps1 + +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RootDir = Split-Path -Parent (Split-Path -Parent $ScriptDir) +$SignedPdf = Join-Path $RootDir 'samples\output\sign\01-basic-signed.pdf' +$TrustCert = Join-Path $RootDir 'samples\output\sign\keys\signing.crt' + +if (-not (Test-Path $SignedPdf)) { + Write-Host ' ✗ Signed PDF not found. Run samples\sign\01-basic.ps1 first.' + exit 1 +} + +Write-Host '→ Offline revocation (default, no network):' +& pdfnative verify ` + --input $SignedPdf ` + --trust $TrustCert ` + --revocation offline ` + --revocation-policy soft-fail ` + --format json + +Write-Host '' +Write-Host 'Online variant (network; SSRF-guarded). Uncomment for a CA-issued' +Write-Host 'cert that publishes OCSP/CRL endpoints:' +Write-Host '' +Write-Host ' pdfnative verify --input --trust --revocation online --revocation-policy strict --format json' +Write-Host '' +Write-Host "Policy: soft-fail rejects only an explicit 'revoked'; strict rejects" +Write-Host " any non-'good' status (including 'unknown')." + +# --- Online invocation (disabled by default; requires AIA/CDP endpoints) ----- +# & pdfnative verify ` +# --input $SignedPdf ` +# --trust $TrustCert ` +# --revocation online ` +# --revocation-policy strict ` +# --format json diff --git a/samples/verify/06-online-revocation.sh b/samples/verify/06-online-revocation.sh new file mode 100644 index 0000000..46fa2bc --- /dev/null +++ b/samples/verify/06-online-revocation.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# verify/06-online-revocation.sh — opt-in online revocation (OCSP/CRL) +# +# `verify` is OFFLINE by default. With --revocation online it additionally +# fetches OCSP responders (from the certificate AIA extension) and CRLs (from +# CDP) — but ONLY through the SSRF-guarded HTTP(S) client: scheme allow-list, +# private/loopback/link-local/CGNAT/multicast blocking, no redirects, plus +# timeout and size caps. CRL/OCSP/TSA signatures are always verified; data that +# cannot be verified yields "unknown", never "good". +# +# A self-signed demo certificate has no revocation authority, so this script +# runs OFFLINE by default and shows the online invocation as a commented hint +# — uncomment it only against a certificate that publishes AIA/CDP endpoints. +# +# Prerequisites: +# - samples/sign/01-basic.sh has produced a signed PDF +# +# Usage: +# bash samples/verify/06-online-revocation.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SIGNED_PDF="$ROOT_DIR/samples/output/sign/01-basic-signed.pdf" +TRUST_CERT="$ROOT_DIR/samples/output/sign/keys/signing.crt" + +if [ ! -f "$SIGNED_PDF" ]; then + echo " ✗ Signed PDF not found. Run samples/sign/01-basic.sh first." + exit 1 +fi + +echo "→ Offline revocation (default, no network):" +pdfnative verify \ + --input "$SIGNED_PDF" \ + --trust "$TRUST_CERT" \ + --revocation offline \ + --revocation-policy soft-fail \ + --format json + +echo "" +echo "Online variant (network; SSRF-guarded). Uncomment for a CA-issued cert" +echo "that publishes OCSP/CRL endpoints:" +echo "" +echo " pdfnative verify \\" +echo " --input \"$SIGNED_PDF\" \\" +echo " --trust \"$TRUST_CERT\" \\" +echo " --revocation online \\" +echo " --revocation-policy strict \\" +echo " --format json" +echo "" +echo "Policy: soft-fail rejects only an explicit 'revoked'; strict rejects any" +echo " non-'good' status (including 'unknown')." + +# --- Online invocation (disabled by default; requires AIA/CDP endpoints) ----- +# pdfnative verify \ +# --input "$SIGNED_PDF" \ +# --trust "$TRUST_CERT" \ +# --revocation online \ +# --revocation-policy strict \ +# --format json diff --git a/src/commands/batch.ts b/src/commands/batch.ts index c438302..7775ea3 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -9,7 +9,9 @@ import { readdir, mkdir } from 'node:fs/promises'; import { join, basename, extname } from 'node:path'; import { type ParsedArgs, getStringFlag, hasFlag } from '../utils/args.js'; import { validatePath } from '../utils/io.js'; -import { CliError } from '../utils/error.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { isJsonMode, isDryRun } from '../utils/agent.js'; +import { selectFields, serializeJson, parseFieldList } from '../utils/projection.js'; import { style } from '../utils/colors.js'; import { render } from './render.js'; @@ -17,6 +19,7 @@ import { render } from './render.js'; const BATCH_ONLY_FLAGS = new Set([ 'input-dir', 'output-dir', 'concurrency', 'fail-fast', 'format', 'input', 'i', 'output', 'o', 'watch', 'stream', 'stream-page-by-page', + 'summary', 'fields', 'pretty', ]); interface FileResult { @@ -71,8 +74,10 @@ async function runPool( export async function batch(args: ParsedArgs): Promise { const inputDir = getStringFlag(args.flags, 'input-dir'); const outputDir = getStringFlag(args.flags, 'output-dir'); - const format = getStringFlag(args.flags, 'format') ?? 'text'; + // Agent mode (global --json) forces a machine-readable summary on stdout. + const format = isJsonMode() ? 'json' : (getStringFlag(args.flags, 'format') ?? 'text'); const failFast = hasFlag(args.flags, 'fail-fast'); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); if (inputDir === undefined) { throw new CliError('batch requires --input-dir .', 2); @@ -100,14 +105,18 @@ export async function batch(args: ParsedArgs): Promise { try { entries = await readdir(inputDir); } catch { - throw new CliError(`Cannot read --input-dir: ${inputDir}`, 1); + throw new CliError(`Cannot read --input-dir: ${inputDir}`, 1, ErrorCode.IO); } const inputs = entries.filter((e) => extname(e).toLowerCase() === '.json').sort(); if (inputs.length === 0) { - throw new CliError(`No .json files found in ${inputDir}.`, 1); + throw new CliError(`No .json files found in ${inputDir}.`, 1, ErrorCode.INPUT); } - await mkdir(outputDir, { recursive: true }); + // In dry-run we validate every input via render (which short-circuits before + // writing); no output directory is created and no PDF is written. + if (!dryRun) { + await mkdir(outputDir, { recursive: true }); + } const results: FileResult[] = []; let aborted = false; @@ -132,9 +141,17 @@ export async function batch(args: ParsedArgs): Promise { const succeeded = results.length - failures; if (format === 'json') { - process.stdout.write( - JSON.stringify({ total: inputs.length, succeeded, failed: failures, results }, null, 2) + '\n', - ); + const summary = hasFlag(args.flags, 'summary'); + const fieldsRaw = getStringFlag(args.flags, 'fields'); + let out: unknown = summary + ? { total: inputs.length, succeeded, failed: failures } + : { total: inputs.length, succeeded, failed: failures, results }; + if (fieldsRaw !== undefined) { + out = selectFields(out, parseFieldList(fieldsRaw)); + } + // Compact for agents (--json), pretty for humans; --pretty forces pretty. + const pretty = hasFlag(args.flags, 'pretty') || !isJsonMode(); + process.stdout.write(serializeJson(out, pretty) + '\n'); } else { process.stdout.write(`Rendered ${succeeded}/${inputs.length} file(s), ${failures} failed.\n`); } diff --git a/src/commands/completion.ts b/src/commands/completion.ts index 94c7e0f..4bcba47 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -16,19 +16,25 @@ interface CommandSpec { readonly flags: readonly string[]; } -const GLOBAL_FLAGS = ['--help', '--version', '--no-color', '--quiet', '--config', '--no-config']; +const GLOBAL_FLAGS = ['--help', '--version', '--no-color', '--quiet', '--json', '--dry-run', '--config', '--no-config']; const COMMANDS: readonly CommandSpec[] = [ { name: 'render', summary: 'Render a JSON document definition to PDF', flags: [ - '--input', '--output', '--stream', '--stream-page-by-page', '--watch', '--template', + '--input', '--output', '--stream', '--stream-page-by-page', '--stream-true', + '--max-blocks', '--watch', '--template', '--variant', '--table-wrap', '--repeat-header', '--zebra', '--min-row-height', '--cell-padding', '--layout', '--page-size', '--margin', '--tagged', '--compress', - '--lang', '--font', '--watermark-text', '--watermark-image', '--watermark-opacity', - '--watermark-rotation', '--encrypt', '--owner-password', '--user-password', - '--permissions', '--attachment', + '--lang', '--font', + '--header-left', '--header-center', '--header-right', + '--footer-left', '--footer-center', '--footer-right', + '--watermark-text', '--watermark-image', '--watermark-opacity', + '--watermark-angle', '--watermark-color', '--watermark-font-size', + '--watermark-position', + '--encrypt-algorithm', '--encrypt-owner-pass', '--encrypt-user-pass', + '--encrypt-permissions', '--attachment', ], }, { @@ -36,23 +42,28 @@ const COMMANDS: readonly CommandSpec[] = [ summary: 'Apply a digital signature to a PDF', flags: [ '--input', '--output', '--key', '--cert', '--cert-chain', '--algorithm', - '--reason', '--name', '--location', '--contact', '--signing-time', + '--reason', '--name', '--location', '--contact', '--signing-time', '--timestamp', ], }, { name: 'verify', summary: 'Verify embedded PDF signatures', - flags: ['--input', '--trust', '--strict', '--revocation', '--revocation-policy', '--format'], + flags: ['--input', '--trust', '--strict', '--revocation', '--revocation-policy', '--format', '--summary', '--fields', '--pretty'], }, { name: 'inspect', summary: 'Analyse a PDF and output metadata', - flags: ['--input', '--format', '--verbose', '--pages', '--check'], + flags: ['--input', '--format', '--verbose', '--pages', '--pdfua', '--check', '--summary', '--fields', '--pretty'], }, { name: 'batch', summary: 'Render many JSON inputs to PDF in parallel', - flags: ['--input-dir', '--output-dir', '--concurrency', '--continue-on-error', '--layout', '--variant'], + flags: ['--input-dir', '--output-dir', '--concurrency', '--fail-fast', '--format', '--layout', '--variant', '--summary', '--fields', '--pretty'], + }, + { + name: 'schema', + summary: 'Print a JSON Schema for a CLI input/output shape', + flags: [], }, { name: 'completion', diff --git a/src/commands/inspect.ts b/src/commands/inspect.ts index ca2c088..2baf9ee 100644 --- a/src/commands/inspect.ts +++ b/src/commands/inspect.ts @@ -1,10 +1,12 @@ -import { openPdf } from '../core-bridge/index.js'; -import type { PdfReader } from '../core-bridge/index.js'; +import { openPdf, validatePdfUA, isStream } from '../core-bridge/index.js'; +import type { PdfReader, PdfUAValidationResult } from '../core-bridge/index.js'; import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; import { readFileOrStdin } from '../utils/io.js'; -import { CliError } from '../utils/error.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { isJsonMode } from '../utils/agent.js'; +import { selectFields, serializeJson, parseFieldList } from '../utils/projection.js'; -const VALID_CHECKS = new Set(['pdfa', 'signed', 'encrypted']); +const VALID_CHECKS = new Set(['pdfa', 'signed', 'encrypted', 'pdfua']); interface PageInfo { readonly index: number; @@ -29,6 +31,11 @@ interface InspectResult { readonly producer: string | null; }; readonly pages?: readonly PageInfo[]; + readonly pdfua?: { + readonly valid: boolean; + readonly errors: readonly string[]; + readonly warnings: readonly string[]; + }; readonly verbose?: { readonly trailerKeys: readonly string[]; readonly catalogKeys: readonly string[]; @@ -71,11 +78,7 @@ function readXmp(reader: PdfReader): string | null { const metaRef = catalog.get('Metadata'); if (metaRef === undefined) return null; const metaObj = reader.resolveValue(metaRef); - if ( - typeof metaObj !== 'object' || - metaObj === null || - !('streamData' in metaObj) - ) { + if (!isStream(metaObj)) { return null; } const decoded = reader.decodeStream( @@ -158,6 +161,11 @@ function inspectPages(reader: PdfReader): readonly PageInfo[] { return out; } +function runPdfUaCheck(bytes: Uint8Array): NonNullable { + const res: PdfUAValidationResult = validatePdfUA(bytes); + return { valid: res.valid, errors: res.errors, warnings: res.warnings }; +} + function buildVerbose(reader: PdfReader): InspectResult['verbose'] { const trailerKeys: string[] = []; for (const k of reader.trailer.keys()) trailerKeys.push(k); @@ -177,6 +185,16 @@ function buildVerbose(reader: PdfReader): InspectResult['verbose'] { }; } +/** Canonical minimal verdict for agents (`--summary`). Stable, schema-pinned. */ +function toInspectSummary(result: InspectResult): Record { + return { + pages: result.pageCount, + encrypted: result.encrypted, + signatures: result.signatures, + pdfa: result.pdfaConformance, + }; +} + function evaluateChecks(checks: readonly string[], result: InspectResult): CheckResult { const out: { name: string; passed: boolean }[] = []; for (const c of checks) { @@ -189,6 +207,7 @@ function evaluateChecks(checks: readonly string[], result: InspectResult): Check 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 }); + if (c === 'pdfua') out.push({ name: c, passed: result.pdfua?.valid === true }); } return { checks: out.map((x) => `${x.name}=${x.passed ? 'pass' : 'fail'}`), @@ -202,6 +221,7 @@ export async function inspect(args: ParsedArgs): Promise { const verbose = hasFlag(args.flags, 'verbose'); const includePages = hasFlag(args.flags, 'pages'); const checks = getStringFlagAll(args.flags, 'check'); + const includePdfua = hasFlag(args.flags, 'pdfua') || checks.includes('pdfua'); if (format !== 'json' && format !== 'text') { throw new CliError(`Invalid --format value "${format}". Valid: json, text.`, 2); @@ -215,7 +235,7 @@ export async function inspect(args: ParsedArgs): Promise { reader = openPdf(pdfBytes); } catch (e) { const message = e instanceof Error ? e.message : String(e); - throw new CliError(`Failed to read PDF: ${message}`, 1); + throw new CliError(`Failed to read PDF: ${message}`, 1, ErrorCode.PARSE); } const info = reader.getInfo(); @@ -237,11 +257,20 @@ export async function inspect(args: ParsedArgs): Promise { const result: InspectResult = { ...baseResult, ...(includePages ? { pages: inspectPages(reader) } : {}), + ...(includePdfua ? { pdfua: runPdfUaCheck(pdfBytes) } : {}), ...(verbose ? { verbose: buildVerbose(reader) } : {}), }; if (format === 'json') { - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + const summary = hasFlag(args.flags, 'summary'); + const fieldsRaw = getStringFlag(args.flags, 'fields'); + let out: unknown = summary ? toInspectSummary(result) : result; + if (fieldsRaw !== undefined) { + out = selectFields(out, parseFieldList(fieldsRaw)); + } + // Compact for agents (--json), pretty for humans; --pretty forces pretty. + const pretty = hasFlag(args.flags, 'pretty') || !isJsonMode(); + process.stdout.write(serializeJson(out, pretty) + '\n'); } else { const lines = [ `Version: ${result.version}`, @@ -263,6 +292,11 @@ export async function inspect(args: ParsedArgs): Promise { ); } } + if (result.pdfua !== undefined) { + lines.push(`PDF/UA: ${result.pdfua.valid ? 'valid' : 'invalid'}`); + for (const err of result.pdfua.errors) lines.push(` error: ${err}`); + for (const warn of result.pdfua.warnings) lines.push(` warning: ${warn}`); + } if (result.verbose !== undefined) { lines.push(`Trailer keys: ${result.verbose.trailerKeys.join(', ')}`); lines.push(`Catalog keys: ${result.verbose.catalogKeys.join(', ')}`); @@ -278,10 +312,16 @@ export async function inspect(args: ParsedArgs): Promise { if (checks.length > 0) { const evaluation = evaluateChecks(checks, result); if (!evaluation.allPassed) { - process.stderr.write(`check failed: ${evaluation.checks.join(', ')}\n`); - // exit 1 = check failure (semantic), distinct from a usage error (2) or runtime error (1). - // Reuse exit code 1 to keep the "failure" semantics consistent. - throw new CliError('', 1); + const detail = `check failed: ${evaluation.checks.join(', ')}`; + // exit 1 = check failure (semantic), distinct from a usage error (2) + // or runtime error (1). Human mode prints the breakdown to stderr and + // throws an empty message (the dispatcher would otherwise re-print it); + // agent mode carries the detail in the JSON error envelope instead. + if (!isJsonMode()) { + process.stderr.write(detail + '\n'); + throw new CliError('', 1, ErrorCode.CHECK_FAILED); + } + throw new CliError(detail, 1, ErrorCode.CHECK_FAILED); } } } diff --git a/src/commands/render.ts b/src/commands/render.ts index c3f171e..7ce8e7a 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -6,9 +6,11 @@ import { buildDocumentPDFBytes, buildDocumentPDFStream, buildDocumentPDFStreamPageByPage, + buildDocumentPDFStreamTrue, buildPDFBytes, buildPDFStream, buildPDFStreamPageByPage, + buildPDFStreamTrue, initNodeCompression, loadFontData, hasFontLoader, @@ -34,7 +36,8 @@ import { writeStreamingOutput, assertJsonSizeLimit, } from '../utils/io.js'; -import { CliError } from '../utils/error.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; import { buildLayoutOptions, assertStreamingCompatible, @@ -52,8 +55,35 @@ const VALID_VARIANTS = new Set(['document', 'table']); * stays predictable and free from path-based RCE vectors. */ const BUNDLED_FONT_MODULES: Readonly> = Object.freeze({ + // Latin + monochrome / COLRv1 colour emoji latin: 'noto-sans-data.js', emoji: 'noto-emoji-data.js', + 'color-emoji': 'noto-color-emoji-data.js', + // 22 Unicode scripts (pdfnative ≥ 1.3.0). The shortcut name doubles as the + // `--lang` code; pdfnative routes each code point to the font whose cmap + // covers it, so any registered script font is used automatically. + ar: 'noto-arabic-data.js', + hy: 'noto-armenian-data.js', + bn: 'noto-bengali-data.js', + ru: 'noto-cyrillic-data.js', + hi: 'noto-devanagari-data.js', + am: 'noto-ethiopic-data.js', + ka: 'noto-georgian-data.js', + el: 'noto-greek-data.js', + he: 'noto-hebrew-data.js', + ja: 'noto-jp-data.js', + km: 'noto-khmer-data.js', + ko: 'noto-kr-data.js', + my: 'noto-myanmar-data.js', + pl: 'noto-polish-data.js', + zh: 'noto-sc-data.js', + si: 'noto-sinhala-data.js', + ta: 'noto-tamil-data.js', + te: 'noto-telugu-data.js', + th: 'noto-thai-data.js', + bo: 'noto-tibetan-data.js', + tr: 'noto-turkish-data.js', + vi: 'noto-vietnamese-data.js', }); let cachedFontsDir: string | null = null; @@ -289,11 +319,13 @@ interface RenderConfig { readonly variant: string; readonly useStream: boolean; readonly usePageStream: boolean; + readonly useStreamTrue: boolean; readonly inputPath: string | undefined; readonly outputPath: string | undefined; readonly langs: readonly string[]; readonly layout: Partial; readonly tableDefaults: TableDefaults | undefined; + readonly dryRun: boolean; } async function loadTemplate(templatePath: string): Promise { @@ -329,18 +361,29 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { throw new CliError( 'JSON input must be a PdfParams object (with title, headers, rows) when --variant table is used.', 1, + ErrorCode.INPUT, ); } + if (cfg.dryRun) { + emitStatus({ command: 'render', variant: 'table', dryRun: true, output: cfg.outputPath ?? '-' }); + return; + } + let bytes: number | null = null; if (cfg.usePageStream) { const generator = buildPDFStreamPageByPage(parsedInput, cfg.layout); await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStreamTrue) { + const generator = buildPDFStreamTrue(parsedInput, cfg.layout); + await writeStreamingOutput(generator, cfg.outputPath); } else if (cfg.useStream) { const generator = buildPDFStream(parsedInput, cfg.layout); await writeStreamingOutput(generator, cfg.outputPath); } else { const pdfBytes = buildPDFBytes(parsedInput, cfg.layout); + bytes = pdfBytes.length; await writeOutput(pdfBytes, cfg.outputPath); } + emitStatus({ command: 'render', variant: 'table', dryRun: false, output: cfg.outputPath ?? '-', bytes }); return; } @@ -349,6 +392,7 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { throw new CliError( 'JSON input must be a DocumentParams object (with a "blocks" array).', 1, + ErrorCode.INPUT, ); } @@ -381,20 +425,40 @@ async function renderOnce(cfg: RenderConfig, template: unknown): Promise { 2, ); } + if (cfg.useStreamTrue && hasTocBlock(params)) { + throw new CliError( + '--stream-true is incompatible with TOC blocks (multi-pass pagination required).', + 2, + ); + } + if (cfg.dryRun) { + emitStatus({ command: 'render', variant: 'document', dryRun: true, output: cfg.outputPath ?? '-' }); + return; + } + + let bytes: number | null = null; if (cfg.usePageStream) { // Page-by-page streaming assembles the full PDF, then chunks it at PDF // object boundaries — so TOC blocks and {pages} placeholders are fully // supported (unlike single-pass --stream). const generator = buildDocumentPDFStreamPageByPage(params, effectiveLayout); await writeStreamingOutput(generator, cfg.outputPath); + } else if (cfg.useStreamTrue) { + // True constant-memory streaming: parts are emitted and freed as they + // go, so the joined binary never materialises. Same constraints as + // --stream (no TOC, no {pages}); byte-identical to buildDocumentPDFBytes. + const generator = buildDocumentPDFStreamTrue(params, effectiveLayout); + await writeStreamingOutput(generator, cfg.outputPath); } else if (cfg.useStream) { const generator = buildDocumentPDFStream(params, effectiveLayout); await writeStreamingOutput(generator, cfg.outputPath); } else { const pdfBytes = buildDocumentPDFBytes(params, effectiveLayout); + bytes = pdfBytes.length; await writeOutput(pdfBytes, cfg.outputPath); } + emitStatus({ command: 'render', variant: 'document', dryRun: false, output: cfg.outputPath ?? '-', bytes }); } export async function render(args: ParsedArgs): Promise { @@ -402,7 +466,9 @@ export async function render(args: ParsedArgs): Promise { const outputPath = getStringFlag(args.flags, 'output', 'o'); const useStream = hasFlag(args.flags, 'stream'); const usePageStream = hasFlag(args.flags, 'stream-page-by-page'); + const useStreamTrue = hasFlag(args.flags, 'stream-true'); const useWatch = hasFlag(args.flags, 'watch'); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); const variant = getStringFlag(args.flags, 'variant') ?? 'document'; const langsRaw = getStringFlag(args.flags, 'lang'); const templatePath = getStringFlag(args.flags, 'template'); @@ -416,9 +482,9 @@ export async function render(args: ParsedArgs): Promise { ); } - if (useStream && usePageStream) { + if ([useStream, usePageStream, useStreamTrue].filter(Boolean).length > 1) { throw new CliError( - 'Use either --stream or --stream-page-by-page, not both.', + 'Use only one of --stream, --stream-page-by-page, or --stream-true.', 2, ); } @@ -433,7 +499,7 @@ export async function render(args: ParsedArgs): Promise { } const layout = await buildLayoutOptions(args); - if (useStream) assertStreamingCompatible(layout); + if (useStream || useStreamTrue) assertStreamingCompatible(layout); if (layout.compress === true) { // Required once per process for FlateDecode in Node ESM. @@ -453,17 +519,19 @@ export async function render(args: ParsedArgs): Promise { variant, useStream, usePageStream, + useStreamTrue, inputPath, outputPath, langs, layout, tableDefaults, + dryRun, }; // Initial render (always runs, even in --watch mode). await renderOnce(cfg, template); - if (!useWatch || inputPath === undefined) return; + if (dryRun || !useWatch || inputPath === undefined) return; // Watch loop: 200 ms debounce, stderr-only logs. Re-render errors are // reported and the watcher stays alive (renderOnce never escapes here). diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 0000000..c2c05ba --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,318 @@ +// `pdfnative schema [subject]` — print a JSON Schema for a CLI input/output +// shape so autonomous agents (and humans) can self-validate before invoking +// the CLI. +// +// Schemas are CLI-scoped: they describe the TOP-LEVEL shape the CLI accepts or +// emits, not every nested pdfnative block type (those live in pdfnative and its +// docs). They are hand-authored and versioned via a `$id` that embeds the CLI +// version, so drift is detectable and a test pins the contract. +// +// Philosophy: zero runtime deps, pure data. No validation engine is bundled — +// the CLI only PRODUCES schemas; callers validate with their own tooling. + +import { createRequire } from 'node:module'; +import type { ParsedArgs } from '../utils/args.js'; +import { CliError, ErrorCode } from '../utils/error.js'; + +type JsonSchema = Readonly>; + +const SUBJECTS = [ + 'render', + 'inspect', + 'verify', + 'batch', + 'inspect-summary', + 'verify-summary', + 'batch-summary', +] as const; +type Subject = (typeof SUBJECTS)[number]; + +function cliVersion(): string { + const require = createRequire(import.meta.url); + const pkg = require('../../package.json') as { version: string }; + return pkg.version; +} + +const DRAFT = 'https://json-schema.org/draft/2020-12/schema'; +const ID_BASE = 'https://pdfnative.dev/schema/cli'; + +function id(subject: Subject): string { + return `${ID_BASE}/${cliVersion()}/${subject}.schema.json`; +} + +function renderSchema(): JsonSchema { + const documentVariant: JsonSchema = { + type: 'object', + title: 'DocumentParams', + description: 'Free-form document input (default variant). The blocks array ' + + 'is validated by pdfnative; see pdfnative docs for block types.', + required: ['blocks'], + properties: { + blocks: { + type: 'array', + description: 'Ordered document blocks (text, table, image, toc, …).', + items: { type: 'object' }, + }, + layout: { type: 'object', description: 'PdfLayoutOptions overrides.' }, + fontEntries: { + type: 'array', + description: 'Pre-registered font entries (usually set via --font/--lang).', + items: { type: 'object' }, + }, + }, + }; + const tableVariant: JsonSchema = { + type: 'object', + title: 'PdfParams', + description: 'Table-centric input (use with `render --variant table`).', + required: ['title', 'headers', 'rows'], + properties: { + title: { type: 'string' }, + headers: { type: 'array', items: { type: 'string' } }, + rows: { type: 'array', items: { type: 'array' } }, + }, + }; + return { + $schema: DRAFT, + $id: id('render'), + title: 'pdfnative-cli render input', + description: 'JSON accepted on stdin or via --input by `pdfnative render`. ' + + 'One of two variants depending on --variant.', + oneOf: [documentVariant, tableVariant], + }; +} + +function inspectSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('inspect'), + title: 'pdfnative-cli inspect output', + description: 'JSON emitted by `pdfnative inspect --format json`.', + type: 'object', + required: ['version', 'pageCount', 'encrypted', 'pdfaConformance', 'signatures', 'metadata'], + additionalProperties: false, + properties: { + version: { type: 'string' }, + pageCount: { type: 'integer', minimum: 0 }, + encrypted: { type: 'boolean' }, + pdfaConformance: { type: ['string', 'null'] }, + signatures: { type: 'integer', minimum: 0 }, + metadata: { + type: 'object', + additionalProperties: false, + properties: { + title: { type: ['string', 'null'] }, + author: { type: ['string', 'null'] }, + creationDate: { type: ['string', 'null'] }, + subject: { type: ['string', 'null'] }, + producer: { type: ['string', 'null'] }, + }, + }, + pages: { + type: 'array', + items: { + type: 'object', + properties: { + index: { type: 'integer' }, + width: { type: ['number', 'null'] }, + height: { type: ['number', 'null'] }, + rotation: { type: 'number' }, + annotations: { type: 'integer' }, + formFields: { type: 'integer' }, + }, + }, + }, + pdfua: { + type: 'object', + properties: { + valid: { type: 'boolean' }, + errors: { type: 'array', items: { type: 'string' } }, + warnings: { type: 'array', items: { type: 'string' } }, + }, + }, + verbose: { + type: 'object', + properties: { + trailerKeys: { type: 'array', items: { type: 'string' } }, + catalogKeys: { type: 'array', items: { type: 'string' } }, + objectCount: { type: 'integer' }, + xmpMetadata: { type: ['string', 'null'] }, + }, + }, + }, + }; +} + +function verifySchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('verify'), + title: 'pdfnative-cli verify output', + description: 'JSON emitted by `pdfnative verify --format json`.', + type: 'object', + required: ['signatures', 'allValid'], + additionalProperties: false, + properties: { + allValid: { type: 'boolean' }, + signatures: { + type: 'array', + items: { + type: 'object', + properties: { + index: { type: 'integer' }, + fieldName: { type: ['string', 'null'] }, + subFilter: { type: ['string', 'null'] }, + signerSubject: { type: ['string', 'null'] }, + signerIssuer: { type: ['string', 'null'] }, + signingTime: { type: ['string', 'null'] }, + reason: { type: ['string', 'null'] }, + location: { type: ['string', 'null'] }, + digest: { type: ['string', 'null'] }, + integrity: { type: 'boolean' }, + chainValid: { type: 'boolean' }, + trustedRoot: { type: 'boolean' }, + signatureValid: { type: 'boolean' }, + signatureAlgorithm: { type: ['string', 'null'], enum: ['rsa-sha256', 'ecdsa-sha256', null] }, + timestampPresent: { type: 'boolean' }, + timestampValid: { type: 'boolean' }, + timestampTime: { type: ['string', 'null'] }, + tsaSubject: { type: ['string', 'null'] }, + revocationChecked: { type: 'boolean' }, + revocationStatus: { type: 'string', enum: ['unknown', 'good', 'revoked'] }, + revocationSource: { type: 'string', enum: ['embedded', 'online', 'none'] }, + revocationMethod: { type: ['string', 'null'], enum: ['ocsp', 'crl', null] }, + revocationRevokedAt: { type: ['string', 'null'] }, + notes: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }; +} + +function batchSchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('batch'), + title: 'pdfnative-cli batch output', + description: 'JSON emitted by `pdfnative batch --format json`.', + type: 'object', + required: ['total', 'succeeded', 'failed', 'results'], + additionalProperties: false, + properties: { + total: { type: 'integer', minimum: 0 }, + succeeded: { type: 'integer', minimum: 0 }, + failed: { type: 'integer', minimum: 0 }, + results: { + type: 'array', + items: { + type: 'object', + required: ['input', 'output', 'ok', 'error'], + additionalProperties: false, + properties: { + input: { type: 'string' }, + output: { type: 'string' }, + ok: { type: 'boolean' }, + error: { type: ['string', 'null'] }, + }, + }, + }, + }, + }; +} + +// --- Agent summary shapes (`--summary`) ----------------------------------- +// Compact, canonical verdicts emitted when a command is run with `--summary`. +// Pinned here so agents can validate the minimal output independently. + +function inspectSummarySchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('inspect-summary'), + title: 'pdfnative-cli inspect summary output', + description: 'JSON emitted by `pdfnative inspect --summary` (minimal verdict).', + type: 'object', + required: ['pages', 'encrypted', 'signatures', 'pdfa'], + additionalProperties: false, + properties: { + pages: { type: 'integer', minimum: 0 }, + encrypted: { type: 'boolean' }, + signatures: { type: 'integer', minimum: 0 }, + pdfa: { type: ['string', 'null'] }, + }, + }; +} + +function verifySummarySchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('verify-summary'), + title: 'pdfnative-cli verify summary output', + description: 'JSON emitted by `pdfnative verify --summary` (minimal verdict).', + type: 'object', + required: ['valid', 'signatures', 'invalid'], + additionalProperties: false, + properties: { + valid: { type: 'boolean' }, + signatures: { type: 'integer', minimum: 0 }, + invalid: { type: 'integer', minimum: 0 }, + }, + }; +} + +function batchSummarySchema(): JsonSchema { + return { + $schema: DRAFT, + $id: id('batch-summary'), + title: 'pdfnative-cli batch summary output', + description: 'JSON emitted by `pdfnative batch --summary` (minimal verdict, no per-file results).', + type: 'object', + required: ['total', 'succeeded', 'failed'], + additionalProperties: false, + properties: { + total: { type: 'integer', minimum: 0 }, + succeeded: { type: 'integer', minimum: 0 }, + failed: { type: 'integer', minimum: 0 }, + }, + }; +} + +const BUILDERS: Readonly JsonSchema>> = { + render: renderSchema, + inspect: inspectSchema, + verify: verifySchema, + batch: batchSchema, + 'inspect-summary': inspectSummarySchema, + 'verify-summary': verifySummarySchema, + 'batch-summary': batchSummarySchema, +}; + +function isSubject(value: string): value is Subject { + return (SUBJECTS as readonly string[]).includes(value); +} + +export async function schema(args: ParsedArgs): Promise { + const subject = args.positionals[0]; + + if (subject === undefined) { + // No subject → the most common need: the render input schema. + process.stdout.write(JSON.stringify(BUILDERS.render(), null, 2) + '\n'); + return Promise.resolve(); + } + + if (subject === 'list') { + process.stdout.write(JSON.stringify({ subjects: SUBJECTS }, null, 2) + '\n'); + return Promise.resolve(); + } + + if (!isSubject(subject)) { + throw new CliError( + `Unknown schema subject "${subject}". Valid: ${SUBJECTS.join(', ')}, list.`, + 2, + ErrorCode.USAGE, + ); + } + + process.stdout.write(JSON.stringify(BUILDERS[subject](), null, 2) + '\n'); + return Promise.resolve(); +} diff --git a/src/commands/sign.ts b/src/commands/sign.ts index ef49da6..7164bb1 100644 --- a/src/commands/sign.ts +++ b/src/commands/sign.ts @@ -1,8 +1,9 @@ import { signPdfBytes, addSignaturePlaceholder, ensureCryptoReady } from '../core-bridge/index.js'; import type { PdfSignOptions, SignatureAlgorithm } from '../core-bridge/index.js'; -import { type ParsedArgs, getStringFlag, getStringFlagAll } from '../utils/args.js'; +import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; import { readFileOrStdin, writeOutput } from '../utils/io.js'; -import { CliError } from '../utils/error.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { emitStatus, isDryRun } from '../utils/agent.js'; import { loadRsaPrivateKey, loadEcPrivateKey, @@ -47,6 +48,7 @@ export async function sign(args: ParsedArgs): Promise { const signingTimeRaw = getStringFlag(args.flags, 'signing-time'); const chainPaths = getStringFlagAll(args.flags, 'cert-chain'); const timestampUrl = getStringFlag(args.flags, 'timestamp'); + const dryRun = hasFlag(args.flags, 'dry-run') || isDryRun(); if (!VALID_ALGORITHMS.has(algorithm)) { throw new CliError( @@ -69,6 +71,7 @@ export async function sign(args: ParsedArgs): Promise { + 'Timestamp VALIDATION is already supported — run `pdfnative verify` on a ' + 'timestamped PDF.', 2, + ErrorCode.UNSUPPORTED, ); } @@ -124,7 +127,15 @@ export async function sign(args: ParsedArgs): Promise { pdfBytes = addSignaturePlaceholder(pdfBytes); } catch (e) { if (e instanceof CliError) throw e; - throw new CliError('Failed to prepare PDF for signing.', 1); + throw new CliError('Failed to prepare PDF for signing.', 1, ErrorCode.SIGN); + } + + // Dry-run: credentials parsed, PDF read and placeholder-prepared. Stop + // before producing (or writing) a signature. No key material is touched + // beyond the validation already performed above. + if (dryRun) { + emitStatus({ command: 'sign', dryRun: true, algorithm, output: outputPath ?? '-' }); + return; } let signedBytes: Uint8Array; @@ -133,7 +144,14 @@ export async function sign(args: ParsedArgs): Promise { } catch (e) { // Never include the underlying message — it may reference key bytes or hashes. if (e instanceof CliError) throw e; - throw new CliError('Failed to sign PDF.', 1); + throw new CliError('Failed to sign PDF.', 1, ErrorCode.SIGN); } await writeOutput(signedBytes, outputPath); + emitStatus({ + command: 'sign', + dryRun: false, + algorithm, + output: outputPath ?? '-', + bytes: signedBytes.length, + }); } diff --git a/src/commands/verify.ts b/src/commands/verify.ts index 57318e6..b5d013c 100644 --- a/src/commands/verify.ts +++ b/src/commands/verify.ts @@ -18,7 +18,9 @@ import type { } from '../core-bridge/index.js'; import { type ParsedArgs, getStringFlag, getStringFlagAll, hasFlag } from '../utils/args.js'; import { readFileOrStdin } from '../utils/io.js'; -import { CliError } from '../utils/error.js'; +import { CliError, ErrorCode } from '../utils/error.js'; +import { isJsonMode } from '../utils/agent.js'; +import { selectFields, serializeJson, parseFieldList } from '../utils/projection.js'; import { walkAbs, sliceNode, sliceContent, type AbsNode } from '../utils/asn1-walk.js'; import { loadPemChain, parseCertificateChain } from '../utils/keys.js'; import { verifyCmsSignatureValue, extractUnsignedAttrs, extractSignerSignatureValue } from '../utils/cms-verify.js'; @@ -399,7 +401,7 @@ export async function verify(args: ParsedArgs): Promise { reader = openPdf(pdfBytes); } catch (e) { const message = e instanceof Error ? e.message : String(e); - throw new CliError(`Failed to read PDF: ${message}`, 1); + throw new CliError(`Failed to read PDF: ${message}`, 1, ErrorCode.PARSE); } const fields = findSignatureFields(reader); @@ -571,7 +573,21 @@ export async function verify(args: ParsedArgs): Promise { const result: VerifyResult = { signatures: reports, allValid }; if (format === 'json') { - process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + const summary = hasFlag(args.flags, 'summary'); + const fieldsRaw = getStringFlag(args.flags, 'fields'); + let out: unknown = summary + ? { + valid: result.allValid, + signatures: result.signatures.length, + invalid: result.signatures.filter((s) => !s.signatureValid).length, + } + : result; + if (fieldsRaw !== undefined) { + out = selectFields(out, parseFieldList(fieldsRaw)); + } + // Compact for agents (--json), pretty for humans; --pretty forces pretty. + const pretty = hasFlag(args.flags, 'pretty') || !isJsonMode(); + process.stdout.write(serializeJson(out, pretty) + '\n'); } else { process.stdout.write(`Signatures: ${reports.length}\n`); for (const r of reports) { @@ -598,6 +614,6 @@ export async function verify(args: ParsedArgs): Promise { } if (strict && !allValid) { - throw new CliError('', 1); + throw new CliError('', 1, ErrorCode.VERIFY_FAILED); } } diff --git a/src/core-bridge/index.ts b/src/core-bridge/index.ts index 0bf8f7c..d99c5d5 100644 --- a/src/core-bridge/index.ts +++ b/src/core-bridge/index.ts @@ -9,6 +9,8 @@ export { buildPDFBytes, buildPDFStream } from 'pdfnative'; // ── Render (page-by-page streaming, v1.2.0) ────────────────────────── export { buildDocumentPDFStreamPageByPage, buildPDFStreamPageByPage } from 'pdfnative'; +// ── Render (true constant-memory streaming, v1.3.0) ────────────── +export { buildDocumentPDFStreamTrue, buildPDFStreamTrue } from 'pdfnative'; // ── PDF/A conformance targets (single source of truth, v1.2.0) ─────── export { PDF_A_CONFORMANCE_TARGETS } from 'pdfnative'; @@ -43,6 +45,9 @@ export { // ── Inspect / Verify — PDF parser helpers ──────────────────────────── export { openPdf, isRef, isName, isDict, isArray, isStream, nameValue } from 'pdfnative'; +// ── Inspect — PDF/UA structural validator (ISO 14289-1, v1.3.0) ────── +export { validatePdfUA } from 'pdfnative'; + // ── Fonts (multi-language --lang flag, v1.1.0 latin/emoji modules) ── export { registerFont, registerFonts, loadFontData, hasFontLoader } from 'pdfnative'; @@ -78,3 +83,4 @@ export type { export type { PdfReader, PdfValue, PdfName, PdfRef, PdfStream } from 'pdfnative'; export type { ParsedDict as PdfDict, ParsedArray as PdfArray } from 'pdfnative'; +export type { PdfUAValidationResult } from 'pdfnative'; diff --git a/src/index.ts b/src/index.ts index c529974..59ab9a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import { parseArgs, hasFlag, getStringFlag } from './utils/args.js'; import { CliError } from './utils/error.js'; +import { isJsonMode, emitJsonError } from './utils/agent.js'; import { loadConfig, applyConfigDefaults } from './utils/config.js'; // Lazy-import commands to keep startup fast for --help / --version @@ -18,6 +19,7 @@ Commands: verify Verify embedded PDF signatures inspect Analyse a PDF and output metadata / conformance info batch Render every JSON file in a directory to PDF (parallel) + schema Print a JSON Schema for a CLI input/output shape completion Emit a shell completion script (bash|zsh|fish) Options: @@ -29,7 +31,12 @@ Global options (any command): --no-config Ignore any .pdfnativerc.json --quiet, -q Suppress progress output on stderr --no-color Disable ANSI colour (also respects NO_COLOR) + --json Agent mode: emit a JSON status/error envelope on stderr + (data stays on stdout). Errors carry a stable code. + --dry-run Validate inputs and exit without writing output + (render, sign, batch). +For autonomous/agent usage see AGENTS.md. Run \`pdfnative --help\` for per-command options. `; @@ -49,6 +56,11 @@ I/O: Stream output chunked at PDF object boundaries. Assembles the full document first, so TOC blocks and {pages} ARE supported. Mutually exclusive with --stream. + --stream-true True constant-memory streaming (pdfnative 1.3.0): parts are + emitted and freed as they go, so the joined binary never + materialises. Same constraints as --stream (no TOC, no + {pages}); byte-identical output. Mutually exclusive with the + other --stream* flags. --watch Re-render on input file change (requires --input and a file --output; logs to stderr; debounce 200 ms). --template Path to JSON template file. Stdin / --input is deep-merged @@ -72,9 +84,13 @@ Layout (flags override values from --layout file): --tagged none|pdfa1b|pdfa2b|pdfa2u|pdfa3b (PDF/A flag) --conformance DEPRECATED — alias for --tagged pdfa{1b|2b|3b} --compress Enable Flate compression (initialises Node compression) - --lang Comma-separated language packs (e.g. th,ja,ar) - --font Register a bundled font shortcut (repeatable). Allowed: - latin, emoji. The registered name is then usable via --lang. + --max-blocks Max document blocks before pdfnative aborts (default 100000) + --lang Comma-separated language packs (e.g. th,ja,ar,te,si,km) + --font Register a bundled font shortcut (repeatable). The name + doubles as the --lang code. Allowed: latin, emoji, + color-emoji, and the 22 script codes ar, hy, bn, ru, hi, am, + ka, el, he, ja, km, ko, my, pl, zh, si, ta, te, th, bo, tr, + vi. Header / Footer: --header-left, --header-center, --header-right @@ -162,6 +178,9 @@ Options: soft-fail only an explicit "revoked" status fails strict a non-"good" status fails the signature --format, -f json (default) or text + --summary Emit only the minimal verdict { valid, signatures, invalid } + --fields Comma-separated dot-paths to keep (e.g. valid,signatures.signatureValid) + --pretty Force indented JSON even under --json (agent mode is compact) --help, -h Show this help message Reported per signature: @@ -189,8 +208,13 @@ Options: --verbose, -v Include trailerKeys, catalogKeys, objectCount, XMP metadata length --pages Per-page width/height/rotation/annotation/formField counts + --pdfua Include a PDF/UA (ISO 14289-1) structural validation report + (valid + errors + warnings) --check Assert a property; repeatable; AND semantics; exits 1 on - failure. Values: pdfa | signed | encrypted + failure. Values: pdfa | signed | encrypted | pdfua + --summary Emit only the minimal verdict { pages, encrypted, signatures, pdfa } + --fields Comma-separated dot-paths to keep (e.g. pageCount,metadata.title) + --pretty Force indented JSON even under --json (agent mode is compact) --help, -h Show this help message `; @@ -206,6 +230,9 @@ Options: --concurrency Maximum parallel renders (default: 4) --fail-fast Stop at the first failure (default: render all, then report) --format, -f Summary format: text (default) or json + --summary Emit only the minimal verdict { total, succeeded, failed } + --fields Comma-separated dot-paths to keep (e.g. total,failed) + --pretty Force indented JSON even under --json (agent mode is compact) --help, -h Show this help message All other flags (--variant, --layout, --page-size, --tagged, --compress, @@ -213,6 +240,27 @@ smart-table flags, …) are forwarded to each render. Per-file --input/--output are managed automatically. Exit code 1 if any file fails. `; +const SCHEMA_USAGE = `\ +pdfnative schema — Print a JSON Schema for a CLI input/output shape + +Usage: + pdfnative schema [subject] + +Subjects: + render Input for \`render\` (document or table variant) — default + inspect Output of \`inspect --format json\` + verify Output of \`verify --format json\` + batch Output of \`batch --format json\` + inspect-summary Output of \`inspect --summary\` + verify-summary Output of \`verify --summary\` + batch-summary Output of \`batch --summary\` + list Print the available subjects as JSON + +With no subject, the \`render\` input schema is printed. Schemas are JSON Schema +Draft 2020-12 and carry a versioned \\$id, so agents can self-validate input +before invoking the CLI. +`; + const COMPLETION_USAGE = `\ pdfnative completion — Emit a shell completion script @@ -257,6 +305,10 @@ async function loadCommand(name: string): Promise { const m = await import('./commands/completion.js'); return m.completion; } + case 'schema': { + const m = await import('./commands/schema.js'); + return m.schema; + } default: return Promise.reject( new CliError(`Unknown command: ${name}. Run pdfnative --help for usage.`, 1), @@ -264,6 +316,9 @@ async function loadCommand(name: string): Promise { } } +// The command being dispatched, captured for the agent JSON error envelope. +let activeCommand: string | null = null; + async function main(): Promise { const argv = process.argv.slice(2); const args = parseArgs(argv); @@ -275,6 +330,12 @@ async function main(): Promise { if (hasFlag(args.flags, 'quiet', 'q')) { process.env['PDFNATIVE_QUIET'] = '1'; } + if (hasFlag(args.flags, 'json')) { + process.env['PDFNATIVE_JSON'] = '1'; + } + if (hasFlag(args.flags, 'dry-run')) { + process.env['PDFNATIVE_DRY_RUN'] = '1'; + } if (hasFlag(args.flags, 'help', 'h') && args.positionals.length === 0) { process.stdout.write(USAGE); @@ -298,6 +359,8 @@ async function main(): Promise { process.exit(0); } + activeCommand = commandName; + if (hasFlag(args.flags, 'help', 'h')) { switch (commandName) { case 'render': process.stdout.write(RENDER_USAGE); break; @@ -305,6 +368,7 @@ async function main(): Promise { case 'verify': process.stdout.write(VERIFY_USAGE); break; case 'inspect': process.stdout.write(INSPECT_USAGE); break; case 'batch': process.stdout.write(BATCH_USAGE); break; + case 'schema': process.stdout.write(SCHEMA_USAGE); break; case 'completion': process.stdout.write(COMPLETION_USAGE); break; default: process.stderr.write(`Unknown command: ${commandName}. Run pdfnative --help for usage.\n`); @@ -338,6 +402,14 @@ async function main(): Promise { } main().catch((e: unknown) => { + // Agent mode: a single JSON error envelope on stderr, with a stable code. + if (isJsonMode()) { + emitJsonError(activeCommand, e); + if (process.env['PDFNATIVE_DEBUG'] === '1' && e instanceof Error) { + process.stderr.write((e.stack ?? e.message) + '\n'); + } + process.exit(e instanceof CliError ? e.exitCode : 1); + } if (e instanceof CliError) { if (e.message.length > 0) { process.stderr.write(e.message + '\n'); diff --git a/src/utils/agent.ts b/src/utils/agent.ts new file mode 100644 index 0000000..3ce99ae --- /dev/null +++ b/src/utils/agent.ts @@ -0,0 +1,75 @@ +// Agent-mode helpers — the cross-cutting "machine contract" that lets an +// autonomous caller (an AI agent, a CI step, another program) drive the CLI +// deterministically. +// +// Contract (see docs/KNOWLEDGE_BASE.md → "Agent automation contract"): +// • stdout carries the primary artifact (PDF, JSON report, schema, script). +// • stderr carries ALL diagnostics, including the agent envelopes below. +// • Global `--json` sets PDFNATIVE_JSON=1 (done in index.ts). In that mode: +// – on any failure, a single JSON object is written to stderr: +// { "ok": false, "command": , +// "error": { "code": "E_*", "message": "…" } } +// – render/sign/batch emit a success status envelope to stderr: +// { "ok": true, "command": "render", … } +// • Numeric exit codes (0/1/2) are unchanged in every mode. +// +// This module is intentionally tiny and dependency-free: agent mode is a thin +// presentation layer over the existing dispatch, never a separate runtime. + +import { CliError, ErrorCode, type ErrorCodeValue } from './error.js'; + +/** True when the caller passed the global `--json` flag (agent mode). */ +export function isJsonMode(): boolean { + return process.env['PDFNATIVE_JSON'] === '1'; +} + +/** True when `--dry-run` is in effect (set by index.ts for the active command). */ +export function isDryRun(): boolean { + return process.env['PDFNATIVE_DRY_RUN'] === '1'; +} + +export interface AgentErrorEnvelope { + readonly ok: false; + readonly command: string | null; + readonly error: { + readonly code: ErrorCodeValue; + readonly message: string; + }; +} + +const DEFAULT_MESSAGE: Readonly> = { + [ErrorCode.USAGE]: 'usage error', + [ErrorCode.INPUT]: 'invalid input', + [ErrorCode.PARSE]: 'failed to parse input', + [ErrorCode.IO]: 'I/O error', + [ErrorCode.SIGN]: 'failed to sign PDF', + [ErrorCode.VERIFY_FAILED]: 'one or more signatures failed verification', + [ErrorCode.CHECK_FAILED]: 'one or more checks failed', + [ErrorCode.UNSUPPORTED]: 'unsupported operation', + [ErrorCode.RUNTIME]: 'runtime error', +}; + +/** Build the machine-readable error envelope for any thrown value. */ +export function buildErrorEnvelope(command: string | null, err: unknown): AgentErrorEnvelope { + if (err instanceof CliError) { + const message = err.message.length > 0 ? err.message : DEFAULT_MESSAGE[err.code]; + return { ok: false, command, error: { code: err.code, message } }; + } + const message = err instanceof Error ? err.message : String(err); + return { ok: false, command, error: { code: ErrorCode.RUNTIME, message } }; +} + +/** Write the JSON error envelope to stderr (single line, newline-terminated). */ +export function emitJsonError(command: string | null, err: unknown): void { + process.stderr.write(JSON.stringify(buildErrorEnvelope(command, err)) + '\n'); +} + +/** + * Emit a success status envelope to stderr when in agent (`--json`) mode. + * No-op otherwise, so commands can call it unconditionally. stdout is never + * touched here — it stays reserved for the primary artifact. + */ +export function emitStatus(envelope: Readonly>): void { + if (!isJsonMode()) return; + process.stderr.write(JSON.stringify({ ok: true, ...envelope }) + '\n'); +} diff --git a/src/utils/error.ts b/src/utils/error.ts index d335c94..7c80606 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,17 +1,52 @@ +/** + * Stable, machine-readable error codes surfaced in the agent JSON envelope + * (see {@link ../utils/agent.ts}). These are part of the CLI's public contract: + * they let autonomous callers branch on a failure class without parsing the + * human-readable message. Numeric exit codes (0/1/2) are unchanged. + */ +export const ErrorCode = { + /** Usage error — missing/invalid flag or argument (exit 2). */ + USAGE: 'E_USAGE', + /** Invalid input payload (wrong shape, failed validation). */ + INPUT: 'E_INPUT', + /** Failed to parse JSON / PDF / DER input. */ + PARSE: 'E_PARSE', + /** Filesystem or stream I/O failure. */ + IO: 'E_IO', + /** Signing failed (message is always generic — never leaks key material). */ + SIGN: 'E_SIGN', + /** `verify --strict` found one or more invalid signatures. */ + VERIFY_FAILED: 'E_VERIFY_FAILED', + /** `inspect --check` assertion failed. */ + CHECK_FAILED: 'E_CHECK_FAILED', + /** Requested capability is reserved / not yet available. */ + UNSUPPORTED: 'E_UNSUPPORTED', + /** Catch-all runtime error (exit 1). */ + RUNTIME: 'E_RUNTIME', +} as const; + +export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]; + /** * CLI Error — thrown by commands when a user-facing error occurs. * * Exit code conventions: * 1 = runtime error (invalid input, I/O failure) * 2 = usage error (missing required argument) + * + * The optional `code` is a stable {@link ErrorCode} string used by the agent + * JSON envelope. When omitted it defaults from the exit code (2 → `E_USAGE`, + * otherwise `E_RUNTIME`), so existing call sites keep a sensible code for free. */ export class CliError extends Error { public readonly exitCode: number; + public readonly code: ErrorCodeValue; - constructor(message: string, exitCode = 1) { + constructor(message: string, exitCode = 1, code?: ErrorCodeValue) { super(message); this.name = 'CliError'; this.exitCode = exitCode; + this.code = code ?? (exitCode === 2 ? ErrorCode.USAGE : ErrorCode.RUNTIME); } } diff --git a/src/utils/layout.ts b/src/utils/layout.ts index 975efb6..897ee1d 100644 --- a/src/utils/layout.ts +++ b/src/utils/layout.ts @@ -390,6 +390,19 @@ export async function buildLayoutOptions( out.compress = true; } + // --max-blocks (pdfnative 1.3.0 layout.maxBlocks; default DEFAULT_MAX_BLOCKS = 100000) + const maxBlocks = getStringFlag(args.flags, 'max-blocks'); + if (maxBlocks !== undefined) { + const n = Number.parseInt(maxBlocks, 10); + if (!Number.isInteger(n) || n <= 0 || String(n) !== maxBlocks.trim()) { + throw new CliError( + `Invalid --max-blocks value "${maxBlocks}". Expected a positive integer.`, + 2, + ); + } + out.maxBlocks = n; + } + // --tagged / deprecated --conformance const tagged = getStringFlag(args.flags, 'tagged'); const conformance = getStringFlag(args.flags, 'conformance'); diff --git a/src/utils/projection.ts b/src/utils/projection.ts new file mode 100644 index 0000000..d8983b7 --- /dev/null +++ b/src/utils/projection.ts @@ -0,0 +1,98 @@ +// Agent output projection layer. +// +// A thin, dependency-free abstraction that lets autonomous AI agents (and CI) +// shrink the JSON the CLI emits on stdout — typically by ~90 % — without losing +// the information they actually branch on. Three composable levers: +// +// 1. Compact serialization — `serializeJson(value, pretty=false)` drops the +// 2-space indentation agents never read (smaller, still valid JSON). +// 2. `--fields a,b.c` — `selectFields` projects a result down to a set +// of dot-paths; an array segment maps over every element. +// 3. (canonical summaries live in each command — they feed this layer.) +// +// Everything here is pure data manipulation: no I/O, no globals, no deps. + +/** Parse a comma-separated `--fields` value into trimmed, non-empty dot-paths. */ +export function parseFieldList(csv: string): string[] { + return csv + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Serialize a value as JSON. Agents get compact output (no indentation); humans + * get pretty 2-space output. The compact form is byte-for-byte smaller while + * remaining valid JSON, which is the bulk of the token saving. + */ +export function serializeJson(value: unknown, pretty: boolean): string { + return JSON.stringify(value, null, pretty ? 2 : 0); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Project a value down to a single dot-path, preserving its nesting. + * - An empty path returns the whole subtree (leaf). + * - On an array, the remaining path is mapped over every element. + * - A missing/typed-out path yields `undefined` (the caller omits it). + */ +function pick(value: unknown, segments: readonly string[]): unknown { + if (segments.length === 0) return value; + if (Array.isArray(value)) { + return value.map((el) => pick(el, segments)); + } + if (isPlainObject(value)) { + const [head, ...rest] = segments; + if (head === undefined || !(head in value)) return undefined; + const picked = pick(value[head], rest); + if (picked === undefined) return undefined; + return { [head]: picked }; + } + // A primitive with path left to walk → the path does not exist. + return undefined; +} + +/** Deep-merge two projections so multiple `--fields` paths combine into one. */ +function deepMerge(a: unknown, b: unknown): unknown { + if (b === undefined) return a; + if (a === undefined) return b; + if (Array.isArray(a) && Array.isArray(b)) { + const len = Math.max(a.length, b.length); + const out: unknown[] = []; + for (let i = 0; i < len; i++) out.push(deepMerge(a[i], b[i])); + return out; + } + if (isPlainObject(a) && isPlainObject(b)) { + const out: Record = { ...a }; + for (const [k, v] of Object.entries(b)) { + out[k] = k in out ? deepMerge(out[k], v) : v; + } + return out; + } + return b; // scalar conflict: last path wins +} + +/** + * Build a pruned projection of `value` containing only the requested dot-paths. + * + * - Paths are dot-separated; a segment landing on an array maps over its items + * (e.g. `signatures.signatureValid` → `{ signatures: [{ signatureValid }, …] }`). + * - Unknown or non-existent paths are silently omitted (lenient by design, so an + * agent never crashes the CLI by asking for a field that is conditionally absent). + * - Multiple paths are deep-merged into a single object. + */ +export function selectFields(value: unknown, paths: readonly string[]): unknown { + let result: unknown; + for (const path of paths) { + const segments = path + .split('.') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + if (segments.length === 0) continue; + result = deepMerge(result, pick(value, segments)); + } + return result === undefined ? {} : result; +} diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 5871180..87511b2 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import { batch } from '../../src/commands/batch.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; function capture(fn: () => Promise): Promise { return new Promise((resolve, reject) => { @@ -109,4 +109,82 @@ describe('batch', () => { batch(parseArgs(['--input-dir', inDir, '--output-dir', path.join(inDir, 'o')])), ).rejects.toBeInstanceOf(CliError); }); + + it('tags an empty directory failure with E_INPUT', async () => { + const inDir = await makeInputDir({ 'note.txt': 'x' }); + const err = await batch(parseArgs(['--input-dir', inDir, '--output-dir', path.join(inDir, 'o')])) + .catch((e: unknown) => e); + expect((err as CliError).code).toBe(ErrorCode.INPUT); + }); + + it('--dry-run validates inputs without creating the output directory', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const inDir = await makeInputDir({ 'a.json': DOC, 'b.json': DOC }); + const outDir = path.join(inDir, 'out'); + await capture(() => + batch(parseArgs(['--input-dir', inDir, '--output-dir', outDir, '--dry-run'])), + ); + await expect(fs.stat(outDir)).rejects.toThrow(); + }); + + it('agent json mode forces a machine-readable summary on stdout', async () => { + const origJson = process.env['PDFNATIVE_JSON']; + process.env['PDFNATIVE_JSON'] = '1'; + process.env['PDFNATIVE_QUIET'] = '1'; + try { + const inDir = await makeInputDir({ 'a.json': DOC }); + const outDir = path.join(inDir, 'out'); + const out = await capture(() => + batch(parseArgs(['--input-dir', inDir, '--output-dir', outDir])), + ); + const summary = JSON.parse(out) as { total: number; succeeded: number; failed: number }; + expect(summary).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); + } finally { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + } + }); + + describe('agent output projection', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + it('--summary drops the per-file results array', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const inDir = await makeInputDir({ 'a.json': DOC, 'b.json': DOC }); + const outDir = path.join(inDir, 'out'); + const out = await capture(() => + batch(parseArgs(['--input-dir', inDir, '--output-dir', outDir, '--format', 'json', '--summary'])), + ); + const doc = JSON.parse(out) as Record; + expect(doc).toEqual({ total: 2, succeeded: 2, failed: 0 }); + expect(doc).not.toHaveProperty('results'); + }); + + it('--json compacts the summary output (no indentation)', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + process.env['PDFNATIVE_QUIET'] = '1'; + const inDir = await makeInputDir({ 'a.json': DOC }); + const outDir = path.join(inDir, 'out'); + const out = await capture(() => + batch(parseArgs(['--input-dir', inDir, '--output-dir', outDir])), + ); + expect(out.trimEnd()).not.toContain('\n'); + expect(out).not.toContain(' '); + }); + + it('--fields projects only the requested paths', async () => { + process.env['PDFNATIVE_QUIET'] = '1'; + const inDir = await makeInputDir({ 'a.json': DOC }); + const outDir = path.join(inDir, 'out'); + const out = await capture(() => + batch(parseArgs(['--input-dir', inDir, '--output-dir', outDir, '--format', 'json', '--fields', 'total,failed'])), + ); + expect(JSON.parse(out)).toEqual({ total: 1, failed: 0 }); + }); + }); }); diff --git a/tests/commands/completion.test.ts b/tests/commands/completion.test.ts index d87246d..b9fdaa9 100644 --- a/tests/commands/completion.test.ts +++ b/tests/commands/completion.test.ts @@ -50,4 +50,13 @@ describe('completion', () => { it('throws CliError(2) for an unsupported shell', async () => { await expect(completion(parseArgs(['powershell']))).rejects.toBeInstanceOf(CliError); }); + + it('includes the schema command and agent global flags in each shell', async () => { + for (const shell of ['bash', 'zsh', 'fish']) { + const out = await capture(() => completion(parseArgs([shell]))); + expect(out).toContain('schema'); + expect(out).toContain('json'); + expect(out).toContain('dry-run'); + } + }); }); diff --git a/tests/commands/inspect.test.ts b/tests/commands/inspect.test.ts index f364932..dce876b 100644 --- a/tests/commands/inspect.test.ts +++ b/tests/commands/inspect.test.ts @@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises'; import { inspect } from '../../src/commands/inspect.js'; import { render } from '../../src/commands/render.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; const minimalParams = JSON.stringify({ title: 'Inspect Test', @@ -240,4 +240,235 @@ describe('inspect', () => { // No throw → success. expect(true).toBe(true); }); + + it('--pdfua includes a structural report in JSON output', async () => { + const pdfPath = await generateTestPdf(); + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: unknown) => { + chunks.push(String(c)); + return true; + }; + try { + await inspect(parseArgs(['--input', pdfPath, '--pdfua', '--format', 'json'])); + } finally { + process.stdout.write = original; + } + const result = JSON.parse(chunks.join('')) as { + pdfua?: { valid: boolean; errors: string[]; warnings: string[] }; + }; + expect(result.pdfua).toBeDefined(); + expect(typeof result.pdfua?.valid).toBe('boolean'); + expect(Array.isArray(result.pdfua?.errors)).toBe(true); + expect(Array.isArray(result.pdfua?.warnings)).toBe(true); + }); + + it('--pdfua renders a report section in text output', async () => { + const pdfPath = await generateTestPdf(); + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: unknown) => { + chunks.push(String(c)); + return true; + }; + try { + await inspect(parseArgs(['--input', pdfPath, '--pdfua', '--format', 'text'])); + } finally { + process.stdout.write = original; + } + expect(chunks.join('')).toContain('PDF/UA:'); + }); + + it('--check pdfua fails on a non-tagged PDF', async () => { + const pdfPath = await generateTestPdf(); + const errStream: string[] = []; + const origStdout = process.stdout.write.bind(process.stdout); + const origStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = () => true; + process.stderr.write = (c: unknown) => { + errStream.push(String(c)); + return true; + }; + try { + const err = await inspect(parseArgs(['--input', pdfPath, '--check', 'pdfua'])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(1); + expect(errStream.join('')).toContain('pdfua=fail'); + } finally { + process.stdout.write = origStdout; + process.stderr.write = origStderr; + } + }); + + it('--check pdfua passes on a tagged (PDF/A) document', async () => { + const inputPath = path.join(os.tmpdir(), `inspect-ua-in-${Date.now()}.json`); + const outputPath = path.join(os.tmpdir(), `inspect-ua-out-${Date.now()}.pdf`); + tmpFiles.push(inputPath, outputPath); + await fs.writeFile(inputPath, minimalParams, 'utf8'); + await render(parseArgs(['--input', inputPath, '--output', outputPath, '--tagged', 'pdfa2b'])); + + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = () => true; + try { + // No throw → check passed (exit 0). + await inspect(parseArgs(['--input', outputPath, '--check', 'pdfua'])); + } finally { + process.stdout.write = original; + } + expect(true).toBe(true); + }); + + it('detects PDF/A conformance from XMP metadata', async () => { + const inputPath = path.join(os.tmpdir(), `inspect-pdfa-in-${Date.now()}.json`); + const outputPath = path.join(os.tmpdir(), `inspect-pdfa-out-${Date.now()}.pdf`); + tmpFiles.push(inputPath, outputPath); + await fs.writeFile(inputPath, minimalParams, 'utf8'); + await render(parseArgs(['--input', inputPath, '--output', outputPath, '--tagged', 'pdfa2b'])); + + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }; + try { + await inspect(parseArgs(['--input', outputPath, '--format', 'json'])); + } finally { + process.stdout.write = original; + } + + const result = JSON.parse(chunks.join('')) as InspectResult; + expect(result.pdfaConformance).toBe('2b'); + + // No throw → check passed (exit 0). + const silenced = process.stdout.write.bind(process.stdout); + process.stdout.write = () => true; + try { + await inspect(parseArgs(['--input', outputPath, '--check', 'pdfa'])); + } finally { + process.stdout.write = silenced; + } + }); + + describe('agent mode (error codes)', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + it('tags an unreadable PDF with E_PARSE', async () => { + const badPath = path.join(os.tmpdir(), `inspect-bad-${Date.now()}.pdf`); + tmpFiles.push(badPath); + await fs.writeFile(badPath, 'not a pdf', 'utf8'); + const err = await inspect(parseArgs(['--input', badPath])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.PARSE); + }); + + it('tags a failed --check with E_CHECK_FAILED', async () => { + const pdfPath = await generateTestPdf(); + const origStdout = process.stdout.write.bind(process.stdout); + const origStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = () => true; + process.stderr.write = () => true; + try { + const err = await inspect(parseArgs(['--input', pdfPath, '--check', 'encrypted'])) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.CHECK_FAILED); + } finally { + process.stdout.write = origStdout; + process.stderr.write = origStderr; + } + }); + + it('in --json mode the check detail rides in the error message (not stderr text)', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const pdfPath = await generateTestPdf(); + const errStream: string[] = []; + const origStdout = process.stdout.write.bind(process.stdout); + const origStderr = process.stderr.write.bind(process.stderr); + process.stdout.write = () => true; + process.stderr.write = (c: unknown) => { + errStream.push(String(c)); + return true; + }; + let thrown: unknown; + try { + thrown = await inspect(parseArgs(['--input', pdfPath, '--check', 'encrypted'])) + .catch((e: unknown) => e); + } finally { + process.stdout.write = origStdout; + process.stderr.write = origStderr; + } + // In JSON mode the command does NOT pre-print the detail to stderr + // (the dispatcher serialises the envelope); the message carries it. + expect(errStream.join('')).toBe(''); + expect(thrown).toBeInstanceOf(CliError); + expect((thrown as CliError).code).toBe(ErrorCode.CHECK_FAILED); + expect((thrown as CliError).message).toContain('encrypted'); + }); + }); + + describe('agent output projection', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + async function runJson(flags: string[]): Promise { + const pdfPath = await generateTestPdf(); + const chunks: string[] = []; + const original = process.stdout.write.bind(process.stdout); + process.stdout.write = (c: unknown) => { + chunks.push(String(c)); + return true; + }; + try { + await inspect(parseArgs(['--input', pdfPath, ...flags])); + } finally { + process.stdout.write = original; + } + return chunks.join(''); + } + + it('--summary emits the canonical minimal verdict', async () => { + const out = await runJson(['--summary']); + const doc = JSON.parse(out); + expect(Object.keys(doc).sort()).toEqual(['encrypted', 'pages', 'pdfa', 'signatures']); + expect(typeof doc.pages).toBe('number'); + expect(typeof doc.encrypted).toBe('boolean'); + }); + + it('--json compacts the output (no indentation)', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const out = await runJson([]); + expect(out.endsWith('\n')).toBe(true); + expect(out.trimEnd()).not.toContain('\n'); + expect(out).not.toContain(' '); + }); + + it('--pretty restores indentation even in --json mode', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const out = await runJson(['--pretty']); + expect(out).toContain('\n '); + }); + + it('--fields projects only the requested paths', async () => { + const out = await runJson(['--fields', 'pageCount,metadata.title']); + const doc = JSON.parse(out); + expect(Object.keys(doc).sort()).toEqual(['metadata', 'pageCount']); + expect(doc.metadata).toEqual({ title: expect.anything() }); + }); + + it('--summary and --fields compose', async () => { + const out = await runJson(['--summary', '--fields', 'pages']); + expect(JSON.parse(out)).toEqual({ pages: expect.any(Number) }); + }); + }); }); diff --git a/tests/commands/render.test.ts b/tests/commands/render.test.ts index 840d03d..7ccfdeb 100644 --- a/tests/commands/render.test.ts +++ b/tests/commands/render.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; @@ -372,4 +372,217 @@ describe('render', () => { ).rejects.toThrowError(/--output/); }); }); + + it('produces valid PDF with --stream-true (document variant)', async () => { + const outPath = path.join(os.tmpdir(), `render-streamtrue-${Date.now()}.pdf`); + tmpFiles.push(outPath); + + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', outPath, '--stream-true'])); + }); + + const bytes = await fs.readFile(outPath); + expect(bytes.slice(0, 4).toString('ascii')).toBe('%PDF'); + expect(bytes.toString('ascii').includes('%%EOF')).toBe(true); + }); + + it('--stream-true is byte-identical to non-streaming output', async () => { + const bufferedPath = path.join(os.tmpdir(), `render-buf-${Date.now()}.pdf`); + const truePath = path.join(os.tmpdir(), `render-true-${Date.now()}.pdf`); + tmpFiles.push(bufferedPath, truePath); + + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', bufferedPath])); + await render(parseArgs(['--input', inputPath, '--output', truePath, '--stream-true'])); + }); + + const a = await fs.readFile(bufferedPath); + const b = await fs.readFile(truePath); + expect(b.equals(a)).toBe(true); + }); + + it('--stream-true produces valid PDF for --variant table', async () => { + const tableParams = JSON.stringify({ + title: 'Table', + infoItems: [], + balanceText: '', + countText: '', + headers: ['A', 'B'], + rows: [ + { cells: ['1', '2'], type: 'normal', pointed: false }, + { cells: ['3', '4'], type: 'normal', pointed: false }, + ], + footerText: 'footer', + }); + const outPath = path.join(os.tmpdir(), `render-streamtrue-table-${Date.now()}.pdf`); + tmpFiles.push(outPath); + + await withTempFile('.json', tableParams, async (inputPath) => { + await render(parseArgs([ + '--input', inputPath, '--output', outPath, + '--variant', 'table', '--stream-true', + ])); + }); + + const bytes = await fs.readFile(outPath); + expect(bytes.slice(0, 4).toString('ascii')).toBe('%PDF'); + }); + + it('--stream-true rejects TOC blocks', async () => { + const tocParams = JSON.stringify({ + blocks: [ + { type: 'toc' }, + { type: 'heading', level: 1, text: 'Section' }, + ], + }); + await withTempFile('.json', tocParams, async (inputPath) => { + const err = await render(parseArgs([ + '--input', inputPath, '--output', '-', '--stream-true', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + }); + + it('rejects combining multiple --stream* flags', async () => { + await withTempFile('.json', minimalParams, async (inputPath) => { + const err = await render(parseArgs([ + '--input', inputPath, '--output', '-', + '--stream', '--stream-true', + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + }); + }); + + it('--max-blocks accepts a positive integer', async () => { + const outPath = path.join(os.tmpdir(), `render-maxblocks-${Date.now()}.pdf`); + tmpFiles.push(outPath); + + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', outPath, '--max-blocks', '500'])); + }); + + const bytes = await fs.readFile(outPath); + expect(bytes.slice(0, 4).toString('ascii')).toBe('%PDF'); + }); + + it('--max-blocks rejects non-positive / non-integer values', async () => { + await withTempFile('.json', minimalParams, async (inputPath) => { + for (const bad of ['0', '-3', 'abc', '1.5']) { + const err = await render(parseArgs([ + '--input', inputPath, '--output', '-', '--max-blocks', bad, + ])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).exitCode).toBe(2); + } + }); + }); + + it('--font registers a new pdfnative 1.3.0 script (te) usable by --lang', async () => { + const params = JSON.stringify({ + blocks: [{ type: 'paragraph', text: 'తెలుగు' }], + }); + const outPath = path.join(os.tmpdir(), `render-font-te-${Date.now()}.pdf`); + tmpFiles.push(outPath); + + await withTempFile('.json', params, async (inputPath) => { + await render(parseArgs([ + '--input', inputPath, + '--font', 'te', + '--lang', 'te', + '--output', outPath, + ])); + }); + + const stat = await fs.stat(outPath); + expect(stat.size).toBeGreaterThan(1000); + }); + + it('--font accepts the COLRv1 color-emoji shortcut', async () => { + const params = JSON.stringify({ + blocks: [{ type: 'paragraph', text: 'Hi 🎉' }], + }); + const outPath = path.join(os.tmpdir(), `render-font-coloremoji-${Date.now()}.pdf`); + tmpFiles.push(outPath); + + await withTempFile('.json', params, async (inputPath) => { + await render(parseArgs([ + '--input', inputPath, + '--font', 'color-emoji', + '--lang', 'color-emoji', + '--output', outPath, + ])); + }); + + const stat = await fs.stat(outPath); + expect(stat.size).toBeGreaterThan(1000); + }); + + describe('agent mode', () => { + const origJson = process.env['PDFNATIVE_JSON']; + const origDry = process.env['PDFNATIVE_DRY_RUN']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + if (origDry === undefined) delete process.env['PDFNATIVE_DRY_RUN']; + else process.env['PDFNATIVE_DRY_RUN'] = origDry; + }); + + it('--dry-run validates without writing the output file', async () => { + const outPath = path.join(os.tmpdir(), `render-dryrun-${Date.now()}.pdf`); + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', outPath, '--dry-run'])); + }); + await expect(fs.stat(outPath)).rejects.toThrow(); + }); + + it('--json --dry-run emits an ok:true dryRun envelope on stderr', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const outPath = path.join(os.tmpdir(), `render-dryrun-json-${Date.now()}.pdf`); + const lines: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c: unknown) => { + lines.push(String(c)); + return true; + }); + try { + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', outPath, '--dry-run'])); + }); + } finally { + spy.mockRestore(); + } + await expect(fs.stat(outPath)).rejects.toThrow(); + const envelope = lines.map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)).at(-1); + expect(envelope).toMatchObject({ + ok: true, + command: 'render', + variant: 'document', + dryRun: true, + }); + }); + + it('--json emits an ok:true status envelope with byte count on success', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const outPath = path.join(os.tmpdir(), `render-json-${Date.now()}.pdf`); + tmpFiles.push(outPath); + const lines: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c: unknown) => { + lines.push(String(c)); + return true; + }); + try { + await withTempFile('.json', minimalParams, async (inputPath) => { + await render(parseArgs(['--input', inputPath, '--output', outPath])); + }); + } finally { + spy.mockRestore(); + } + const envelope = lines.map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)).at(-1); + expect(envelope).toMatchObject({ ok: true, command: 'render', dryRun: false }); + expect(typeof envelope.bytes).toBe('number'); + expect(envelope.bytes).toBeGreaterThan(0); + }); + }); }); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts new file mode 100644 index 0000000..70deefb --- /dev/null +++ b/tests/commands/schema.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { schema } from '../../src/commands/schema.js'; +import { parseArgs } from '../../src/utils/args.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; + +function captureStdout(): { calls: string[]; restore: () => void } { + const calls: string[] = []; + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + calls.push(String(chunk)); + return true; + }); + return { calls, restore: () => spy.mockRestore() }; +} + +describe('schema', () => { + afterEach(() => vi.restoreAllMocks()); + + it('prints the render schema when no subject is given', async () => { + const out = captureStdout(); + await schema(parseArgs([])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.title).toBe('pdfnative-cli render input'); + expect(doc.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); + expect(Array.isArray(doc.oneOf)).toBe(true); + }); + + it.each(['render', 'inspect', 'verify', 'batch', 'inspect-summary', 'verify-summary', 'batch-summary'])( + 'prints a valid Draft 2020-12 schema for "%s"', + async (subject) => { + const out = captureStdout(); + await schema(parseArgs([subject])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); + expect(typeof doc.title).toBe('string'); + }, + ); + + it('embeds the CLI version in the schema $id', async () => { + const out = captureStdout(); + await schema(parseArgs(['inspect'])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.$id).toMatch( + /^https:\/\/pdfnative\.dev\/schema\/cli\/\d+\.\d+\.\d+\/inspect\.schema\.json$/, + ); + }); + + it('lists the available subjects', async () => { + const out = captureStdout(); + await schema(parseArgs(['list'])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.subjects).toEqual([ + 'render', + 'inspect', + 'verify', + 'batch', + 'inspect-summary', + 'verify-summary', + 'batch-summary', + ]); + }); + + it('embeds the CLI version in a summary schema $id', async () => { + const out = captureStdout(); + await schema(parseArgs(['verify-summary'])); + out.restore(); + const doc = JSON.parse(out.calls.join('')); + expect(doc.$id).toMatch( + /^https:\/\/pdfnative\.dev\/schema\/cli\/\d+\.\d+\.\d+\/verify-summary\.schema\.json$/, + ); + expect(doc.required).toEqual(['valid', 'signatures', 'invalid']); + }); + + it('rejects an unknown subject with a usage error', async () => { + await expect(schema(parseArgs(['bogus']))).rejects.toBeInstanceOf(CliError); + await schema(parseArgs(['bogus'])).catch((err: CliError) => { + expect(err.exitCode).toBe(2); + expect(err.code).toBe(ErrorCode.USAGE); + }); + }); +}); diff --git a/tests/commands/sign.test.ts b/tests/commands/sign.test.ts index 37cdfde..2ef97fd 100644 --- a/tests/commands/sign.test.ts +++ b/tests/commands/sign.test.ts @@ -1,12 +1,17 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; import { sign } from '../../src/commands/sign.js'; import { render } from '../../src/commands/render.js'; import { parseArgs } from '../../src/utils/args.js'; import { CliError } from '../../src/utils/error.js'; +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); +const RSA_KEY = path.join(FIXTURES, 'rsa-key.pem'); +const RSA_CERT = path.join(FIXTURES, 'rsa-cert.pem'); + // Minimal self-signed RSA key + cert for testing. // Generated with: openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=Test" // These are test credentials — NOT for production use. @@ -215,4 +220,58 @@ describe('sign', () => { // Generic message — must not leak crypto internals. expect((err as CliError).message).not.toMatch(/-----BEGIN/); }); + + describe('agent mode', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + it('--dry-run validates credentials and PDF without writing output', async () => { + const pdfPath = await makeTestPdf(); + const outPath = path.join(os.tmpdir(), `sign-dryrun-${Date.now()}.pdf`); + await sign(parseArgs([ + '--input', pdfPath, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--output', outPath, + '--dry-run', + ])); + await expect(fs.stat(outPath)).rejects.toThrow(); + }); + + it('--json --dry-run emits an ok:true envelope and never leaks key material', async () => { + const pdfPath = await makeTestPdf(); + const outPath = path.join(os.tmpdir(), `sign-dryrun-json-${Date.now()}.pdf`); + process.env['PDFNATIVE_JSON'] = '1'; + const lines: string[] = []; + const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c: unknown) => { + lines.push(String(c)); + return true; + }); + try { + await sign(parseArgs([ + '--input', pdfPath, + '--key', RSA_KEY, + '--cert', RSA_CERT, + '--output', outPath, + '--dry-run', + ])); + } finally { + spy.mockRestore(); + } + await expect(fs.stat(outPath)).rejects.toThrow(); + const joined = lines.join(''); + expect(joined).not.toMatch(/-----BEGIN/); + const envelope = lines.map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l)).at(-1); + expect(envelope).toMatchObject({ + ok: true, + command: 'sign', + dryRun: true, + algorithm: 'rsa-sha256', + }); + }); + }); }); diff --git a/tests/commands/verify.test.ts b/tests/commands/verify.test.ts index 36a7387..f93f312 100644 --- a/tests/commands/verify.test.ts +++ b/tests/commands/verify.test.ts @@ -5,7 +5,7 @@ import * as fs from 'node:fs/promises'; import { verify } from '../../src/commands/verify.js'; import { render } from '../../src/commands/render.js'; import { parseArgs } from '../../src/utils/args.js'; -import { CliError } from '../../src/utils/error.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; const minimalParams = JSON.stringify({ title: 'Verify Test', @@ -98,4 +98,55 @@ describe('verify', () => { expect(err).toBeInstanceOf(CliError); expect((err as CliError).exitCode).toBe(1); }); + + it('tags an unreadable PDF with E_PARSE', async () => { + const bad = path.join(os.tmpdir(), `verify-badcode-${Date.now()}.pdf`); + tmpFiles.push(bad); + await fs.writeFile(bad, 'not a pdf', 'utf8'); + const err = await verify(parseArgs(['--input', bad])).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.PARSE); + }); + + it('tags a --strict failure with E_VERIFY_FAILED', async () => { + const pdf = await makeUnsignedPdf(); + const err = await captureStdout(() => + verify(parseArgs(['--input', pdf, '--strict'])), + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + expect((err as CliError).code).toBe(ErrorCode.VERIFY_FAILED); + }); + + describe('agent output projection', () => { + const origJson = process.env['PDFNATIVE_JSON']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + }); + + it('--summary emits the canonical minimal verdict', async () => { + const pdf = await makeUnsignedPdf(); + const out = await captureStdout(() => + verify(parseArgs(['--input', pdf, '--summary'])), + ); + expect(JSON.parse(out)).toEqual({ valid: false, signatures: 0, invalid: 0 }); + }); + + it('--json compacts the output (no indentation)', async () => { + process.env['PDFNATIVE_JSON'] = '1'; + const pdf = await makeUnsignedPdf(); + const out = await captureStdout(() => verify(parseArgs(['--input', pdf]))); + expect(out.trimEnd()).not.toContain('\n'); + expect(out).not.toContain(' '); + }); + + it('--fields projects only the requested paths', async () => { + const pdf = await makeUnsignedPdf(); + const out = await captureStdout(() => + verify(parseArgs(['--input', pdf, '--fields', 'allValid'])), + ); + expect(JSON.parse(out)).toEqual({ allValid: false }); + }); + }); }); diff --git a/tests/utils/agent.test.ts b/tests/utils/agent.test.ts new file mode 100644 index 0000000..d0795cd --- /dev/null +++ b/tests/utils/agent.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + isJsonMode, + isDryRun, + buildErrorEnvelope, + emitJsonError, + emitStatus, +} from '../../src/utils/agent.js'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; + +describe('agent mode helpers', () => { + const origJson = process.env['PDFNATIVE_JSON']; + const origDry = process.env['PDFNATIVE_DRY_RUN']; + + afterEach(() => { + if (origJson === undefined) delete process.env['PDFNATIVE_JSON']; + else process.env['PDFNATIVE_JSON'] = origJson; + if (origDry === undefined) delete process.env['PDFNATIVE_DRY_RUN']; + else process.env['PDFNATIVE_DRY_RUN'] = origDry; + vi.restoreAllMocks(); + }); + + describe('isJsonMode / isDryRun', () => { + it('reflect the env flags', () => { + process.env['PDFNATIVE_JSON'] = '1'; + process.env['PDFNATIVE_DRY_RUN'] = '1'; + expect(isJsonMode()).toBe(true); + expect(isDryRun()).toBe(true); + }); + + it('are false when unset or not exactly "1"', () => { + delete process.env['PDFNATIVE_JSON']; + process.env['PDFNATIVE_DRY_RUN'] = 'true'; + expect(isJsonMode()).toBe(false); + expect(isDryRun()).toBe(false); + }); + }); + + describe('buildErrorEnvelope', () => { + it('uses the CliError code and message', () => { + const env = buildErrorEnvelope('inspect', new CliError('bad pdf', 1, ErrorCode.PARSE)); + expect(env).toEqual({ + ok: false, + command: 'inspect', + error: { code: ErrorCode.PARSE, message: 'bad pdf' }, + }); + }); + + it('substitutes a default message when the CliError message is empty', () => { + const env = buildErrorEnvelope('verify', new CliError('', 1, ErrorCode.VERIFY_FAILED)); + expect(env.error.code).toBe(ErrorCode.VERIFY_FAILED); + expect(env.error.message.length).toBeGreaterThan(0); + }); + + it('maps a plain Error to E_RUNTIME', () => { + const env = buildErrorEnvelope('render', new Error('kaboom')); + expect(env).toEqual({ + ok: false, + command: 'render', + error: { code: ErrorCode.RUNTIME, message: 'kaboom' }, + }); + }); + + it('stringifies a non-Error throw', () => { + const env = buildErrorEnvelope(null, 'oops'); + expect(env.command).toBeNull(); + expect(env.error).toEqual({ code: ErrorCode.RUNTIME, message: 'oops' }); + }); + }); + + describe('emitJsonError', () => { + it('writes a single JSON line to stderr', () => { + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + emitJsonError('sign', new CliError('Failed to sign PDF.', 1, ErrorCode.SIGN)); + expect(spy).toHaveBeenCalledTimes(1); + const line = spy.mock.calls[0]![0] as string; + expect(line.endsWith('\n')).toBe(true); + expect(JSON.parse(line.trim())).toEqual({ + ok: false, + command: 'sign', + error: { code: ErrorCode.SIGN, message: 'Failed to sign PDF.' }, + }); + }); + }); + + describe('emitStatus', () => { + it('writes an ok:true envelope to stderr in json mode', () => { + process.env['PDFNATIVE_JSON'] = '1'; + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + emitStatus({ command: 'render', output: 'out.pdf', bytes: 42 }); + expect(spy).toHaveBeenCalledTimes(1); + const parsed = JSON.parse((spy.mock.calls[0]![0] as string).trim()); + expect(parsed).toEqual({ ok: true, command: 'render', output: 'out.pdf', bytes: 42 }); + }); + + it('is a no-op outside json mode', () => { + delete process.env['PDFNATIVE_JSON']; + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + emitStatus({ command: 'render' }); + expect(spy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utils/error.test.ts b/tests/utils/error.test.ts new file mode 100644 index 0000000..4019872 --- /dev/null +++ b/tests/utils/error.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { CliError, ErrorCode } from '../../src/utils/error.js'; + +describe('CliError', () => { + it('defaults exitCode to 1 and code to E_RUNTIME', () => { + const err = new CliError('boom'); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe('CliError'); + expect(err.exitCode).toBe(1); + expect(err.code).toBe(ErrorCode.RUNTIME); + }); + + it('derives E_USAGE from exit code 2 when no code is given', () => { + const err = new CliError('missing flag', 2); + expect(err.exitCode).toBe(2); + expect(err.code).toBe(ErrorCode.USAGE); + }); + + it('keeps E_RUNTIME for non-2 exit codes when no code is given', () => { + const err = new CliError('io', 1); + expect(err.code).toBe(ErrorCode.RUNTIME); + }); + + it('honours an explicit code over the exit-code default', () => { + const err = new CliError('bad pdf', 1, ErrorCode.PARSE); + expect(err.exitCode).toBe(1); + expect(err.code).toBe(ErrorCode.PARSE); + }); + + it('allows an explicit code that disagrees with the exit code', () => { + const err = new CliError('reserved', 2, ErrorCode.UNSUPPORTED); + expect(err.exitCode).toBe(2); + expect(err.code).toBe(ErrorCode.UNSUPPORTED); + }); + + it('exposes a stable, distinct set of error codes', () => { + const values = Object.values(ErrorCode); + expect(new Set(values).size).toBe(values.length); + expect(values).toContain('E_USAGE'); + expect(values).toContain('E_PARSE'); + expect(values).toContain('E_SIGN'); + expect(values.every((v) => v.startsWith('E_'))).toBe(true); + }); +}); diff --git a/tests/utils/projection.test.ts b/tests/utils/projection.test.ts new file mode 100644 index 0000000..d70f914 --- /dev/null +++ b/tests/utils/projection.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { selectFields, serializeJson, parseFieldList } from '../../src/utils/projection.js'; + +describe('parseFieldList', () => { + it('splits, trims and drops empty entries', () => { + expect(parseFieldList('a, b ,,c')).toEqual(['a', 'b', 'c']); + }); + + it('returns an empty array for a blank string', () => { + expect(parseFieldList(' ')).toEqual([]); + }); +}); + +describe('serializeJson', () => { + const value = { a: 1, b: [2, 3] }; + + it('emits compact JSON (no indentation) when pretty is false', () => { + const out = serializeJson(value, false); + expect(out).toBe('{"a":1,"b":[2,3]}'); + expect(out).not.toContain('\n'); + }); + + it('emits 2-space pretty JSON when pretty is true', () => { + const out = serializeJson(value, true); + expect(out).toContain('\n'); + expect(out).toContain(' "a": 1'); + }); + + it('compact is strictly smaller than pretty for the same value', () => { + expect(serializeJson(value, false).length).toBeLessThan(serializeJson(value, true).length); + }); +}); + +describe('selectFields', () => { + const result = { + version: '1.7', + pageCount: 3, + encrypted: false, + metadata: { title: 'Doc', author: 'A' }, + signatures: [ + { index: 0, signatureValid: true, fieldName: 'Sig1' }, + { index: 1, signatureValid: false, fieldName: 'Sig2' }, + ], + allValid: false, + }; + + it('projects a single top-level scalar path', () => { + expect(selectFields(result, ['pageCount'])).toEqual({ pageCount: 3 }); + }); + + it('preserves nesting for a dotted path', () => { + expect(selectFields(result, ['metadata.title'])).toEqual({ metadata: { title: 'Doc' } }); + }); + + it('maps an array segment over every element', () => { + expect(selectFields(result, ['signatures.signatureValid'])).toEqual({ + signatures: [{ signatureValid: true }, { signatureValid: false }], + }); + }); + + it('deep-merges multiple paths into one object', () => { + expect(selectFields(result, ['allValid', 'signatures.index', 'signatures.fieldName'])).toEqual({ + allValid: false, + signatures: [ + { index: 0, fieldName: 'Sig1' }, + { index: 1, fieldName: 'Sig2' }, + ], + }); + }); + + it('silently omits unknown paths (lenient)', () => { + expect(selectFields(result, ['nope', 'metadata.missing'])).toEqual({}); + }); + + it('keeps an entire subtree when the path is a container', () => { + expect(selectFields(result, ['metadata'])).toEqual({ metadata: { title: 'Doc', author: 'A' } }); + }); + + it('returns an empty object when no paths resolve', () => { + expect(selectFields(result, [])).toEqual({}); + }); +});