* Use chrono for UTC/calendar conversion instead of hand-rolled copies
Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the
leap-year/days-in-month tables with chrono as a workspace dependency:
- apps/vectorcraft/src/logging.rs: timestamp (log line time)
- crates/doc/src/metadata.rs: civil (Unix seconds -> [y,m,d,h,mi,s]) and iso8601
- crates/pdf/src/file_info.rs: unix (date -> seconds) and in_month, used by the
PDF/XMP date parsers (pdf_date, xmp_date)
Output formats and parsing semantics are unchanged and pinned by the existing
tests (PDF D:... and XMP dates, offsets, leap-year rejection, year clamp 0-9999,
pre-epoch clamp). chrono uses only std, so wasm stays safe. AGENTS.md/CLAUDE.md
now say time goes through chrono.
Follow-up to storytold/cadcraft#1785
* Never panic on a clock outside chrono's range; keep 5-digit years unprefixed
chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. timestamp now uses the fallible DateTime::from_timestamp
and reads such a clock (or a pre-epoch one) as the epoch instead. Years past
9999 are written with {:04} like before, because %Y would add a '+'. Tests
cover the out-of-range and 5-digit-year cases.
* 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.
* time: preserve old out-of-range date conversion (no epoch fallback)
`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant
past chrono's year range to 1970-01-01. Restore the old conversion domain with
a cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact).
- apps/vectorcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the
pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch.
- crates/doc/src/metadata.rs: `civil` cycle-based, keeping its clamp EXACTLY
as the old code had it: the input is clamped to 0000-01-01..9999-12-31 first
and the year is clamped to 0..=9999 afterwards. (With the input clamp the old
`unwrap_or_default()` was already unreachable; this drops it and makes the
conversion explicit, with identical output.)
Pinned old outputs in tests:
logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch),
10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z
metadata civil(i64::MIN)[0]=0, civil(i64::MAX)[0]=9999;
iso8601(i64::MIN)=0000-01-01T00:00:00Z,
iso8601(i64::MAX)=9999-12-31T23:59:59Z (year clamp, as before)
* 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.
The PDF writer's metadata has no copyright fields, so the XMP packet of
PDF exports and PDF-compatible .ai and .ait saves left out File Info's
copyright status, notice and URL. A PDF exported without its editing
data opened with the status Unknown and an empty notice and URL,
although the reader takes them from dc:rights, xmpRights:WebStatement
and xmpRights:Marked.
The export now adds those three properties to the XMP packet after
the writer has written it, as it does for the modification date.
Marked is True for Copyrighted and False for Public Domain, and is left
out for Unknown. The notice and URL are escaped for XML, and characters
XML 1.0 does not allow are left out. A document without a copyright
gets none of the properties.
Docs: docs/mcp.md (File Info), docs/target-app-parity.md.
The layers of PDF and PDF-compatible .ai files come from their
optional content groups, whose /Name is a PDF text string. The import
read a name without a byte order mark as Latin-1. Such a string is
PDFDocEncoding (ISO 32000-1 Annex D, Table D.2), which differs from
Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0. A group named
(Art\205final) opened as a layer named "Art", U+0085, "final" instead
of "Art–final", and curly double quotes (0x8D, 0x8E) and the euro sign
(0xA0) became control characters and a no-break space.
text_string now reads such a string with the PDFDocEncoding table that
File Info already used for the document information dictionary. The
table moves from file_info.rs to import_scan.rs, and File Info reads
the information dictionary's strings with text_string.
When a PDF, PDF-compatible .ai or .ait opens, crates/pdf/src/file_info.rs
reads its File Info from the XMP packet with roxmltree, which parses
nested elements recursively and has no limit on their depth. A packet
with elements nested 10,000 deep (70 KB) overflowed a 1 MB stack, and
one nested 100,000 deep (700 KB) overflowed an 8 MB stack. A stack
overflow aborts the app.
File Info now counts how deep the packet's elements nest before roxmltree
parses it, and leaves out a packet nested more than 256 deep, as it
leaves out a packet that is not XML. The document information
dictionary then gives the File Info.
A PDF, PDF-compatible .ai or .ait opened from its pages had an empty
File Info. Nothing read the file's document information
dictionary or its XMP packet, so file.info reported no author,
description or keywords, even for a PDF that VectorCraft exported.
vectorcraft_pdf::file_info reads both. The author, description,
keywords, copyright status, notice and URL and the created and modified
dates come from the XMP packet (dc:creator, dc:description, dc:subject
or pdf:Keywords, xmpRights:Marked, dc:rights, xmpRights:WebStatement,
xmp:CreateDate, xmp:ModifyDate), else from the dictionary's Author,
Subject, Keywords, CreationDate and ModDate. document.open,
file.newFromTemplate and file.revert put each value through file.info's
checks and leave out a value that file.info refuses. A packet that is
not well-formed XML leaves the dictionary's values. File Info does not
set the title, and a file restored from the document it carries keeps
that document's File Info. vectorcraft-pdf depends on roxmltree 0.21,
which usvg already brings into Cargo.lock.
Docs: docs/mcp.md (File Info), docs/file-format-parity.md,
docs/target-app-parity.md.