bookmark_export returns an outline, or one bookmark's subtree, as JSON:
titles, nesting, open state, colour, bold/italic and destinations as page
plus view in PDF user space (ISO 32000-2 12.3.2.2), optionally written to
a file. bookmark_import takes it back, inline or from that file, under any
bookmark at any position or in place of the outline, as one undoable edit;
bookmark_copy does the same from another open document by page number.
A bookmark whose page the target lacks keeps its place without a
destination and is reported in `unmapped`. Every node is checked before
anything is written (title, page, colour, view type and arity, 33 levels,
100,000 bookmarks), and the error names the node.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
- vec3: length divides by the largest component before squaring, so
a camera 1e200 away keeps a finite near plane; unit_cross normalizes
each edge first, so edges of very different sizes keep their normal.
- render: MAX_RENDER_WORK (2^31) caps the pixel centres one render
tests, charged per triangle's clipped bounding box before scanning;
past it, RenderError::TooMuchWork.
- Bounds::centre halves each end before adding, finite for any finite
box. Bounds::of and fit_meshes use only corners of triangles render
draws. Camera::frame builds its basis from the direction, not from
points near the model, so a small model far from the origin fits.
Each new test fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
Review fixes from #6087:
- Picture and uncompressed blocks are charged to the parse ceiling before
they are copied. File-structure headers may overlap, so each structure's
copy counts. The ceiling test now includes the 3-byte picture.
- Schema expressions use checked multiply, add and subtract; an overflow is
Malformed rather than a wrapped loop count.
- Each face's triangles are counted from its entity sizes and charged before
it is triangulated.
- Matrix::mirrors divides each row by its largest entry before taking the
determinants, so a mirror whose determinant underflows still reverses the
winding.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
normalize already scales by the largest component, but the face normal
was the normalized cross product of two edges, and edges near 1e160
overflow that product to infinity first, so the triangle was dropped.
unit_cross divides both edges by their largest component before the
cross product. a_huge_square_is_drawn fails with the old line (white
instead of [190,192,200]).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A negative w (uniform or projective) turns mapped triangles inside out
though the 3x3 block does not mirror; the flip and the normal sign now
follow the 4x4 determinant, falling back to the 3x3 block when the last
row is zero.
The tree walk read each structure's globals schema with its own full
allowance, then again for the globals. It now reads it once, charged to
the walk, and continues from there for the globals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
A 3D reference dictionary holds the shared stream under /3D (ISO
32000-2 §13.6.3), not /3DD; it is followed once, and the caller refuses
anything that is not a stream, so a chain or cycle of references ends.
The tool is renamed model_render, since MCP tool names here are lowercase
letters and underscores only, and to match model_add.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
pdfcraft-annot reads a page's 3D annotations and the model each shows:
the /3DD stream, or the stream a /3DRef names (at most four deep, so a
cycle ends), decoded within 256 MB and typed PRC or U3D by /Subtype.
pdfcraft-engine exposes both on Document.
The automation tool model3d_render picks a page's Nth 3D model, parses
PRC with pdfcraft-prc, assembles and frames it with pdfcraft-prc-render
from a chosen direction (default an isometric-style view, Z up), and
returns the image as a PNG. U3D gives an error until a decoder lands.
parity misc.3d-view moves to partial.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
- assemble: a placement naming a tessellation its file structure does not
have is now PrcError::Malformed instead of a silently missing part;
wires, markups and compressed tessellations are still skipped.
- assemble: positions, normals and texture coordinates are charged against
a new MAX_ASSEMBLED_VERTICES (2^24) with RenderError::TooManyVertices, so
a one-triangle mesh with many vertices placed many times cannot outgrow
the triangle ceiling's memory bound. A refused mesh spends nothing.
- raster: a field of view too small for a finite focal length is a Camera
error, not a background-only image.
- raster: a pixel centre exactly on an edge belongs to one triangle only
(the side the triangle lies on, +x first, then +y), so translucent
quads no longer blend their diagonal twice.
- raster: under a perspective camera the shade is interpolated with the
1/z depth key, so it follows the surface rather than the screen.
- vec3: normalize divides by the largest component first, so a cross
product above ~1e154 (or a subnormal one) still normalizes.
- render docs and README state the near plane: 1/10,000 of the
eye-to-target distance.
Each new test fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
- Huffman leaf tables where one code begins another are malformed; the
shorter leaf would end the longer code early and misalign the payload.
- A model-file end past the input is malformed rather than clipped.
- Each schema entry costs one element, so empty programs are not free.
- tessellations() carries the globals schema's remaining allowance into
the tessellation reader: one stream, one budget.
- A face behaviour wider than 16 bits is malformed, not zeroed.
- Coordinate arrays that are not whole points are malformed; a partial
last normal drops the stored normals, as an unusable normal slot does.
- Triangles a TESS_3D's faces emit are charged to the element budget as
they are made, since faces may reuse one index range.
- TriangleMesh::transformed drops stored normals under a projective
matrix (last row not [0, 0, 0, w]).
- Placement::tessellation's doc says placements() does not check it
against the tessellation section.
Each fix has a test, seen failing with the fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
New L3 crate over pdfcraft-prc: assemble places every product-tree
tessellation into world space with its colours (pure `place` does the
transform), Camera fits a view to the model, and render fills
depth-buffered triangles with headlight or unlit shading into an RGBA
image. Opaque triangles draw first, translucent ones blend over them;
perspective views clip at a near plane.
Never panics: MAX_RENDER_PIXELS and MAX_ASSEMBLED_TRIANGLES cap the
work, every pixel and vertex index is checked, and damage returns
RenderError. Single-threaded.
Tests: 24 unit tests (two drive the product-tree placement path) plus an end-to-end one that builds a synthetic
PRC stream in-test, renders it, and checks the background, the nearer
triangle and the farther one by pixel; every truncation of that stream
parses and assembles without panicking. Reverting the depth test or the
placement transform fails the matching tests.
Adapted from pdfcer (MIT, same author) at 912437c3, trimmed to
the camera, raster and headlight parts and rewritten to the
pdfcraft-prc types.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
* build(i18n): embed the craft-i18n catalogs from a pinned checkout
- `craft-i18n.rev` pins storytold/craft-i18n at e45ddf7 (the PdfCraft cutover sync of
pdfcraft@ecdd1e6); `.gitattributes` keeps it LF.
- The `craft-i18n` crate (craft-libs 787a7a0, PdfCraft's existing pin) is a direct dependency
of the UI crate; it was already in the tree through craft-ui.
- `crates/ui-egui/build.rs` writes `CRAFT_I18N` from `CRAFT_I18N_DIR`: one (locale, common.tsv,
pdfcraft.tsv) entry per locale with a `pdfcraft.tsv`, paths written with `{:?}` and never
canonicalized. Unset it is empty (English only); `CRAFT_I18N_REQUIRED=1` makes a missing or
invalid checkout an error.
- Both flatpak manifests name the craft-i18n commit and `CRAFT_I18N_DIR`; a test keeps both
commits equal to the trimmed pin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(i18n): look strings up through craft-i18n behind a thin Lang wrapper
`i18n.rs` keeps PdfCraft's API (tr, tr_ctx, tr_id, trn, key_name, fmt,
Lang, LANGUAGES, detection) and hands lookups to the craft-i18n crate:
`get` for key names, `trn_plain` for plurals (no isolates, no digit
grouping), PdfCraft's own `fmt` (no FSI isolates). `@id` rows and the
"Undo {action}" template are read only where PdfCraft had them, so menus
and history labels read exactly as before.
Detection is unchanged (LC_ALL/LC_MESSAGES/LANG, macOS defaults,
sys_locale); the crate's match_tag would drop nb for no/nn, so PdfCraft's
own tag mapping stays, with one parity test per path.
Completeness tests become reports; tests that need catalogs skip with a
note when the build has none. Font coverage tests read the embedded
catalogs (shared and PdfCraft rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(i18n): remove the in-repo catalogs; craft-i18n holds them
The 34 `crates/ui-egui/src/i18n/*.tsv` catalogs were migrated into
craft-i18n at pdfcraft@ecdd1e6 and are now embedded from the pinned
checkout. ATTRIBUTION.toml drops their 34 entries for one craft-i18n
`[[build_input]]` (craft-i18n's ATTRIBUTION.md credits the translators);
AGENTS.md and docs/localization.md describe the new system and how to
add a language.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(i18n): skip catalog tests without craft-i18n; drop the catalog hash exemption
Tests that switch to a non-English language skip with a note when the
build embeds no catalogs (CRAFT_I18N_DIR unset), so the English-only
build tests green. `skip_without_catalogs` moves to the integration
test support module.
With no catalogs left in the repository, `cargo xtask assets` no longer
exempts `crates/ui-egui/src/i18n/*.tsv` from hashing: every `[[asset]]`
carries a sha256 again, and a `.tsv` added back would need one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): watch the locales directory before reading it
A CRAFT_I18N_DIR that doesn't exist yet now reruns the build script once it appears (review).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* build(i18n): pin craft-i18n dfbe955, the merge of storytold/craft-i18n#129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): skip an @id row that changes the label's placeholders or ellipsis
craft-i18n checks placeholders and the trailing ellipsis on every row whose
source is English text, but not on @id rows, whose source is the command id.
The in-repo catalog test used to fail on such a row; since the cutover it only
prints it, while tr_id and menu_label still showed it. Both now use PdfCraft's
own @id row only when it keeps the label's shape (same_shape, the rule
craft-i18n applies elsewhere), and otherwise show the label's translation, as
bundled_catalogs_are_consistent says.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with an open brace; list unused @id rows in the gaps report
same_shape skipped a `{` with no `}`, so an @id row such as "Öffnen {name…"
for "Open…" was still shown. It now counts as a changed shape.
bundled_catalogs_are_consistent printed the @id rows PdfCraft doesn't use, but
CI captures test output, so nobody saw them. translation_gaps_report, which
`cargo xtask i18n-report` runs with --nocapture, now lists them
(unused_id_rows).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): reject an @id row with a `}` that closes no placeholder
same_shape counted only the `}` that close a `{`, so "Öffnen name}…" for
"Open…" or "{name}} öffnen" for "Open {name}" still passed. A row whose `}`
count differs from its placeholders now counts as a changed shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(i18n): no braces in the assert messages of the brace test
assert! reads its message as a format string, so "a `}` without its `{`"
did not compile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
A refinement of the page refines "the current contents of the page
buffer, restricted to the area of the page buffer specified by this
segment's region segment information field" (ISO/IEC 14492 7.4.7.4), at
an offset of 0 (Table 35), and reads 0 outside that area (6.3.5.2).
hayro-jbig2 read the whole page at the refinement's location, the
pixels around the area too, so a refinement of the page decoded to
other pixels than were coded, and one wider or taller than the page was
refused.
Vendored patch 16: Bitmap::area copies the page's area, 0 off the page
and past its width whatever its rows' padding holds, and both handlers
refine that. Every refinement region now reads its reference at offset
0, so the per-page offset #6016 kept goes.
And RefinementContextGatherer::start_row loads the reference's words a
column left of where a row starts in it, as the reload would, not from
column 0. With the reference offset 32 or more left of a refinement (a
refined text instance from a narrower bitmap or an RDX below -31, a
refined symbol from such an RDX), each row's first context read 0 for
the reference column it starts at, and from 33 left for the one before
it too. That half was left out of #6048: alone
it read the page column left of a page refinement's area, which the
area now keeps at 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
In continuous view the only demand off screen was the pages near the
window. A fast wheel spin crosses them faster than a heavy page renders,
so pages came into view blank.
The view now keeps the direction it last moved and also asks for the
next two rows that way (four pages in two-up). A zoom or a layout change
clears the direction. Scrolling the other way drops those pages from the
queue, which cancels their renders. Pages on screen stay at the head of
the queue in their usual order, and the byte and count limits bound the
prefetch. The web build renders on the UI thread and does not prefetch.
A raster that arrives over the allowance now evicts the cached pages the
frame does not ask for first, then those behind the scroll, so it no
longer evicts a prefetched page that the next frame asks for again.
A prefetched page that is tiled at the current zoom keeps a cached
whole-page raster sharper than its backdrop, as a tiled page on screen
does. This also applies to the prefetch of single-page and two-page view.
Whole-page rasters shared a fixed 128 MiB allowance. In a 3440 x 1440
window a Letter page at fit width is a 2,980 x 3,850 raster (43.8 MiB),
so two pages on screen left no room for another, and scrolling back
over five pages rendered all five again. Zooming in on a smaller window
has the same result.
The allowance now holds eight rasters the size of the largest whole
page on screen, between 128 MiB and 384 MiB (256 MiB in the web build).
The size comes from the page's request at the current scale, or from
the raster held for it while a zoom hold leaves the request out. A
tiled page is on screen as a small backdrop and does not raise the
allowance. Queue admission, keeping pages already rendered, and
texture admission use the same allowance. Tiles keep their own
128 MiB allowance.
The template (and the saved form state) was found anywhere under the XDP root, so a
template-shaped element inside imported datasets could supply the form and its event
scripts. A date field's pattern went into AFDate_FormatEx/KeystrokeEx string literals
unescaped.
A region and its refinement, directly and through an intermediate
refinement, decode otherwise than the same refinements of the page, and
at 2, 3 to exactly the pixels they decode to at 0, 0, moved. A handler
that refines the page instead, or reads the region at the page's
offsets, fails it, as does dropping intermediate refinements; no test
caught those. The test's helpers move to the module. A comment still
said a refinement of the page reads "its own area"; it reads the page at
its own location. And only a location within a region's size of 2^32
came back onto the page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
"GRREFERENCEDX 0, GRREFERENCEDY 0" (ISO/IEC 14492 Table 35): a refinement
region segment that refers to another region refines that region where
it is. hayro-jbig2 offset the reference by the difference of the two
locations, which 7.4.7.5 says must be equal; where a stream has them
differ, the refinement decoded against a shifted reference, or, the
region past i32::MAX, against none. It now decodes as coded, and only a
refinement of the page takes an offset, from its own location.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
A region's location is unsigned (ISO/IEC 14492 5.4.4), and hayro-jbig2
took it for a signed one. A region at 0xFFFFFFFC was drawn back onto the
page four pixels left of it, and a refinement anywhere past i32::MAX, of
the page or of a region at the same place (7.4.7.5), was refused as an
overflow, and the page with it.
Vendored patch 14 (13 is #5999): Bitmap::combine_region keeps such a
location past the page, for the four kinds of region drawn onto it, and
a refinement's offset to its reference is taken in 64 bits and held to
an i32, which leaves every reference pixel it reads outside the
reference, as the exact offset does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
The faux-redaction audit held every operator of a page's content and of each
form XObject in a vector before walking it, so a stream of millions of short
operators (compressed to a few kilobytes) was materialised whole within the
page's decode budget. Each Do and gs also copied the whole XObject or ExtGState
table to pick one entry, and each annotation listed in /Annots was deep-copied
before its subtype was read.
The walk now reads operators one at a time through pdfcraft_content::ops,
borrows resource tables and annotation dictionaries through the resolved Arc,
charges a hidden line's text bytes (padding included) against the comparison
bound once per cover, and charges each finding's record against the output
bound, so a shared annotation list on many pages or withheld text cannot grow
the finding list for free.
* M5: keep Fill & Sign editing on Enter and preserve line breaks (#5850)
* M5: preserve typewriter whitespace through readers and edits (#5850)
* M5: retain direct-subtype comment listing for Fill & Sign fix (#5850)
* feat: add multi-file Bates numbering
* fix(automation): doc_bates_multi rejects a malformed start_number and >> in prefix or suffix, accepts a range ending at u64::MAX, and counts only staged outputs
Addresses CodeRabbit's quick-win notes. Parity is partial until a GUI dialog exists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(export): add simple HTML mode
* test(automation): reject simple HTML mode for other formats
* test(export): check simple HTML without new parser dev-dependencies
The parser test pulled html5ever, markup5ever_rcdom and seven more crates
into Cargo.lock for one test; plain string checks cover the same markup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Windows exporters map the hyphen of an Arial they don't embed to U+00AD in
/ToUnicode. The CFF standard substitute has no soft hyphen, so the CID was
taken as its glyph index and drew an unrelated glyph (an Ë, or ! in bold).
ISO 32000-2 Annex D.2 calls the soft hyphen typographically the same as
the hyphen, as patch (30) already reads WinAnsi 0xAD: draw the
substitute's hyphen. The text keeps U+00AD.