29 Commits
Author SHA1 Message Date
FredApps 97c062a14b Complete additive integration fields and test boundaries 2026-10-29 02:40:41 +01:00
FredApps 7fc2033421 Integrate KenM76 PR #5199
# Conflicts:
#	crates/render/tests/it/main.rs
#	vendor/README.md
2026-10-29 02:38:56 +01:00
FredApps 1b0e3b6110 Integrate KenM76 PR #5178
# Conflicts:
#	vendor/README.md
2026-10-29 02:38:44 +01:00
FredApps ebd7578cc3 Integrate KenM76 PR #4913
# Conflicts:
#	crates/render/tests/it/main.rs
2026-10-29 02:36:20 +01:00
FredApps b0555cb918 Integrate KenM76 PR #4898
# Conflicts:
#	vendor/README.md
2026-10-29 02:35:58 +01:00
KenM76andClaude Opus 5.5 11d9b4cda9 test(render): don't claim which vello_cpu pipeline the Luminosity guard runs in
The previous commit's message and this header said the Luminosity ramp
passes because pages use the u8 pipeline. That wasn't established; the
case simply renders correctly here, so the test is a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 14:49:50 -04:00
KenM76andClaude Opus 5.5 05630b5f15 fix(render): a gray drawn with the Color blend mode is no longer drawn black
vello_cpu 0.0.8's clip_color, shared by the nonseparable blend modes,
divided 0 by 0 when a gray's equal components were rounded just below 0
or above 1 after set_lum. The NaN drew the pixel black at the source's
alpha whatever the backdrop: nine gray levels under /BM /Color in an
empty isolated group (0.96 g among them) rendered (0, 0, 0), and two
more at half opacity came out wrong.

Vendor vello_cpu 0.0.8 (Apache-2.0 OR MIT) with a backport of
linebender/vello#2000, which clips only when the denominator is
positive. No vello_cpu release carries the fix yet; drop the copy when
one does.

