The deny-list paragraph and the chrono calendar exception now live in the Rust rules. CLAUDE.md imports AGENTS.md, and the workspace-lints requirement stays with the new-crate notes.
* craft-font-catalog: run the Unix font-folder tests only on Unix
Five tests build an Os::Unix FontEnv from Unix paths (/home/me, <dir>/sys/fonts</dir>).
Windows doesn't treat those paths as absolute, so the tests failed there. Production code
is unaffected: on Windows FontEnv::from_process picks Os::Windows, whose folders never go
through the Unix path logic. FontEnv's docs now say its paths follow the host's path rules.
The font-store workflow, which already triggers on crates/font-catalog, now also runs
craft-font-catalog's clippy and tests on Linux and Windows.
Refs #619
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-font-catalog: keep the store override and no-store tests on Windows
These assertions don't depend on Unix path rules, so they now sit in their own
ungated test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-font-catalog: verify store override precedence
Exercise the override with platform defaults present on every OS variant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Keep both craft-web-api and craft-web-host in the workspace list, the
README and the lockfile. Point the host README at the command API that
landed in #413, and the command API README at these host messages.
Co-authored-by: Cursor <cursoragent@cursor.com>
CI: the package repair between ffmpeg install tries gets a time limit
too, so a blocked configure script cannot use up the job's time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI: time-limit and retry the ffmpeg install. A stalled apt mirror used
up the oracle job's 20 minutes on #442, #443 and #447 before any test
ran, and its log is only printed when the step fails. Each try now gets
3 minutes, up to three tries, and a timed-out install prints why.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FilmCraft's FLAC encoder behind `flac-encode`, kept over SoundCraft's:
linear prediction, stereo decorrelation and the STREAMINFO MD5 give
0.1-37% smaller files on 12 test signals, and the output is bit-exact
through `flac` and ffmpeg. It is 3-5x slower than SoundCraft's, still
about 56x real time.
`lossless-encode` is now `pcm-encode` plus `flac-encode`, and
`EncodeFormat::Flac` writes it through `encode` and `FileEncoder`.
With only one of the two features, the other's formats are refused as
`Unsupported`.
The tests that use FLAC files as decode fixtures (truncation,
corruption, metadata bounds, zero-frame files, container preflight)
come with it, as do the FLAC oracle tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: core types, WAV/AIFF read and write, peaks, symphonia
The new crate without any codec module: what every later codec plugs
into.
- Shared types: `AudioBuffer`, `AudioInfo`, `ChannelLayout`/`Speaker`,
`AudioError`, `EncodeOptions` (built with `new` and `with_*`),
`EncodeFormat`, `BitDepth`, `BwfInfo`, `SampleFormat`.
- WAV/BWF/RF64 and AIFF/AIFF-C reading and writing from SoundCraft's
`soundcraft-audio-io`, kept over EffectCraft's and FilmCraft's
smaller writers (only it has BWF, dither, any speaker mask and
streaming without a known length). `encode` and `FileEncoder` behind
`pcm-encode`; `lossless-encode` and `encode` turn it on.
- `decode`, `probe`, `tracks` and the streaming, seekable `Decoder`
(feature `decode-native`). The native path reads WAV, AIFF, and PCM in
MP4/MOV and Matroska through craft-media's demuxers, packet by packet,
with edit lists and `CodecDelay`/`DiscardPadding` applied.
`TrackDecoder` decodes PCM; each codec PR adds its codec to it.
- symphonia 0.6 (MPL-2.0, used unmodified) behind the `symphonia`
feature, part of `decode`: the fallback for every format with no
native decoder yet. Its size fields are checked before it reads, and
its panics are caught where they unwind.
- `peaks`: the min/max/RMS pyramid for waveform drawing.
Workspace: craft-media is a workspace dependency without default
features, so an encode-only craft-audio does not build the MP4
demuxer, and `symphonia-bundle-flac` builds without overflow checks in
dev profiles (its 32-bit LPC restore wraps on valid input).
CI: `audio.yml`, with clippy per feature, rustdoc, WASM, MSRV, and the
tests with ffmpeg oracles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes (CodeRabbit on #441)
- Reading: AIFF that declares frames without an SSND chunk is Malformed; a Matroska PCM track's
length counts from its first frame; seek targets saturate; an empty FLAC has one track; a
failed header read is an Io error; deinterleave allocates fallibly; concealed silence is capped
like a decoded packet, and symphonia's uses the damaged packet's own duration when known.
- Peaks sum squares in f64, so loud float samples give a finite RMS.
- Writing: .aifc maps to AIFF; BWF text is cut at a character boundary; a WAV byte rate past
32 bits is an error; the FileEncoder docs say nothing may follow the sink's position.
- README: the Limits list says which codecs go through symphonia and which MP4 edit lists apply.
- Tests for each, alias and dither coverage, and a FLAC case in the routing oracle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, second round (CodeRabbit on #441)
- Every header read is checked: `Native::open`, `native::tracks` and the no-symphonia error read
it with `head_checked`. craft-media demuxer errors that are I/O failures (not a file ending
early) are returned as `AudioError::Io` instead of sending the file to the other path.
- A Matroska track's presented length runs from its first frame plus `CodecDelay` (unclamped,
in timestamp ticks with the delay rounded to one) to its end.
- symphonia's concealment allocates fallibly, and a damaged first packet is concealed when the
demuxer gives its duration. `deinterleave` reserves its channel list fallibly too.
- `TrackDecoder::conceal` documents its cap at the decode limit.
- Tests: an I/O failure past the first 64 KiB, codec delay with the first block at -20, 0 and
50 ms (checked with `probe`), a seek back after an overshoot, and the loud-sample RMS value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: TrackDecoder::conceal returns a Result
Concealment allocates fallibly and fails with TooLarge when the silence would pass the 2^30-sample
decode limit, instead of returning less than the packet's length. The native path, the README,
the packets example and the doc example pass the error on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, third round (CodeRabbit on #441)
- Concealed silence is held to the decode limit inside pcm::silence, so symphonia's path checks
frames × channels too, and symphonia's decoded audio is copied into fallibly allocated planes.
- TrackDecoder::reset forgets the last packet's length.
- Without symphonia, a damaged MP4, Matroska or Ogg file (truncated, invalid, no moov) is
Malformed instead of Unsupported; a feature the demuxer does not read still names the build.
- Tests: an MP4 with no moov in a native-only build, conceal after a reset and past the limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, fourth round (CodeRabbit on #441)
- A file the native demuxers find damaged is Malformed with symphonia too, when symphonia calls
it unsupported; before, an ftyp-only MP4 was Unsupported in symphonia builds.
- symphonia's decoded blocks are held to the 2^30-sample decode limit before allocating.
- Symph::seek forgets the last packet's length, so a lost packet after a seek is not concealed
with the length of one before it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: review fixes, fifth round (CodeRabbit on #441)
- The damaged-file test covers Matroska as well as MP4, and fails when either branch of
native::damage is removed.
- native::damage leaves out Ogg: ogg::open reports no damage (it skips damaged pages and calls a
file with none it can read not Ogg), and symphonia reports damaged Ogg as malformed itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-audio: run the ffmpeg oracle tests on ubuntu-26.04
ubuntu-latest's apt ships ffmpeg 6.1.1, which writes MP2 and E-AC-3 in
Matroska with ends a few milliseconds early, so the routing tests on the
later codec PRs cannot pass on it. ubuntu-26.04 ships ffmpeg 8.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Ogg demuxer
FilmCraft's `filmcraft-ogg` as `craft_media::ogg`, behind the opt-in
`ogg` feature: pages (CRC-32 checked, damage skipped by searching for
the next `OggS`), logical streams, packets across pages, and the Ogg
Opus, Vorbis and FLAC mappings. `OpusTiming` gives each Opus packet's
start, the pre-skip and the final page's end trimming.
`Stream::codec_params()` returns `CodecParams` like the other
containers; for FLAC each stream keeps its own header count, and an
unstated count is bounded (it ends at the first audio frame, or at
32 MiB with a warning). `ogg::ByteSource` is `craft_media::ByteSource`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Ogg review fixes: bounded recovery, lost pages, chained links, caps
From the review of the Ogg demuxer (storytold/craft-libs#440):
- The damage scan charges the positions it examines, not a whole 1 MiB read,
reads 64 KiB at a time, and says when the 64 MiB budget runs out. A file
with many damaged pages lost its tail without a warning.
- A page whose lacing claims more bytes than the file has left is skipped
and the scan resumes, instead of ending the parse.
- A gap in a stream's page sequence numbers drops the packet the lost page
continued; its two ends were joined into one corrupted packet.
- A beginning-of-stream page for a serial already seen starts a new stream,
so a chained file whose links reuse a serial no longer hands the second
link's headers to the decoder as audio.
- The packets kept from a file are capped by its size (as Matroska caps
frames), so zero lacing values cannot make the packet lists many times the
file. FLAC metadata under an unstated header count must be a block of the
length its header states, and at most 1024 are kept.
- A packet over 4 GiB is dropped instead of overflowing its size.
- OpusTiming saturates, so hostile granule positions cannot overflow, and a
clip whose only audio page is also the last is trimmed at the end
(RFC 7845 §4.5) instead of at the start.
- Speex streams read extra_headers (offset 68) for their header count.
codec_params gives Theora its Xiph-laced headers and Speex its header,
rate and channels; the FLAC extradata flags exactly the last block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Ogg damage recovery charges every page it reads and hashes
From CodeRabbit's review of 2308071: a page that fails its CRC was read and
hashed in full (up to about 64 KB) before the parser stepped one byte on,
and the damage scan read 64 KiB per call however near the next page was.
Neither was charged to the recovery budget, so a file packed with fake page
headers could cost gigabytes of reading and hashing.
- A failed page's bytes are charged to the recovery budget; opening stops
with a warning when it is spent.
- find_capture starts with a 256-byte read and doubles it up to 64 KiB.
- Warnings about damaged pages are capped at 1024, with a closing count of
the rest.
- Filing a completed packet as header or audio moves into Stream::add_packet,
keeping open under the cognitive-complexity limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Ogg recovery budget in proportion to the file; stray BOS; header caps
- Damage recovery charges every byte it reads again (a high-water mark), against a budget of
twice the file's length plus 1 MiB, instead of a fixed 64 MiB that cut off large damaged
files. A page's body is read without reading its header again, so a page is not charged twice.
- A beginning-of-stream page that carries on the page sequence of a stream with no
end-of-stream page yet is a stray flag: a warning, and the stream is not split. A link that
starts again before its end-of-stream page warns.
- Header packet counts a file states (FLAC, Speex) are capped like the unstated FLAC count.
- Reserved FLAC metadata block types (7-126) are metadata blocks.
- Theora codec_params carries the picture size; the extradata table lists Theora and Speex.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Ogg header reads checked before they are made; scan span on 32-bit
- A header packet past the size cap is checked by its stated size and not read, and a stream's
first packet is read once (only its first 80 bytes when it is too large to be a header).
- MAX_HEADER_BYTES is 64 MiB, room for two of the largest FLAC metadata blocks with their block
headers and the rest of the headers.
- find_capture clamps the scan span in u64 before casting, so a span of 2^32 bytes cannot become
0 on 32-bit targets.
- The Recovery doc says which reads it charges, and what header packet reads add.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- pcraft_layer_rgba builds a name-to-entry map of the bundle once per
call instead of walking the central directory for every tile.
- A tile that several references name is read, decoded and counted
against max_total once per call. A hash referenced at two pixel sizes
is malformed.
- Surfaces stored as RGB or grayscale are read in documents of every
mode, so bitmap, duotone, indexed and multichannel documents read. A
CMYK or Lab layer or mask gives Unsupported, and an RGB mask gives its
first channel.
- A canvas of zero width or height is malformed.
- The docs name video layers, the masks of layers without pixels, and
tile references counting against max_items.
- doc.yml runs the tests without features again, next to
--all-features.
pcraft_layer_rgba(bytes, layer, &Limits) decodes one layer of a .pcraft
bundle over the canvas: a raster layer's surface, or the rendered copy
saved with a text, shape or smart object layer, as RGBA at the layer's
8, 16 or 32-bit sample type, with the layer mask as its own plane.
Grayscale repeats the gray value, missing tiles hold the default pixel,
and tiles outside the canvas are not read. Other color modes give
Unsupported; groups, adjustment and fill layers give None.
Tiles are read with craft-pcraft-format's TileRef and tile paths,
decoded with ruzstd and checked against their BLAKE3 hash, as PhotoCraft
reads them. The decoded layer and mask count against max_entry and
max_total before they are allocated; a frame is decoded only up to its
tile's size, and a frame larger than the tile plus an eighth and 4 KiB,
or one that declares a window over 8 MiB, is refused.
The feature adds ruzstd and blake3, both pure Rust. The doc workflow
runs clippy and the tests with --all-features.
Part of #59.
* Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps
A new crate, craft-doc, reads the native files of three apps from bytes:
PhotoCraft .pcraft (a ZIP bundle), VectorCraft .vectorcraft (JSON, plain
or gzip, with the version 4 data after the JSON) and EffectCraft .ecproj
(JSON).
- sniff tells which app wrote a file from its first bytes.
- info gives the format version, the writer the file names, and whether
the version is one the crate reads.
- renditions returns VectorCraft's preview and PDF and PhotoCraft's
thumbnail and composite, when the file has them.
- outline returns PhotoCraft's canvas and layer tree, VectorCraft's
artboards and top-level layers, and EffectCraft's compositions with
their layers.
A file whose version is newer than the crate reads is refused with
Error::TooNew. Limits bound the JSON, each entry, the total bytes
decompressed and the JSON nesting, and each is checked before the bytes
it covers are allocated. ZIP entries are read by a small STORE and
DEFLATE reader in the crate. The crate depends on serde, serde_json,
thiserror and flate2 and builds for wasm32-unknown-unknown.
The tests build every document they read: JSON written by hand, PNG,
ZIP and gzip bytes assembled in the test. doc.yml runs fmt, clippy, the
tests and a wasm32 check.
* ci: do not keep the checkout token in the doc workflow
The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.
* craft-doc: read each format's envelope through its format crate
craft-doc no longer keeps its own copies of the envelope types and
constants. It reads them with craft-pcraft-format (`MANIFEST`,
`FORMAT_VERSION`, `THUMB`, `COMPOSITE`, `check_manifest`),
craft-vectorcraft-format (`Head`, `Image`, `sniff`, `check`, `VERSION`,
`BLOB_MAGIC`, base64) and craft-ecproj-format (`Head::read`,
`SCHEMA_VERSION`). Its own base64 module is removed. A `.pcraft`
manifest is still read in one pass without building a
`serde_json::Value`, with the checks `read_manifest` applies. The
outlines are still read with craft-doc's read-only views of each
document, and the public API (`sniff`, `info`, `renditions`, `outline`,
`Limits`) does not change.
These inputs are now read as the format crates read them:
- A `.pcraft` manifest without `generator`, or with a `thumbnail` other
than `thumb.png` or a `composite` other than `composite/preview.png`,
is malformed for `renditions` and `outline`; `info` still reports its
version. A manifest with more than 4,194,304 JSON items is refused
with `LimitExceeded`.
- A `.vectorcraft` preview or PDF member without `mime` gives no
rendition.
- `sniff` takes `format` only from a top-level member, so
`{"meta":{"format":"vectorcraft"}}` is not a VectorCraft file.
tests/envelopes.rs writes a file of each format with the crates' types
and reads it through every call, and checks the inputs above.
doc.yml also runs when one of the three crates changes.
* craft-doc: sniff reads only top-level members of an EffectCraft project
`sniff` took any JSON object whose first member is `savedBy` for an
EffectCraft project when `"schema"` appeared anywhere in its first
bytes, so `{"savedBy":"1.0","metadata":{"schema":1}}` was claimed. It
now checks the top-level member names only, one at a time, without
keeping them.
* craft-doc: bound list elements with max_items; no rendition without a MIME type
Limits gains max_items (default 1,048,576): EffectCraft project items and
composition layers, VectorCraft artboards and layers, and PhotoCraft layers
at every level are counted before each one is read, and a file that lists
more gives Error::LimitExceeded. A list of empty objects within max_json no
longer expands into gigabytes of layers.
A VectorCraft preview or pdf member with an empty mime gives no rendition, as
one without a mime does; a preview may be PNG or JPEG. An EffectCraft
composition without a duration or a frame rate gets no timing instead of a
duration of 0.
* craft-media: shared codec identifiers, one ByteSource, the testkit
The types a codec crate needs to describe a file and configure a
decoder, ahead of craft-audio:
- `Container`, `CodecId`, `PcmFormat` and `CodecParams` (`id.rs`),
always built, with optional serde.
- `codec_params()` on `isobmff::Track` and `matroska::Track`, both
returning `CodecParams` in the same form; `extradata` follows
Matroska's `CodecPrivate`.
- One `ByteSource` trait (`source.rs`), with `ReadSeekSource`;
`isobmff::ByteSource` and `matroska::ByteSource` are that trait.
- craft-media-testkit (`publish = false`): the ffmpeg oracle helpers
from craft-media's tests, so the codec crates share one copy.
The existing tests prove the refactor; the new types have their own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: APV extradata is the bare record; reseek after a poisoned lock
An MP4 apvC track gave CodecParams.extradata with the 4-byte FullBox header
in front, where Matroska's V_APV CodecPrivate is the bare
APVDecoderConfigurationRecord. ApvConfig::record_bytes serializes the record
alone and extradata uses it.
ReadSeekSource kept its tracked position after recovering a poisoned lock or
a failed seek, so a reader moved by a panic was read from the wrong offset
with no error. Both now force a seek on the next read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: MP4 codec params per sample description
Track::codec_params always described the first stsd entry, while each sample
selects its own through description_index. It now describes the first
sample's entry; codec_params_for gives the parameters of any entry, and
has_mixed_descriptions says whether one set covers every sample.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: test file-backed ByteSources; a File read past i64::MAX is an EOF
A File and a ReadSeekSource over a file now have the buffer's test: the same
bytes at the same offsets, and UnexpectedEof past the end. The test found
that a File read at an offset past i64::MAX failed with the OS's
InvalidInput instead of the UnexpectedEof the trait promises; it now
reports the end of the source like the other implementations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: Matroska codec params match MP4 for ALAC, DNx and Opus
Matroska's codec_params gave Unknown for A_ALAC and for DNxHD/DNxHR stored
as V_MS/VFW/FOURCC (AVdn, AVdh), where MP4 gives Alac and Dnxhd; ALAC's
extradata is the CodecPrivate, the ALACSpecificConfig MP4's alac box holds.
Opus now reports 48 kHz from Matroska as from MP4, whatever rate the muxer
declared.
ReadSeekSource clears the poison when it recovers the lock, so one
panicked read costs one recovery instead of a seek on every later read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-media: MP4 reports the MPEG audio layer the first frame names
An MP4 track with MPEG audio reported CodecId::Mp3 whatever the layer, since
mp4a + 0x69/0x6B names none (ffmpeg stores MP2 that way), while Matroska
reports Mp1/Mp2/Mp3 from its CodecID. open() now reads the frame header of
each such entry's first sample and codec_params() reports the layer it
names; without a valid header, QuickTime's .mp2 gives Mp2 and anything else
Mp3 as before. CodecConfig::Mp3 is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add craft-pcraft-format: the envelope of PhotoCraft's .pcraft bundle
The manifest around the document, with the document as a type parameter
(Manifest<D>), TileRef, the bundle's entry paths and hash check,
FORMAT_VERSION, the manifest decode limits with the nesting and item
scan, and the migration runner as a generic step list. Messages and
serialized field order are PhotoCraft's, so its saved manifests stay
byte-identical when it switches to these types.
Part of #59.
* ci: do not keep the checkout token in the pcraft-format workflow
The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.
* pcraft-format: build no entry name from an invalid hash
tile_path and blob_path return None unless the hash is 64 lowercase hex
digits, so a hash read from a file such as "../x" never becomes an entry
name. TileRef::path gives a tile's entry the same way.
The docs note that MAX_GROUP_DEPTH is a copy of PhotoCraft's constant,
and that check_manifest also refuses a manifest larger than
max_manifest_bytes, which PhotoCraft's check before a save does not.
* ci: pin actions/checkout by commit and Rust to 1.95 in the pcraft-format workflow
* pcraft-format: read_manifest is the one reader of a manifest
read_manifest(json, limits, steps, target) checks the JSON against the
limits, refuses a format_version newer than target, runs the migration
steps, and reads the document as D and the other envelope members.
read_manifest_with reads the document with the caller's function
instead. Manifest no longer implements Deserialize.
Error::TooNew holds the manifest's generator, so a message can name the
writer; its message is unchanged. TooNew, Manifest and TileRef are
#[non_exhaustive], and Manifest::new and TileRef::new build them.
TILES_DIR, BLOBS_DIR, COMPOSITE_DIR and OBJECT_SUFFIX name the bundle's
directories and the object suffix. The docs of max_blob_bytes and
max_total_bytes say the caller enforces them.
* pcraft-format: read_manifest reports errors in serde's struct order
read_manifest reads the envelope members and the document in the order
the parsed object holds them, and reports a missing member after the
members that are present. A manifest with several faults gives the same
error as a derived Deserialize of the whole manifest, as PhotoCraft
reads it today.
* pcraft-format: document that a missing or invalid format_version is Corrupt
read_manifest's docs and the README say that a missing or invalid
format_version gives Error::Corrupt from migrate, and that a missing
generator or document, or any other mistyped member, gives Error::Json.
A test covers a string and a fractional format_version.
* pcraft-format: read_manifest refuses preview names other than its own
A manifest's `thumbnail` and `composite` named any entry the reader then
opened, while tile entries go through `is_valid_hash`. `read_manifest`
now gives `Error::Corrupt` for a `thumbnail` other than `thumb.png` or a
`composite` other than `composite/preview.png`, the only names PhotoCraft
writes. The check runs after the members are read, so JSON errors keep
their order.
The reader's doc now says a missing or invalid `format_version` is
`Error::Corrupt`, from `migrate`, not `Error::Json`.
* Add craft-vectorcraft-format: the .vectorcraft envelope, with the document as a type parameter
File<D>, Head and Image hold the members VectorCraft writes, in its
order, with its skip rules: serde_json output of a File<D> is the JSON
VectorCraft writes, compact or pretty. Tail places entries after the
JSON from version 4 (INLINE_MAX bytes or more, after BLOB_MAGIC), and
split_tail and Image::bytes read them back. The crate also has the
version and name constants, check() for the format name and newer
versions, sniff() for plain and gzip files (the top-level "format"
member must be vectorcraft or drawcraft), and standard base64.
No dependency on any app; builds for wasm32-unknown-unknown.
* ci: do not keep the checkout token in the vectorcraft-format workflow
The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.
* vectorcraft-format: limit the bytes copied from the data after the JSON
Entries::bytes reads a file's entries and returns Error::TooBig once the
blob ranges it has copied add up to more than the data after the JSON
holds, so entries that name the same bytes again and again are refused,
as VectorCraft's reader refuses them.
A generator, preview or pdf of the wrong shape now reads as None in File
and Head, as VectorCraft opens such a file. File::tail gives a Tail for
the file's own version.
* ci: pin actions/checkout by commit and Rust to 1.95 in the vectorcraft-format workflow
* vectorcraft-format: document that base64 decode ignores the bits before the padding
decode reads a group such as Zh== like Zg==, and Zm9= like Zm8=, as
VectorCraft's own base64_decode does. The doc comment says so, and a
test pins both cases to the canonical bytes.
* vectorcraft-format: entries naming the same range share one buffer
VectorCraft's reader keeps the bytes of each [offset, length] it has
read and gives a repeated entry the same buffer without counting it
again (crates/format/src/unpack.rs). Entries::bytes refused such a
file once the repeats passed the size of the data after the JSON. It
now returns Arc<[u8]>, caches each range, and counts it once.
Overlapping ranges that differ are still copied and counted each time.
* vectorcraft-format: a refused entry does not count against the copy limit
Entries::bytes added a refused entry's length to the bytes copied, so a later entry that fit was refused too. It now counts an entry only once it is read.
* vectorcraft-format: Contents reads the document without keeping the preview and PDF
File<D> checks the shape of generator, preview and pdf by holding each
value in memory, and keeps the preview and PDF base64 it reads. Contents<D>
reads format, version, profiles, document and images, and skips the other
three as the JSON is read. It accepts and refuses the same files as File<D>.
* vectorcraft-format: Contents reads a repeated generator, preview or pdf
VectorCraft opens a file whose generator, preview or pdf member is repeated, since its reader does not declare those members, and refuses a repeated format, version, profiles, document or images. Contents now leaves the three members undeclared, so serde skips every copy of them, and still refuses the other repeated members. File and Head are unchanged.
* vectorcraft-format: sniff decodes JSON escapes in the format member
serde_json reads a file whose format key or value is written with escapes, such as \u0066ormat, as a VectorCraft file, and sniff compared the raw bytes and refused it. sniff now decodes the key and the value before comparing them.
* vectorcraft-format: File::read and Contents::read check the version
File::read and Contents::read are the readers of a file's JSON. Both
refuse another format or a version newer than VERSION after the JSON
reads, with ReadError::Envelope holding the Error (Format or TooNew), and
return ReadError::Json otherwise. File and Contents no longer implement
Deserialize, so a reader cannot skip the check.
Both read one implementation and treat repeated members the same way:
a repeated generator, preview or pdf is accepted, and File keeps the last
value, as serde_json reads a repeated key into a Value. A repeated
format, version, document, images or profiles is refused. A top-level
array holds format, version, document, images and profiles in that
order, as VectorCraft reads it; Contents' fields follow that order.
- Image::bytes returns None for an entry of more than MAX_ENTRY (1 GiB,
VectorCraft's limit on one entry of a compressed file), and
Image::bytes_at_most takes another limit. Entries keeps no per-entry
limit, as VectorCraft opens larger entries in uncompressed files.
- Entries::blob reads a range; Entries returns Arc<Vec<u8>>.
- marker_at finds BLOB_MAGIC where the writer puts it in a file's first
bytes; split_tail uses it.
- File, Contents, Head, Image, Part and ReadError are #[non_exhaustive].
* Add craft-ecproj-format: the envelope of EffectCraft project files
An .ecproj file is one JSON object: savedBy, then the saved view's
members, then the project's members, written with two-space indentation.
The crate defines that envelope with the view and the project as type
parameters, so the project model stays in EffectCraft:
- SCHEMA_VERSION (2) and check_schema, which refuses a newer schema
with EffectCraft's own message.
- File { saved_by, view, project }, serialized with both parts
flattened, and File::to_json.
- Head, which reads savedBy and schema from a JSON object, and
saved_by, which reads savedBy alone.
The tests write synthetic projects and compare the text with JSON
written by hand in the shape EffectCraft's writer gives. The workflow
runs fmt, clippy, the tests and a wasm32-unknown-unknown check.
* ci: do not keep the checkout token in the ecproj-format workflow
The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token.
* ecproj-format: write compact files, read savedBy as EffectCraft opens it
EffectCraft's Save Compact Project Files setting (effectcraft 07639b5f)
writes .ecproj files on one line with serde_json::to_string.
File::to_json_compact writes the same text, and the docs say the crate
writes and reads both forms.
Head now reads every file EffectCraft's Project::from_json opens: a
savedBy that is not a string, or appears more than once, reads as None,
and a project written as a JSON array reads with its first element as
the schema. saved_by also gives None for a repeated savedBy, as
EffectCraft's saved_by does. File's docs say the crate does not check
values and that serde_json writes NaN and infinity as null.
* ecproj-format: skip a non-string savedBy without keeping it
Head::read and saved_by read savedBy into a serde_json::Value before
dropping any value that is not a string, so a wide or deeply nested
savedBy was held in memory, and one nested past serde_json's recursion
limit made Head::read fail. A non-string savedBy, and every repeated
savedBy, is now skipped element by element with IgnoredAny, as other
members are. It still reads as None.
* ecproj-format: Head::read refuses a newer schema and reads objects only
Head::read is the one reader of a project file's envelope. It returns
ReadError: Json when the text is not one JSON object with a u32 schema
(the array form included), or TooNew when the schema is newer than
SCHEMA_VERSION, holding the file's savedBy so a message can name the
version that wrote it. TooNew reads as before. saved_by() is removed,
and Head no longer implements Deserialize.
TooNew, ReadError and ReadError::TooNew are #[non_exhaustive].
- build.rs: unset CRAFT_PROOF_EMBED embeds de, pl, pt, it patterns and ru-extra as before (DEFAULT_EMBED); `en` embeds English and our supplements only; `de,pl` and `all` as before. Making `en` the default is CP4.4, in the release that publishes and pins language-packs-1. The default-build data tests cover pl/it/de/pt again; CI's extra step is now the English-only build.
- licence_texts: one Moby entry (the data's, else the built-in en-words.dic) and en-extra.txt (WordCraft contributors, MIT OR Apache-2.0); the deleted core-list notice is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
craft-libs#483 review (P2): English spelling checked the union of the three
SCOWL dictionaries, so dialects crossed (en-US took colourblind, greyscale;
en-GB took colorblind, esophagi). Each English now checks its own dictionary
only. British English also takes the -ize spellings of its -ise words
(SCOWL's en-GB is -ise only; Word accepts both), and en-variants.txt widens
each dialect by its pairs; judgment/judgement come from craft-i18n#114, which
CI now pins. English with no dialect (is_correct) still takes a word any
English knows. Test: scowl_dialects_dont_cross samples the stems one
dialect has and another lacks, for every pair.
P3s:
- English suggestions (a Tuning of their own): a missing space only between
two lower-case words of three letters or more, ranked after every one-word
candidate (publically: publicly first; no "Thea tr", "Che Que"), and no
word the dictionary only capitalizes (Col, Wis) for a lower-case word.
- A FORBIDDENWORD whole word is never rebuilt from compound parts.
- to_bytes writes ONLYINCOMPOUND without compound rules too (round-trip test).
- Failed lookups: the compound part table is filled lazily, and a word that
doesn't start like a compound part (SCOWL: a digit) skips compounding;
20,000 failed long words take 83 ms, as without compounding (1.09 s before).
- README: en-CA is not a British-spelling English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
craft-i18n's SCOWL rel-2026.02.25 dictionaries (en-us, en-gb -ise, en-ca)
are embedded by build.rs and read per English: en-US, the British-spelling
Englishes (en-AU, en-NZ, en-IE...) through en-GB, en-CA. Suggestions come from
the run's own dictionary; a word is right when any of the three (or
en-extra.txt) knows it, unless en-variants.txt marks it as the other dialect's.
en-variants.txt stays: SCOWL's size-60 lists keep one spelling per dialect and
Word accepts more (organize and organise in en-GB, judgement in en-US).
English lookup order: dictionaries folder (the run's own English only),
[installed English pack, craft-libs#485], embedded SCOWL, system Hunspell
(own English only; not searched while SCOWL is embedded), the built-in Moby
list. The embedded Moby list is no
longer a spelling source (the built-in list is the same list); it stays for
hyphenation. The dialect policy applies to every source.
The affix engine gains COMPOUNDRULE (one-character flags, * and ?),
ONLYINCOMPOUND and COMPOUNDMIN, which SCOWL uses for ordinals (21st, 112th).
Hostile patterns are capped (64 rules, 32 tokens, 48-byte words) and checked
by a memoised walk. load_english now reads them instead of ignoring them (and
keeps ONLYINCOMPOUND stems as compound parts); only flag-based compounding and
OCONV are still ignored for English.
scowl::notice() exposes the SCOWL/Ispell/Titze notice for About. Embedded
Language data grows by 1,542,396 bytes. CI pins craft-i18n#109's merge commit efb1127.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
Review of craft-libs#482:
- craft_proof_data is set only when every Language data file is embedded, and the new
craft_proof_no_data only when none is, so a partial folder (en-us but no pl) runs neither
the pattern tests nor the no-data tests.
- A CRAFT_I18N_DIR without language-data/ is watched (rerun-if-changed), so the build
notices when the checkout gains it.
- A CRAFT_I18N_DIR that isn't valid UTF-8 is reported and embeds nothing, instead of a lossy
path in include_str!.
- proof.yml checks out craft-i18n at 062a6375c322c88b96691a17f7b5fed71c51f7a8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
- Package renamed wordcraft-proof -> craft-proof; [workspace.dependencies]
entry; public API unchanged.
- build.rs embeds craft-i18n language-data/ (en-us, de, pl, pt, it, ru)
instead of include_str!/include_bytes! of WordCraft's assets/: warns when
CRAFT_I18N_DIR is unset, errors under CRAFT_I18N_REQUIRED=1.
- No data: English spelling marks and suggests nothing, hyphenation that
needs data is off, grammar and rule-based hyphenation work. no_data.rs runs
every public entry point without data; data-pinning tests are ignored then
(cfg craft_proof_data).
- es timing bound 75 -> 150 ms: craft-libs tests run at opt-level 0.
- proof.yml (fmt, clippy -D warnings, tests with and without data, release
guard, wasm32), README, CODEOWNERS for crates/proof.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
* Add craft-dialogs: native file and message dialogs that never block the event loop
Requests queue and show at the end of the frame on the main thread; answers come
back with the app's continuation on a later frame. One dialog at a time, a drop
guard and a panic guard so a dialog never stays open, and an override for tests.
On macOS, Open and Folder show as a free-standing NSOpenPanel
(beginWithCompletionHandler, which rfd can't show), Save and message boxes as
sheets through rfd's async dialogs, never falling back to runModal. Filter
extensions match in any letter case on Linux.
* craft-dialogs: Dialogs::raise brings the open macOS panel to the front
* CI: dialogs workflow (Linux clippy and tests for craft-dialogs)
* craft-dialogs: per-instance macOS panel, parent window check, override panic guard, CI lints macOS code
* craft-dialogs: raise only a visible panel; Open and Folder need a running app
* ci: window and ui run macOS only when started by hand
macOS runners are kept for releases. Pushes and pull requests run Linux and Windows; workflow_dispatch runs all three.
* ci: the other workflows run macOS only when started by hand
atomic, docpath, ai-core, i18n, print, pdf, update and keymap follow window and ui: Linux and Windows on pushes and pull requests (keymap: Windows), all their platforms on workflow_dispatch.
A no-plane PowerShot frame whose payload is shorter than plane + header ends
inside the scan window with the record's real `ff d9 00` trailer, so rule (a)
read the `ff d9` as a plane byte, reported 12 bits and then refused a valid
file. Both discriminator signs now require a payload long enough to hold a
plane, so a flat G1 frame decodes instead of being rejected.
Also from the review: clamp samples to the declared depth so a damaged stream
cannot put more than `bits` into a consumer's `1 << bits` LUT, report zero
sensor dimensions as corrupt rather than as a limit, take a signed ImageInfo
rotation modulo 360 (so -90 is 270), warn against pairing BlackLevel::values
with Header::cfa by index, and pin actions/cache in the crate's workflow.
Tests cover the trailer payload, end-of-block chain values, the clamp, zero
dimensions, both white-balance slot layouts with every Canon setting, and each
rotation spelling. The 11 CC0 samples still decode exact with
CRAFT_CRW_REQUIRE_SAMPLES=1.
The clean-room Canon CRW decoder written in LightCraft (crates/raw/src/vendor/crw.rs,
crw_tables.rs, branch clean-room-crw-format-decode, revision 70c755e4) becomes the shared
crate craft-crw: the CIFF heap container, the entropy coding derived by black-box analysis
(frozen code tables for selectors 0-2), sensor black and white levels, as-shot white
balance, orientation and the make/model/time records. Public types are the crate's own
(Error, Limits, Cfa, Rect, BlackLevel, Header, Image); the capture time is handed on as
Unix seconds so no calendar code lives here; LightCraft's shared plateau estimate is
copied as saturation_plateau. Algorithm, tables and thresholds are unchanged.
Tests: the ported synthetic round-trip, table-structure and hostile-input tests; a
proptest mutation sweep (tests/robust.rs); and tests/samples.rs, which pins the eleven
CC0 raw.pixls.us sample files (tests/samples.json: URL, SHA-256, licence) to the SHA-256
of the verified decode plus levels, white balance and metadata, so the port is proven
byte-identical to the LightCraft decode without redistributing the reference TIFFs.
scripts/fetch-crw-samples.py downloads the files outside the checkout; CRAFT_CRW_SAMPLES
and CRAFT_CRW_ORACLE point the tests at them. The crate builds for wasm32.
The whole derivation record (starting assumptions, plan with the maintainer's decisions,
source log, container and payload notes, derivation log, frozen format, verification,
colour/levels notes, private-corpus and chart notes, scripts, logs and the frozen table
JSON) moves verbatim to crates/crw/clean-room/ with a README that indexes it and maps the
LightCraft paths it cites; tools/gen_tables.py regenerates src/tables.rs from the JSON,
byte for byte.
Repository: .github/workflows/crw.yml (fmt, clippy, tests on Linux, Windows and macOS;
wasm check; the sample-file job with cached downloads), README and ATTRIBUTION rows,
NOTICE source provenance, docs/clean-room.md (the clean-room standard a format crate
follows here), and AGENTS.md expanded with the start-of-session steps, the new-crate
checklist, clean-room, assets and test rules; CLAUDE.md points at it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
tests/mutation.rs runs a seeded mutation pass over the test images and
small SOF9, SOF10 and 12-bit seeds, checking that decode never panics
and stays within small limits. fuzz/ is a cargo-fuzz crate (own
workspace) with a decode target and those seeds. jpeg.yml runs fmt,
clippy, tests and a wasm32 check on Linux. The README gains a testing
section.
* hayro-interpret: ink colors, overprint and font bytes for the flattener
Local patches for craft-prepress's PDF-in adapter, labeled Craft patch
(flatten) and listed in vendor/README.craft-reader.md:
- Device::set_overprint and Device::set_isolated, called where the
blend mode is set and after an isolated group is pushed.
- Color::ink_paint, ColorSpace::ink_space and ink_values: colors and
image samples as the inks they print, without converting to RGB.
RasterImage::with_inks gives native samples with Decode and Matte.
- OutlineFontData::embedded and index, and the CFF bytes of Type1C
fonts, so text can be embedded again.
- The sh operator ignores /Background (ISO 32000-2 8.7.4.5.1).
* plate-test: flattened-page checks, image alpha and scenes F01, F02
- report::run_flattened separates each scene's page after a caller's
flattener as well as the original, and structure::transparency lists
what transparency a page still has. plate-test doesn't depend on the
flattener.
- Scene images take an alpha channel, written as /SMask.
- F01: a partly transparent image over a flat fill; where its alpha is
0 the fill keeps its exact inks (vectorcraft#2160).
- F02: art hidden under opaque fills leaves no trace after flattening
(vectorcraft#2161).
* prepress: transparency flattener (flatten feature)
craft_prepress::flatten turns PDF pages with transparency into pages
without it, keeping inks, overprint and live text, for PDF/X-1a and
other output that can't carry transparency.
- pdf: reads a page into a display list in inks through hayro-interpret:
paths, outlined strokes, clips, groups, soft masks, overprint, spot
and DeviceN colors, images in their own color space, gradients,
rasterized mesh shadings, tiling patterns and glyph runs.
- font: prepares the source fonts for embedding again (TrueType,
OpenType, CFF wrapped as OpenType); others fall back to outlines with
ActualText.
- geom: planar regions with the shapes that cover each one (linesweeper).
- compose: composites each region per ink (separable and non-separable
blend modes, isolated and knockout groups, overprint per plate as ISO
32000 defines it); regions under images, gradients and soft masks are
marked complex.
- raster: complex regions as DeviceN images through plate mode, grid
snapped with a one-pixel outset; flatten_page.
- write: writes the result with the vendored krilla; flatten_document
writes every page into one PDF with the output intent, page boxes and
fonts shared across pages when their bytes match.
The regions and the compositor are ported from VectorCraft (commit
c2b15982, crates/pathops and crates/engine/src/cmd/flatten.rs), credited
in the crate README. scripts/plate-check.sh --flatten separates every
scene's flattened page with Ghostscript (local only). CI runs
craft-prepress with all features.
* krilla: DeviceN /Colorants from PDF 1.3, NChannel attributes from 1.6 (patch 11)
* prepress: flatten_document output PDF version and PDF/X version
* prepress: format the PDF/X-1a flatten test
* krilla, craft-prepress: write GTS_PDFXConformance next to GTS_PDFXVersion
PDF/X-1a:2003 files carry GTS_PDFXVersion (PDF/X-1:2003) and
GTS_PDFXConformance (PDF/X-1a:2003). Krilla patch 12 adds
SerializeSettings::pdfx_conformance, and FlattenDocumentOptions passes it
through.
* krilla, craft-pdf, craft-prepress: carry the modification date over
Krilla patch 13 adds Metadata::modification_date, written as /ModDate and
xmp:ModifyDate in place of the creation date. craft-pdf's Metadata gains
modified, and flatten_document takes it from the source's ModDate.
* craft-prepress: count the print annotations a read leaves out
ReadReport::dropped_annotations counts the annotations a printer would
paint (Print flag, not Hidden, with a normal appearance) when
ReadOptions::annotations is false; FlattenTotals sums it.
* craft-prepress: degenerate groups rasterize without a soft mask
A group the region sweep fails on is clipped to its members' boxes, which
no other group's items reach, so the paper is its only backdrop. Its
image is written opaque, as the plates of the items over paper, and the
page keeps no transparency for PDF/X-1a.
* craft-prepress: write untouched JPEG images as they are
A DCT-encoded DeviceGray or DeviceCMYK image that krilla's JPEG
passthrough writes with the same meaning (8 bits, no decode parameters,
the default gray Decode or the inverted Adobe CMYK one) keeps its data
in ImageItem::jpeg, and the writer embeds it unchanged when the image
has no soft mask. Other images are still 8-bit Flate samples.
* craft-prepress: keep group members with no transparency above them
Groups are transitive by bounding box, so a background frame put all the
body text on it under a transparent object far away, and the text came
back as outlined fills. A member is now drawn as it is when it is opaque
and, in every region its shapes are in, only members kept this way are
above it. The region fills under it are the composite of the other
members, and it is drawn over them in paint order.
* prepress: keep the backdrop under elements of a knockout group
* prepress: rasterize complex regions thinner than a point
* prepress: mark flatten errors, reasons and reports non_exhaustive
* prepress: bound the items a tiling pattern fill expands into
Each of up to MAX_TILING_CELLS cells copied the whole cell content, and a
cell can hold many items or an expanded pattern of its own. A fill whose
cells would hold more than MAX_TILING_ITEMS items in all, counting groups,
masks and nested patterns, is read as an image.
* prepress: stroke solid past the dash budget
A dash pattern that cuts a stroke into more than 250,000 pieces is outlined
solid, with the estimate hayro's renderer uses.
* prepress: bound the sampling and memory of rasterized tiling patterns
Each pixel sampled every cell over it, which for a BBox far larger than
its step is millions of cells per pixel; past MAX_TILING_CELLS cells per
pixel the fill paints nothing, and below it the image resolution drops to
keep the samples within MAX_TILING_SAMPLES. The canvases also hold a value
per page ink, so their pixel limits shrink with the ink count to keep the
memory of CMYK canvases, and the canvas size uses checked arithmetic.
* prepress: mark the page reader's error and report non_exhaustive
* prepress: format the tiling tests
* prepress: CodeRabbit fixes: no nonzero overprint mode for images and shadings, count dropped tilings, widen the hhea ascender
* prepress: refuse DeviceN spaces past the output's colorant limit and regions past the separated spot inks
Preserve the reviewed current-main print/vendor changes, render the benchmark
font path as readable JSON, and document the implemented example harness.
Conflicts: NOTICE, vendor/README.craft-reader.md and the hayro-interpret and
hayro-syntax SOURCE.json keep the ec0a965 provenance and add hayro-cmap
(a2ec27b) and hayro-postscript (crates.io 0.1.0). type0.rs keeps PdfCraft's
flat sample array with its 64-bit largest sample and main's 32-bit test.
Local sha256 values recomputed for the merged files.
Preserve the reviewed shared UI tree and the frozen app dependency commit while
bringing in current main. Port the new keymap UI APIs and use verified central
fonts in its strict UI tests and snapshots.