Repository navigation
M7: keep embedded CMap ranges compact instead of truncating expansion - #837
Merged
Merged
Conversation
A ToUnicode CMap's sequential bfrange used to expand into one HashMap entry per code, and the parser stopped at 2^20 expanded codes. A valid CMap with more codes than that lost every mapping past the cap. Each sequential range is now one record. Codes below 65 536 find their covering record in a table, higher codes by binary search over a cover index; the later declaration wins where ranges and explicit entries overlap. Explicit entries keep their strings. The budget counts an explicit entry as one and a range as two, since a range covers at least two codes, so every CMap with at most 2^20 codes is kept whole. encode finds the lowest code per character from the same records. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Member
|
Thank you @cyfung1031, this is merged! One change before merging: the below-64k owner table and bit set are now sized to the highest code a font covers, rather than a fixed 256 KB per font. This keeps pages with many small subset fonts cheap. There's a test for it. If you haven't already, come join us on Discord: https://discord.gg/artcraft |
This was referenced Oct 10, 2026
krichelj
pushed a commit
to krichelj/pdfcraft
that referenced
this pull request
Oct 11, 2026
…storytold#837) * M7: keep embedded CMap ranges compact instead of truncating expansion A ToUnicode CMap's sequential bfrange used to expand into one HashMap entry per code, and the parser stopped at 2^20 expanded codes. A valid CMap with more codes than that lost every mapping past the cap. Each sequential range is now one record. Codes below 65 536 find their covering record in a table, higher codes by binary search over a cover index; the later declaration wins where ranges and explicit entries overlap. Explicit entries keep their strings. The budget counts an explicit entry as one and a range as two, since a range covers at least two codes, so every CMap with at most 2^20 codes is kept whole. encode finds the lowest code per character from the same records. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> * ToUnicode: size the low-code tables to the codes a font covers --------- Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
codestubes
pushed a commit
to codestubes/pdfcraft-UniCode
that referenced
this pull request
Oct 11, 2026
Brings the branch up to date with main (db26eb2). Conflicts resolved: the ToUnicode CMap rework (storytold#837) changed `text_of` to return `Cow<str>` and stores ranges compactly, so the glyph-text lookup in edit/text.rs and `Metrics::maps_arabic` follow it (with a unit test for ranges); the Edit text box keeps the dark fill for pale text and the hold behaviour (storytold#921) alongside the right-to-left layout; tests, the page_add_text description and the progress log keep both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A ToUnicode CMap's sequential
bfrangeis now kept as one record instead of oneHashMapentry per code. The parser's 2^20 cap used to count expanded codes and stop parsing, so a valid CMap with more codes than that silently lost its later mappings. The cap now counts mappings (an explicit entry counts 1, a range counts 2 because it covers at least two codes), so every CMap with at most 2^20 codes is kept whole. Lookups below code 65 536 read a table; higher codes use a binary search.Problem and root cause
parse_to_unicodeexpanded every code of every sequentialbfrangeinto aHashMap<u32, String>entry and stopped atMAX_CMAP_ENTRIES(1 << 20) expanded codes. The cap protected that expansion by discarding valid mappings: 17 ranges of 65 536 codes (about 1.1 million codes from 752 bytes of CMap) lost the last range, and the same expansion cost about 120 MiB for a 6 KB CMap. The guard was applied to the expanded representation rather than to the compact PDF one.Scope and non-goals
In scope:
crates/fonts/src/pdf.rs: sequential ToUnicode ranges becomeBfRangerecords. A table gives the covering record for codes below 65 536, and a sorted cover index serves higher codes. Explicitbfcharand array-form entries keep their strings, and so does a one-code range. The later declaration still wins where entries overlap.MAX_CMAP_ENTRIESnow counts mappings (explicit = 1, range = 2).Metrics::text_ofreturnsOption<Cow<str>>(it returnedOption<&str>). The one caller, incrates/edit/src/text.rs, changespush_str(t)topush_str(&t).encodefinds the lowest code per character from explicit entries, the encoding and each record, with no per-code expansion.Non-goals (unchanged): codespace and CID range parsing and their
MAX_CMAP_RANGESlimit; the 65 536-code width limit perbfrange(wider ranges are still ignored, as before, because a lifted limit would need a boundedencodesearch); Symbol and ZapfDingbats policy, glyph names, shaping, font substitution; stream decode limits; page-content decoding and its 256 MiB and 512 MiB limits (novendor/orcrates/renderchange).Before and after behaviour
sequential_ranges_past_the_entry_cap_still_map(17 ranges of 65 536 codes; code 0x100019 must read "Z"). Before:assertion left: None, right: Some("Z"). After: passes.encoderesult. Checked by the randomized testlookups_match_the_expanded_mappingsand by the harness hashes below.encodecan return them. Where a later range overrode an earlier mapping after the old cut, the later one now wins.text_ofbuilds a new string for each code that a sequential range maps (the old code returned a stored string). Ordinary decode is faster overall. A CMap in which every code sits in a range (the "dense" row) decodes more slowly per lookup, but uses about half the memory.Time and memory
Harness: a local
cmap_benchtest binary (not committed) that buildsMetricsfrom one synthetic ToUnicode CMap and then runs the workload. Decode scenarios perform 65 536text_oflookups per pass (or 40 000 / 3 400 for the range-only scenarios) over 20 passes. Encode scenarios callencodeon 300 synthetic strings over 20 passes. "Before" is the base commit5d590604867fdac7ed71c555c40477ae3331a60abuilt with the same harness. Medians of 5 rounds. Peak footprint is the macOS "peak memory footprint" of a single invocation; net is peak minus the peak of the same binary loading the same input with no work.CMAP_BENCH_SCENARIO=ordinary-decode,ordinary.cmap(97,680 bytes: 6,000 bfchar, 500 bfrange)CMAP_BENCH_SCENARIO=ordinary-encode,ordinary.cmapCMAP_BENCH_SCENARIO=dense-decode,dense.cmap(681,180 bytes: 32,000 two-code ranges)CMAP_BENCH_SCENARIO=dense-encode,dense.cmapCMAP_BENCH_SCENARIO=wide-decode,wide200.cmap(6,083 bytes: 200 ranges of 65 536 codes)CMAP_BENCH_SCENARIO=seventeen-decode,seventeen.cmap(752 bytes: 17 ranges of 65 536 codes)CMAP_BENCH_SCENARIO=adv-decode,adv.cmap(29,280,226 bytes: 1,000,000 heavily overlapping two-code ranges)Rounds and input sizes: one warm-up run per side, then 5 timed rounds per side with the order alternating each round. Decode runs time the mean of 20 back-to-back invocations, because a single invocation takes 10 to 40 ms, close to the 10 ms clock resolution. Encode and adversarial runs time one invocation. Footprint and hashes come from a separate single invocation in the same round. All runs were accepted: the 1-minute load stayed between 3.84 and 6.10, below 16, and nothing was discarded. The whole run of all seven scenarios, warm-ups included, took 4 min 25 s.
The adversarial row is the worst shape for the new representation. The old expansion kept only the distinct codes of overlapping ranges (about 65 thousand), while the new code keeps one record per range up to the budget (500 thousand here). Its peak rises by about 25 MiB and stays bounded by the budget; time still falls.
Output identity
Hashes are FNV-1a (64-bit) over the UTF-8 text of each looked-up code (a 0xFF byte after each text, 0xFE for a missing code); for
encode, over the produced bytes. Matching hashes: ordinary-decode25132a3b221f69d3, ordinary-encode9538fbf21c3cfcc3, dense-decodec34995344cd3b397, dense-encodeedbca6d32d0be03a, adv-decode680c008c1dfb3a70. The two mismatching rows differ only in codes past the old cut, as described above.The randomized test
lookups_match_the_expanded_mappingsbuilds 300 random ToUnicode CMaps (1 to 12 mappings each, with overlapping ranges, explicit overrides, array-form entries, wrap-around destinations, lone surrogates, surrogate pairs and U+FFFD). It comparestext_offor codes 0 to 320, andencodefor every character involved, against the expansion in declaration order.Local checks run
All commands ran from the worktree on the final code (commit on this branch). Results are literal.
cargo fmt -p pdfcraft-fonts, thencargo fmt --all -- --check: exit 0.cargo clippy -p pdfcraft-fonts --all-targets -- -D warnings(through the build slot): exit 0.cargo test -p pdfcraft-fonts(through the build slot): 33 passed, 0 failed, exit 0. The new tests area_range_is_one_record_however_many_codes,explicit_and_range_mappings_override_the_encoding,sequential_ranges_count_against_the_budget,lookups_match_the_expanded_mappings,fffd_lookups_skip_overridden_surrogates,hostile_ranges_stay_bounded,low_codes_resolve_to_the_covering_range, and the regression testsequential_ranges_past_the_entry_cap_still_map.cargo clippy -p pdfcraft-edit --all-targets -- -D warnings: exit 0.cargo test -p pdfcraft-edit -p pdfcraft-redact: edit 47 passed, redact 21 passed, 0 failed, exit 0.cargo test -p pdfcraft-fonts: 25 passed, 1 failed (sequential_ranges_past_the_entry_cap_still_map,left: None, right: Some("Z")).cargo test --release -p pdfcraft-fonts --test cmap_bench --no-run), then the timed runs above.Checks deferred to CI
The full workspace (
cargo test --workspace,cargo clippy --workspace -- -D warnings),cargo xtask ci(assets, parity, layers, WASM check),cargo xtask fuzz, and the Windows, Linux, FreeBSD and WASM builds were not run locally. Local disk and CPU were shared and RULES §3 limits the runs to targeted crates. The CMap parser is a candidate for the fuzzer.Malformed and hostile input safety
MAX_CMAP_ENTRIES. The result is at most 2^20 explicit entries or 2^19 range records, plus a 256 KiB table for codes below 65 536 (only when some range exists) and an 8 KiB bit set for explicit codes below 65 536.hostile_ranges_stay_bounded). Malformed hex, surrogates and overflowing code counters are covered by the existingmalformed_cmaps_never_panic.encodecosts O(explicit entries + records × distinct wanted characters). The U+FFFD search checks at most 2,049 surrogate offsets per record.unwrap,expect,panic!,unreachable!,todo!,unimplemented!orunsafe. Indexing goes throughgetandget_mut. Arithmetic on input-derived numbers is checked or saturating. The#![deny]clippy lints incrates/fonts/src/lib.rspass.adv-decode(29 MB CMap, one million records) peaks at 91 MiB against 66 MiB before, within the budget.Independence
origin/mainat5d590604867fdac7ed71c555c40477ae3331a60a, checked again before pushing. The branch is one commit on that base.gh pr view 797 --json fileslists ten paths (crates/render/src/raster.rsandvendor/…).git diff --name-only origin/main...HEADlistscrates/edit/src/text.rsandcrates/fonts/src/pdf.rs. There is no common path. M2: stream page content past the 256 MiB cap (item 1 of storytold/pdfcraft#623) #797 changes page-content decoding limits; this change touches only font CMap parsing and lookup.crates/edit/src/text.rs); Fix non-WinAnsi Latin diacritics (ă, ș, ț) in form appearances and support page-level widget annotations #447 (crates/fonts/src/pdf.rs).unicode_map(encoding inserts) or thetext_ofcallers, a rebase may be needed.Environment
macOS (Darwin 23.6.0), 8 cores shared with other sessions, rustc 1.95.0. 1-minute load averages during the timed runs were 3.84 to 6.10 (below 16). About 9 GiB of disk was free on
/at the start.🤖 Generated with Claude Code