Files
pdfcraft/CLAUDE.md
T
Dylan M. Taylor 606c560d24 Use chrono instead of hand-rolled UTC conversion (#3824)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days/days_from_civil copies and the
leap-year/month math around them (log timestamp, PDF /Info dates, ASN.1
UTCTime/GeneralizedTime, FormCalc Date/Num2Date, the Combine modified-time
stamp, the form date picker, and the demo-PDF timestamps) with chrono as a
workspace dependency. The engine's existing chrono (clock) is lifted to the
workspace. Every output format is unchanged and pinned by tests. CLAUDE.md now
says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Guard chrono date paths: no panic on overflow, no '+' on 5-digit years

Systemic chrono follow-ups to the UTC/calendar PR:

- logging::timestamp and combine_ui::utc_stamp dropped chrono's
  From<SystemTime> (which unwraps internally and panics on a clock past
  year 262143) for the fallible DateTime::from_timestamp, reading an
  out-of-range clock as the epoch, as the old clamping code did.
- timestamp, utc_stamp, pdf_date and the xtask Stamp now write the year
  with {:04} instead of %Y, which prefixes a '+' before years past 9999;
  the old hand-rolled code wrote the plain zero-padded year.
- forms_ui calendar navigation and length, and formcalc days_in, use
  checked_add for the December year successor so y + 1 can't overflow at
  i32::MAX / i64::MAX (reachable from a parsed or persisted date).

Docstrings added to the chrono-touched civil_from_days and Time::from_unix
for the docstring-coverage gate. Tests cover the out-of-range-epoch and
10000-01-01 cases for each.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Keep FormCalc dates exact past chrono's range

MAX_DAYS accepts day numbers up to 1e8 (years past 270000), but NaiveDate
stops near ±262143, so Num2Date(99000000) and Format fell back to
1900-01-01. days_from_civil, civil_from_days and days_in now have chrono
convert within the date's 400-year Gregorian cycle and add whole cycles
back (via i128), which gives the pre-chrono results for every year. Tests
cover accepted inputs past chrono's range on both sides of the epoch,
through Num2Date and Format, with values from the old formula.

* Bump rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382/0383

rustls-webpki 0.103.15 -> 0.103.16 and webpki-root-certs 1.0.9 -> 1.0.10
(both semver-compatible, lockfile only) drop the distrusted emSign
intermediates and root. Only these two entries change; cargo deny check
advisories passes. Upstream main fails the same check.

* logging/cos/sign/combine/demo: keep extended-year dates, no epoch fallback

For consistency with the stricter no-epoch-fallback rule now agreed across the sibling
chrono PRs (wordcraft#3427, filmcraft#2662): `DateTime::from_timestamp(..).unwrap_or_default()`
changes a timestamp the old code converted past chrono's year 262143 range — it collapses
to 1970-01-01 instead of the real proleptic-Gregorian date. The FormCalc Num2Date path was
already fixed (FredApps approved); this applies the same fix to every remaining conversion.
Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar
logic: chrono converts the day within one cycle, whole cycles carry in the year (i128,
saturated into i64). PDF/ASN.1 format-specific year handling is kept exactly as the old code
had it. Verified the cycle result equals the old formula across a wide range and at the extremes.

- apps/pdfcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp
  and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
  (was 1970); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch).
- crates/cos/src/writer.rs `pdf_date` (i64): cycle converter, no year clamp (old code wrote `y`
  as-is, so a negative year keeps its sign). Pins: pdf_date(10_000_000_000_000)="D:3188570520174640Z",
  pdf_date(-10_000_000_000_000)="D:-3149180813061320Z".
- crates/sign/src/der.rs `Time::from_unix` (i64): cycle converter; the `year` field is a u32, so
  the year is cast `as u32` (wrapping) exactly as the old code did — ASN.1 year width preserved
  byte-for-byte. Pins: from_unix(10e12).year=318857, from_unix(-10e12).year=4_294_652_378,
  from_unix(i64::MAX).year=219_250_468, from_unix(i64::MIN).year=4_075_720_767.
- crates/ui-egui/src/combine_ui.rs `utc_stamp` (u64): cycle converter; pre-1970 epoch clamp kept.
  Pins: utc_stamp(10e12)="318857-05-20 17:46 UTC", u64::MAX/2 s="292277026596-12-04 15:30 UTC".
- xtask/src/demo_pdf/pdf.rs `Stamp::from_unix` (i64): cycle converter, no year clamp. Pins:
  from_unix(10e12).iso="318857-05-20T17:46:40Z"/.pdf="D:3188570520174640Z",
  from_unix(-10e12).iso="-314918-08-13T06:13:20Z".

cos/sign/xtask built, tested (pdf_dates_are_utc_and_byte_identical, der::tests::times,
stamps_round_trip), fmt and clippy clean. pdfcraft-ui-egui and the pdfcraft app can't link
here (unrelated LLVM intrinsic bug in the rten-gemm dependency, 1.98.1), so their logging and
combine_ui functions + tests were copied verbatim into a scratch crate with chrono 0.4.45 and
pass there; fmt --check clean on all five packages. CI builds these on all platforms.

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:46 -04:00

10 KiB
Raw Blame History

PdfCraft — instructions for agents

PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Adobe Acrobat Pro parity. It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. It is the sibling of ../photocraft (a Photoshop-class editor) and follows the same conventions.

Start every session here

  1. Read plan/STATUS.md: the current milestone, the next unchecked task and any blockers. ROADMAP.md is the one-page summary (stage, numbers, progress log) and docs/roadmap.md the milestone table; at the end of every session update the milestone row and add a line to the ROADMAP.md progress log.
    • Read docs/gaps.md (the ranked gaps: Acrobat interop, the open-issue backlog, the renderer and a fidelity harness, editing existing content, Pro workflows, 1.0 polish) and docs/target-app-parity.md (the numbers and how they are measured) before choosing work. Prefer the gaps over new P2/P3 features, and keep both documents true when things change (docs/ progress docs follow craftrules standards/progress-docs.md: status line, revision history).
    • "Shipped" in parity/ means "exists and tested", not "as good as Acrobat". Don't mark a feature shipped on a generic test, and say in its notes what is still missing.
  2. Read that task in plan/execution-plan.md §3, the relevant section of plan/architecture.md, and the README of the crate you're touching.
  3. Follow the autonomous operation protocol in plan/execution-plan.md §7 (orient → plan → implement + test → verify → record → commit). Don't stop to ask unless §7 lists the decision as the user's.

plan/ is gitignored (local-only, like PhotoCraft). The machine-readable parity checklist lives in parity/ (committed).

Non-negotiables

  • Assets: read AGENTS.md §1 before adding or showing any icon, image, font or document. No assets from Adobe products, ever. Only openly licensed or contributor-original assets are allowed, each with an entry in ATTRIBUTION.toml. cargo xtask assets enforces this. AGENTS.md overrides this file.
  • Fonts live in storytold/craft-fonts. Never commit font files here (AGENTS.md §1.4; team members: craftrules standards/fonts.md, internal). It is the optional build input CRAFT_FONTS_DIR: git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR=../craft-fonts cargo test --workspace embeds the Japanese fonts (UI fallback, Japanese text in edited PDFs) and runs their tests, which otherwise skip. Code using pdfcraft_fonts::CRAFT_FONTS must work when it is empty.
  • Clean-room.
    • Never read, disassemble or copy anything inside the Acrobat bundle (names and listings only). Never open Contents/Resources/JavaScripts/.
    • Behaviour comes from public docs, specs (ISO 32000-2, the Arlington model) and black-box observation (plan/acrobat/).
    • Never copy GPL/AGPL code. MuPDF, Ghostscript, Poppler, veraPDF and DSS run only as external oracle processes.
    • See plan/README.md §Clean-room and plan/adr/0001.
  • Privacy. When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (plan/acrobat/tools/). Never commit Acrobat outputs, corpus files or personal data.
  • Layering. Nothing below L7 depends on egui/winit/eframe/rfd (plan/architecture.md §3). cos/filters/crypt/arlington stay standalone.
  • Fidelity. The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
  • Never panic. Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return Result (the crate's error enum) and propagate with ?, or fall back leniently and record it. In non-test code:
    • No unwrap/expect/panic!/unreachable!/todo! unless it is provably infallible, with a comment saying why.
    • No indexing or slicing with input-derived positions (use get, and slice strings only at char boundaries).
    • Use checked or saturating arithmetic on input-derived numbers, and guard against division by zero and NaN/inf casts.
    • Cap allocations sized by input, and bound recursion with depth limits or seen-sets.
    • Handle lock poisoning.
    • No unsafe (unsafe_code = "forbid").
    • Every crash fix gets a synthetic regression test. See AGENTS.md §4 (and, for team members, the internal craftrules/standards/never-crash.md).
  • Rust only in the product and build (xtask). No handwritten JS/TS.
  • Time goes through chrono (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
  • Review every update as you make it, at four zooms. Not an end-of-task pass — a per-update habit, so a defect never gets built on. Clause: checked arithmetic, get() not [i], char-boundary slicing, the right error through ?, < against <=, the 1-based/0-based seam, capped allocations. Function: is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. File/module: two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. Project: layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching automation tool or registry entry, and whether a parity/ status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
    • Tests mirror the zooms: property → unit → crate integration → end-to-end.
    • Prove a test fails before the fix by actually reverting the fix. A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
    • Rebasing is a review step, not a mechanical one. Read every hunk you resolve, and the auto-merged hunks in the files you touched too — main moves fast enough that a file can change by hundreds of lines under a branch.
  • External PRs are held to safety, security and completeness before style. Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or unsafe, then --root confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless automation tool, tests at the right zoom and an honest parity/ entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
  • Quality gates before every commit: cargo fmt --check, clippy with -D warnings, the tests, cargo xtask layers, cargo xtask assets, and the wasm check once xtask ci exists (M0).
    • Scope builds and tests to the crates you touched — cargo clippy -p <crate> -j 2 --all-targets and cargo test -p <crate> -j 2, under a timeout. A full --workspace build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
  • Commits and PRs: main is the only integration branch and is never pushed to directly. Branch from an up-to-date main as <type>/<slug> (feat/, fix/, docs/, chore/, refactor/), one task id per commit (e.g. M1.4: xref stream reader), commit only green states, and open a PR into main using .github/pull_request_template.md. Merge only when CI is green.
    • main moves fast enough that PRs rot. It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day main moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how contested the files are, not how large the change is: tooling, tests and docs are nearly free, while crates/fonts, ui-egui and the i18n catalogues are where several people collide at once.

Running and looking at the app

  • cargo run -p pdfcraft -- <file.pdf> opens the desktop app.
  • cargo run -p pdfcraft-cli -- run --script steps.json --root DIR drives the engine headlessly through the automation tools (pdfcraft-cli tools lists them). Use it, together with page_render, to check engine changes. pdfcraft-cli mcp is the opt-in MCP server (AGENTS.md §3).
  • cargo xtask fuzz --time 300 mutation-fuzzes open/render/edit/save in child processes. Findings land in fuzz-out/findings/ (git-ignored; never commit corpus-derived files). Turn every real finding into a small synthetic regression test before fixing it.
  • cargo xtask parity [--partial] reports Acrobat-parity progress from parity/acrobat-features.toml. Update the entry when a feature ships.
  • cargo xtask demo-pdf builds dist/demo/pdfcraft-showcase.pdf (needs Chrome) for visual checks.
  • For UI work, look at the result. Either launch pdfcraft --control FILE doc.pdf and use pdfcraft-cli ui --control FILE screenshot --out x.png (plus inspect, click, key, type, command, set), or take a headless shot with cargo run -p pdfcraft-ui-egui --example shot. Compare against plan/acrobat/02-ui-ux.md. Control-channel tests use kittest (crates/ui-egui/tests/control.rs).
  • Parallel agents: use a separate CARGO_TARGET_DIR per agent and separate git worktrees.

Current bootstrap debt (tracked in STATUS.md)

  • pdfcraft-render renders through the hayro crate directly and inspects documents through lopdf. Both get replaced by cos / model / the DisplayList device (M1–M2, ADR-0004).