Tests in it::nonseparable_gray run every gray level. The two Color
ramps fail with the unpatched blend.rs. The Luminosity ramp (black over
each opaque gray, upstream's other case) passes either way here: pages
use the u8 pipeline, and that case only failed in the f32 one; it stays
as a guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 14:45:32 -04:00
KenM76andClaude Opus 5.5 46282373fb M7: the missing-CFF fallback test checks it draws the stand-in
A wrapper without a `CFF ` table must draw exactly what the same font
with no embedded program draws, and not the embedded program. The old
check (some ink on the page) also passed if the program were read
despite the missing table; with the lookup changed to take the first
table whatever its tag, the new assertion fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 12:59:31 -04:00
KenM76andClaude Opus 5.5 aad8a3841c M7: Type1 fonts with an OpenType-wrapped CFF program draw their own face
A Type1 font whose FontFile3 has subtype /OpenType (ISO 32000-2 §9.9)
keeps its CFF program in the `CFF ` table of an OpenType wrapper.
CffFontBlob::new rejected the wrapper, so the font fell back to the
standard-font stand-in and the text drew in the wrong face and weight.
The CFF table is now taken from the wrapper, as composite fonts already
do (hayro-interpret patch (69)).

The test wraps the renderer's bundled CFF program in a one-table OTTO
directory at run time (no font file added) and requires the wrapped and
bare programs to give identical pixels; it fails with the fix reverted.
A wrapper without a `CFF ` table still draws, with the stand-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-28 11:37:10 -04:00
KenM76andClaude Opus 5.5 6a76d28016 fix(render): a shading in a knockout group replaces only what is under its shape
Review of #4898 (CodeRabbit): a shading clearing in another blend-mode
pass cleared its whole drawing path, so a small mesh triangle erased the
earlier elements of its pass around it. The same happened in the
shading's own pass, and in any knockout group, because a knockout paint
is copied over the path and a mesh texture is transparent outside its
triangles.

A shading in a knockout group now goes through knockout_shading: the
shading at full opacity, composited with Compose::DestOut, clears its
own shape (mesh triangles, /BBox), then in its own pass it is painted
with SrcOver. A shading that paints nothing still clears nothing. The
clear uses the shading without the fill opacity it carries, so a
half-transparent shading still replaces all of what is under it.

Test: it::knockout_backdrop::a_small_mesh_replaces_only_what_is_under_its_triangle
(own pass, another pass, isolated group). Revert-proven per half; keeping
the opacity in the clear fails
raster::tests::knockout_groups_replace_what_their_elements_cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-27 13:22:02 -04:00
KenM76andClaude Opus 5.5 5abf32ed2f fix(render): knockout passes clear a masked image's coverage, and nothing for an empty mesh
Review of #4898 (FredApps):

- An image or nested group of another blend mode than the pass's composited
  its layer at opacity 0, on the assumption that its shape was cleared first.
  `knockout_image` skips images with an `/SMask` and stencil masks, so a
  white Difference square stayed under a blue Normal masked image in the
  Difference pass and turned it yellow. The layer now composites with
  `Compose::DestOut` at full opacity and no soft mask, clearing exactly its
  coverage.
- In another pass (and an erase pass), a shading cleared under its path
  before the mesh-extent check that makes an off-page mesh paint nothing,
  so a Difference `sh` of such a mesh erased the blue under it. The clear
  now makes the same decision (`shading_bbox`, `mesh_area`, shared with the
  paint path).

Tests: `it::knockout_backdrop` gains a masked image, a stencil and an
off-page mesh case; each fails with its half of the fix reverted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-27 12:03:33 -04:00
KenM76andClaude Opus 5.5 a7b07bfc56 fix(render): a stroke whose outline comes back with NaN is drawn, not dropped (#4907)
kurbo 0.13.1's stroker can give a cubic that is nearly a line (all control points within a
rounding error of its chord) a NaN join, and vello_common's `flatten::fill` drops any path
containing NaN, so the whole outline vanished. An Illustrator outline ending in
`73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c` did (#4907). kurbo tracks the
bug class in linebender/kurbo#617 and #519; #610 is unreleased.

Vendored vello_common patch (2): when the expanded stroke is not finite, `flatten::stroke`
strokes the path again with every quadratic or cubic whose control points lie within the
tolerance of its chord replaced by a line, one element out per element in. A finite outline
is unchanged, and a path with non-finite input is still dropped. `stroke` and
`StripGenerator::generate_stroked_path` take a `Clone` path; vello_cpu's callers pass
`&BezPath` and a copied slice iterator.

Tests: `flatten::tests::a_tiny_degenerate_cubic_does_not_drop_the_stroke` and the render
crate's `stroke_degenerate_cubic` pixel test fail with the fallback disabled;
`a_finite_outline_is_stroked_as_before` and `a_path_with_nan_is_still_dropped` are guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-27 00:34:47 -04:00
KenM76andClaude Opus 5.5 ad33ab626e fix(render): a non-isolated knockout group blends its elements with the page under it
A non-isolated knockout group starts from the backdrop under it (ISO 32000-2
§11.4.7, §11.4.8), but was drawn into a layer of its own starting transparent,
so a blend mode inside it had nothing to blend with: white with /BM /Difference
over magenta stayed white (#4886).

Entered plainly (full opacity, no soft mask, Normal, not itself a knockout
element), the group is now drawn once per blend mode its elements use. Each pass
is a knockout layer composited onto the backdrop in that mode; elements of other
modes clear what is under their shape instead of painting. The Normal pass finds
the other modes, so a group without blend modes takes one pass as before.

hayro-interpret patch 67 (Device::begin_knockout_pass / end_knockout_pass,
draw_knockout_passes), hayro patch 26 (the renderer side). Test:
pdfcraft-render it::knockout_backdrop, three cases failing with the fix
reverted, plus an isolated-group control.

Fixes #4886

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-26 20:18:32 -04:00
KenM76andClaude Opus 5.5 bac061e473 fix(render): a form's stray Q no longer pops the page's saved states
A form XObject or annotation appearance with more `Q` than `q` popped
graphics states saved by the content that drew it, so later page text,
paint and clip lost the page's `cm` and clip, and search redaction of that
text failed closed at `redact_apply`.

A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2,
§8.10.1). Vendored hayro-interpret patch (68): `interpret()` records the
state depth at which its stream starts (`Context::enter_stream`) and
restores the outer floor on exit; a `Q` at the floor is ignored with a
warning. Page content (`interpret_content`) is unchanged.

Tests: pdfcraft-render `it::stray_q_in_a_form` (three behaviour tests fail
with the fix reverted, one guard) and pdfcraft-automation
`it::redact_after_stray_q_form` (end to end; fails at `redact_apply` with
the fix reverted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
2026-10-26 11:34:17 -04:00
mtlcl410andClaude Opus 5.5 7dd44301db M2: a luminosity soft mask in a DeviceCMYK group converts as ISO 32000-2 10.4.2 says, so 100% K masks fully (#4423)
A luminosity soft mask is drawn into an RGB buffer and its value read
as the luminance of that buffer. A /DeviceCMYK colour reached the
buffer through the CMYK display profile, where 100% K is a dark grey,
so a mask painted 100% K in a DeviceCMYK group let about an eighth of
the masked paint through (mask value 0.125 instead of 0).

ISO 32000-2 11.5.3 converts a device-space group colour to gray "with
no compensation for gamma or other colour calibration", and 10.4.2.3
gives DeviceCMYK to DeviceGray as
gray = 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k).

hayro-interpret patch (63): when the group colour space of a luminosity
soft mask is /DeviceCMYK itself (not an ICC-based or calibrated space),
the mask's context paints /DeviceCMYK fill and stroke colours as that
gray, and a four-component /BC becomes the same gray. A gray reads back
as itself through any luminance weights that sum to one. CMYK on the
page, CMYK in DeviceGray/DeviceRGB mask groups, and CMYK images,
shadings and tiling patterns inside such a mask keep the profile.

Tests: color::device_cmyk_gray_tests (hayro-interpret) and
it::luminosity_group_space (pdfcraft-render); the three DeviceCMYK
render tests fail with the patch reverted.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:02:49 -04:00
Brandon ThomasandClaude Opus 5.5 d445410807 Order the render integration-test modules (cargo fmt) (#4486)
#4430's merge with main left mod type3_text_clip before mod tiling_cell_placement,
so main's fmt check failed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:57:58 -04:00
0dd871f772 M2: a Separation /All colour paints every colourant, gray 1 − tint on screen (ISO 32000-2 8.6.6.4) (#4439)
The special colourant name All refers to every colourant of the output
device at once, and on an additive device its subtractive tint is
complemented before it is applied (ISO 32000-2 §8.6.6.4). The vendored
colour code treated All as an ordinary spot colourant and drew it through
its tint transform, so a /DeviceCMYK transform giving cyan drew 0.5 scn
as light cyan instead of a 50% gray.

Vendored hayro-interpret patch 66: Separation::convert_f32 writes the gray
1 − t for each tint of an All space without evaluating the tint transform.
Fills, strokes, images and shadings in that space all convert through it,
and so does the RGB print raster. /None still paints nothing, other names
still use their alternate, and a DeviceN space naming All keeps its
alternate (the clause defines the name for Separation spaces).

Tests: crates/render/tests/it/separation_all.rs (synthetic).


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 10:36:24 -04:00
6718f1f34e M2: Type 3 text in rendering mode 7 is not rendered and adds no clip (ISO 32000-2 9.3.6) (#4430)
ISO 32000-2 9.3.6: for a Type 3 font, modes 3 and 7 shall not be
rendered, the other modes render the glyph descriptions, and modes
4 to 7 add nothing to the clipping path. Vendored hayro-interpret
patch 42 drew every mode but 3 filled, so 7 Tr painted the glyphs
in the fill colour (black by default) wherever a following fill
did not cover them.

Patch 64: show_glyph keeps mode 7 for Type 3 glyphs and reports
them as clipping text (GlyphDrawMode::Clip, painting nothing, kept
by extraction), and clip_glyph's Type 3 arm is a deliberate no-op.
Modes 0-2 and 4-6 still render filled.


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-24 10:34:36 -04:00
mtlcl410andClaude Opus 5.5 a08ec799f5 M2: tiling pattern cells sit at their XStep/YStep multiples at every scale (ISO 32000-2 8.7.3.1) (#4421)
The cell pixmap was round(scale x step) pixels and repeated, but the cell
was drawn, and the paint transform mapped back, at the unrounded scale.
So the pixmap repeated a fraction of a pixel too far at every step (31 px
for a 30.56 px step at 110 dpi), and cells drifted further from their
place with every step from the pattern's origin.

Vendored hayro patch (25), `tiling_cell_pixels`: the cell is drawn at
round(scale x step) / step, so one step is exactly the pixmap's side, and
the paint transform uses that same scale. The side stays within 1 and the
existing 3000 px cap; a non-finite product keeps the scale and a bounded
side.

Tests: crates/render/tests/it/tiling_cell_placement.rs (fill, turned and
scaled matrix, stroke, all at 110 dpi far from the origin; the helper's
bounds).


Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:30:02 -04:00
aiongg 4ad99af221 Merge PR #4380: M2: a non-embedded CIDFont's fallback request carries its descendant's font descriptor
Conflicts: vendor/README.md: hayro-interpret row keeps patches (55) to (57) and #4380's (58), with their test lists.
2026-10-24 06:25:24 +00:00
mtlcl410andClaude Opus 5.5 f2e222ebc5 M2: a non-embedded CIDFont's fallback request carries its descendant's font descriptor (ISO 32000-2 §9.7.4, §9.8)
Type0Font::new built its FallbackFontQuery from the Type 0 font
dictionary, which has no /FontDescriptor (ISO 32000-2 §9.7.6.1,
Table 119); the descriptor belongs to the descendant CIDFont (§9.7.4.1,
Table 115). Every request for a non-embedded CID font therefore said
not serif, not italic, weight 400, and no resolver could tell a Song or
Mincho font from a Hei or Gothic one, or see /FontWeight 700 (§9.8,
Tables 120, 121).

FallbackFontQuery::with_descriptor takes the name from the font
dictionary and the style from the given descriptor; new() calls it with
the dictionary's own descriptor, so simple fonts are unchanged. The CID
path passes the descendant's descriptor it already reads.

Vendored hayro-interpret patch (58). Tests in
crates/render/tests/it/cid_fallback_query.rs record the request a
font_resolver receives. The CID-fonts parity note now says the Japanese
face also follows the Serif flag and /FontWeight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
2026-10-24 13:31:31 +08:00
mtlcl410andClaude Opus 5.5 4287340c04 M2: a stand-in for a non-embedded font keeps its glyph shapes at wider /Widths advances
A simple font that a document names but doesn't embed (Arial,Bold, ArialMT, ...) is
drawn with a standard-font stand-in, and each glyph was scaled horizontally to its
/Widths advance, narrower or wider. With 600-unit widths, Helvetica-Bold's i and l
(278 units) were widened 2.16 times into solid blocks, and the line read as a heavy
fixed-pitch face. The advance already places the next glyph (ISO 32000-2 §9.2.4;
Widths, Table 109), so a substituted glyph is still narrowed to a narrower advance
but no longer widened. Exact standard-14 fonts are unchanged.

Vendored hayro-interpret patch 57 (StandardKind::outline_glyph), with tests in
crates/render/tests/it/substitute_widths.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
2026-10-24 12:30:56 +08:00
aiongg 9526363152 Build each crate's integration tests as one binary (#4087)
* Build each crate's integration tests as one binary

Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos,
crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and
the support modules move into tests/it/ too. Crates with a single test
file keep it. The test executables go from 150 to 52, and every crate
runs the same number of tests as before.

ui-egui perf keeps its own binary (own process): its frame-time budgets
would charge each measurement for the other tests running beside it.

What the move needs:
- include_str!/include_bytes! and #[path] paths gain one "../".
- ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file
  ones (content, document_links, editing, fill_sign, view), so one
  process creates one wgpu device at a time. The drop-line test in ui.rs
  and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and
  CRAFT_UI_DOCKING_DIR, which render without holding it, take it too.
- pdfcraft winit_wayland_scroll: the vendored winit source it compiles
  names crate::event, so that shim lives in main.rs.
- parity evidence names the new paths.

* Name the one-binary test paths in docs and comments

- READMEs, docs, ATTRIBUTION and source comments name tests/it/<file>.rs.
- The oracle README's example filters with --test it encryption::qpdf_agrees.
- pdfcraft's GPU lock comment points at the UI tests' shared lock in
  crates/ui-egui/tests/it/main.rs.
2026-10-22 11:28:58 -04:00
aiongg b2f913a7b6 Merge PR #3438: fix(render): use unfiltered inline image byte length before EI heuristic (#3107)
docs/roadmap.md: main's M2 notes with the PR's sentence appended
ROADMAP.md: the PR's progress-log line moved to the top (newest first)
2026-10-22 01:52:27 +00:00
Ahmad Mujahid 3b3787078e Fix named properties in forms without resources 2026-10-20 15:39:35 +04:00
jonathanwxh-cell 4ff9bd6634 fix(render): cover all color spaces, padding and align /L policy with content crate (#3107) 2026-10-20 11:15:45 +08:00
jonathanwxh-cell f9d37c089f fix(render): use unfiltered inline image byte length before EI heuristic (#3107) 2026-10-19 17:33:48 +08:00
Sir Clangington III.andLabDennis 2048a18e53 M2: Bound literal text search with prefix matching (#1725)
Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
2026-10-15 02:21:53 -04:00
Tanish MankuandClaude Opus 5 e0c63546bb test(render): pin that every JBIG2 conformance fixture decodes to the same picture
The render oracle put four JBIG2 fixtures at the top of its disagreement list against Poppler, and
the obvious reading was that hayro-jbig2, which carries local patches, had a bug. Investigating it
found the reverse.

The 96 `bitmap-*.pdf` corpus files are a conformance suite: one small drawing encoded through every
corner of JBIG2 -- each generic-region template, custom AT pixels, TPGDON and TPGRON, refinement,
halftone with and without skip, symbol dictionaries with Huffman tables, MMR, the composition
operators, random access, unknown-height segments. Those features change how the bytes are
compressed, not what the picture is, so a correct decoder renders all 96 identically. That is a
real test with no reference image and no external oracle: the suite checks itself.

We render 1 distinct image across all 96. Poppler renders 8: it draws a blank page for the seven
halftone-skip and symbol fixtures, and its output changes when TPGRON is switched on, which a
correct decoder's cannot. So roughly a fifth of Poppler's JBIG2 output is wrong, the top of the
oracle's worst list is its bug rather than ours, and Poppler is not a usable oracle for JBIG2.

Watched failing by admitting one unrelated file to the set: the test fails and names it as the odd
one out rather than merely reporting a count.

The expectation holds of the pinned corpus. Whoever re-pins it to a newer pdf.js should re-check
this rather than assume it still holds, which the test's own documentation says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 15:12:52 -07:00