diff --git a/ROADMAP.md b/ROADMAP.md index 338e5f838..417356715 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,6 +5,9 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) + +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) @@ -180,12 +183,15 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged. - **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged. +- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged. - **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged. - **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged. +- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged. - **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged. - **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged. - **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508). +- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change. - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. @@ -226,6 +232,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. - **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged. +- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged. - **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged. @@ -613,3 +620,4 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. - **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). +- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 9ec86f3db..9bf6ad4df 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -557,6 +557,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before /// its window appeared. D3D12 is the native, best-supported backend there. +/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain +/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window +/// is being resized, Windows stretched the window's swap chain, still at the old size, to the +/// new one, so the text shimmered and the left panel's width bounced for as long as the drag +/// lasted (issue #2738). A visual's last frame is shown at its own size instead. fn configure_gpu(native: &mut eframe::NativeOptions) { let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return }; // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a @@ -604,6 +609,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() { setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL; } + if cfg!(target_os = "windows") { + setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env()); + } +} + +/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless +/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`). +fn dx12_presentation(asked: Option) -> eframe::wgpu::Dx12SwapchainKind { + asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual) } /// Surface textures must fit the device's enabled limits, not just the physical GPU's limits. @@ -878,7 +892,7 @@ mod tests { } #[test] - fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() { + fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() { let mut native = eframe::NativeOptions::default(); super::configure_gpu(&mut native); let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else { @@ -896,6 +910,18 @@ mod tests { assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}"); assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}"); } + use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual}; + assert_eq!(super::dx12_presentation(None), DxgiFromVisual); + assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides"); + assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual); + let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system; + if cfg!(target_os = "windows") { + // A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise. + let asked = eframe::wgpu::Dx12SwapchainKind::from_env(); + assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain"); + } else { + assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it"); + } } #[test] diff --git a/crates/render/src/pixels.rs b/crates/render/src/pixels.rs index 1fc112861..ff686ce32 100644 --- a/crates/render/src/pixels.rs +++ b/crates/render/src/pixels.rs @@ -12,6 +12,10 @@ pub struct Pixels(Vec); impl Pixels { /// Zeroed pixels for a `width` × `height` raster; `None` when the size overflows. pub(crate) fn zeroed(width: usize, height: usize) -> Option { + #[cfg(test)] + if allocation_probe::refuse(width, height) { + return None; + } Some(Self(vec![0; width.checked_mul(height)?])) } @@ -57,3 +61,61 @@ impl std::fmt::Debug for Pixels { write!(f, "Pixels({} bytes)", self.len()) } } + +/// Observe the real allocator boundary without allocating a large raster in tests. +#[cfg(test)] +pub(crate) mod allocation_probe { + use std::cell::Cell; + + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] + pub(crate) struct Attempts { + pub calls: u8, + pub first: Option<(usize, usize)>, + } + + thread_local! { + static ACTIVE: Cell> = const { Cell::new(None) }; + } + + struct Reset; + + impl Drop for Reset { + fn drop(&mut self) { + ACTIVE.set(None); + } + } + + pub(crate) fn observe(f: impl FnOnce() -> T) -> (T, Attempts) { + assert!(ACTIVE.get().is_none(), "nested raster allocation probe"); + let _reset = Reset; + ACTIVE.set(Some(Attempts::default())); + let result = f(); + let attempts = ACTIVE.get().expect("active raster allocation probe"); + (result, attempts) + } + + pub(super) fn refuse(width: usize, height: usize) -> bool { + let Some(mut attempts) = ACTIVE.get() else { return false }; + attempts.calls = attempts.calls.checked_add(1).expect("bounded allocation probe calls"); + attempts.first.get_or_insert((width, height)); + ACTIVE.set(Some(attempts)); + true + } + + #[test] + fn raster_allocation_probe_restores_after_unwind() { + let panic = std::panic::catch_unwind(|| { + observe(|| { + assert!(super::Pixels::zeroed(2, 3).is_none()); + panic!("intentional test-harness unwind outside rendering"); + }); + }); + assert!(panic.is_err()); + assert!(ACTIVE.get().is_none()); + assert_eq!(super::Pixels::zeroed(2, 3).expect("probe restored").len(), 24); + let (pixels, attempts) = observe(|| super::Pixels::zeroed(3, 2)); + assert!(pixels.is_none()); + assert_eq!(attempts, Attempts { calls: 1, first: Some((3, 2)) }); + assert!(ACTIVE.get().is_none()); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d4e054cf7..bcb705047 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -16,6 +16,8 @@ use std::sync::mpsc::SyncSender; #[cfg(not(target_arch = "wasm32"))] use std::sync::mpsc::{Receiver, sync_channel}; +#[cfg(test)] +mod radial_softmask_tests; mod work_queue; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -60,7 +62,8 @@ pub struct RenderConfig { /// View ▸ Hide all comments: markup annotations aren't drawn (fields and links still are). pub hide_comments: bool, /// Refuse a whole-page raster that would be reduced by the renderer's hard size caps. - /// Tiled requests remain available for pages that exceed those caps. + /// Tiled requests remain available for pages that exceed those caps. Both modes refuse + /// a raster whose rounded integer dimensions exceed the final pixel-allocation budget. pub reject_oversize: bool, /// View ▸ Reading colours: solid text and line-art paints are remapped for readability and /// the page clears to the setting's background. Display only; the document never changes. @@ -365,6 +368,9 @@ fn render_page<'a>( }; // Rendered straight into a buffer of whole pixels, which the GUI takes over as is. let (w, h) = render_size(page, &rs); + if u64::from(w).checked_mul(u64::from(h)).is_none_or(|pixels| pixels > MAX_PIXELS as u64) { + return Err(format!("page {} raster size {w}x{h} exceeds renderer pixel limit", req.page + 1)); + } let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?; // Previews render at low detail (tiny images are skipped); finals always draw all. let mut preview_settings = settings.clone(); @@ -2801,6 +2807,92 @@ trailer << /Root 1 0 R >> assert_eq!((tile.width, tile.height), (32, 32)); } + fn allocation_budget_page(width: f32, height: f32, content: &str) -> Arc> { + Arc::new(format!("%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", content.len()).into_bytes()) + } + + #[test] + fn rounded_pixel_budget_rejects_before_actual_allocation() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), RenderConfig { reject_oversize, ..Default::default() }); + assert_eq!(renderer.page_count(), 1); + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts::default(), "rounded over-cap raster reached allocator; strict={reject_oversize}"); + assert_eq!(out.error.as_deref(), Some("page 1 raster size 8001x8000 exceeds renderer pixel limit")); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1, "normal rejection must not trigger panic recovery"); + } + } + + #[test] + fn rounded_pixel_budget_allows_exact_cap_to_reach_allocator() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(8000.0, 8000.0, ""), RenderConfig { reject_oversize, ..Default::default() }); + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts { calls: 1, first: Some((8000, 8000)) }); + // The test probe refuses the actual allocation: this is admission evidence, + // not a successful giant-raster rendering or a 256MB test allocation. + assert_eq!(out.error.as_deref(), Some("page 1 is too large to render")); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } + } + + #[test] + fn rounded_pixel_budget_preserves_small_pixels_and_large_page_tiles() { + let mut previous = None; + for reject_oversize in [false, true] { + let config = RenderConfig { reject_oversize, ..Default::default() }; + let mut small = PageRenderer::new(allocation_budget_page(16.0, 12.0, "0 0 1 rg 4 3 8 6 re f"), config.clone()); + let out = small.render(RenderRequest { scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (16, 12)); + let expected: Vec = (0..12) + .flat_map(|y| { + (0..16).flat_map(move |x| if (4..12).contains(&x) && (3..9).contains(&y) { [0, 0, 255, 255] } else { [255, 255, 255, 255] }) + }) + .collect(); + assert_eq!(&*out.rgba, expected.as_slice()); + if let Some(previous) = &previous { + assert_eq!(previous, &out.rgba); + } + previous = Some(out.rgba); + let mut large = PageRenderer::new(allocation_budget_page(8000.5, 7999.5, ""), config); + let tile = large.render(RenderRequest { scale: 1.0, tile: Some(Tile { x: 0, y: 0, w: 32, h: 32 }), ..Default::default() }); + assert!(tile.error.is_none(), "{:?}", tile.error); + assert_eq!((tile.width, tile.height), (32, 32)); + assert_eq!(&*tile.rgba, &[255; 32 * 32 * 4]); + assert_eq!(large.stats().parser_builds, 1); + } + } + + #[test] + fn rounded_pixel_budget_invalid_requests_never_reach_allocator() { + use crate::pixels::allocation_probe::{Attempts, observe}; + for reject_oversize in [false, true] { + let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize, ..Default::default() }); + let (out, attempts) = observe(|| renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() })); + assert_eq!(attempts, Attempts::default()); + assert!(out.error.is_some()); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } + let mut renderer = PageRenderer::new(allocation_budget_page(16.0, 12.0, ""), RenderConfig { reject_oversize: true, ..Default::default() }); + for scale in [0.0, -1.0, f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let (out, attempts) = observe(|| renderer.render(RenderRequest { scale, ..Default::default() })); + assert_eq!(attempts, Attempts::default()); + assert!(out.error.as_deref().is_some_and(|error| error.contains("exceeds renderer limits"))); + assert_eq!((out.width, out.height), (0, 0)); + assert!(out.rgba.is_empty()); + assert_eq!(renderer.stats().parser_builds, 1); + } + } + /// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last /// partial row and column. Like pdftoppm it is now 596×842, the edge pixels partly covered. #[test] @@ -3360,6 +3452,60 @@ trailer << /Root 1 0 R >> assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw"); } + /// #2986: an optional content membership dictionary with a visibility expression (`/VE`, + /// ISO 32000-2 §8.11.2.2) shows its content only when the expression is true, and the + /// expression takes precedence over `/OCGs` and `/P`. It was ignored, so content under it + /// stayed visible when its layers were hidden. A malformed expression falls back to `/OCGs`. + #[test] + fn visibility_expressions_decide_what_a_membership_dictionary_shows() { + let render = |ocmd: &str, off: &[i32]| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /M1 7 0 R >> >> >> endobj +4 0 obj << /Length 44 >> stream +/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC +endstream endobj +5 0 obj << /Type /OCG /Name (A) >> endobj +6 0 obj << /Type /OCG /Name (B) >> endobj +7 0 obj {ocmd} endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let layers = off.iter().map(|n| (*n, 0, false)).collect(); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig { layers: Arc::new(layers), ..Default::default() }); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }); + p.rgba[..4] == [255, 0, 0, 255] + }; + let and = "<< /Type /OCMD /VE [/And 5 0 R 6 0 R] >>"; + assert!(render(and, &[]), "And: both on"); + assert!(!render(and, &[5]), "And: one off"); + let or = "<< /Type /OCMD /VE [/Or 5 0 R 6 0 R] >>"; + assert!(render(or, &[5]), "Or: one on"); + assert!(!render(or, &[5, 6]), "Or: both off"); + let not = "<< /Type /OCMD /VE [/Not 5 0 R] >>"; + assert!(!render(not, &[]), "Not: the group is on"); + assert!(render(not, &[5]), "Not: the group is off"); + let nested = "<< /Type /OCMD /VE [/And [/Not 5 0 R] 6 0 R] >>"; + assert!(render(nested, &[5]), "nested: A off, B on"); + assert!(!render(nested, &[]), "nested: A on"); + // /VE wins over /OCGs and /P. + let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; + assert!(render(both, &[5]), "the expression, not /P, decides"); + // A malformed expression falls back to /OCGs and /P. + // So is one naming an object that isn't a listed group: a missing one, or the page + // (review on #2987). + for bad in ["[/Xor 5 0 R]", "[/Not 5 0 R 6 0 R]", "[/And]", "[5 0 R]", "[/And (5)]", "[/Or 8 0 R]", "[/Or 3 0 R]"] { + let ocmd = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {bad} >>"); + assert!(render(&ocmd, &[]), "{bad}: /OCGs on"); + assert!(!render(&ocmd, &[5]), "{bad}: /OCGs off"); + } + // Nesting past the limit is malformed too, and does not recurse without end. + let deep = format!("<< /Type /OCMD /OCGs [5 0 R] /VE {}5 0 R{} >>", "[/Not ".repeat(41), "]".repeat(41)); + assert!(!render(&deep, &[5]), "too deep: /OCGs decides"); + } + /// From `cargo xtask fuzz`: a tiling pattern with no /Resources inherits the page's, where /// its own name points back at it. It painted itself until the stack overflowed (vendored /// hayro-interpret patch: `MAX_PAINT_NESTING`). Ordinary patterns must still paint. @@ -3751,6 +3897,62 @@ trailer << /Root 1 0 R >> pdf } + /// Review on #3184: a palette image whose indices are 1-bit samples (`ihdr` BPC 0, `Ssiz` 0) + /// and whose palette holds 8-bit colours. Its colours are decoded to 8 bits; rescaling them + /// to the indices' 1-bit precision kept only 0 or 255 per channel. Contributor-original: + /// one 1×1 tile with an empty packet, so its one sample is the 1-bit midpoint (index 1). + #[test] + fn a_jp2_palette_keeps_its_colours_whatever_the_index_precision() { + let mut codestream = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; // SOC, SIZ (Lsiz 41), Rsiz. + for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 0, 1, 1]); // One component: 1 bit, no subsampling. + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); + codestream.extend_from_slice(&[0xff, 0xd9]); + let jp2_box = |kind: &[u8], body: &[u8]| [&((8 + body.len()) as u32).to_be_bytes()[..], kind, body].concat(); + let ihdr = [&1_u32.to_be_bytes()[..], &1_u32.to_be_bytes(), &[0, 1, 0, 7, 0, 0]].concat(); + let colr = [&[1, 0, 0][..], &16_u32.to_be_bytes()].concat(); // sRGB + // pclr: two entries, three 8-bit columns: red, then (64, 128, 192). + let pclr = [0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192]; + // cmap: channels 0..2 come from component 0 through palette columns 0..2. + let cmap = [0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2]; + let jp2 = [ + vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a], + jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 "), + jp2_box(b"jp2h", &[jp2_box(b"ihdr", &ihdr), jp2_box(b"colr", &colr), jp2_box(b"pclr", &pclr), jp2_box(b"cmap", &cmap)].concat()), + jp2_box(b"jp2c", &codestream), + ] + .concat(); + let content = "q 20 0 0 20 5 5 cm /Im1 Do Q"; + // No /ColorSpace (and no /BitsPerComponent): the JP2 header gives the colours, through its + // palette (ISO 32000-2 §7.4.9). With an /Indexed space the image holds raw indices at + // their own precision, so index 1 picks the space's entry 1, not entry 255. + let lut = format!("FF00004080C0{}00FF00", "000000".repeat(253)); + for colour_space in [String::new(), format!("/ColorSpace [/Indexed /DeviceRGB 255 <{lut}>] ")] { + let mut pdf = format!( + "%PDF-1.7\n\ +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\ +4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ +5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 {colour_space}/Filter /JPXDecode /Length {} >> stream\n", + content.len(), + jp2.len() + ) + .into_bytes(); + pdf.extend_from_slice(&jp2); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let px = &page.rgba[((40 - 15) * 40 + 15) * 4..][..3]; + assert!(px.iter().zip([64u8, 128, 192]).all(|(a, b)| a.abs_diff(b) <= 2), "{colour_space:.30}: entry 1: {px:?}"); + } + } + /// #3306: a JP2 image whose colour specification names an enumerated colour space the /// decoder refuses (CIEJab in a veraPDF fixture, or YPbPr, or bilevel for one component) is /// drawn as if it had none, by its component count, as MuPDF and Poppler draw it: the @@ -4602,6 +4804,78 @@ trailer << /Root 1 0 R >> assert!(grey[0] == grey[1] && grey[1] == grey[2] && grey[0] < 255, "the image covers the page: {grey:?}"); } + /// A five-point open polyline is not a rectangle. `path_as_rect` took any five-element path + /// whose points covered the four corners of its bounding box for that box, and a `MoveTo` with + /// four `LineTo` is five elements, so an open zigzag stroked with `S` was painted as its + /// bounding rectangle: the peak between its ends vanished and the open side came out closed. + #[test] + fn a_five_point_polyline_is_not_painted_as_its_bounding_box() { + // The ends and the points beside them sit on the four corners of the bounding box; the + // peak at (20, 20) lies between them and is on no corner. + let content = b"0 0 0 RG 2 w 30 30 m 30 10 l 20 20 l 10 10 l 10 30 l S".to_vec(); + let pdf = one_page_pdf(&content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the polyline must render"); + assert!(page.error.is_none(), "{:?}", page.error); + // Device y counts down the 40 pt page, so the two ends at y = 30 are on row 10. + let dark = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] < 128 + }; + assert!((17..=23).any(|x| (17..=23).any(|y| dark(x, y))), "the peak between the ends draws"); + assert!(!(14..=26).any(|x| dark(x, 10)), "the side the path leaves open stays open"); + } + + /// Five points all on the corners still need not trace the rectangle: three sides and a step + /// back along the third is such a path, and it was painted as the box -- drawing a fourth + /// side the path does not have (FredApps on #3327). + #[test] + fn a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle() { + // Bottom, right, top, then back along the top. The left side is never drawn. + let content = b"0 0 0 RG 2 w 10 10 m 30 10 l 30 30 l 10 30 l 30 30 l S".to_vec(); + let pdf = one_page_pdf(&content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the path must render"); + assert!(page.error.is_none(), "{:?}", page.error); + let dark = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] < 128 + }; + // Device y counts down the 40 pt page: the bottom side at y = 10 is row 30. + assert!((14..=26).any(|x| dark(x, 30)), "the sides the path does draw are drawn"); + assert!(!(14..=26).any(|y| dark(10, y)), "the left side it never draws is not drawn"); + } + + /// A rectangle still takes the rectangle route, written either way: `m l l l` and closed, or + /// `m l l l l` back to its first corner. + #[test] + fn a_rectangle_is_still_painted_as_a_rectangle() { + for content in [&b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l f"[..], &b"0 0 0 rg 10 10 m 30 10 l 30 30 l 10 30 l 10 10 l f"[..]] { + let pdf = one_page_pdf(content, ""); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("the rectangle must render"); + assert!(page.error.is_none(), "{:?}", page.error); + let pixel = |x: u32, y: u32| { + let offset = ((y * page.width + x) * 4) as usize; + page.rgba[offset] + }; + assert!(pixel(20, 20) < 128, "the rectangle is filled"); + assert_eq!(pixel(5, 5), 255, "and nothing outside it is"); + } + } + /// A one-page PDF whose content is the stream `stream`, with the entries `dict` in its /// dictionary. fn one_page_pdf(stream: &[u8], dict: &str) -> Vec { @@ -5347,6 +5621,126 @@ trailer << /Root 1 0 R >> assert!(p != [255, 255, 255, 255] && p[3] == 255, "the triangles are drawn: {p:?}"); } + /// Two triangles of a mesh that share an edge cover it together. The sampler tested each + /// pixel's corner, and only strictly inside a triangle, so the diagonal two triangles share + /// was left white wherever it ran through the samples, and so was a pixel row or column + /// along the mesh's own sides. Vendored hayro-interpret patch 36. + #[test] + fn mesh_triangles_sharing_an_edge_leave_no_seam() { + // A red square from 10 to 40 on a 50 × 50 page, cut along its diagonal; in device space + // the diagonal runs through pixel centres and corners alike. + let vertex = |flag: u8, x: u8, y: u8| [flag, x, y, 255, 0, 0]; + let (lo, hi) = (10, 40); + let mut triangles = Vec::new(); + for [a, b, c] in [[(lo, lo), (hi, lo), (hi, hi)], [(lo, lo), (hi, hi), (lo, hi)]] { + for (x, y) in [a, b, c] { + triangles.extend(vertex(0, x, y)); + } + } + // Each triangle as its own three vertices, and the second one as a strip (flag 1). + let mut strip = Vec::new(); + for (flag, (x, y)) in [(0, (lo, lo)), (0, (hi, lo)), (0, (hi, hi)), (2, (lo, hi))] { + strip.extend(vertex(flag, x, y)); + } + for (label, data) in [("separate triangles", triangles), ("a strip", strip)] { + let content = b"/Sh0 sh"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{label}: {:?}", page.error); + assert_eq!((page.width, page.height), (50, 50)); + let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); + for y in 0..50_usize { + for x in 0..50_usize { + let p = &page.rgba[(y * 50 + x) * 4..][..4]; + let inside = (10..40).contains(&x) && (10..40).contains(&y); + if inside && p != [255, 0, 0, 255] { + white_inside.push((x, y)); + } else if !inside && p != [255, 255, 255, 255] { + red_outside.push((x, y)); + } + } + } + assert!(white_inside.is_empty(), "{label}: unshaded pixels inside the square: {white_inside:?}"); + assert!(red_outside.is_empty(), "{label}: shaded pixels outside the square: {red_outside:?}"); + } + + // One triangle alone, the square's upper left half: a pixel whose corner lies on its + // diagonal is taken by its centre, so one wholly across the diagonal (x + y ≥ 50 in + // device space) is not shaded, and one wholly inside is. + let mut data = Vec::new(); + for (x, y) in [(lo, lo), (hi, hi), (lo, hi)] { + data.extend(vertex(0, x, y)); + } + let content = b"/Sh0 sh"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + let (mut spilled, mut missing) = (Vec::new(), Vec::new()); + for y in 10..40_usize { + for x in 10..40_usize { + let p = &page.rgba[(y * 50 + x) * 4..][..4]; + if x + y >= 50 && p == [255, 0, 0, 255] { + spilled.push((x, y)); + } else if x + y + 2 <= 50 && p != [255, 0, 0, 255] { + missing.push((x, y)); + } + } + } + assert!(spilled.is_empty(), "shaded pixels wholly across the diagonal: {spilled:?}"); + assert!(missing.is_empty(), "unshaded pixels wholly inside the triangle: {missing:?}"); + } + + /// A pixel whose corner is inside one triangle and whose centre is inside its neighbour + /// takes the colour at its corner, whichever triangle comes first (patch 36): the + /// centre-coverage pass only fills pixels no triangle has given a colour yet. Here the + /// shared diagonal lies at x + y = 50.25 in device space, so pixel (20, 30) has its corner + /// in the red triangle and its centre in the blue one. + #[test] + fn a_shared_edge_pixel_keeps_the_colour_at_its_corner() { + let vertex = |x: u8, y: u8, rgb: [u8; 3]| [0, x, y, rgb[0], rgb[1], rgb[2]]; + let (lo, hi) = (10, 40); + let (red, blue) = ([255, 0, 0], [0, 0, 255]); + // In PDF space the red triangle is the square's upper left half, the blue one the lower + // right; `cm` moves both down a quarter point. + let red_triangle = [(lo, lo, red), (hi, hi, red), (lo, hi, red)]; + let blue_triangle = [(lo, lo, blue), (hi, lo, blue), (hi, hi, blue)]; + for (label, order) in [("red first", [red_triangle, blue_triangle]), ("blue first", [blue_triangle, red_triangle])] { + let mut data = Vec::new(); + for triangle in order { + for (x, y, rgb) in triangle { + data.extend(vertex(x, y, rgb)); + } + } + let content = b"1 0 0 1 0 -0.25 cm /Sh0 sh"; + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 50 50] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes()); + pdf.extend_from_slice(content); + pdf.extend_from_slice(format!("\nendstream endobj\n5 0 obj << /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >> stream\n", data.len()).as_bytes()); + pdf.extend_from_slice(&data); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{label}: {:?}", page.error); + let pixel = |x: usize, y: usize| page.rgba[(y * 50 + x) * 4..][..4].to_vec(); + assert_eq!(pixel(20, 30), [255, 0, 0, 255], "{label}: the corner of pixel (20, 30) is in the red triangle"); + // Controls: wholly inside one triangle or the other. + assert_eq!(pixel(15, 20), [255, 0, 0, 255], "{label}"); + assert_eq!(pixel(35, 30), [0, 0, 255, 255], "{label}"); + } + } + /// Review of the mesh-shading patch: every patch was cut into 722 triangles before anything /// limited them (about 150 KB per patch), and a lattice mesh with `/VerticesPerRow 0` pushed /// empty rows for ever. Vendored hayro-interpret patch: patches are triangulated one at a @@ -6168,6 +6562,258 @@ trailer << /Root 1 0 R >> assert_eq!(pixel(&on, 150, 55), &[255, 255, 255, 255], "the image's white quadrant stays white"); } + /// #2975: a stencil mask (`/ImageMask true`) is painted in the fill colour at the fill + /// opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at `ca 0.5` beside it + /// (it was a quarter as opaque, the opacity applied by both the image's group and its paint). + /// The same holds when the fill is a pattern, whose paint keeps the opacity in force when it + /// was set with `scn`: an uncoloured tiling pattern and a shading pattern. + #[test] + fn stencil_masks_are_drawn_at_the_fill_opacity_once() { + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + let fills = [("colour", "0 0 1 rg"), ("uncoloured tiling pattern", "/CsP cs 0 0 1 /Tp scn"), ("shading pattern", "/Pattern cs /Sp scn")]; + for (fill_name, fill) in fills { + for alpha in ["1", "0.5", "0.25"] { + let content = format!("/A gs {fill} q 100 0 0 100 20 50 cm /Msk Do Q 160 50 100 100 re f"); + let tile = "0 0 10 10 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream +\0\0 +endstream endobj +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream +{tile} +endstream endobj +7 0 obj << /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 300 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + tile.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(p.error.is_none(), "{fill_name}, ca {alpha}: {:?}", p.error); + // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. + let (mask, rect) = (px(&p, 70, 100), px(&p, 210, 100)); + assert!(rect != [255, 255, 255], "{fill_name}, ca {alpha}: the rectangle is painted"); + assert!(mask.iter().zip(&rect).all(|(m, r)| m.abs_diff(*r) <= 1), "{fill_name}, ca {alpha}: mask {mask:?}, rectangle {rect:?}"); + } + } + } + + /// #2971: in a knockout transparency group (`/K true`) each element composites with the + /// group's initial backdrop, not with the elements drawn before it in the group, over the + /// element's shape. A blue square at 50 % opacity over a red one at 50 % shows only the blue + /// where they overlap; without `/K` the two blend. The same holds for an image (its shape + /// is its rectangle; a soft-masked image's shape is its mask, so its transparent part leaves + /// what is under it) and for a nested group (its shape is the union of its elements' + /// shapes, while its own elements still blend with each other). + #[test] + fn knockout_groups_replace_what_their_elements_cover() { + let render_as = |group: &str, form: &str, preview: bool| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> /ExtGState << /G << /ca 0.5 >> >> >> >> endobj +4 0 obj << /Length 39 >> stream +/Fm1 Do /G gs 0 1 0 rg 10 10 30 30 re f +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency {group} >> /Resources << /ExtGState << /A << /ca 0.5 >> >> /XObject << /In 6 0 R /Msk 7 0 R /Neu 8 0 R /Img 9 0 R /Sm 10 0 R /Nimg 12 0 R /Deep 13 0 R /Shg 16 0 R /Big 17 0 R >> >> /Length {} >> stream +{form} +endstream endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length 34 >> stream +/A gs 0 0 1 rg 100 50 150 120 re f +endstream endobj +7 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >> stream +0000> +endstream endobj +8 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> >> /Length 62 >> stream +/H gs 0 1 0 rg 80 40 100 100 re f 0 0 1 rg 130 80 100 100 re f +endstream endobj +9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +10 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode /Length 13 >> stream +0000FF0000FF> +endstream endobj +11 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 5 >> stream +00FF> +endstream endobj +12 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /Img 9 0 R >> >> /Length 38 >> stream +/H gs q 160 0 0 100 60 50 cm /Img Do Q +endstream endobj +13 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /ExtGState << /H << /ca 0.5 >> >> /XObject << /In3 14 0 R >> >> /Length 13 >> stream +/H gs /In3 Do +endstream endobj +14 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Length 28 >> stream +0 0 1 rg 100 50 150 120 re f +endstream endobj +15 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0 0 1] /C1 [0 0 1] /N 1 >> /Extend [true true] /BBox [100 50 140 170] >> >> endobj +16 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency >> /Resources << /Pattern << /P 15 0 R >> >> /Length 38 >> stream +/Pattern cs /P scn 100 50 150 120 re f +endstream endobj +17 0 obj << /Type /XObject /Subtype /Image /Width 20000 /Height 20000 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >> stream +0000FF> +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + form.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview, + }); + assert!(p.error.is_none(), "{group} {form}: {:?}", p.error); + p + }; + let render = |group: &str, form: &str| render_as(group, form, false); + // Pixel rows run top down: PDF (x, y) is pixel (x, 200 - y). + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((200 - y) * p.width + x) as usize * 4..][..3].to_vec(); + let check = |p: &RenderedPage, x: u32, y: u32, want: [u8; 3], what: &str| { + let got = px(p, x, y); + assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what}: {got:?}, expected {want:?}"); + }; + let red = "/A gs 1 0 0 rg 20 20 150 150 re f "; + let ko = "/I true /K true"; + + let squares = render(ko, &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&squares, 130, 100, [128, 128, 255], "knocked out"); + check(&squares, 50, 40, [255, 128, 128], "red alone"); + check(&squares, 210, 100, [128, 128, 255], "blue alone"); + check(&squares, 280, 190, [255, 255, 255], "paper"); + // Page content after the group composites normally again: green at 50 % over the red. + check(&squares, 30, 30, [128, 191, 64], "after the group"); + let plain = render("/I true", &format!("{red}0 0 1 rg 100 50 150 120 re f")); + check(&plain, 130, 100, [128, 64, 191], "blended without /K"); + + // An image at 50 % replaces the red under its rectangle. + let image = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Img Do Q")); + check(&image, 100, 100, [128, 128, 255], "image knocks out"); + check(&image, 40, 40, [255, 128, 128], "red beside the image"); + // A soft-masked image: its transparent left half leaves the red, its opaque half replaces it. + let masked = render(ko, "1 0 0 rg 20 20 150 150 re f q 160 0 0 100 60 50 cm /Sm Do Q"); + check(&masked, 100, 100, [255, 0, 0], "under the transparent half"); + check(&masked, 155, 100, [0, 0, 255], "under the opaque half"); + + // A nested group entered at 50 % (blue at 25 % in all) replaces the red under its shape. + let nested = render(ko, &format!("{red}/In Do")); + check(&nested, 130, 100, [191, 191, 255], "nested group knocks out"); + check(&nested, 50, 40, [255, 128, 128], "red beside the nested group"); + // A nested group at full opacity: it knocks out the red as a whole, and its own two + // squares at 50 % still blend with each other where they overlap. + let neutral = render(ko, "1 0 0 rg 20 20 150 150 re f /Neu Do"); + check(&neutral, 100, 60, [128, 255, 128], "green over the knocked-out red"); + check(&neutral, 150, 110, [64, 128, 191], "the nested squares blend"); + check(&neutral, 40, 40, [255, 0, 0], "red beside the nested group"); + + // An image inside a nested group is drawn once (blue at 50 %), over the knocked-out red. + let nested_image = render(ko, "1 0 0 rg 20 20 150 150 re f /Nimg Do"); + check(&nested_image, 100, 100, [128, 128, 255], "image in a nested group"); + // A group inside a nested group knocks out the red as well. + let deep = render(ko, "1 0 0 rg 20 20 150 150 re f /Deep Do"); + check(&deep, 130, 100, [128, 128, 255], "group in a nested group"); + check(&deep, 40, 40, [255, 0, 0], "red beside it"); + + // A stencil mask must not erase the red beside it. + let stencil = render(ko, &format!("{red}0 0 1 rg q 150 0 0 120 100 50 cm /Msk Do Q")); + check(&stencil, 50, 40, [255, 128, 128], "red beside a stencil"); + // Where the stencil paints (every sample 0), its blue is drawn (review on #2974: the check + // above alone passed even if the stencil drew nothing). A stencil composites as it did + // before rather than knocking the red out, and its exact colour also depends on #2975 (a + // stencil's fill opacity applied twice), so only "blue was drawn here" is pinned. + let under = px(&stencil, 130, 100); + assert!(under != [255, 128, 128] && under[2] >= 150 && under[0] < 230, "stencil drew nothing: {under:?}"); + + // A shading's /BBox clips it (ISO 32000-2 Table 77), so a shading fill's shape is its path + // inside the box: the erase pass must clear only that, or the red outside the box but + // inside the path stays erased (review on #2974). + let shaded = render(ko, &format!("{red}/Shg Do")); + check(&shaded, 120, 100, [128, 128, 255], "the shading inside its box"); + check(&shaded, 155, 100, [255, 128, 128], "red outside the shading's box"); + + // An image that isn't decoded (here over the renderer's pixel limit) draws nothing, so it + // must not clear what is under its rectangle either (review on #2974). + let undecoded = render(ko, &format!("{red}q 160 0 0 100 60 50 cm /Big Do Q")); + check(&undecoded, 100, 100, [255, 128, 128], "red under an image that isn't drawn"); + // Nor one a preview skips for covering only a few device pixels (review on #2974): the + // red under a 3 x 3 image stays in a preview, and a final render knocks it out. + let tiny = format!("{red}q 3 0 0 3 100 100 cm /Img Do Q"); + check(&render_as(ko, &tiny, true), 101, 101, [255, 128, 128], "red under a preview's skipped image"); + check(&render_as(ko, &tiny, false), 101, 101, [128, 128, 255], "the tiny image in a final render"); + } + + /// #2970: a page's objects composite with a transparent backdrop and the page group is then + /// put on the paper, so a separable blend mode only blends where something was drawn. A + /// green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square is yellow + /// (or whatever the mode gives) over the red, and plain green over the empty page: blending + /// with the white paper made it white or magenta. + #[test] + fn blend_modes_over_an_empty_part_of_the_page_keep_their_colour() { + let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + for (mode, over_red) in [ + ("Normal", Some([0, 255, 0])), + ("Multiply", Some([0, 0, 0])), + ("Screen", Some([255, 255, 0])), + ("Lighten", Some([255, 255, 0])), + ("Difference", Some([255, 255, 0])), + ("Exclusion", Some([255, 255, 0])), + ("ColorDodge", Some([255, 0, 0])), + ("Overlay", Some([255, 0, 0])), + // The non-separable modes mix hue, saturation and luminosity; over an empty part of + // the page they too must keep the source colour. + ("Hue", None), + ("Saturation", None), + ("Color", None), + ("Luminosity", None), + ] { + let content = "1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /ExtGState << /G1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /ExtGState /BM /{mode} >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let p = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + ..Default::default() + }); + assert!(p.error.is_none(), "{mode}: {:?}", p.error); + // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. + assert_eq!(px(&p, 210, 100), vec![0, 255, 0, 255], "{mode} over the empty page"); + if let Some(over_red) = over_red { + assert_eq!(px(&p, 130, 100)[..3], over_red, "{mode} over the red square"); + } + assert_eq!(px(&p, 280, 10), vec![255, 255, 255, 255], "{mode}: the paper stays white"); + } + } + /// Reading colours replace *document* colours, never annotation colours (issue #1548): a /// highlight keeps its `/C`, so on a dark background the content under it shows through the /// Multiply blend in the highlight's colour instead of being darkened into invisibility. @@ -6605,6 +7251,9 @@ endstream endobj assert_eq!(decodes("B"), 5, "a JPX soft mask decodes for the size it is drawn at"); } + // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). + mod icc_profile_cache; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); @@ -7146,6 +7795,38 @@ trailer << /Root 1 0 R >> assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask"); } + /// Issue #3176: `CA` / `ca` belong to the graphics state at paint time, so a `gs` after + /// `SCN` / `scn` still fades a shading pattern, and a stroke takes the stroke alpha. The + /// pattern took the fill alpha current at `scn`/`SCN`, so both shapes came out opaque. + #[test] + fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = "/Pattern CS /P0 SCN /S1 gs 10 w 0 75 m 100 75 l S /Pattern cs /P0 scn /F1 gs 0 0 100 50 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R + /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] + /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // Red at 25% over white is about (255, 191, 191); opaque red would be (255, 0, 0). + for (what, x, y) in [("the stroke under /CA 0.25", 50, 25), ("the fill under /ca 0.25", 50, 75)] { + let c = px(x, y); + assert!(c[0] >= 250 && (185..=197).contains(&c[1]) && (185..=197).contains(&c[2]), "{what}: {c:?}"); + } + } + /// ISO 32000-2 §8.5.4: `W` / `W*` clip with the current path once whichever path-painting /// operator ends it has painted it, `S`, `f` or `B` as much as `n`. The vendored interpreter /// applied the clip only on `n`: after `re W* S` everything later painted outside the clip, @@ -7238,4 +7919,7 @@ trailer << /Root 1 0 R >> assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph"); } } + + // #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38). + mod default_colour_spaces; } diff --git a/crates/render/src/raster/radial_softmask_tests.rs b/crates/render/src/raster/radial_softmask_tests.rs new file mode 100644 index 000000000..674dce5d6 --- /dev/null +++ b/crates/render/src/raster/radial_softmask_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +fn masked_shading(shading: &str, extra: &str, ink: &str) -> Vec { + let content = format!("20.25 20.25 279.5 279.5 re W n /M gs {ink} 0 0 320 320 re f"); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 320 320] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> /Pattern << /P << /PatternType 2 /Shading << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [160 160 0 160 160 180] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0.5 0] /N 1 >> >> >> >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /ExtGState /SMask << /S /Luminosity /G 6 0 R >> >> endobj +6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 320 320] /Group << /S /Transparency /CS /DeviceCMYK >> /Resources << /Shading << /Sh0 {shading} >> >> /Length 7 >> stream +/Sh0 sh +endstream endobj +{extra} +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + pdf.into_bytes() +} + +fn render_mask(bytes: Vec, request: RenderRequest) -> RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(request); + assert!(page.error.is_none(), "{:?}", page.error); + page +} + +#[test] +fn radial_luminosity_mask_fades_when_painted_by_one_shading() { + let bytes = masked_shading(RADIAL_MASK, "", "/Pattern cs /P scn"); + + let page = render_mask(bytes, RenderRequest { scale: 1.0, ..Default::default() }); + + for (x, blue) in [(160_u32, 0..=5), (250, 124..=132), (299, 200..=250), (300, 255..=255)] { + let offset = usize::try_from((160 * page.width + x) * 4).unwrap(); + let pixel = &page.rgba[offset..offset + 4]; + assert!(blue.contains(&pixel[2]), "centre/midpoint/antialiased clip/outside, x {x}: {pixel:?}, blue {blue:?}"); + } +} + +const RADIAL_MASK: &str = "<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [160 160 0 160 160 180] /Extend [true true] /AntiAlias false /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >>"; + +#[test] +fn radial_luminosity_mask_matches_per_pixel_shading_when_scaled_or_tiled() { + let code = "{ 160 sub dup mul exch 160 sub dup mul add sqrt 180 div 1 exch sub }"; + let extra = format!("7 0 obj << /FunctionType 4 /Domain [0 320 0 320] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj", code.len()); + for ink in ["1 0 0 rg", "/Pattern cs /P scn"] { + let radial = masked_shading(RADIAL_MASK, "", ink); + let sampled = masked_shading("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 320 0 320] /Function 7 0 R >>", &extra, ink); + for scale in [0.5, 1.0, 2.0] { + for preview in [false, true] { + for tile in [None, Some(Tile { x: 64, y: 64, w: 160, h: 160 })] { + let request = RenderRequest { scale, preview, tile, ..Default::default() }; + + let native = render_mask(radial.clone(), request); + let reference = render_mask(sampled.clone(), request); + + assert_eq!((native.width, native.height), (reference.width, reference.height)); + let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max(); + assert!(worst.is_some_and(|d| d <= 4), "{ink}, {request:?}: radial luminosity mask differs from sampled reference by {worst:?}"); + } + } + } + } +} diff --git a/crates/render/src/raster/tests/default_colour_spaces.rs b/crates/render/src/raster/tests/default_colour_spaces.rs new file mode 100644 index 000000000..60eeab75f --- /dev/null +++ b/crates/render/src/raster/tests/default_colour_spaces.rs @@ -0,0 +1,397 @@ +//! #1201: the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource +//! dictionary stand in for the device spaces its content selects, with colour operators, with +//! images and with shadings (ISO 32000-2 §8.6.5.6, vendored hayro-interpret patch 38). hayro +//! drew device colour as it was, so pages that calibrate their device colour this way (common +//! in PDF/A) came out in the wrong colours. +//! +//! The defaults are synthetic ICC profiles built here, each sending device colour somewhere it +//! cannot go uncalibrated: red to green (or blue), black to mid grey, black ink to blue. + +use super::{ONE_PIXEL_RGB, image_cache_pdf, render_first_page, rgb_at}; + +/// The width in pixels of the 130 × 70 pt page `image_cache_pdf` makes, rendered at 72 dpi. +const WIDTH: usize = 130; + +const RED: [u8; 3] = [255, 0, 0]; +const GREEN: [u8; 3] = [0, 255, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; +const BLACK: [u8; 3] = [0, 0, 0]; +/// Device black through `gray_profile`: half the luminance of white, sRGB-encoded. +const MID_GREY: [u8; 3] = [188, 188, 188]; +/// Full black ink through `cmyk_profile`: CIELAB (40, 20, -60) relative to the D50 white of the +/// profile connection space, which is XYZ (0.1378, 0.1125, 0.3956) and a mid blue in sRGB. +const INK_BLUE: [u8; 3] = [67, 86, 194]; + +/// An s15Fixed16Number (ICC.1 §4.6). +fn s15f16(value: f64) -> [u8; 4] { + ((value * 65536.0).round() as i32).to_be_bytes() +} + +/// An ICC v2 profile (ICC.1:2001-04 §6) of device class `class` for the colour space `space`, +/// with `tags` (signature, data) in that order. The device model is left blank: hayro takes a +/// profile whose model reads `sRGB` for standard sRGB and skips its transform. +fn icc(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec)]) -> Vec { + let start = 128 + 4 + 12 * tags.len(); + let (mut table, mut data) = (Vec::new(), Vec::new()); + for (signature, tag) in tags { + table.extend_from_slice(signature); + table.extend_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + table.extend_from_slice(&u32::try_from(tag.len()).unwrap().to_be_bytes()); + data.extend_from_slice(tag); + data.resize(data.len().next_multiple_of(4), 0); + } + let mut profile = vec![0; 128]; + profile[0..4].copy_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + profile[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + profile[12..16].copy_from_slice(class); + profile[16..20].copy_from_slice(space); + profile[20..24].copy_from_slice(pcs); + profile[36..40].copy_from_slice(b"acsp"); + // The illuminant of the profile connection space, D50. + for (i, value) in [0.9642, 1.0, 0.8249].into_iter().enumerate() { + profile[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15f16(value)); + } + profile.extend_from_slice(&u32::try_from(tags.len()).unwrap().to_be_bytes()); + profile.extend(table); + profile.extend(data); + profile +} + +/// An `XYZType` tag. +fn xyz([x, y, z]: [f64; 3]) -> Vec { + [b"XYZ ".as_slice(), &[0; 4], &s15f16(x), &s15f16(y), &s15f16(z)].concat() +} + +/// A `curveType` tag: one entry is a gamma (u8Fixed8), more are points spread evenly over 0..1. +fn curve(points: &[u16]) -> Vec { + let mut tag = [b"curv".as_slice(), &[0; 4], &u32::try_from(points.len()).unwrap().to_be_bytes()].concat(); + for point in points { + tag.extend_from_slice(&point.to_be_bytes()); + } + tag +} + +/// sRGB's red, green and blue colorants, adapted to D50 as sRGB profiles record them. +const SRGB_COLORANTS: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + +/// A linear RGB profile whose red, green and blue take the sRGB colorants `primaries` names: +/// with `[1, 2, 0]` red is drawn as sRGB green, green as blue and blue as red. +fn rgb_profile(primaries: [usize; 3]) -> Vec { + let mut tags = Vec::new(); + for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) { + tags.push((signature, xyz(SRGB_COLORANTS[primary]))); + } + for signature in [*b"rTRC", *b"gTRC", *b"bTRC"] { + tags.push((signature, curve(&[0x0100]))); + } + icc(b"mntr", b"RGB ", b"XYZ ", &tags) +} + +/// A gray profile whose tone curve rises from half the luminance of white to all of it, so +/// device black (`0 g`) is drawn mid grey. +fn gray_profile() -> Vec { + icc(b"mntr", b"GRAY", b"XYZ ", &[(*b"kTRC", curve(&[0x8000, 0xffff]))]) +} + +/// A CMYK profile with an 8-bit lookup table into CIELAB (`lut8Type`, ICC.1 §6.5.7) that draws +/// full black ink as L*a*b* (40, 20, -60) and no black ink as white, whatever the other inks. +fn cmyk_profile() -> Vec { + let mut lut = [b"mft1".as_slice(), &[0; 4], &[4, 3, 2, 0]].concat(); + // The matrix, only used for XYZ input: identity. + for value in [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] { + lut.extend_from_slice(&s15f16(value)); + } + // An identity input table per ink. + for _ in 0..4 { + lut.extend(0..=255_u8); + } + // The grid, two points per ink, black ink varying fastest. Lab is encoded as L* · 2.55, + // a* + 128 and b* + 128. + for point in 0..16 { + lut.extend_from_slice(if point % 2 == 1 { &[102, 148, 68] } else { &[255, 128, 128] }); + } + // An identity output table per component. + for _ in 0..3 { + lut.extend(0..=255_u8); + } + icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)]) +} + +/// An `/ICCBased` profile stream of `n` components. +fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { + (format!("<< /N {n} /Length {} >>", profile.len()), profile) +} + +/// A stream with the entries `dict` and the content `content`. +fn stream(dict: &str, content: &str) -> (String, Vec) { + (format!("<< {dict} /Length {} >>", content.len()), content.as_bytes().to_vec()) +} + +/// A form XObject covering the page, with the dictionary entries `extra`. +fn form(extra: &str, content: &str) -> (String, Vec) { + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra}"), content) +} + +/// Draw `content` with `resources` on `image_cache_pdf`'s page, whose objects 5, 6, … are the +/// streams `objects`, and give the colour at each of `points` (from the top left). +fn colours(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + let objects = objects.iter().map(|(dict, data)| (dict.as_str(), data.as_slice())).collect::>(); + let page = render_first_page(image_cache_pdf(content, resources, &objects)); + assert_eq!((page.width as usize, page.height), (WIDTH, 70)); + points.iter().map(|&(x, y)| rgb_at(&page.rgba, WIDTH, x, y)).collect() +} + +#[track_caller] +fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { + assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}"); +} + +/// Device CMYK black ink through hayro's own CMYK profile: near black. +#[track_caller] +fn assert_dark(got: [u8; 3], what: &str) { + assert!(got.iter().all(|c| *c < 90), "{what}: got {got:?}, expected near black"); +} + +/// `rg`, `RG`, `/DeviceRGB cs` and a `/ColorSpace` resource naming DeviceRGB all select +/// DeviceRGB, so all draw in the page's DefaultRGB. Without one they draw device red as before. +#[test] +fn device_rgb_operators_draw_in_default_rgb() { + let content = "1 0 0 rg 0 0 30 70 re f /DeviceRGB cs 1 0 0 sc 32 0 30 70 re f /CS0 cs 1 0 0 sc 64 0 30 70 re f \ + 1 0 0 RG 20 w 111 0 m 111 70 l S"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let what = ["rg", "/DeviceRGB cs", "/CS0 cs (/DeviceRGB)", "RG"]; + let objects = [profile_stream(3, rgb_profile([1, 2, 0]))]; + let calibrated = colours(content, "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, GREEN, &format!("{what} with DefaultRGB")); + } + let device = colours(content, "<< /ColorSpace << /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in device.into_iter().zip(what) { + assert_eq!(got, RED, "{what} without DefaultRGB"); + } +} + +/// `k`, `K` and `/DeviceCMYK cs` draw in the page's DefaultCMYK. +#[test] +fn device_cmyk_operators_draw_in_default_cmyk() { + let content = "0 0 0 1 k 0 0 40 70 re f /DeviceCMYK cs 0 0 0 1 sc 45 0 40 70 re f 0 0 0 1 K 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["k", "/DeviceCMYK cs", "K"]; + let objects = [profile_stream(4, cmyk_profile())]; + let calibrated = colours(content, "<< /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, INK_BLUE, &format!("{what} with DefaultCMYK")); + } + for (got, what) in colours(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_dark(got, &format!("{what} without DefaultCMYK")); + } +} + +/// `g`, `G` and `/DeviceGray cs` draw in the page's DefaultGray. +#[test] +fn device_gray_operators_draw_in_default_gray() { + let content = "0 g 0 0 40 70 re f /DeviceGray cs 0 sc 45 0 40 70 re f 0 G 20 w 110 0 m 110 70 l S"; + let points = [(20, 35), (65, 35), (110, 35)]; + let what = ["g", "/DeviceGray cs", "G"]; + let objects = [profile_stream(1, gray_profile())]; + let calibrated = colours(content, "<< /ColorSpace << /DefaultGray [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, MID_GREY, &format!("{what} with DefaultGray")); + } + for (got, what) in colours(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_eq!(got, BLACK, "{what} without DefaultGray"); + } +} + +/// Images whose `/ColorSpace` is a device space are drawn in the default for it too: image +/// XObjects, inline images (whose `/RGB` and `/G` abbreviate the device spaces) and an inline +/// image naming a `/ColorSpace` resource that is DeviceRGB. +#[test] +fn device_images_draw_in_the_default_colour_spaces() { + let content = "q 20 0 0 70 0 0 cm /R Do Q q 20 0 0 70 22 0 cm /G Do Q q 20 0 0 70 44 0 cm /K Do Q \ + q 20 0 0 70 66 0 cm BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 88 0 cm BI /W 1 /H 1 /CS /CS0 /BPC 8 /F /AHx ID ff0000> EI Q \ + q 20 0 0 70 110 0 cm BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI Q"; + let points = [(10, 35), (32, 35), (54, 35), (76, 35), (98, 35), (120, 35)]; + let image = |space: &str, data: &[u8]| { + let dict = format!("<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /{space} /BitsPerComponent 8 /Length {} >>", data.len()); + (dict, data.to_vec()) + }; + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + profile_stream(4, cmyk_profile()), + image("DeviceRGB", &[255, 0, 0]), + image("DeviceGray", &[0]), + image("DeviceCMYK", &[0, 0, 0, 255]), + ]; + let x_objects = "/XObject << /R 8 0 R /G 9 0 R /K 10 0 R >>"; + let calibrated = colours( + content, + &format!( + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] /DefaultCMYK [/ICCBased 7 0 R] /CS0 /DeviceRGB >> {x_objects} >>" + ), + &objects, + &points, + ); + assert_near(calibrated[0], GREEN, "DeviceRGB image"); + assert_near(calibrated[1], MID_GREY, "DeviceGray image"); + assert_near(calibrated[2], INK_BLUE, "DeviceCMYK image"); + assert_near(calibrated[3], GREEN, "inline /RGB image"); + assert_near(calibrated[4], GREEN, "inline image in /CS0 (/DeviceRGB)"); + assert_near(calibrated[5], MID_GREY, "inline /G image"); + + let device = colours(content, &format!("<< /ColorSpace << /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_eq!(device[0], RED, "DeviceRGB image without DefaultRGB"); + assert_eq!(device[1], BLACK, "DeviceGray image without DefaultGray"); + assert_dark(device[2], "DeviceCMYK image without DefaultCMYK"); + assert_eq!(device[3], RED, "inline /RGB image without DefaultRGB"); + assert_eq!(device[4], RED, "inline /CS0 image without DefaultRGB"); + assert_eq!(device[5], BLACK, "inline /G image without DefaultGray"); +} + +/// Shadings whose `/ColorSpace` is a device space are drawn in the default for it too: painted +/// by `sh` or as a shading pattern, with their colours from a function (axial) or from their +/// vertices (a Type 4 mesh). +#[test] +fn device_shadings_draw_in_the_default_colour_spaces() { + let axial = |space: &str, colour: &str| { + format!( + "<< /ShadingType 2 /ColorSpace /{space} /Coords [0 0 130 0] /Extend [true true] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [{colour}] /C1 [{colour}] /N 1 >> >>" + ) + }; + // Two red triangles over the page: a flag, x, y and red, green and blue, a byte each. + let mut mesh: Vec = Vec::new(); + for (x, y) in [(0, 0), (255, 0), (0, 255), (255, 0), (255, 255), (0, 255)] { + mesh.extend_from_slice(&[0, x, y, 255, 0, 0]); + } + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + profile_stream(1, gray_profile()), + ( + format!( + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \ + /Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >>", + mesh.len() + ), + mesh, + ), + ]; + let content = "q 0 0 30 70 re W n /A sh Q /Pattern cs /P scn 32 0 30 70 re f \ + q 64 0 30 70 re W n /M sh Q q 96 0 30 70 re W n /G sh Q"; + let points = [(15, 35), (47, 35), (79, 35), (111, 35)]; + let shadings = format!( + "/Shading << /A {} /M 7 0 R /G {} >> /Pattern << /P << /PatternType 2 /Shading {} >> >>", + axial("DeviceRGB", "1 0 0"), + axial("DeviceGray", "0"), + axial("DeviceRGB", "1 0 0") + ); + let calibrated = colours( + content, + &format!("<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >> {shadings} >>"), + &objects, + &points, + ); + assert_near(calibrated[0], GREEN, "sh, an axial shading in DeviceRGB"); + assert_near(calibrated[1], GREEN, "a shading pattern in DeviceRGB"); + assert_near(calibrated[2], GREEN, "sh, a Type 4 mesh in DeviceRGB"); + assert_near(calibrated[3], MID_GREY, "sh, an axial shading in DeviceGray"); + + let device = colours(content, &format!("<< {shadings} >>"), &objects, &points); + assert_near(device[0], RED, "sh, an axial shading without DefaultRGB"); + assert_near(device[1], RED, "a shading pattern without DefaultRGB"); + assert_near(device[2], RED, "sh, a Type 4 mesh without DefaultRGB"); + assert_near(device[3], BLACK, "sh, an axial shading without DefaultGray"); +} + +/// One image drawn by a page with DefaultRGB and by a form that switches it off is decoded +/// for each: the decoded-image cache keeps the two apart (hayro-interpret patch 32 shares a +/// decode between every draw of an image). +#[test] +fn an_image_drawn_with_and_without_a_default_keeps_both_colours() { + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + (ONE_PIXEL_RGB.to_owned(), vec![255, 0, 0]), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "q 40 0 0 70 70 0 cm /R Do Q"), + ]; + let got = colours( + "q 40 0 0 70 0 0 cm /R Do Q /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /R 6 0 R /F 7 0 R >> >>", + &objects, + &[(20, 35), (90, 35)], + ); + assert_near(got[0], GREEN, "the image on the page, in DefaultRGB"); + assert_eq!(got[1], RED, "the same image in the form without DefaultRGB"); +} + +/// A form XObject draws device colour in the default of the resources it is drawn with: its +/// own when it has one, else the page's (with or without a `/Resources` of its own, with a +/// `/ColorSpace` of its own that has no default, and through a form nested in it). Poppler and +/// MuPDF draw these forms in the page's default too, though §8.6.5.6 names only the current +/// resource dictionary. A form whose own default is a device space or not a usable default +/// draws device colour, not the page's default. +#[test] +fn forms_inherit_the_default_colour_spaces_they_do_not_override() { + let blue = rgb_profile([2, 0, 1]); + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + // hayro caches a profile by its stream's dictionary, which here would be byte for byte + // the first profile's, so this one says more. + (format!("<< /N 3 /Alternate /DeviceRGB /Length {} >>", blue.len()), blue), + form("", "1 0 0 rg 22 37 20 33 re f"), + form("/Resources << /ProcSet [/PDF] >>", "1 0 0 rg 44 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/ICCBased 6 0 R] >> /XObject << /N 12 0 R >> >>", "1 0 0 rg 66 37 20 33 re f /N Do"), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "1 0 0 rg 88 37 20 33 re f"), + form("/Resources << /ColorSpace << /DefaultRGB [/Lab << /WhitePoint [0.9642 1 0.8249] >>] >> >>", "1 0 0 rg 110 37 20 33 re f"), + form("", "1 0 0 rg 0 0 20 33 re f"), + form("/Resources << /ColorSpace << /CS1 /DeviceGray >> >>", "1 0 0 rg 22 0 20 33 re f"), + ]; + let got = colours( + "1 0 0 rg 0 37 20 33 re f /A Do /B Do /C Do /D Do /E Do /F Do", + "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /A 7 0 R /B 8 0 R /C 9 0 R /D 10 0 R /E 11 0 R /F 13 0 R >> >>", + &objects, + &[(10, 17), (32, 17), (54, 17), (76, 17), (98, 17), (120, 17), (10, 53), (32, 53)], + ); + assert_near(got[0], GREEN, "the page"); + assert_near(got[1], GREEN, "a form without /Resources"); + assert_near(got[2], GREEN, "a form whose /Resources has no /ColorSpace"); + assert_near(got[3], BLUE, "a form with a DefaultRGB of its own"); + assert_eq!(got[4], RED, "a form whose DefaultRGB is /DeviceRGB"); + assert_eq!(got[5], RED, "a form whose DefaultRGB is a Lab space"); + assert_near(got[6], BLUE, "a form nested in the one with its own DefaultRGB"); + assert_near(got[7], GREEN, "a form whose /ColorSpace has no DefaultRGB"); +} + +/// A default that cannot stand in for its device space (ISO 32000-2 §8.6.5.6: a Lab, Indexed +/// or Pattern space, another device space, one with the wrong number of components, or no +/// colour space at all) leaves device colour as it is, and the dictionary's other defaults +/// still apply. +#[test] +fn unusable_defaults_leave_device_colour_alone() { + let objects = + [profile_stream(1, gray_profile()), profile_stream(4, cmyk_profile()), (String::from("<< /N 3 /Length 13 >>"), b"not a profile".to_vec())]; + for default in [ + "[/Lab << /WhitePoint [0.9642 1 0.8249] >>]", + "[/CalGray << /WhitePoint [0.9642 1 0.8249] >>]", + "[/Indexed /DeviceRGB 0 <00FF00>]", + "/Pattern", + "[/Pattern /DeviceRGB]", + "/DeviceCMYK", + "/DeviceGray", + "[/ICCBased 6 0 R]", + "[/ICCBased 7 0 R]", + "42", + "[/NoSuchSpace]", + "/DefaultRGB", + ] { + let got = colours( + "1 0 0 rg 0 0 60 70 re f 0 g 70 0 60 70 re f", + &format!("<< /ColorSpace << /DefaultRGB {default} /DefaultGray [/ICCBased 5 0 R] >> >>"), + &objects, + &[(30, 35), (100, 35)], + ); + assert_eq!(got[0], RED, "DefaultRGB {default}: device RGB is drawn as it is"); + assert_near(got[1], MID_GREY, &format!("DefaultRGB {default}: DefaultGray still applies")); + } +} diff --git a/crates/render/src/raster/tests/icc_profile_cache.rs b/crates/render/src/raster/tests/icc_profile_cache.rs new file mode 100644 index 000000000..dd6bce8d5 --- /dev/null +++ b/crates/render/src/raster/tests/icc_profile_cache.rs @@ -0,0 +1,160 @@ +//! Two `/ICCBased` profiles whose stream dictionaries are byte for byte the same (`/N 3` and one +//! `/Length`) each draw through their own profile (vendored hayro-interpret patch 48). hayro +//! cached the colour space it read from a profile stream by the dictionary's bytes alone, so +//! the second profile drew through whichever was read first. +//! +//! The profiles are synthetic ones built here: a linear profile with the sRGB colorants, and the +//! same profile with red and blue swapped, which draws red as blue. They have the same length. + +use super::{image_cache_pdf, render_first_page, rgb_at}; + +/// The width in pixels of the 130 × 70 pt page `image_cache_pdf` makes, rendered at 72 dpi. +const WIDTH: usize = 130; + +const RED: [u8; 3] = [255, 0, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; + +/// An s15Fixed16Number (ICC.1 §4.6). +fn s15f16(value: f64) -> [u8; 4] { + ((value * 65536.0).round() as i32).to_be_bytes() +} + +/// sRGB's red, green and blue colorants, adapted to D50 as sRGB profiles record them. +const SRGB_COLORANTS: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]]; + +/// An ICC v2 RGB display profile (ICC.1:2001-04) with linear tone curves, whose red, green and +/// blue take the sRGB colorants `primaries` names: `[0, 1, 2]` is linear sRGB, and `[2, 1, 0]` +/// draws red as blue. The device model is left blank: hayro takes a profile whose model reads +/// `sRGB` for standard sRGB and skips its transform. Every profile is 300 bytes long. +fn rgb_profile(primaries: [usize; 3]) -> Vec { + let mut tags: Vec<([u8; 4], Vec)> = Vec::new(); + for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) { + let [x, y, z] = SRGB_COLORANTS[primary]; + tags.push((signature, [b"XYZ ".as_slice(), &[0; 4], &s15f16(x), &s15f16(y), &s15f16(z)].concat())); + } + for signature in [*b"rTRC", *b"gTRC", *b"bTRC"] { + // A `curveType` with no entries: the identity. + tags.push((signature, [b"curv".as_slice(), &[0; 8]].concat())); + } + let start = 128 + 4 + 12 * tags.len(); + let (mut table, mut data) = (Vec::new(), Vec::new()); + for (signature, tag) in &tags { + table.extend_from_slice(signature); + table.extend_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + table.extend_from_slice(&u32::try_from(tag.len()).unwrap().to_be_bytes()); + data.extend_from_slice(tag); + } + let mut profile = vec![0; 128]; + profile[0..4].copy_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes()); + profile[8..12].copy_from_slice(&[2, 0x10, 0, 0]); + profile[12..16].copy_from_slice(b"mntr"); + profile[16..20].copy_from_slice(b"RGB "); + profile[20..24].copy_from_slice(b"XYZ "); + profile[36..40].copy_from_slice(b"acsp"); + // The illuminant of the profile connection space, D50. + for (i, value) in [0.9642, 1.0, 0.8249].into_iter().enumerate() { + profile[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15f16(value)); + } + profile.extend_from_slice(&u32::try_from(tags.len()).unwrap().to_be_bytes()); + profile.extend(table); + profile.extend(data); + profile +} + +/// An `/ICCBased` space whose profile stream is written directly in the space's array, not as +/// an object of its own (hayro reads such a stream when its data is plain tokens): the profile +/// as ASCIIHex digits, each a token of its own, in upper case (a lone `f` reads as a broken +/// `false`, and the page's dictionary with it). Its dictionary reports the object holding the +/// array, which is the page for every one of them. +fn direct_space(profile: &[u8]) -> String { + let hex: String = profile.iter().flat_map(|b| [b >> 4, b & 0xf]).map(|d| format!("{d:X} ")).collect(); + format!("[/ICCBased << /N 3 /Filter /ASCIIHexDecode /Length {} >> stream\n{hex}\nendstream]", hex.len()) +} + +/// A page that fills six 20 pt squares, side by side, with `1 0 0 sc` (red) in the spaces `/A` +/// (object 5, linear sRGB), `/B` (object 6, red and blue swapped), `/A2` (object 5 again, +/// through an array of other bytes, so the profile is looked up again), `/A3` (object 7, a copy +/// of object 5), `/C` and `/D` (the two profiles again, each a stream written directly in its +/// array). Objects 5, 6 and 7 have the same dictionary, and so do the two direct streams. +fn page() -> Vec { + let (srgb, swapped) = (rgb_profile([0, 1, 2]), rgb_profile([2, 1, 0])); + assert_eq!(srgb.len(), swapped.len(), "the profiles have the same length, so the same dictionary"); + assert_ne!(srgb, swapped); + let dict = format!("<< /N 3 /Length {} >>", srgb.len()); + let resources = format!( + "<< /ColorSpace << /A [/ICCBased 5 0 R] /B [/ICCBased 6 0 R] /A2 [ /ICCBased 5 0 R ] /A3 [/ICCBased 7 0 R] /C {} /D {} >> >>", + direct_space(&srgb), + direct_space(&swapped) + ); + let content = ["A", "B", "A2", "A3", "C", "D"] + .iter() + .enumerate() + .map(|(i, name)| format!("/{name} cs 1 0 0 sc {} 20 20 30 re f", 2 + 21 * i)) + .collect::>() + .join("\n"); + image_cache_pdf(&content, &resources, &[(&dict, &srgb), (&dict, &swapped), (&dict, &srgb)]) +} + +#[track_caller] +fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) { + assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}"); +} + +/// Each square is drawn through its own profile: red through linear sRGB, blue through the +/// swapped one, whether the profile is an object (5 and 6) or written in its array (`/C` and +/// `/D`), and object 5 looked up again and its copy (object 7) still draw red. +#[test] +fn icc_profiles_with_identical_dictionaries_draw_their_own_colours() { + let rendered = render_first_page(page()); + assert_eq!((rendered.width as usize, rendered.height), (WIDTH, 70)); + let expected = [ + ("/A, object 5, linear sRGB", RED), + ("/B, object 6, red and blue swapped", BLUE), + ("/A2, object 5 again", RED), + ("/A3, object 7, a copy of object 5", RED), + ("/C, linear sRGB written in its array", RED), + ("/D, red and blue swapped written in its array", BLUE), + ]; + for (i, (what, want)) in expected.into_iter().enumerate() { + assert_near(rgb_at(&rendered.rgba, WIDTH, 12 + 21 * i, 35), want, what); + } +} + +/// The profiles are still cached: the four different ones (objects 5 and 6, and the two written +/// in their arrays) are read once each, and neither object 5 looked up again nor its copy, object +/// 7, is read again. A second render through the same cache reads none. +#[test] +fn icc_profiles_used_again_are_read_once() { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::sync::Arc; + + /// Draws nothing: only the colour spaces the page selects matter here. + struct Nothing; + impl<'a> Device<'a> for Nothing { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let pdf = Pdf::new(Arc::new(page())).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + for render in ["first", "second"] { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut Nothing); + assert_eq!(cache.icc_profile_reads(), 4, "after the {render} render: each different profile is read once"); + } +} diff --git a/crates/render/tests/inline_image_ei.rs b/crates/render/tests/inline_image_ei.rs new file mode 100644 index 000000000..0cc4b1981 --- /dev/null +++ b/crates/render/tests/inline_image_ei.rs @@ -0,0 +1,197 @@ +//! Regression tests for inline images whose unfiltered samples contain `"EI "` (#3107). + +use std::fmt::Write as _; +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +fn build_pdf(pages: &[Vec], extra_objects: &[(usize, Vec)], page_xobjects: &str) -> Vec { + let n = pages.len(); + let font = 3 + 2 * n; + let mut objs: Vec<(usize, Vec)> = Vec::new(); + objs.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec())); + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + 2 * i)).collect(); + objs.push((2, format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" ")).into_bytes())); + for (i, content) in pages.iter().enumerate() { + let page_num = 3 + 2 * i; + let stream_num = 4 + 2 * i; + objs.push(( + page_num, + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {stream_num} 0 R /Resources << /Font << /F1 {font} 0 R >> {page_xobjects} >> >>") + .into_bytes(), + )); + let mut stm = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + stm.extend_from_slice(content); + stm.extend_from_slice(b"\nendstream"); + objs.push((stream_num, stm)); + } + objs.push((font, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec())); + for (id, body) in extra_objects { + objs.push((*id, body.clone())); + } + objs.sort_by_key(|(id, _)| *id); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::with_capacity(objs.len()); + for (id, body) in &objs { + offs.push(out.len()); + out.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref_offset = out.len(); + let size = objs.len() + 1; + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for off in offs { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + out +} + +fn page_stream(image: &[u8], n: usize) -> Vec { + let mut s = b"q 200 0 0 200 100 300 cm ".to_vec(); + s.extend_from_slice(image); + s.extend_from_slice(format!(" Q BT /F1 18 Tf 72 700 Td (After image P{n}) Tj ET").as_bytes()); + s +} + +fn mean_grey_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> f64 { + let mut sum = 0u64; + let mut count = 0u64; + for y in y0..y1 { + for x in x0..x1 { + let idx = ((y * width + x) * 4) as usize; + sum += u64::from(rgba[idx]); + count += 1; + } + } + sum as f64 / count.max(1) as f64 +} + +fn dark_pixels_in_box(rgba: &[u8], width: u32, x0: u32, y0: u32, x1: u32, y1: u32) -> usize { + let mut dark = 0usize; + for y in y0..y1 { + for x in x0..x1 { + let idx = ((y * width + x) * 4) as usize; + if rgba[idx] < 128 && rgba[idx + 1] < 128 && rgba[idx + 2] < 128 { + dark += 1; + } + } + } + dark +} + +#[test] +fn unfiltered_inline_image_samples_containing_ei_are_not_executed_as_content() { + let samples = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q"; + let w = samples.len(); + let mut hexed = String::with_capacity(w * 2 + 1); + for b in samples { + let _ = write!(hexed, "{b:02X}"); + } + hexed.push('>'); + + let mut img1 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID ").into_bytes(); + img1.extend_from_slice(samples); + img1.extend_from_slice(b" EI"); + + let mut img2 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /L {w} ID 0AB").into_bytes(); + img2.extend_from_slice(&samples[3..]); + img2.extend_from_slice(b" EI"); + + let img3 = format!("BI /W {w} /H 1 /BPC 8 /CS /G /F /AHx ID {hexed} EI").into_bytes(); + let img4 = b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0 EI".to_vec(); + + let pdf = Arc::new(build_pdf(&[page_stream(&img1, 1), page_stream(&img2, 2), page_stream(&img3, 3), page_stream(&img4, 4)], &[], "")); + + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + let mut greys = Vec::new(); + for page_idx in 0..4 { + let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(text_page.error.is_none(), "page {} text error: {:?}", page_idx + 1, text_page.error); + let text = text_page.text.expect("page text"); + let plain = text.plain_text(); + assert_eq!(plain.trim(), format!("After image P{}", page_idx + 1), "page {}", page_idx + 1); + assert!(text.find("Injected").is_empty(), "page {} must not match Injected in Find", page_idx + 1); + + let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "page {} render error: {:?}", page_idx + 1, rendered.error); + + let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196); + assert_eq!(dark, 0, "page {} must not draw Injected at (72, 600)", page_idx + 1); + + let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482); + greys.push(grey); + } + + // Page 1 and Page 3 have the exact same 47 samples (unfiltered vs ASCIIHexDecode). + assert!((greys[0] - greys[2]).abs() < 1.0, "page 1 mean grey ({}) must match page 3 ({})", greys[0], greys[2]); + assert!((greys[0] - greys[1]).abs() < 2.0, "page 1 mean grey ({}) must match control page 2 ({})", greys[0], greys[1]); + assert!((greys[3] - 128.0).abs() < 2.0, "page 4 control mean grey ({})", greys[3]); +} + +#[test] +fn form_xobject_inline_image_with_ei_in_samples_keeps_all_samples() { + let form_content = b"q 200 0 0 200 100 300 cm BI /W 12 /H 1 /BPC 8 /CS /G ID 0EI 1 0 0 rg EI Q BT /F1 18 Tf 72 700 Td (Form after) Tj ET"; + let mut form_obj = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Length {} >>\nstream\n", + form_content.len() + ) + .into_bytes(); + form_obj.extend_from_slice(form_content); + form_obj.extend_from_slice(b"\nendstream"); + + let page_content = b"/Fm1 Do".to_vec(); + // 1 page => font is object 5, form XObject is object 6. + let pdf = Arc::new(build_pdf(&[page_content], &[(6, form_obj)], "/XObject << /Fm1 6 0 R >>")); + + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + let rendered = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + + let grey = mean_grey_in_box(&rendered.rgba, rendered.width, 110, 302, 290, 482); + assert!((grey - 54.37).abs() < 1.0, "form inline image mean grey {grey} must be ~54.37 (not ~2 from 1-byte cut)"); +} + +#[test] +fn unfiltered_inline_image_layouts_and_mismatched_l_do_not_inject_content() { + let inject_prefix = b"0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q"; + let make_image = |params: &str, len: usize| { + let mut samples = inject_prefix.to_vec(); + samples.resize(len, b'0'); + let mut img = format!("BI {params} ID ").into_bytes(); + img.extend_from_slice(&samples); + img.extend_from_slice(b" EI"); + img + }; + + let cases = [ + ("/W 16 /H 2 /CS /RGB /BPC 8", 96), + ("/W 12 /H 2 /CS /CMYK /BPC 8", 96), + ("/W 70 /H 4 /CS /G /BPC 1", 36), + ("/IM true /W 70 /H 4", 36), + ("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47), + ]; + + let pages: Vec> = cases.iter().enumerate().map(|(i, (params, len))| page_stream(&make_image(params, *len), i + 1)).collect(); + + let pdf = Arc::new(build_pdf(&pages, &[], "")); + let mut renderer = PageRenderer::new(Arc::clone(&pdf), RenderConfig::default()); + + for (i, (params, _)) in cases.iter().enumerate() { + let page_idx = i; + let text_page = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(text_page.error.is_none(), "case {params} text error: {:?}", text_page.error); + let text = text_page.text.expect("page text"); + let plain = text.plain_text(); + assert_eq!(plain.trim(), format!("After image P{}", i + 1), "case {params}"); + assert!(text.find("Injected").is_empty(), "case {params} must not match Injected in Find"); + + let rendered = renderer.render(RenderRequest { page: page_idx, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(rendered.error.is_none(), "case {params} render error: {:?}", rendered.error); + + let dark = dark_pixels_in_box(&rendered.rgba, rendered.width, 72, 172, 200, 196); + assert_eq!(dark, 0, "case {params} must not draw Injected at (72, 600)"); + } +} diff --git a/crates/render/tests/named_form_properties.rs b/crates/render/tests/named_form_properties.rs new file mode 100644 index 000000000..2d36275f5 --- /dev/null +++ b/crates/render/tests/named_form_properties.rs @@ -0,0 +1,294 @@ +//! Contributor-original synthetic forms for named marked-content properties. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +fn fixture(own: bool) -> Vec { + let res1 = "<< /Font << /F1 3 0 R >> /XObject << /Fm0 8 0 R >> /Properties << /P1 << /ActualText (REPLACED) >> >> >>"; + let res2 = "<< /Font << /F1 3 0 R >> /XObject << /Fm0 9 0 R >> /Properties << /oc1 7 0 R >> >>"; + let own1 = if own { format!("/Resources {res1}") } else { String::new() }; + let own2 = if own { format!("/Resources {res2}") } else { String::new() }; + let body1 = "/Span /P1 BDC BT /F1 24 Tf 72 700 Td (glyphs) Tj ET EMC"; + let body2 = "/OC /oc1 BDC BT /F1 24 Tf 72 700 Td (HIDDENTEXT) Tj ET EMC"; + let page1 = "q /Fm0 Do Q"; + let page2 = "q /Fm0 Do Q BT /F1 24 Tf 72 600 Td (visible) Tj ET"; + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [7 0 R] /D << /OFF [7 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 6 0 R /Resources {res1} >>"), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 10 0 R /Resources {res2} >>"), + stream("", page1), + "<< /Type /OCG /Name (Hidden) >>".into(), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {own1}"), body1), + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {own2}"), body2), + stream("", page2), + ]) +} + +fn text(renderer: &mut PageRenderer, page: usize) -> Arc { + let result = renderer.render(RenderRequest { page, kind: RequestKind::Text, ..Default::default() }); + assert!(result.error.is_none(), "{:?}", result.error); + result.text.unwrap() +} + +#[test] +fn named_actual_text_in_resource_less_form_replaces_glyphs() { + for own in [true, false] { + let mut renderer = PageRenderer::new(Arc::new(fixture(own)), RenderConfig::default()); + let result = text(&mut renderer, 0); + assert_eq!(result.plain_text(), "REPLACED", "own Resources: {own}"); + assert_eq!(result.find("REPLACED").len(), 1); + assert!(result.find("glyphs").is_empty()); + } +} + +#[test] +fn named_off_layer_in_resource_less_form_stays_hidden() { + for own in [true, false] { + let mut renderer = PageRenderer::new(Arc::new(fixture(own)), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 1, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + assert_eq!((image.width, image.height), (612, 792)); + let upper = image + .rgba + .as_chunks::<4>() + .0 + .iter() + .enumerate() + .filter(|(index, _)| (70..105).contains(&(index / 612))) + .filter(|(_, pixel)| **pixel != [255, 255, 255, 255]) + .count(); + let lower = image + .rgba + .as_chunks::<4>() + .0 + .iter() + .enumerate() + .filter(|(index, _)| (170..200).contains(&(index / 612))) + .filter(|(_, pixel)| **pixel != [255, 255, 255, 255]) + .count(); + let result = text(&mut renderer, 1); + println!( + "own={own}: upper ink={upper}; lower ink={lower}; text={:?}; hidden Find={:?}; visible Find={:?}", + result.plain_text(), + result.find("HIDDENTEXT"), + result.find("visible") + ); + assert_eq!(upper, 0, "OFF layer has no upper ink, own Resources: {own}"); + assert!(lower > 0, "ordinary page text stays visible"); + assert_eq!(result.plain_text(), "visible", "own Resources: {own}"); + assert_eq!(result.find("visible").len(), 1); + assert!(result.find("HIDDENTEXT").is_empty()); + } +} + +// Contributor-original synthetic PDFs. No font or document assets are embedded. +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn stream(dict: &str, body: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len()) +} + +fn resources(properties: &str) -> String { + format!("<< /Font << /F1 3 0 R >> /XObject << /Fm 6 0 R /Inner 7 0 R >> {properties} >>") +} + +fn form(resources: &str, body: &str) -> String { + stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] {resources}"), body) +} + +const GLYPHS: &str = "/Span /P BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC"; +const OFF_PAINT: &str = "/OC /O BDC 1 0 0 rg 10 100 20 20 re f EMC 0 0 1 rg 50 100 20 20 re f"; + +fn objects(page_resources: &str, form_resources: &str, body: &str) -> Vec { + vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [8 0 R 9 0 R] /D << /OFF [8 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {page_resources} >>"), + stream("", "/Fm Do"), + form(form_resources, body), + form("", GLYPHS), + "<< /Type /OCG /Name (PageOff) >>".into(), + "<< /Type /OCG /Name (FormOn) >>".into(), + ] +} + +fn assert_text(bytes: Vec, page: usize, expected: &str) { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let result = text(&mut renderer, page); + assert_eq!(result.plain_text(), expected); + assert_eq!(result.find(expected).len(), 1); + if expected != "glyphs" { + assert!(result.find("glyphs").is_empty()); + } +} + +#[test] +fn own_form_dictionary_stops_property_fallback() { + let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> >>")); + for (own, expected) in [ + ("", "PAGE"), + ("/Resources << /Properties << /P << /ActualText (OWN) >> >> >>", "OWN"), + ("/Resources << >>", "glyphs"), + ("/Resources << /Properties << >> >>", "glyphs"), + ("/Resources << /Properties << /Other << /ActualText (OTHER) >> >> >>", "glyphs"), + ("/Resources << /Properties << /P 42 >> >>", "glyphs"), + ("/Resources << /Properties << /P 99 0 R >> >>", "glyphs"), + ("/Resources << /Properties 42 >>", "glyphs"), + ("/Resources 42", "glyphs"), + ("/Resources 99 0 R", "glyphs"), + // Preserve the pre-fix behavior for an explicitly present null entry. + ("/Resources null", "glyphs"), + ] { + assert_text(pdf(&objects(&page, own, GLYPHS)), 0, expected); + } +} + +#[test] +fn nested_resource_less_form_uses_page_origin_and_own_form_shadows_it() { + let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> >>")); + let outer = format!("/Resources {}", resources("/Properties << /P << /ActualText (CALLER) >> >>")); + for (inner, expected) in [("", "PAGE"), ("/Resources << /Properties << /P << /ActualText (INNER) >> >> >>", "INNER")] { + let mut fixture = objects(&page, &outer, "/Inner Do"); + fixture[6] = form(inner, GLYPHS); + assert_text(pdf(&fixture), 0, expected); + } +} + +#[test] +fn shared_form_uses_each_pages_properties() { + let first = format!("/Resources {}", resources("/Properties << /P << /ActualText (FIRST) >> >>")); + let second = format!("/Resources {}", resources("/Properties << /P << /ActualText (SECOND) >> >>")); + let mut fixture = objects(&first, "", GLYPHS); + fixture[1] = "<< /Type /Pages /Kids [4 0 R 10 0 R] /Count 2 >>".into(); + fixture.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {second} >>")); + let bytes = pdf(&fixture); + assert_text(bytes.clone(), 0, "FIRST"); + assert_text(bytes, 1, "SECOND"); +} + +#[test] +fn page_tree_properties_inherit_only_until_an_own_resources_dictionary() { + let root = resources("/Properties << /P << /ActualText (ROOT) >> >>"); + let near = resources("/Properties << /P << /ActualText (NEAR) >> >>"); + for (node, page, expected) in [ + ("".to_string(), "".to_string(), "ROOT"), + (format!("/Resources {near}"), "".to_string(), "NEAR"), + ("/Resources << >>".to_string(), "".to_string(), "glyphs"), + ("".to_string(), "/Resources << >>".to_string(), "glyphs"), + ] { + let mut fixture = objects(&page, "", GLYPHS); + fixture[1] = format!("<< /Type /Pages /Resources {root} /Kids [10 0 R] /Count 1 >>"); + fixture[3] = fixture[3].replace("/Parent 2 0 R", "/Parent 10 0 R"); + fixture.push(format!("<< /Type /Pages /Parent 2 0 R /Kids [4 0 R] /Count 1 {node} >>")); + assert_text(pdf(&fixture), 0, expected); + } +} + +fn assert_paint(bytes: Vec, page: usize, layers: Vec<(i32, i32, bool)>, visible: bool) { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig { layers: Arc::new(layers), ..Default::default() }); + let image = renderer.render(RenderRequest { page, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + assert_eq!((image.width, image.height), (200, 200)); + let pixel = |x: usize, y: usize| &image.rgba[(y * 200 + x) * 4..(y * 200 + x) * 4 + 4]; + assert_eq!(pixel(60, 90), [0, 0, 255, 255], "unmarked control remains blue"); + assert_eq!(pixel(20, 90), if visible { [255, 0, 0, 255] } else { [255; 4] }); +} + +#[test] +fn indirect_page_properties_preserve_ocg_identity_and_visibility_overrides() { + let page = format!("/Resources {}", resources("/Properties 10 0 R")); + let mut fixture = objects(&page, "", OFF_PAINT); + fixture.push("<< /O 8 0 R >>".into()); + let bytes = pdf(&fixture); + assert_paint(bytes.clone(), 0, vec![], false); + assert_paint(bytes.clone(), 0, vec![(9, 0, true)], false); + assert_paint(bytes.clone(), 0, vec![(8, 0, true)], true); + assert_paint(bytes, 0, vec![(8, 0, false)], false); +} + +#[test] +fn nested_ocg_uses_page_origin_and_own_dictionary_boundaries() { + let page = format!("/Resources {}", resources("/Properties << /O 8 0 R >>")); + let caller = format!("/Resources {}", resources("/Properties << /O 9 0 R >>")); + for (inner, visible) in [ + ("", false), + ("/Resources << /Properties << /O 9 0 R >> >>", true), + ("/Resources << >>", true), + ("/Resources << /Properties << /Other 8 0 R >> >>", true), + ("/Resources << /Properties << /O 42 >> >>", true), + ("/Resources << /Properties << /O 99 0 R >> >>", true), + ] { + let mut fixture = objects(&page, &caller, "/Inner Do"); + fixture[6] = form(inner, OFF_PAINT); + assert_paint(pdf(&fixture), 0, vec![], visible); + } +} + +#[test] +fn direct_and_inline_properties_and_unmarked_content_keep_existing_behavior() { + let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (DIRECT) >> >>")); + for (body, expected) in [ + (GLYPHS, "DIRECT"), + ("/Span << /ActualText (INLINE) >> BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC", "INLINE"), + ("/Span BMC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC", "glyphs"), + ("/Span << /ActualText (OUTER) >> BDC /Span /P BDC BT /F1 12 Tf 10 150 Td (glyphs) Tj ET EMC EMC", "OUTER"), + ] { + assert_text(pdf(&objects(&page, "", body)), 0, expected); + } + let inline_ref = OFF_PAINT.replace("/O BDC", "<< /OC 8 0 R >> BDC"); + assert_paint(pdf(&objects(&page, "", &inline_ref)), 0, vec![], false); + assert_paint(pdf(&objects(&page, "", &inline_ref)), 0, vec![(8, 0, true)], true); + let direct_ocg = format!("/Resources {}", resources("/Properties << /O << /Type /OCG /Name (Direct) >> >>")); + assert_paint(pdf(&objects(&direct_ocg, "", OFF_PAINT)), 0, vec![], true); + assert_paint(pdf(&objects(&page, "", &OFF_PAINT.replace("/O BDC", "<< /Type /OCG /Name (Inline) >> BDC"))), 0, vec![], true); +} + +#[test] +fn save_reopen_preserves_missing_resources_and_effective_named_properties() { + let page = format!("/Resources {}", resources("/Properties << /P << /ActualText (PAGE) >> /O 8 0 R >>")); + for own in ["", "/Resources << /Properties << /P << /ActualText (PAGE) >> /O 8 0 R >> >>"] { + let bytes = pdf(&objects(&page, own, GLYPHS)); + let mut document = lopdf::Document::load_mem(&bytes).unwrap(); + let before = document.get_object((6, 0)).unwrap().as_stream().unwrap().dict.clone(); + let mut saved = Vec::new(); + document.save_to(&mut saved).unwrap(); + let reopened = lopdf::Document::load_mem(&saved).unwrap(); + assert_eq!(reopened.get_object((6, 0)).unwrap().as_stream().unwrap().dict, before); + assert_text(saved, 0, "PAGE"); + let painted = objects(&page, own, OFF_PAINT); + let mut document = lopdf::Document::load_mem(&pdf(&painted)).unwrap(); + let mut saved = Vec::new(); + document.save_to(&mut saved).unwrap(); + assert_paint(saved, 0, vec![], false); + } +} + +#[test] +fn shared_form_uses_each_pages_ocg_reference() { + let first = format!("/Resources {}", resources("/Properties << /O 8 0 R >>")); + let second = format!("/Resources {}", resources("/Properties << /O 9 0 R >>")); + let mut fixture = objects(&first, "", OFF_PAINT); + fixture[1] = "<< /Type /Pages /Kids [4 0 R 10 0 R] /Count 2 >>".into(); + fixture.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 5 0 R {second} >>")); + let bytes = pdf(&fixture); + assert_paint(bytes.clone(), 0, vec![], false); + assert_paint(bytes.clone(), 1, vec![], true); + assert_paint(bytes.clone(), 0, vec![(8, 0, true)], true); + assert_paint(bytes, 1, vec![(9, 0, false)], false); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6a75a0b7b..69fbdb094 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2566,6 +2566,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } let Some(doc) = app.session.get(app.views[index].id) else { return }; let info = &doc.info; + let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { + let [r, g, b] = p.background; + Color32::from_rgb(r, g, b) + }); if info.pages.is_empty() { app.views[index].auto_scroll.cancel(); ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); @@ -3115,7 +3119,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; // Soft shadow + paper. painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); - painter.rect_filled(r, CornerRadius::ZERO, Color32::WHITE); + painter.rect_filled(r, CornerRadius::ZERO, paper); if let Some(err) = view.errors.get(&i) { painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); icons::paint( diff --git a/crates/ui-egui/tests/reading_colors.rs b/crates/ui-egui/tests/reading_colors.rs index 6dd8e11f1..398a85e8e 100644 --- a/crates/ui-egui/tests/reading_colors.rs +++ b/crates/ui-egui/tests/reading_colors.rs @@ -9,7 +9,11 @@ use pdfcraft_ui_egui::PdfCraftApp; /// One valid page (proper xref, so no repair banner covers the dialog): black text on white. fn pdf() -> Vec { - let objs: Vec> = vec![ + pdf_pages(1) +} + +fn pdf_pages(count: usize) -> Vec { + let mut objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), @@ -19,6 +23,12 @@ fn pdf() -> Vec { }, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), ]; + let mut kids = vec!["3 0 R".to_owned()]; + for _ in 1..count { + kids.push(format!("{} 0 R", objs.len() + 1)); + objs.push(objs[2].clone()); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {count} >>", kids.join(" ")).into_bytes(); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -36,6 +46,47 @@ fn pdf() -> Vec { out } +#[test] +fn pending_pages_use_document_reading_background() { + use egui::{Color32, Context, RawInput, Rect, Shape, pos2, vec2}; + + let custom = ReadingColors::parse("custom", Some("#eeeeee"), Some("#182430"), None, None).unwrap(); + for (colors, expected) in [ + (ReadingColors::default(), Color32::WHITE), + (ReadingColors::BLACK_ON_WHITE, Color32::WHITE), + (ReadingColors::WHITE_ON_BLACK, Color32::BLACK), + (ReadingColors::YELLOW_ON_BLACK, Color32::BLACK), + (ReadingColors::GREEN_ON_BLACK, Color32::BLACK), + (custom, Color32::from_rgb(0x18, 0x24, 0x30)), + ] { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("pending.pdf", None, pdf_pages(3)).unwrap(); + let id = app.views[0].id; + // Deliberately differ from the app preference: the document is the source of truth. + app.session.set_reading_colors(id, colors); + let ctx = Context::default(); + for page in 0..3 { + app.views[0].go_to_page(page); + // Draw the real canvas without finishing the render frame, so no texture can + // race the assertion or hide the pending-page background. + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(900.0, 600.0))), ..Default::default() }, |ui| { + pdfcraft_ui_egui::canvas::document_area(&mut app, 0, ui); + }); + output.textures_delta.clear(); + let view = &app.views[0]; + assert_eq!(view.raster_memory().page_count, 0, "the page must still be waiting for its raster"); + let rect = view.page_screen_rect(page).expect("target page visible"); + let fill = output.shapes.iter().find_map(|s| match &s.shape { + Shape::Rect(r) if r.rect == rect && r.stroke.width == 0.0 => Some(r.fill), + _ => None, + }); + assert_eq!(fill, Some(expected), "page {page}, reading mode {}", colors.mode_name()); + } + } +} + /// A small PNG for the Create ▸ Images path, which opens the document it builds. fn png() -> Vec { let mut out = Vec::new(); diff --git a/docs/development.md b/docs/development.md index e599effc7..1c6cce6a9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -127,6 +127,7 @@ copies retain their current preferences locations. | `XDG_DATA_HOME` | Linux/FreeBSD: base of the log folder and crash recovery; previous preferences are migrated from here | | `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | +| `WGPU_DX12_PRESENTATION_SYSTEM` | Windows, Direct3D 12: `hwnd` presents through a swap chain on the window itself, which Windows stretches while the window is resized (#2738); unset or `visual`, PdfCraft presents through a DirectComposition visual | | `WGPU_VALIDATION_INDIRECT_CALL` | `1` turns wgpu's check of indirect draw arguments back on. PdfCraft draws nothing indirectly and turns it off, since the compute shader the check needs fails to compile on some drivers (Metal on an Intel Iris Pro), which lost the device at start-up | | `PDFCRAFT_RENDERER` | `gl` starts with OpenGL (glow) and never loads wgpu; `wgpu` reports a wgpu failure instead of retrying with OpenGL; unset, wgpu is retried with OpenGL when it can't start (see [Renderer fallback](#renderer-fallback)) | | `PDFCRAFT_TITLEBAR` | Linux: `app` draws PdfCraft's own title bar (window buttons in the tab strip, in the order of GNOME's `button-layout`, and resize edges); `system` keeps the frame winit or the desktop draws; unset, PdfCraft draws its own when the Wayland compositor draws no frames (GNOME), and leaves it to the desktop otherwise (KDE, Sway, Hyprland, X11). Where PdfCraft draws it, Preferences ▸ Use system title bar switches back to winit's frame without a restart; `system` also hides that option | diff --git a/docs/roadmap.md b/docs/roadmap.md index 1e66c99a1..35252fdcb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) +> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged) > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -117,7 +118,7 @@ total matches the parity estimate. |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | | M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). A full rewrite keeps unknown trailer entries, including objects reachable only from them (#1338). Fuzzing runs nightly (`xtask fuzz`). Raw-deflate fallback preserves decoding-limit errors, including through tolerant COS stream decoding. Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | +| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. | @@ -153,9 +154,9 @@ total matches the parity estimate. | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | | M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import | | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645). | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). | @@ -225,6 +226,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. | | 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. | | 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | +| 2026-10-14 | minor | M12: pending page backgrounds respect reading colours (#2059); estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f72721f23..7f576f1fb 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -9116,8 +9116,8 @@ milestone = "M12" status = "shipped" commands = [] tools = ["view_reading_colors"] -evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open"] -notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Closes #897 (duplicate #488)." +evidence = ["crates/automation/tests/automation.rs::reading_colors_through_tools", "crates/render/src/raster.rs::reading_colors_map_solid_paints_and_leave_images", "crates/render/src/raster.rs::reading_colors_tiles_match_full_render", "crates/render/src/raster.rs::print_preview_requests_render_with_reading_colors_off", "crates/render/src/raster.rs::reading_colors_keep_highlight_annotations_readable", "crates/ui-egui/tests/reading_colors.rs::reading_colours_preference_round_trips", "crates/ui-egui/tests/reading_colors.rs::reading_colours_seed_on_open", "crates/ui-egui/tests/reading_colors.rs::pending_pages_use_document_reading_background"] +notes = "View only, off by default: Preferences ▸ Reading colours holds the app-wide preference, seeded into every document as it opens (including created ones), and view_reading_colors reads and sets it per document (Acrobat's Replace Document Colors). Solid text paints and, by default, line-art paints (including stencil-mask image paints) are remapped by luminance — each lerps between the preset's text and background colour by how dark or light the original paint is — while images, shadings and tiling patterns pass through untouched. Annotations keep the colours they were given (both /AP appearances and the no-/AP Highlight fallback), so a Multiply highlight over a dark background shows the marked-up words in the highlight's colour instead of a flat bar that hides them (#1548). Presets: black on white, white on black, yellow on black, green on black, or a custom #RRGGBB pair; only_black limits the mapping to near-black paints (a 10% luminance threshold — our choice; Acrobat's was not observed). Display only: the document is not changed and print and export are unaffected; print previews and view snapshots render with the document's true colours (a preview matches the uncoloured printout, and a snapshot is an export surface), so reading colours apply to the reading view only. Pending canvas pages use the configured reading background before their raster arrives (#2059). Closes #897 (duplicate #488)." [[feature]] id = "a11y.reflow-tagged" diff --git a/vendor/README.md b/vendor/README.md index 57bdd516c..56e4eadae 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -9,12 +9,18 @@ A PdfCraft patch in `hayro-syntax::Page::base_dimensions` expresses the existing In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device space before intersecting clip and shading bounds; `raster::tests::shaded_type3_glyph_draws_at_multiple_positions` covers translated glyphs (#3178). +Named form properties use the effective page dictionary when the form omits `/Resources` (#3574). +`hayro-syntax::Resources` caches the page origin; `hayro-interpret` preserves the form dictionary's absence. +An own dictionary stops fallback, including empty and malformed entries. Explicit null keeps its previous local-empty behavior. +`crates/render/tests/named_form_properties.rs` covers text, Find, OCG identity, visibility overrides, page inheritance, and save/reopen. +Other resource categories retain their existing parent-chain lookup. + | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours` |; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once` -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | -| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` | +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover` +| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path` |; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover` | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time` @@ -34,6 +40,8 @@ The regression `pdfcraft` `tests::pipeline_creation_error_is_returned_and_device uses an invalid three-sample pipeline, then valid and invalid pipelines again on the same device. Later rendering errors, device loss, and non-winit renderer construction are unchanged. +Stencil masks filled with a pattern (#2975): `hayro`'s renderer paints a pattern-filled stencil mask with the fill (non-stroking) paint. It passed `is_stroke = true`, so an uncoloured tiling pattern was drawn in its stroke colour (black by default) instead of the colour given with `scn`. Covered by `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once` (the uncoloured tiling pattern case). + Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`. TIFF predictor decoding: hayro-syntax aligns the output writer to a byte boundary after each @@ -43,6 +51,18 @@ Covered by `raster::tests::tiff_predictor_packed_rgb_rows_keep_their_byte_bounda horizontal differencing and an 8-bit control) and `raster::tests::tiff_predictor_packed_rgb_image_renders_every_row`. +JPEG 2000 palettes (#3179): `hayro-syntax::filter::jpx::decode` applies a JP2 palette (`pclr`) +when the image dictionary has no `/ColorSpace`, since the colour then comes from the JP2 header +(ISO 32000-2 §7.4.9). With a `/ColorSpace` the raw indices are kept, as an `/Indexed` space needs. +A `/ColorSpace` that does not resolve counts as none: the JP2 colours are then the only ones. +Without the patch a palette image with no `/ColorSpace` was drawn as its indices, in gray. +The resolved colours are 8-bit samples whatever the precision of the indices (a 1-bit index no +longer rescales its colours to 0 or 255 per channel); an image without a palette keeps its own. +Covered by `filter::jpx::tests::jpx_palette_is_applied_without_a_color_space`, +`filter::jpx::tests::jpx_palette_indices_stay_raw_for_an_indexed_color_space`, +`filter::jpx::tests::palette_colours_are_eight_bit_and_other_samples_keep_their_precision` and +`raster::tests::a_jp2_palette_keeps_its_colours_whatever_the_index_precision`. + Text extraction: hayro-interpret exposes `OutlineGlyph::is_vertical` from a Type0 font’s CMap writing mode. PdfCraft follows negative glyph-space y for WMode 1 so upright vertical columns can be searched (#126). Lab Bradford follow-up to #1237: Lab adapts XYZ to D65 with white-point ratios diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index e253a7b8c..bbc26e163 100644 --- a/vendor/hayro-interpret/src/cache.rs +++ b/vendor/hayro-interpret/src/cache.rs @@ -4,11 +4,14 @@ use kurbo::Affine; use std::any::Any; use std::collections::HashMap; use std::collections::hash_map::Entry; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; type CacheMap = HashMap>>; +/// The objects read from a document, by key. PdfCraft patch (48): the second field counts the +/// ICC profiles read through it ([`Cache::icc_profile_reads`]). #[derive(Clone)] -pub(crate) struct Cache(Arc>); +pub(crate) struct Cache(Arc>, Arc); impl Default for Cache { fn default() -> Self { Self::new() @@ -17,7 +20,18 @@ impl Default for Cache { impl Cache { pub(crate) fn new() -> Self { - Self(Arc::new(Mutex::new(HashMap::new()))) + Self(Arc::new(Mutex::new(HashMap::new())), Arc::default()) + } + + /// PdfCraft patch (48): count one ICC profile read, the colour space of an `/ICCBased` + /// profile no entry held yet being built from it. + pub(crate) fn count_icc_profile_read(&self) { + self.1.fetch_add(1, Ordering::Relaxed); + } + + /// PdfCraft patch (48): how many ICC profiles were read through this cache. + pub(crate) fn icc_profile_reads(&self) -> usize { + self.1.load(Ordering::Relaxed) } pub(crate) fn get_or_insert_with( @@ -73,7 +87,10 @@ pub(crate) struct DecodedImageCache { /// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it); /// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation, /// the decode target size (only for an image a JPX stream decodes, which is the only -/// decode that reads it: PdfCraft patch 32), and preview or final quality. +/// decode that reads it: PdfCraft patch 32), and preview or final quality; +/// - the default colour space drawn in place of the image's device space, if any (the cache +/// key of its object, PdfCraft patch 38): the same image can be drawn by resources with a +/// `/DefaultRGB` and by resources without one. /// /// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so /// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in @@ -88,6 +105,7 @@ pub(crate) struct DecodedImageKey { pub(crate) is_mask: bool, pub(crate) is_stencil_mask: bool, pub(crate) interpolate: bool, + pub(crate) default_space: Option, pub(crate) target: Option<(u32, u32)>, pub(crate) preview: bool, } diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 56bb04f24..202739659 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -2,6 +2,7 @@ use crate::cache::{Cache, CacheKey}; use crate::function::Function; +use crate::util::hash128; use hayro_syntax::object; use hayro_syntax::object::Array; use hayro_syntax::object::Dict; @@ -9,12 +10,14 @@ use hayro_syntax::object::Name; use hayro_syntax::object::Object; use hayro_syntax::object::Stream; use hayro_syntax::object::dict::keys::*; +use hayro_syntax::page::Resources; use moxcms::{ ColorProfile, DataColorSpace, Layout, Transform8BitExecutor, TransformF32Executor, TransformOptions, }; use smallvec::{SmallVec, ToSmallVec, smallvec}; use std::borrow::Cow; +use std::cell::OnceCell; use std::fmt::{Debug, Formatter}; use std::ops::Deref; use std::sync::{Arc, LazyLock, OnceLock}; @@ -119,6 +122,34 @@ pub(crate) enum ColorSpaceType { DeviceN(DeviceN), } +/// PdfCraft patch (48): what the colour space read from an `/ICCBased` profile stream is cached +/// by. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the +/// same `/N` and `/Length` shared whichever was read first, and colours in the second were +/// drawn through the first. +#[derive(Hash)] +enum IccProfileKey<'a> { + /// The stream object a space names, looked up without reading the stream: an object holds + /// one profile for as long as the document, and so the cache, lives. + Object(object::ObjectIdentifier), + /// What the space is built from: the stream dictionary's key (its bytes, the old key: + /// `/N`, `/Alternate`, the filters) and the stream's own bytes, decrypted but not decoded, + /// which decode to the profile. Copies of a profile share it, and a lookup never decodes: + /// decoding waits for a miss. + Content { dict: u128, data: &'a [u8] }, +} + +/// PdfCraft patch (48): the object of the `/ICCBased` profile `stream` that `array` names, or +/// `None` for a stream written directly in the array (its dictionary lies within the array's +/// bytes). Such a stream has no object of its own: its dictionary reports the object that +/// holds the array, which every other stream written there reports too. +fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option { + let dict = stream.dict(); + if array.data().as_ptr_range().contains(&dict.data().as_ptr()) { + return None; + } + dict.obj_id() +} + impl ColorSpaceType { fn new(object: Object<'_>, cache: &Cache) -> Option { Self::new_inner(object, cache) @@ -137,34 +168,50 @@ impl ColorSpaceType { let dict = icc_stream.dict(); let num_components = dict.get::(N)?; - return cache.get_or_insert_with(icc_stream.cache_key(), || { - if let Some(decoded) = icc_stream.decoded().ok().as_ref() { - ICCProfile::new(decoded, num_components) - .map(|icc| { - // TODO: For SVG and PNG we can assume that the output color space is - // sRGB. If we ever implement PDF-to-PDF, we probably want to - // let the user pass the native color type and don't make this optimization - // if it's not sRGB. - if icc.is_srgb() { - Self::DeviceRgb - } else { - Self::ICCBased(icc) - } - }) - .or_else(|| { - dict.get::>(ALTERNATE) - .and_then(|o| Self::new(o, cache)) - }) - .or_else(|| match dict.get::(N) { - Some(1) => Some(Self::DeviceGray), - Some(3) => Some(Self::DeviceRgb), - Some(4) => Some(Self::DeviceCmyk), - _ => None, - }) - } else { - None + // PdfCraft patch (48): cached by what the profile is read from (see + // `IccProfileKey`), not by the stream dictionary's bytes alone. + let read = || { + let data = icc_stream.raw_data(); + let content = IccProfileKey::Content { + dict: icc_stream.cache_key(), + data: &data, + }; + cache.get_or_insert_with(hash128(&content), || { + cache.count_icc_profile_read(); + if let Some(decoded) = icc_stream.decoded().ok().as_ref() { + ICCProfile::new(decoded, num_components) + .map(|icc| { + // TODO: For SVG and PNG we can assume that the output color space is + // sRGB. If we ever implement PDF-to-PDF, we probably want to + // let the user pass the native color type and don't make this optimization + // if it's not sRGB. + if icc.is_srgb() { + Self::DeviceRgb + } else { + Self::ICCBased(icc) + } + }) + .or_else(|| { + dict.get::>(ALTERNATE) + .and_then(|o| Self::new(o, cache)) + }) + .or_else(|| match dict.get::(N) { + Some(1) => Some(Self::DeviceGray), + Some(3) => Some(Self::DeviceRgb), + Some(4) => Some(Self::DeviceCmyk), + _ => None, + }) + } else { + None + } + }) + }; + return match icc_stream_object(&color_array, &icc_stream) { + Some(object) => { + cache.get_or_insert_with(hash128(&IccProfileKey::Object(object)), read) } - }); + None => read(), + }; } CALCMYK => return Some(Self::DeviceCmyk), CALGRAY => { @@ -350,6 +397,139 @@ impl ColorSpace { self.to_alpha_color(c, opacity, manual_scale) .unwrap_or(AlphaColor::BLACK) } + + /// PdfCraft patch (38): whether this space can be the default colour space of `family` + /// (ISO 32000-2 §8.6.5.6): it has as many components as the device space and is not a + /// Lab, Indexed or Pattern space. Nor is a device space: a default that names one is + /// ignored, so substitution never recurses. + fn can_stand_in_for(&self, family: DeviceFamily) -> bool { + !matches!( + self.0.as_ref(), + ColorSpaceType::DeviceGray + | ColorSpaceType::DeviceRgb + | ColorSpaceType::DeviceCmyk + | ColorSpaceType::Lab(_) + | ColorSpaceType::Indexed(_) + | ColorSpaceType::Pattern(_) + ) && self.num_components() == family.num_components() + } +} + +/// PdfCraft patch (38): a device colour space (ISO 32000-2 §8.6.4), which the resources' +/// default colour space for it replaces wherever content selects it (§8.6.5.6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeviceFamily { + Gray, + Rgb, + Cmyk, +} + +impl DeviceFamily { + /// The device space a colour space operand names: `/DeviceGray`, `/DeviceRGB` or + /// `/DeviceCMYK` (or an abbreviation read as one), alone or at the head of an array. `None` + /// for any other space, including the ICC-based and calibrated ones drawn as device colour + /// here (an sRGB profile, `/CalCMYK`): they are not device spaces. + pub(crate) fn of_object(object: &Object<'_>) -> Option { + match object { + Object::Name(name) => Self::of_name(name), + Object::Array(array) => array + .flex_iter() + .next::>() + .and_then(|name| Self::of_name(&name)), + _ => None, + } + } + + /// The device space `name` names, as [`DeviceFamily::of_object`]. + pub(crate) fn of_name(name: &Name<'_>) -> Option { + match name.deref() { + DEVICE_GRAY | G => Some(Self::Gray), + DEVICE_RGB | RGB => Some(Self::Rgb), + DEVICE_CMYK | CMYK => Some(Self::Cmyk), + _ => None, + } + } + + /// The device space itself. + fn space(self) -> ColorSpace { + match self { + Self::Gray => ColorSpace::device_gray(), + Self::Rgb => ColorSpace::device_rgb(), + Self::Cmyk => ColorSpace::device_cmyk(), + } + } + + fn num_components(self) -> u8 { + match self { + Self::Gray => 1, + Self::Rgb => 3, + Self::Cmyk => 4, + } + } + + /// The `/ColorSpace` resource that holds the default for this space. + fn default_key(self) -> &'static [u8] { + match self { + Self::Gray => DEFAULT_GRAY, + Self::Rgb => DEFAULT_RGB, + Self::Cmyk => DEFAULT_CMYK, + } + } +} + +/// PdfCraft patch (38): the default colour spaces (`/DefaultGray`, `/DefaultRGB` and +/// `/DefaultCMYK`, ISO 32000-2 §8.6.5.6) of the resources content is drawn with, each read the +/// first time the content selects its device space. Like any named resource, a default comes +/// from those resources' `/ColorSpace` or from the resources they inherit: a form XObject +/// without one of its own draws in its page's. +pub(crate) struct DefaultColorSpaces<'r, 'a> { + resources: &'r Resources<'a>, + cache: Cache, + gray: OnceCell>, + rgb: OnceCell>, + cmyk: OnceCell>, +} + +impl<'r, 'a> DefaultColorSpaces<'r, 'a> { + pub(crate) fn new(resources: &'r Resources<'a>, cache: &Cache) -> Self { + Self { + resources, + cache: cache.clone(), + gray: OnceCell::new(), + rgb: OnceCell::new(), + cmyk: OnceCell::new(), + } + } + + /// The default for `family`, with the cache key of the object it was read from. `None` + /// when the resources have none, or theirs is not a colour space that can stand in for + /// the device space: device colour is then drawn as it is. + pub(crate) fn get(&self, family: DeviceFamily) -> Option<(ColorSpace, u128)> { + let found = match family { + DeviceFamily::Gray => &self.gray, + DeviceFamily::Rgb => &self.rgb, + DeviceFamily::Cmyk => &self.cmyk, + }; + found + .get_or_init(|| { + let object = self + .resources + .get_color_space(&Name::new_unescaped(family.default_key()))?; + let key = object.cache_key(); + let space = self + .cache + .get_or_insert_with(key, || ColorSpace::new(object.clone(), &self.cache))?; + space.can_stand_in_for(family).then_some((space, key)) + }) + .clone() + } + + /// The colour space content that selects `family` draws in: its default, else the device + /// space itself. + pub(crate) fn device(&self, family: DeviceFamily) -> ColorSpace { + self.get(family) + .map_or_else(|| family.space(), |(space, _)| space) + } } impl ToRgb for ColorSpace { @@ -1121,6 +1301,12 @@ pub struct Color { } impl Color { + /// PdfCraft patch (46): the same colour, fully opaque (see `Pattern::without_opacity`). + pub(crate) fn opaque(mut self) -> Self { + self.opacity = 1.0; + self + } + pub(crate) fn new(color_space: ColorSpace, components: ColorComponents, opacity: f32) -> Self { Self { color_space, diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index d6772baa1..39806fcd8 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -146,6 +146,13 @@ impl<'a> InterpreterCache<'a> { pub fn decoded_image_decodes(&self) -> usize { self.decoded_cache.decodes() } + + /// PdfCraft patch (48): how many `/ICCBased` profiles were read through this cache, for + /// diagnostics and tests. A profile used again, by the same stream object or by a copy with + /// the same dictionary and bytes, is read once. + pub fn icc_profile_reads(&self) -> usize { + self.object_cache.icc_profile_reads() + } } /// A per-page interpretation context that borrows shared data from an [`InterpreterCache`]. @@ -447,36 +454,66 @@ pub(crate) fn path_as_rect(path: &BezPath) -> Option { return None; } - let bbox = path.fast_bounding_box(); - let (min_x, min_y, max_x, max_y) = (bbox.min_x(), bbox.min_y(), bbox.max_x(), bbox.max_y()); - let mut corners = [false; 4]; - - let mut check_point = |p: Point| { - corners[0] |= p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(min_y); - corners[1] |= p.x.is_nearly_equal(min_x) && p.y.is_nearly_equal(max_y); - corners[2] |= p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(min_y); - corners[3] |= p.x.is_nearly_equal(max_x) && p.y.is_nearly_equal(max_y); - }; - + // PdfCraft patch: the points have to trace the rectangle, not merely reach its corners. The + // length check above admits one MoveTo and four LineTo as well as the intended one MoveTo, + // three LineTo and one ClosePath, and asking only that each corner of the bounding box was + // visited says nothing about the order or the steps between: a five-point polyline whose + // ends reached every corner of its own bounding box was painted as that box, and a clip of + // that shape was discarded. Four distinct points, each step along one axis, the last point + // back at the first: that is a rectangle and nothing else is. + let mut points: Vec = Vec::with_capacity(5); + let mut closed = false; for (idx, el) in path.elements().iter().enumerate() { match el { PathEl::MoveTo(p) => { if idx != 0 { return None; } - - check_point(*p); + points.push(*p); } - PathEl::LineTo(l) => check_point(*l), + PathEl::LineTo(p) => points.push(*p), PathEl::QuadTo(_, _) => return None, PathEl::CurveTo(_, _, _) => return None, - PathEl::ClosePath => {} + // A subpath that ends and starts again is more than one shape. + PathEl::ClosePath => { + if idx + 1 != path.elements().len() { + return None; + } + closed = true; + } } } - if corners[0] && corners[1] && corners[2] && corners[3] { - Some(bbox) - } else { - None + // Exactly equal, not nearly: `is_nearly_equal` allows a 256th of a path unit, and the matrix + // is applied after this, so a `cm` of 10,000 turns that into tens of device units -- a path + // that only nearly closes would be painted as the rectangle, with a side it does not have. + // A rectangle's coordinates are equal in the content stream, not approximately equal, so + // nothing that is one loses the fast path by this; a path that misses takes the general route + // and is painted as written (#3327 review). + let same = |a: Point, b: Point| a.x == b.x && a.y == b.y; + // `m l l l l`: the fourth line goes back to the start, which `m l l l h` does with ClosePath. + if !closed { + if points.len() != 5 || !same(points[4], points[0]) { + return None; + } + points.pop(); } + if points.len() != 4 { + return None; + } + // Distinct, so a degenerate path is not one, and each step along one axis only: the four + // points of an axis-aligned rectangle, in order round it. + for (i, p) in points.iter().enumerate() { + let q = points[(i + 1) % 4]; + let along_x = p.y == q.y && p.x != q.x; + let along_y = p.x == q.x && p.y != q.y; + if !(along_x || along_y) { + return None; + } + if points.iter().skip(i + 1).any(|o| same(*p, *o)) { + return None; + } + } + + Some(path.fast_bounding_box()) } diff --git a/vendor/hayro-interpret/src/device.rs b/vendor/hayro-interpret/src/device.rs index 36c64a23f..f85969dff 100644 --- a/vendor/hayro-interpret/src/device.rs +++ b/vendor/hayro-interpret/src/device.rs @@ -73,6 +73,40 @@ pub trait Device<'a> { fn begin_annotations(&mut self) {} /// PdfCraft patch (29): the page's annotations are done; what follows is page content. fn end_annotations(&mut self) {} + /// PdfCraft patch (39): the transparency group just pushed is a knockout group (`/K true`): + /// each of its elements composites with the group's initial backdrop, not with the + /// elements drawn before it in the group (ISO 32000-2, knockout groups), until the + /// matching `pop_transparency_group`. + fn set_knockout(&mut self) {} + /// PdfCraft patch (39): an element whose shape is `shape` (device space) is about to be + /// drawn into a transparency group of its own. In a knockout group (`set_knockout`) the + /// element replaces what the group drew under its shape, so a device clears that first; + /// the element's layer then composites over the cleared area as usual. + fn knockout_shape(&mut self, _shape: &BezPath) {} + /// PdfCraft patch (39): whether `knockout_shape` would clear anything now, that is whether + /// the innermost transparency group is a knockout group. Lets the interpreter skip work it + /// only needs for the clear. + fn in_knockout_group(&mut self) -> bool { + false + } + /// PdfCraft patch (39): `image`, an unmasked raster image, is about to be drawn with + /// `transform` in a knockout group. A device that will draw it (it isn't skipped and it + /// decodes) clears what the group drew under its placement rectangle first, so the image + /// replaces it. Decided by the device, at the size it draws the image, so the draw reuses + /// that decode. + fn knockout_image(&mut self, _image: &crate::RasterImage<'_>, _transform: Affine) {} + /// PdfCraft patch (39): whether a transparency group drawn now is an element of a knockout + /// group that the device wants cleared under the group's shape first. If so the + /// interpreter draws the group's content once between `begin_knockout_erase` and + /// `end_knockout_erase`, in which every paint clears what is under its shape instead of + /// painting, and then draws the group as usual, in a layer of its own. + fn knockout_erase_wanted(&mut self) -> bool { + false + } + /// PdfCraft patch (39): see `knockout_erase_wanted`. + fn begin_knockout_erase(&mut self) {} + /// PdfCraft patch (39): see `knockout_erase_wanted`. + fn end_knockout_erase(&mut self) {} } /// A device that discards all drawing operations. diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index b3f830ba2..8a76ee417 100644 --- a/vendor/hayro-interpret/src/encode.rs +++ b/vendor/hayro-interpret/src/encode.rs @@ -18,6 +18,13 @@ pub struct EncodedShadingPattern { pub(crate) shading_type: EncodedShadingType, pub(crate) opacity: f32, pub(crate) transfer_function: Option, + /// PdfCraft patch (35): where the shading can paint at all, in device space: for a mesh + /// (types 4–7), the pixels its triangles touched when it was encoded (empty when none + /// lies in the area); `None` for the shading types that paint everywhere (function-based, + /// axial, radial). The renderer samples a texture only there, and skips a mesh that + /// touches nothing: painted over the whole canvas, 310 one-triangle meshes on a plan cost + /// 310 full tiles per tile, and the watchdog gave the page up. + pub extent: Option, } impl EncodedShadingPattern { @@ -50,6 +57,8 @@ impl ShadingPattern { /// Mesh shadings are sampled pixel by pixel, and only there (see `TriangleSampler`). pub fn encode_within(&self, area: Option) -> EncodedShadingPattern { let base_transform; + // PdfCraft patch (35): see `EncodedShadingPattern::extent`. + let mut extent = None; let shading_type = match self.shading.shading_type.as_ref() { ShadingType::FunctionBased { @@ -81,6 +90,7 @@ impl ShadingPattern { let full_transform = self.matrix; let mut sampler = TriangleSampler::new(area); sampler.sample(triangles, full_transform); + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -106,6 +116,7 @@ impl ShadingPattern { break; } } + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -130,6 +141,7 @@ impl ShadingPattern { break; } } + extent = Some(sampler.extent()); let samples = sampler.map; base_transform = Affine::IDENTITY; @@ -162,6 +174,9 @@ impl ShadingPattern { base_transform, opacity: self.opacity, transfer_function: self.transfer_function.clone(), + // A `/Background` is painted wherever the triangles aren't (pattern fills), so such + // a mesh has no extent to confine it to. + extent: if self.shading.background.is_some() { None } else { extent }, } } } @@ -249,6 +264,8 @@ struct TriangleSampler { map: FxHashMap<(u16, u16), ColorComponents>, area: kurbo::Rect, visits_left: u64, + /// PdfCraft patch (35): the pixels sampled so far, as `(x0, y0, x1, y1)` inclusive. + touched: Option<(u16, u16, u16, u16)>, } impl TriangleSampler { @@ -263,6 +280,21 @@ impl TriangleSampler { map: FxHashMap::default(), area: area.unwrap_or(kurbo::Rect::new(0.0, 0.0, 65536.0, 65536.0)), visits_left, + touched: None, + } + } + + /// PdfCraft patch (35): the device-space rectangle covering every pixel sampled so far + /// (whole pixels), or an empty rectangle when no triangle touched the area. + fn extent(&self) -> kurbo::Rect { + match self.touched { + Some((x0, y0, x1, y1)) => kurbo::Rect::new( + f64::from(x0), + f64::from(y0), + f64::from(x1) + 1.0, + f64::from(y1) + 1.0, + ), + None => kurbo::Rect::ZERO, } } @@ -321,9 +353,27 @@ impl TriangleSampler { return false; } self.visits_left -= 1; - let point = Point::new(x as f64, y as f64); - if t.contains_point(point) { - self.map.insert((x, y), t.interpolate(point)); + let corner = Point::new(x as f64, y as f64); + let sampled = if t.contains_point(corner) { + self.map.insert((x, y), t.interpolate(corner)); + true + } else if t.covers(Point::new(x as f64 + 0.5, y as f64 + 0.5)) { + // PdfCraft patch (36): a pixel whose corner no triangle holds strictly (it + // lies on an edge two triangles share, or on the mesh's side) still belongs + // to the triangle that covers its centre, edges included. Testing only the + // corner left a white seam along every shared edge. Such a pixel takes the + // colour at its corner, as every other pixel does (where MuPDF's colours + // agree), unless a triangle that holds the corner gives it one. + self.map.entry((x, y)).or_insert_with(|| t.interpolate(corner)); + true + } else { + false + }; + if sampled { + self.touched = Some(match self.touched { + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + None => (x, y, x, y), + }); } } } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index fe08fc284..1ac17fa52 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -1,5 +1,5 @@ use crate::{BlendMode, FillRule}; -use crate::color::ColorSpace; +use crate::color::{ColorSpace, DefaultColorSpaces, DeviceFamily}; use crate::context::Context; use crate::convert::{convert_line_cap, convert_line_join}; use crate::device::Device; @@ -336,6 +336,30 @@ fn draw_highlight_without_appearance<'a>( context.restore_state(device); } +/// PdfCraft patch (38): the colour space `cs` or `CS` selects by `name`: a device space, else +/// the `/ColorSpace` resource `name`. A device space, named directly or by a resource, is +/// replaced by its default from `defaults` (ISO 32000-2 §8.6.5.6). +fn named_color_space<'a>( + name: &hayro_syntax::object::Name<'_>, + resources: &Resources<'a>, + defaults: &DefaultColorSpaces<'_, '_>, + context: &mut Context<'a>, +) -> Option { + if let Some(named) = ColorSpace::new_from_name(name) { + let default = DeviceFamily::of_name(name).and_then(|family| defaults.get(family)); + return Some(default.map_or(named, |(space, _)| space)); + } + let resource = resources.get_color_space(name); + if let Some((space, _)) = resource + .as_ref() + .and_then(DeviceFamily::of_object) + .and_then(|family| defaults.get(family)) + { + return Some(space); + } + context.get_color_space(resources, name) +} + /// Interprets the instructions that `ops` yields, until they run out or the caller cancels. It runs /// once per window of a content stream, between one `save_state` and its restore (see /// `interpret_content`). @@ -345,6 +369,9 @@ fn interpret_window<'a>( context: &mut Context<'a>, device: &mut impl Device<'a>, ) { + // PdfCraft patch (38): the resources' DefaultGray, DefaultRGB and DefaultCMYK replace the + // device spaces that colour operators and images select (ISO 32000-2 §8.6.5.6). + let defaults = DefaultColorSpaces::new(resources, &context.interpreter_cache.object_cache); while let Some(op) = ops.next() { // PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`). if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) { @@ -353,18 +380,21 @@ fn interpret_window<'a>( match op { TypedInstruction::SaveState(_) => context.save_state(), TypedInstruction::StrokeColorDeviceRgb(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_rgb(); + // PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Rgb); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; } TypedInstruction::StrokeColorDeviceGray(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_gray(); + // PdfCraft patch (38): DeviceGray, or the DefaultGray that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Gray); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; } TypedInstruction::StrokeColorCmyk(s) => { - context.get_mut().graphics_state.stroke_cs = ColorSpace::device_cmyk(); + // PdfCraft patch (38): DeviceCMYK, or the DefaultCMYK that replaces it. + context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Cmyk); context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()]; context.get_mut().graphics_state.stroke_pattern = None; @@ -428,18 +458,21 @@ fn interpret_window<'a>( fill_stroke_path(context, device, FillRule::NonZero); } TypedInstruction::NonStrokeColorDeviceGray(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_gray(); + // PdfCraft patch (38): DeviceGray, or the DefaultGray that replaces it. + context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Gray); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; } TypedInstruction::NonStrokeColorDeviceRgb(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_rgb(); + // PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it. + context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Rgb); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; } TypedInstruction::NonStrokeColorCmyk(s) => { - context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_cmyk(); + // PdfCraft patch (38): DeviceCMYK, or the DefaultCMYK that replaces it. + context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Cmyk); context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()]; context.get_mut().graphics_state.non_stroke_pattern = None; @@ -528,13 +561,9 @@ fn interpret_window<'a>( // Ignore for now. } TypedInstruction::ColorSpaceStroke(c) => { - let cs = if let Some(named) = ColorSpace::new_from_name(c.0) { - named - } else { - context - .get_color_space(resources, c.0) - .unwrap_or(ColorSpace::device_gray()) - }; + // PdfCraft patch (38): see `named_color_space`. + let cs = named_color_space(c.0, resources, &defaults, context) + .unwrap_or(ColorSpace::device_gray()); if !cs.is_pattern() { context.get_mut().graphics_state.stroke_pattern = None; @@ -543,13 +572,9 @@ fn interpret_window<'a>( context.get_mut().graphics_state.stroke_cs = cs; } TypedInstruction::ColorSpaceNonStroke(c) => { - let cs = if let Some(named) = ColorSpace::new_from_name(c.0) { - named - } else { - context - .get_color_space(resources, c.0) - .unwrap_or(ColorSpace::device_gray()) - }; + // PdfCraft patch (38): see `named_color_space`. + let cs = named_color_space(c.0, resources, &defaults, context) + .unwrap_or(ColorSpace::device_gray()); if !cs.is_pattern() { context.get_mut().graphics_state.non_stroke_pattern = None; @@ -574,7 +599,7 @@ fn interpret_window<'a>( context.get_mut().graphics_state.non_stroke_pattern = n.1.and_then(|name| { resources .get_pattern(name) - .and_then(|d| Pattern::new(d, context, resources)) + .and_then(|d| Pattern::new(d, context, resources, &defaults)) }); } TypedInstruction::StrokeColorNamed(n) => { @@ -583,7 +608,7 @@ fn interpret_window<'a>( context.get_mut().graphics_state.stroke_pattern = n.1.and_then(|name| { resources .get_pattern(name) - .and_then(|d| Pattern::new(d, context, resources)) + .and_then(|d| Pattern::new(d, context, resources, &defaults)) }); } TypedInstruction::BeginMarkedContentWithProperties(bdc) => { @@ -794,6 +819,8 @@ fn interpret_window<'a>( &cache, Some(&decoded_cache), transfer_function.clone(), + // PdfCraft patch (38): an image in a device space draws in its default. + &defaults, ) }) { draw_xobject(&x_object, resources, context, device); @@ -804,14 +831,17 @@ fn interpret_window<'a>( let transfer_function = context.get().graphics_state.transfer_function.clone(); let cache = context.interpreter_cache.object_cache.clone(); // PdfCraft patch: inline images are not cached (see `DecodedImageKey`). + // PdfCraft patch (38): an inline image in a device space, named directly or by a + // resource, draws in its default. if let Some(x_object) = ImageXObject::new( i.0, - |name| context.get_color_space(resources, name), + |name| named_color_space(name, resources, &defaults, context), &warning_sink, &cache, None, false, transfer_function, + Some(&defaults), ) { draw_image_xobject(&x_object, context, device); } @@ -830,7 +860,13 @@ fn interpret_window<'a>( .get_shading(s.0) .and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; - Shading::new(dict, stream, &context.interpreter_cache.object_cache) + // PdfCraft patch (38): a shading in a device space draws in its default. + Shading::new( + dict, + stream, + &context.interpreter_cache.object_cache, + &defaults, + ) }) .map(|s| { Pattern::Shading(ShadingPattern { diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index 838ea099f..c1fd47f39 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -159,6 +159,8 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> pattern.set_transfer_function(tf.clone()); } + // PdfCraft patch: `CA`/`ca` can be set by a `gs` after the pattern colour operator, so use the alpha current at paint time. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index c59b70df4..ac3e57529 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -1,9 +1,12 @@ -use hayro_syntax::object::dict::keys::{BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE}; -use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier}; +use hayro_syntax::object::dict::keys::{BASE_STATE, D, OCGS, OCMD, OCPROPERTIES, OFF, ON, P, TYPE, VE}; +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier}; use std::collections::HashSet; pub(crate) struct OcgState { inactive_ocgs: HashSet, + /// PdfCraft patch (40): every group the document lists in `/OCProperties /OCGs`, so a + /// visibility expression naming anything else is known to be malformed. + known_ocgs: HashSet, visibility_stack: Vec, } @@ -11,6 +14,7 @@ impl OcgState { fn dummy() -> Self { Self { inactive_ocgs: HashSet::default(), + known_ocgs: HashSet::default(), visibility_stack: vec![], } } @@ -25,6 +29,15 @@ impl OcgState { }; let mut inactive = HashSet::new(); + let known: HashSet = oc_properties + .get::>(OCGS) + .map(|ocgs| { + ocgs.raw_iter() + .filter_map(|item| item.as_obj_ref()) + .map(Into::into) + .collect() + }) + .unwrap_or_default(); let base_state = config .get::>(BASE_STATE) @@ -61,6 +74,7 @@ impl OcgState { Self { inactive_ocgs: inactive, + known_ocgs: known, visibility_stack: Vec::new(), } } @@ -72,6 +86,14 @@ impl OcgState { } pub(crate) fn begin_ocmd(&mut self, ocmd: &Dict<'_>) { + // PdfCraft patch (40): a visibility expression takes precedence over /OCGs and /P + // (ISO 32000-2 §8.11.2.2); a malformed one is ignored and they decide instead. + if let Some(visible) = ocmd.get::>(VE).and_then(|ve| self.visibility_expression(&ve, 0)) { + let visible = self.is_visible() && visible; + self.visibility_stack.push(visible); + return; + } + let policy = ocmd .get::>(P) .and_then(|n| OcmdPolicy::from_name(n.as_ref())) @@ -104,6 +126,46 @@ impl OcgState { self.visibility_stack.push(visible); } + /// PdfCraft patch (40): evaluate a visibility expression (`/VE`, PDF 1.6): `[/And e…]`, + /// `[/Or e…]` or `[/Not e]`, each `e` an optional content group (by reference) or a nested + /// expression. `None` for a malformed expression: an unknown operator, a `/Not` without + /// exactly one operand, an `/And` or `/Or` without any, an operand that is neither (a + /// reference to an object the document doesn't list as a group is neither), or nesting + /// deeper than `MAX_VISIBILITY_EXPRESSION_DEPTH`. + fn visibility_expression(&self, expr: &Array<'_>, depth: usize) -> Option { + if depth > MAX_VISIBILITY_EXPRESSION_DEPTH { + return None; + } + let mut items = expr.raw_iter(); + let operator = match items.next()? { + MaybeRef::NotRef(Object::Name(name)) => name, + _ => return None, + }; + let (mut count, mut all, mut any) = (0usize, true, false); + for item in items { + let value = match item { + MaybeRef::Ref(r) => { + let id: ObjectIdentifier = r.into(); + if !self.known_ocgs.contains(&id) { + return None; + } + !self.inactive_ocgs.contains(&id) + } + MaybeRef::NotRef(Object::Array(nested)) => self.visibility_expression(&nested, depth + 1)?, + _ => return None, + }; + count = count.saturating_add(1); + all &= value; + any |= value; + } + match (operator.as_ref(), count) { + (b"And", 1..) => Some(all), + (b"Or", 1..) => Some(any), + (b"Not", 1) => Some(!all), + _ => None, + } + } + pub(crate) fn begin_ocg(&mut self, props: &Dict<'_>, ref_id: ObjectIdentifier) { match props.get::>(TYPE).as_deref() { Some(OCMD) => self.begin_ocmd(props), @@ -140,6 +202,9 @@ impl Default for OcgState { } } +/// PdfCraft patch (40): how deep visibility expressions may nest; deeper ones are malformed. +const MAX_VISIBILITY_EXPRESSION_DEPTH: usize = 32; + #[derive(Debug, PartialEq, Eq, Copy, Clone)] enum BaseState { On, diff --git a/vendor/hayro-interpret/src/pattern.rs b/vendor/hayro-interpret/src/pattern.rs index 9295f5170..04750f376 100644 --- a/vendor/hayro-interpret/src/pattern.rs +++ b/vendor/hayro-interpret/src/pattern.rs @@ -1,7 +1,7 @@ //! PDF patterns. use crate::cache::Cache; -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, DefaultColorSpaces}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::font::Glyph; @@ -34,16 +34,19 @@ pub enum Pattern<'a> { } impl<'a> Pattern<'a> { + /// PdfCraft patch (38): `defaults` are those of the resources the pattern is selected with, + /// for a shading pattern's shading in a device space. pub(crate) fn new( object: Object<'a>, ctx: &Context<'a>, resources: &Resources<'a>, + defaults: &DefaultColorSpaces<'_, '_>, ) -> Option { match object { Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new( &dict, &ctx.interpreter_cache.object_cache, - ctx.get().graphics_state.non_stroke_alpha, + defaults, )?)), Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new( stream, ctx, resources, @@ -63,6 +66,28 @@ impl<'a> Pattern<'a> { } } + /// PdfCraft patch (46): the pattern without the fill or stroke opacity it captured when it was + /// set (`scn`). A stencil mask's transparency group applies the opacity in force when the mask + /// is drawn, so a pattern paint carrying it too drew the mask at the opacity squared (#2975). + pub(crate) fn without_opacity(mut self) -> Self { + match &mut self { + Self::Shading(p) => p.opacity = 1.0, + Self::Tiling(p) => { + p.non_stroking_paint = p.non_stroking_paint.clone().opaque(); + p.stroke_paint = p.stroke_paint.clone().opaque(); + } + } + self + } + + /// PdfCraft patch: set the alpha of the graphics state in effect when the pattern is painted. The `gs` operator + /// can come after `scn`/`SCN`, so the alpha must not be captured when the pattern is selected. + pub(crate) fn set_alpha(&mut self, alpha: f32) { + if let Self::Shading(p) = self { + p.opacity = alpha; + } + } + pub(crate) fn set_transfer_function(&mut self, tf: ActiveTransferFunction) { if let Self::Shading(p) = self { p.transfer_function = Some(tf); @@ -93,11 +118,15 @@ pub struct ShadingPattern { } impl ShadingPattern { - pub(crate) fn new(dict: &Dict<'_>, cache: &Cache, opacity: f32) -> Option { + pub(crate) fn new( + dict: &Dict<'_>, + cache: &Cache, + defaults: &DefaultColorSpaces<'_, '_>, + ) -> Option { let shading = dict.get::>(SHADING).and_then(|o| { let (dict, stream) = dict_or_stream(&o)?; - Shading::new(dict, stream, cache) + Shading::new(dict, stream, cache, defaults) })?; let matrix = dict .get::<[f64; 6]>(MATRIX) @@ -110,7 +139,7 @@ impl ShadingPattern { Some(Self { shading: Arc::new(shading), - opacity, + opacity: 1.0, matrix, transfer_function: None, }) diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 2e619e47f..7a8b96cb9 100644 --- a/vendor/hayro-interpret/src/shading.rs +++ b/vendor/hayro-interpret/src/shading.rs @@ -4,9 +4,9 @@ use crate::CacheKey; use crate::cache::Cache; -use crate::color::{ColorComponents, ColorSpace}; +use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily}; use crate::function::{Function, StitchingBounds, Values, interpolate}; -use crate::util::{Float32Ext, PointExt, RectExt}; +use crate::util::{Float32Ext, PointExt, RectExt, hash128}; use hayro_syntax::bit_reader::BitReader; use hayro_syntax::object::Array; use hayro_syntax::object::Dict; @@ -139,12 +139,24 @@ pub struct Shading { } impl Shading { - pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option { - let cache_key = dict.cache_key(); - + /// PdfCraft patch (38): a shading in a device space is drawn in its default colour space + /// from `defaults`, the resources it is painted with (ISO 32000-2 §8.6.5.6), and keyed by + /// it as an image is. + pub(crate) fn new( + dict: &Dict<'_>, + stream: Option<&Stream<'_>>, + cache: &Cache, + defaults: &DefaultColorSpaces<'_, '_>, + ) -> Option { let shading_num = dict.get::(SHADING_TYPE)?; - let color_space = ColorSpace::new(dict.get(COLORSPACE)?, cache)?; + let color_space_obj = dict.get::>(COLORSPACE)?; + let default = + DeviceFamily::of_object(&color_space_obj).and_then(|family| defaults.get(family)); + let (color_space, cache_key) = match default { + Some((space, key)) => (space, hash128(&(dict.cache_key(), key))), + None => (ColorSpace::new(color_space_obj, cache)?, dict.cache_key()), + }; let shading_type = match shading_num { 1 => { @@ -375,6 +387,24 @@ impl Triangle { self.kurbo_tri.winding(pos) != 0 } + /// PdfCraft patch (36): whether the point is inside the triangle or on one of its edges. + /// Two triangles of a mesh share an edge; a sample on it belongs to both (their colours + /// agree there), where `contains_point` can give it to neither and leave a seam. A + /// degenerate triangle covers nothing. + pub fn covers(&self, pos: Point) -> bool { + // Relative to the barycentric coordinates' scale (1), well above f64 rounding. + const EDGE: f64 = 1e-9; + let (a, b, c) = (self.p0.point, self.p1.point, self.p2.point); + let (v0, v1, v2) = (b - a, c - a, pos - a); + let denom = self.d00 * self.d11 - self.d01 * self.d01; + let (d20, d21) = (v2.dot(v0), v2.dot(v1)); + let v = (self.d11 * d20 - self.d01 * d21) / denom; + let w = (self.d00 * d21 - self.d01 * d20) / denom; + // A degenerate triangle divides by zero: NaN fails every comparison, and infinite + // coordinates can't sum to at most one. + v >= -EDGE && w >= -EDGE && v + w <= 1.0 + EDGE + } + /// Return the bounding box of the triangle. pub fn bounding_box(&self) -> kurbo::Rect { self.kurbo_tri.bounding_box() diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index 21b134ce8..304b14a08 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,11 +1,11 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::color::{ColorComponents, ColorSpace, ToRgb}; +use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb}; use crate::context::Context; use crate::device::Device; use crate::function::{Function, interpolate}; use crate::interpret::path::get_paint; use crate::interpret::state::ActiveTransferFunction; -use crate::{BlendMode, CacheKey, ClipPath, Image, RasterImage, StencilImage}; +use crate::{BlendMode, CacheKey, ClipPath, Image, Paint, RasterImage, StencilImage}; use crate::{FillRule, InterpreterWarning, WarningSinkFn, interpret}; use crate::{ImageData, LumaData, RgbData}; use hayro_syntax::bit_reader::BitReader; @@ -36,6 +36,7 @@ impl<'a> XObject<'a> { cache: &Cache, decoded_cache: Option<&DecodedImageCache>, transfer_function: Option, + defaults: &DefaultColorSpaces<'_, '_>, ) -> Option { let dict = stream.dict(); match dict.get::>(SUBTYPE)?.deref() { @@ -47,6 +48,7 @@ impl<'a> XObject<'a> { decoded_cache, false, transfer_function, + Some(defaults), )?)), FORM => Some(Self::FormXObject(FormXObject::new(stream)?)), _ => None, @@ -64,7 +66,7 @@ pub(crate) struct FormXObject<'a> { is_isolated: bool, is_knockout: bool, pub(crate) dict: Dict<'a>, - resources: Dict<'a>, + resources: Option>, } impl<'a> FormXObject<'a> { @@ -73,7 +75,8 @@ impl<'a> FormXObject<'a> { // PdfCraft patch: see `context::MAX_PAGE_CONTENT`. let decoded = crate::context::decode_content(stream)?; - let resources = dict.get::>(RESOURCES).unwrap_or_default(); + // PdfCraft patch: retain absence; present malformed dictionaries stay local-empty. + let resources = dict.contains_key(RESOURCES).then(|| dict.get::>(RESOURCES).unwrap_or_default()); let matrix = Affine::new( dict.get::<[f64; 6]>(MATRIX) @@ -159,7 +162,38 @@ pub(crate) fn draw_form_xobject<'a, 'b>( && state.soft_mask.is_none() && state.blend_mode == BlendMode::Normal }; - let own_layer = x_object.is_transparency_group && !neutral; + // PdfCraft patch (39): a group drawn as an element of a knockout group replaces what that + // group drew under its shape, the union of its own elements' shapes (#2971). The shape is + // cleared by drawing the group's content once in the device's erase mode; the group then + // composites over the cleared area in a layer of its own (also when it would otherwise be + // drawn straight onto its parent, where its elements would knock each other out). + let knockout_element = x_object.is_transparency_group && device.knockout_erase_wanted(); + if knockout_element { + context.save_state(); + device.begin_knockout_erase(); + device.push_clip_path(&ClipPath { + path: context.get().ctm + * Rect::new( + x_object.bbox[0] as f64, + x_object.bbox[1] as f64, + x_object.bbox[2] as f64, + x_object.bbox[3] as f64, + ) + .to_path(0.1), + fill: FillRule::NonZero, + }); + interpret( + TypedIter::new(x_object.decoded.as_ref()), + &Resources::for_form(x_object.resources.clone(), resources.clone()), + context, + device, + ); + device.pop_clip_path(); + device.end_knockout_erase(); + context.restore_state(device); + context.path_mut().truncate(0); + } + let own_layer = x_object.is_transparency_group && (!neutral || knockout_element); if own_layer { device.push_transparency_group( @@ -170,6 +204,10 @@ pub(crate) fn draw_form_xobject<'a, 'b>( context.get_mut().graphics_state.non_stroke_alpha = 1.0; context.get_mut().graphics_state.stroke_alpha = 1.0; + // PdfCraft patch (39): its elements knock each other out (#2971). + if x_object.is_knockout { + device.set_knockout(); + } } device.set_soft_mask(context.get().graphics_state.soft_mask.clone()); @@ -189,7 +227,7 @@ pub(crate) fn draw_form_xobject<'a, 'b>( interpret( iter, - &Resources::from_parent(x_object.resources.clone(), resources.clone()), + &Resources::for_form(x_object.resources.clone(), resources.clone()), context, device, ); @@ -251,6 +289,17 @@ pub(crate) fn draw_image_xobject<'a, 'b>( soft_mask = None; } + // PdfCraft patch (39): an image without a mask covers its whole placement rectangle, so in + // a knockout group it replaces what the group drew there (#2971). A stencil's or a masked + // image's shape is its mask; those composite as before (exact where the mask is opaque or + // transparent, which is what masks mostly are). Only an image the device draws clears: one + // that doesn't decode (over `MAX_IMAGE_PIXELS`, damaged data) or that it skips (a preview's + // tiny image) replaces nothing. The device decides, at its own draw size (review on #2974: + // a full-size decode here cost a second decode of every downscaled image). + if !x_object.is_mask && !has_alpha && device.in_knockout_group() { + device.knockout_image(&RasterImage(x_object.clone()), transform); + } + device.push_transparency_group( context.get().graphics_state.non_stroke_alpha, std::mem::take(&mut soft_mask), @@ -259,10 +308,18 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.set_soft_mask(None); device.set_blend_mode(BlendMode::default()); + // PdfCraft patch (46): the group just pushed applies the fill opacity, so a stencil mask's + // paint (the fill colour) must not carry it again: at `ca 0.5` a mask was drawn at a + // quarter of its opacity (#2975). The state is restored below. + context.get_mut().graphics_state.non_stroke_alpha = 1.0; let image = if x_object.is_mask { Image::Stencil(StencilImage { - paint: get_paint(context, false), + // A pattern paint keeps the opacity it was set with: drop it too (patch 46). + paint: match get_paint(context, false) { + Paint::Pattern(pattern) => Paint::Pattern(Box::new(pattern.without_opacity())), + paint => paint, + }, image_xobject: x_object.clone(), }) } else { @@ -308,9 +365,15 @@ pub(crate) struct ImageXObject<'a> { stream: Stream<'a>, transfer_function: Option, warning_sink: WarningSinkFn, + /// PdfCraft patch (38): the cache key of the default colour space drawn in place of the + /// image's device space, if one is. + default_space: Option, } impl<'a> ImageXObject<'a> { + /// PdfCraft patch (38): an image in a device space is drawn in its default colour space + /// from `defaults`. Masks have none to replace, and `resolve_cs` (an inline image's named + /// colour space) substitutes its own. pub(crate) fn new( stream: &Stream<'a>, resolve_cs: impl FnOnce(&Name<'_>) -> Option, @@ -319,6 +382,7 @@ impl<'a> ImageXObject<'a> { decoded_cache: Option<&DecodedImageCache>, mut is_mask: bool, transfer_function: Option, + defaults: Option<&DefaultColorSpaces<'_, '_>>, ) -> Option { let dict = stream.dict(); @@ -328,6 +392,7 @@ impl<'a> ImageXObject<'a> { .unwrap_or(false); is_mask |= is_stencil_mask; + let mut default_space = None; let image_cs = if is_mask { // Masks are always single-channel. Some(ColorSpace::device_gray()) @@ -336,15 +401,25 @@ impl<'a> ImageXObject<'a> { .get::>(CS) .or_else(|| dict.get::>(COLORSPACE)); - cs_obj - .clone() - .and_then(|c| ColorSpace::new(c, cache)) - // Inline images can also refer to color spaces by name. - .or_else(|| { - cs_obj - .and_then(|c| c.into_name()) - .and_then(|n| resolve_cs(&n)) - }) + let default = cs_obj + .as_ref() + .and_then(DeviceFamily::of_object) + .zip(defaults) + .and_then(|(family, defaults)| defaults.get(family)); + if let Some((space, key)) = default { + default_space = Some(key); + Some(space) + } else { + cs_obj + .clone() + .and_then(|c| ColorSpace::new(c, cache)) + // Inline images can also refer to color spaces by name. + .or_else(|| { + cs_obj + .and_then(|c| c.into_name()) + .and_then(|n| resolve_cs(&n)) + }) + } }; let interpolate = dict @@ -371,6 +446,7 @@ impl<'a> ImageXObject<'a> { stream: stream.clone(), is_mask, is_stencil_mask, + default_space, }) } @@ -434,6 +510,8 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, + // PdfCraft patch (38): the same image drawn in a default colour space or without one. + default_space: self.default_space, // PdfCraft patch (32): see `decode_reads_target`. target: if self.decode_reads_target() { target_dimension @@ -905,7 +983,7 @@ fn resolve_alpha( .get::>(SMASK) .or_else(|| dict.get::>(MASK)) { - let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; + let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; decode_mask(&obj, target_dimension).map(|decoded| decoded.luma) } else if let Some(color_key_mask) = dict.get::>(MASK) { @@ -965,7 +1043,7 @@ fn resolve_matte( let mut matte_rgb = [0_u8; 3]; color_space.convert_f32(&matte, &mut matte_rgb, false); - let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?; + let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?; let alpha = decode_mask(&mask_obj, target_dimension)?.luma; Some((alpha, matte_rgb)) diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 8e47324d0..4dfc9eb6b 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -35,12 +35,12 @@ pub mod ops; use crate::content::ops::TypedInstruction; use crate::object; -use crate::object::dict::InlineImageDict; +use crate::object::dict::{Dict, InlineImageDict}; use crate::object::name::{Name, skip_name_like}; use crate::object::{Array, Null, Number, Object, Stream}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; -use crate::trivia::{Comment, is_white_space_character}; +use crate::trivia::{Comment, is_regular_character, is_white_space_character}; use crate::util::find_needle; use core::array; use core::fmt::{Debug, Formatter}; @@ -242,7 +242,33 @@ impl<'a> UntypedIter<'a> { let mut lookahead_touched = false; let mut accepted = false; - 'outer: while let Some(pos) = find_needle(self.reader.tail()?, b"EI") { + // PdfCraft patch: an unfiltered inline image's byte length is determined by + // its parameters (and `/L` or `/Length` when present and consistent), so + // `EI` inside its samples does not cut the image short (#3107). + match unfiltered_image_end( + stream_data, + &dict, + self.complete, + start_offset.saturating_sub(self.committed), + ) { + UnfilteredEnd::Found(end_data, end_after_ei) => { + let image_data = stream_data.get(..end_data)?; + let stream = Stream::new(image_data, dict.clone()); + self.stack.push(Object::Stream(stream))?; + self.reader.read_bytes(end_after_ei)?; + self.reader.skip_white_spaces(); + accepted = true; + } + UnfilteredEnd::NeedsMore => { + self.needs_more = true; + return None; + } + UnfilteredEnd::Fallback => {} + } + + 'outer: while !accepted + && let Some(pos) = find_needle(self.reader.tail()?, b"EI") + { self.reader.read_bytes(pos)?; if self.reader.peek_bytes(2) == Some(b"EI") { @@ -852,9 +878,163 @@ mod macros { pub(crate) use op6; } +/// PdfCraft patch: outcome of checking an unfiltered inline image's deterministic byte length. +enum UnfilteredEnd { + /// Deterministic end found: `(end_data, end_after_ei)`. + Found(usize, usize), + /// Window ran out before the terminator after the expected sample bytes could be decided. + NeedsMore, + /// Not an unfiltered image with a valid terminator at its computed length; use the heuristic. + Fallback, +} + +/// PdfCraft patch: an unfiltered inline image's sample byte count, including byte padding per row, +/// and `/Length` or `/L` when present and consistent (#3107). +fn unfiltered_image_len(dict: &Dict<'_>) -> Option { + for key in [b"Filter".as_slice(), b"F"] { + if let Some(filter) = dict.get::>(key) + && !matches!(filter, Object::Null(_)) + && !matches!(&filter, Object::Array(filters) if filters.iter::>().next().is_none()) + { + return None; + } + } + for (full, short) in [ + (b"Width".as_slice(), b"W".as_slice()), + (b"Height", b"H"), + (b"ColorSpace", b"CS"), + (b"BitsPerComponent", b"BPC"), + (b"ImageMask", b"IM"), + ] { + if let (Some(full), Some(short)) = (dict.get::>(full), dict.get::>(short)) + && full != short + { + return None; + } + } + let get = |full: &[u8], short: &[u8]| { + dict.get::>(full) + .or_else(|| dict.get::>(short)) + }; + let int = |obj: &Object<'_>| match obj { + Object::Number(n) => i32::try_from(n.as_i64()) + .ok() + .filter(|&i| Number::from_i32(i) == *n) + .map(i64::from), + _ => None, + }; + let width = usize::try_from(int(&get(b"Width", b"W")?)?) + .ok() + .filter(|n| *n > 0)?; + let height = usize::try_from(int(&get(b"Height", b"H")?)?) + .ok() + .filter(|n| *n > 0)?; + let mask = match get(b"ImageMask", b"IM") { + None | Some(Object::Null(_) | Object::Boolean(false)) => false, + Some(Object::Boolean(true)) => true, + _ => return None, + }; + let bits = match get(b"BitsPerComponent", b"BPC") { + None | Some(Object::Null(_)) => None, + Some(value) => Some(int(&value)?), + }; + let (components, bits) = if mask { + if bits.is_some_and(|bits| bits != 1) { + return None; + } + (1usize, 1usize) + } else { + let (components, max_bits) = match get(b"ColorSpace", b"CS")? { + Object::Name(name) => match name.as_ref() { + b"DeviceGray" | b"G" => (1, 16), + b"DeviceRGB" | b"RGB" => (3, 16), + b"DeviceCMYK" | b"CMYK" => (4, 16), + _ => return None, + }, + Object::Array(space) => { + let mut items = space.iter::>(); + if !matches!(items.next(), Some(Object::Name(n)) if matches!(n.as_ref(), b"Indexed" | b"I")) + || items.count() != 3 + { + return None; + } + (1, 8) + } + _ => return None, + }; + let bits = match bits? { + bits @ (1 | 2 | 4 | 8 | 16) => bits as usize, + _ => return None, + }; + if bits > max_bits { + return None; + } + (components, bits) + }; + let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8); + row.checked_mul(height) +} + +/// PdfCraft patch: locate the deterministic `EI` terminator of an unfiltered inline image (#3107). +fn unfiltered_image_end(data: &[u8], dict: &Dict<'_>, complete: bool, header_bytes: usize) -> UnfilteredEnd { + let Some(end_data) = unfiltered_image_len(dict) else { + return UnfilteredEnd::Fallback; + }; + // An inline image shares the content window's budget, not the larger decoded-image + // budget. Include its dictionary/header and the shortest whitespace + EI + delimiter + // look-ahead, or one more byte beyond any separator whitespace already present. + // Missing bytes that cannot fit must use the existing heuristic, not pin the window + // and discard following content. Already-available exact ends still win. + let missing_end = if complete + || end_data + .checked_add(4) + .and_then(|minimum| data.len().checked_add(1).map(|next| minimum.max(next))) + .and_then(|needed| needed.checked_add(header_bytes)) + .is_none_or(|needed| needed > crate::MAX_DECODED_STREAM) + { + UnfilteredEnd::Fallback + } else { + UnfilteredEnd::NeedsMore + }; + let Some(after) = data.get(end_data..) else { + return missing_end; + }; + let Some(&first) = after.first() else { + return missing_end; + }; + if !is_white_space_character(first) { + return UnfilteredEnd::Fallback; + } + let Some(separator) = after.iter().position(|&b| !is_white_space_character(b)) else { + return missing_end; + }; + let Some(rest) = after.get(separator..) else { + return UnfilteredEnd::Fallback; + }; + if rest.first() != Some(&b'E') { + return UnfilteredEnd::Fallback; + } + let Some(&second) = rest.get(1) else { + return missing_end; + }; + if second != b'I' { + return UnfilteredEnd::Fallback; + } + let Some(end_after_ei) = end_data.checked_add(separator).and_then(|ei| ei.checked_add(2)) else { + return UnfilteredEnd::Fallback; + }; + match rest.get(2) { + Some(&b) if !is_regular_character(b) => UnfilteredEnd::Found(end_data, end_after_ei), + Some(_) => UnfilteredEnd::Fallback, + None if complete => UnfilteredEnd::Found(end_data, end_after_ei), + None => missing_end, + } +} + #[cfg(test)] mod tests { use super::{ContentTokenizer, UntypedIter}; + use crate::reader::ReaderExt; fn operators(data: &[u8]) -> Vec> { let mut tokenizer = UntypedIter::new(data); @@ -1014,4 +1194,139 @@ mod tests { assert_eq!(windowed, expected, "{:?}", String::from_utf8_lossy(&data)); } } + + #[test] + fn unfiltered_inline_image_with_ei_in_samples_is_not_cut_early() { + // 48-byte unfiltered DeviceGray image whose first four bytes are `0EI ` + // followed by valid page content operators (`Q BT ... Tj ET q`). + let data = b"q 100 0 0 100 72 650 cm \ +BI /W 48 /H 1 /CS /G /BPC 8 ID 0EI Q BT /F1 18 Tf 72 600 Td (Injected) Tj ET q! EI Q \ +BT /F1 14 Tf 72 550 Td (After) Tj ET"; + assert_eq!( + operators(data), + vec![ + b"q".to_vec(), + b"cm".to_vec(), + b"BI".to_vec(), + b"Q".to_vec(), + b"BT".to_vec(), + b"Tf".to_vec(), + b"Td".to_vec(), + b"Tj".to_vec(), + b"ET".to_vec(), + ] + ); + let expected = slice_instructions(data); + for piece in 1..=data.len() { + assert_eq!(windowed_instructions(data, || piece), expected, "piece size {piece}"); + } + } + + #[test] + fn oversized_unfiltered_inline_image_does_not_hold_following_content() { + for length in [crate::MAX_DECODED_STREAM - 1, crate::MAX_DECODED_STREAM, crate::MAX_DECODED_STREAM + 1] { + // The next BI supplies the existing EI heuristic's bounded look-ahead. The + // stream is not exhausted: an impossible declaration must not pin this window. + let data = format!( + "BI /W {length} /H 1 /CS /G /BPC 8 ID x EI BT (After) Tj ET \ + BI /W 1 /H 1 /CS /G /BPC 8 ID y EI Q" + ); + let mut tokenizer = UntypedIter::new_window(data.as_bytes(), false); + for expected in [b"BI".as_slice(), b"BT", b"Tj", b"ET"] { + let instruction = tokenizer.next_instruction().expect("following content survives"); + assert_eq!(instruction.operator.as_ref(), expected, "declared length {length}"); + } + } + } + + #[test] + fn unfiltered_inline_image_budget_includes_header_and_terminator() { + let mut reader = crate::reader::Reader::new(b"/W 1 /H 1 /CS /G /BPC 8 ID "); + let inline = reader + .read_without_context::>() + .expect("synthetic inline dictionary"); + let dict = inline.get_dict(); + let fits = crate::MAX_DECODED_STREAM - 5; // One sample + whitespace/EI/delimiter. + assert!(matches!(super::unfiltered_image_end(b"", dict, false, fits), super::UnfilteredEnd::NeedsMore)); + assert!(matches!(super::unfiltered_image_end(b"x ", dict, false, fits), super::UnfilteredEnd::Fallback)); + assert!(matches!(super::unfiltered_image_end(b"x EI", dict, false, fits + 1), super::UnfilteredEnd::Fallback)); + assert!(matches!(super::unfiltered_image_end(b"x EI", dict, true, usize::MAX), super::UnfilteredEnd::Found(1, 4))); + for header in [fits + 1, usize::MAX] { + assert!(matches!(super::unfiltered_image_end(b"", dict, false, header), super::UnfilteredEnd::Fallback)); + // The guard is only for missing bytes, never a reason to reinterpret available samples. + assert!(matches!(super::unfiltered_image_end(b"x EI Q", dict, false, header), super::UnfilteredEnd::Found(1, 4))); + } + } + + #[test] + fn large_unfiltered_inline_image_still_waits_for_its_samples() { + // A valid image larger than the interpreter's initial 64 KiB window must + // still use its exact length rather than execute operators in its samples. + let length = (64 << 10) + 1; + let mut data = format!("BI /W {length} /H 1 /CS /G /BPC 8 ID ").into_bytes(); + let sample_start = data.len(); + data.extend_from_slice(b"0EI Q BT (Injected) Tj ET q"); + data.resize(sample_start + length, b'0'); + data.extend_from_slice(b" EI BT (After) Tj ET"); + let mut partial = UntypedIter::new_window(&data[..64 << 10], false); + assert!(partial.next_instruction().is_none()); + assert!(partial.needs_more); + assert_eq!(partial.committed, 0); + assert_eq!(operators(&data), [b"BI".to_vec(), b"BT".to_vec(), b"Tj".to_vec(), b"ET".to_vec()]); + let expected = slice_instructions(&data); + for piece in [4096, 64 << 10, data.len()] { + assert_eq!(windowed_instructions(&data, || piece), expected, "piece size {piece}"); + } + } + + #[test] + fn unfiltered_inline_image_variants_and_fallbacks() { + // ImageMask with 1-bit row padding (width 10 -> 2 bytes per row, height 2 -> 4 bytes) + let mask_stream = b"BI /IM true /W 10 /H 2 ID 0EI\n EI Q"; + assert_eq!(operators(mask_stream), vec![b"BI".to_vec(), b"Q".to_vec()]); + let expected_mask = slice_instructions(mask_stream); + for piece in 1..=mask_stream.len() { + assert_eq!(windowed_instructions(mask_stream, || piece), expected_mask); + } + + // Indexed color space with 4-bit samples (width 5 -> 3 bytes per row, height 2 -> 6 bytes) + // and explicit /L matching computed length. + let indexed_stream = + b"BI /Width 5 /Height 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 6 ID 0EI Q! EI Q"; + assert_eq!(operators(indexed_stream), vec![b"BI".to_vec(), b"Q".to_vec()]); + let expected_indexed = slice_instructions(indexed_stream); + for piece in 1..=indexed_stream.len() { + assert_eq!(windowed_instructions(indexed_stream, || piece), expected_indexed); + } + + // Conflicting /W and /Width falls back to the heuristic scan; mismatched /L does not disrupt the verified layout. + let mismatch_l = b"BI /W 4 /H 1 /CS /G /BPC 8 /L 99 ID abcd EI Q"; + assert_eq!(operators(mismatch_l), vec![b"BI".to_vec(), b"Q".to_vec()]); + let conflict_w = b"BI /W 4 /Width 8 /H 1 /CS /G /BPC 8 ID abcd EI Q"; + assert_eq!(operators(conflict_w), vec![b"BI".to_vec(), b"Q".to_vec()]); + } + + #[test] + fn each_layout_keeps_its_computed_length() { + let inject = b" EI Q BT /F1 9 Tf (X) Tj ET q"; + let case = |params: &str, len: usize| { + let mut samples = inject.to_vec(); + samples.resize(len, b'0'); + let mut data = format!("BI {params} ID ").into_bytes(); + data.extend_from_slice(&samples); + data.extend_from_slice(b" EI Q"); + data + }; + for (params, len) in [ + ("/W 16 /H 2 /CS /RGB /BPC 8", 96), + ("/W 12 /H 2 /CS /CMYK /BPC 8", 96), + ("/W 70 /H 4 /CS /G /BPC 1", 36), + ("/IM true /W 70 /H 4", 36), + ("/W 30 /H 2 /CS [/I /RGB 1 <000000ffffff>] /BPC 4 /L 30", 30), + ("/W 15 /H 2 /CS /G /BPC 16 /L 60", 60), + ("/W 47 /H 1 /CS /G /BPC 8 /L 5", 47), + ] { + assert_eq!(operators(&case(params, len)), vec![b"BI".to_vec(), b"Q".to_vec()], "{params}"); + } + } } diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 74efd8b31..b46bd1d05 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -21,7 +21,13 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option Option ImageColorSpace::Gray, ColorSpace::RGB => ImageColorSpace::Rgb, @@ -98,6 +110,15 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option bool { + boxes(data) + .find(|&(kind, _, _)| kind == *b"jp2h") + .and_then(|(_, start, end)| data.get(start..end)) + .is_some_and(|header| boxes(header).any(|(kind, _, _)| kind == *b"pclr")) +} + /// The enumerated colour spaces (`EnumCS`, ISO 15444-1 I.5.3.3 and 15444-2 M.11.7.2) the decoder /// handles: CMYK, CIELab, sRGB, greyscale, sYCC, e-sRGB and ROMM-RGB. const DECODED_ENUMERATED: [u32; 7] = [12, 14, 16, 17, 18, 20, 21]; @@ -221,8 +242,9 @@ fn scale( #[cfg(test)] mod tests { - use super::{scale, without_refused_enumerated_colour}; + use super::{decode, scale, without_refused_enumerated_colour}; use crate::bit_reader::{BitReader, bit_mask}; + use crate::object::stream::{ImageColorSpace, ImageDecodeParams}; use alloc::vec::Vec; #[test] @@ -358,4 +380,104 @@ mod tests { assert_eq!(without_refused_enumerated_colour(&data), None, "{bad:#x}"); } } + + /// A 4x2 JP2 written for this test: one 8-bit component whose samples (0, 1) index a + /// two-entry sRGB palette (`pclr` + `cmap`): red, then blue. Rows: 0 1 0 1 / 1 0 1 0. + const PALETTE_JP2: &[u8] = &[ + 0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a, 0x00, 0x00, 0x00, + 0x14, 0x66, 0x74, 0x79, 0x70, 0x6a, 0x70, 0x32, 0x20, 0x00, 0x00, 0x00, 0x00, 0x6a, 0x70, + 0x32, 0x20, 0x00, 0x00, 0x00, 0x55, 0x6a, 0x70, 0x32, 0x68, 0x00, 0x00, 0x00, 0x16, 0x69, + 0x68, 0x64, 0x72, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x01, 0x07, 0x07, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x63, 0x6f, 0x6c, 0x72, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, 0x00, 0x00, 0x00, 0x14, 0x70, 0x63, 0x6c, 0x72, 0x00, 0x02, 0x03, 0x07, 0x07, + 0x07, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x14, 0x63, 0x6d, 0x61, 0x70, + 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x02, 0x00, 0x00, 0x00, + 0x8c, 0x6a, 0x70, 0x32, 0x63, 0xff, 0x4f, 0xff, 0x51, 0x00, 0x29, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x07, 0x01, 0x01, 0xff, 0x52, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, + 0x04, 0x04, 0x00, 0x01, 0xff, 0x5c, 0x00, 0x07, 0x40, 0x40, 0x48, 0x48, 0x50, 0xff, 0x64, + 0x00, 0x25, 0x00, 0x01, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x20, 0x62, 0x79, 0x20, + 0x4f, 0x70, 0x65, 0x6e, 0x4a, 0x50, 0x45, 0x47, 0x20, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x20, 0x32, 0x2e, 0x35, 0x2e, 0x34, 0xff, 0x90, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x17, 0x00, 0x01, 0xff, 0x93, 0xcf, 0xb4, 0x08, 0x08, 0x67, 0x90, 0x07, 0x41, 0x08, + 0xff, 0xd9, + ]; + + /// #3179: with no `/ColorSpace` in the image dictionary, the JP2 palette gives the colours. The + /// raw indices (0 and 1) used to come back as a gray image, drawn as noise. + #[test] + fn jpx_palette_is_applied_without_a_color_space() { + let params = ImageDecodeParams { + width: 4, + height: 2, + ..ImageDecodeParams::default() + }; + let result = decode(PALETTE_JP2, ¶ms).unwrap(); + let image = result.image_data.unwrap(); + assert!(matches!(image.color_space, Some(ImageColorSpace::Rgb))); + let red = [255, 0, 0]; + let blue = [0, 0, 255]; + let expected: Vec = [red, blue, red, blue, blue, red, blue, red].concat(); + assert_eq!(&*result.data, &expected[..]); + } + + /// With an `/Indexed` colour space in the dictionary, the PDF supplies the palette, so the raw + /// indices are what the image holds. + #[test] + fn jpx_palette_indices_stay_raw_for_an_indexed_color_space() { + let params = ImageDecodeParams { + is_indexed: true, + num_components: Some(1), + width: 4, + height: 2, + ..ImageDecodeParams::default() + }; + let result = decode(PALETTE_JP2, ¶ms).unwrap(); + assert_eq!(&*result.data, &[0, 1, 0, 1, 1, 0, 1, 0][..]); + } + + /// A 1x1, 1-bit codestream (one empty packet, so its sample is the midpoint 1) as a JP2 file + /// whose header holds `extra` boxes after `ihdr` and `colr`. + fn one_bit_jp2(extra: &[u8]) -> Vec { + let jp2_box = |kind: &[u8], body: &[u8]| { + let mut b = ((8 + body.len()) as u32).to_be_bytes().to_vec(); + b.extend_from_slice(kind); + b.extend_from_slice(body); + b + }; + let mut codestream = alloc::vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; + for value in [1_u32, 1, 0, 0, 1, 1, 0, 0] { + codestream.extend_from_slice(&value.to_be_bytes()); + } + codestream.extend_from_slice(&[0, 1, 0, 1, 1]); + codestream.extend_from_slice(&[0xff, 0x52, 0, 12, 0, 0, 0, 1, 0, 0, 4, 4, 0, 1]); + codestream.extend_from_slice(&[0xff, 0x5c, 0, 4, 0x40, 0x40]); + codestream.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); + codestream.extend_from_slice(&[0xff, 0xd9]); + let mut header = jp2_box(b"ihdr", &[0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 7, 0, 0]); + header.extend(jp2_box(b"colr", &[1, 0, 0, 0, 0, 0, 16])); + header.extend_from_slice(extra); + let mut out = alloc::vec![0, 0, 0, 12, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a]; + out.extend(jp2_box(b"ftyp", b"jp2 \0\0\0\0jp2 ")); + out.extend(jp2_box(b"jp2h", &header)); + out.extend(jp2_box(b"jp2c", &codestream)); + out + } + + /// Review on #3184: resolved palette colours are 8-bit samples whatever the indices' + /// precision; an image without a palette keeps its own (1 bit here). + #[test] + fn palette_colours_are_eight_bit_and_other_samples_keep_their_precision() { + let params = ImageDecodeParams { width: 1, height: 1, ..ImageDecodeParams::default() }; + let bpc = |data: &[u8]| decode(data, ¶ms).and_then(|r| r.image_data).map(|d| d.bits_per_component); + let mut palette = Vec::new(); + for (kind, body) in [(&b"pclr"[..], &[0, 2, 3, 7, 7, 7, 255, 0, 0, 64, 128, 192][..]), (b"cmap", &[0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 2])] { + palette.extend_from_slice(&((8 + body.len()) as u32).to_be_bytes()); + palette.extend_from_slice(kind); + palette.extend_from_slice(body); + } + assert_eq!(bpc(&one_bit_jp2(&palette)), Some(8)); + assert_eq!(bpc(&one_bit_jp2(&[])), Some(1)); + } } diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index d7f0ec953..bb0b4abc0 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -148,8 +148,8 @@ fn resolve_pages<'a>( ctx.rotate = Some(rotate); } - let resources = Resources::from_parent( - pages_dict.get::>(RESOURCES).unwrap_or_default(), + let resources = Resources::for_page( + pages_dict.contains_key(RESOURCES).then(|| pages_dict.get::>(RESOURCES).unwrap_or_default()), resources.clone(), ); @@ -431,8 +431,8 @@ impl<'a> Page<'a> { .map_or(1.0, |u| u.min(MAX_USER_UNIT) as f32); let ctx = resources.ctx.clone(); - let resources = Resources::from_parent( - dict.get::>(RESOURCES).unwrap_or_default(), + let resources = Resources::for_page( + dict.contains_key(RESOURCES).then(|| dict.get::>(RESOURCES).unwrap_or_default()), resources, ); @@ -674,11 +674,13 @@ impl<'a> Page<'a> { pub struct Resources<'a> { parent: Option>, ctx: ReaderContext<'a>, + // PdfCraft patch: legacy forms without Resources use the page, not their caller. + page_properties: Dict<'a>, /// The raw dictionary of external graphics states. pub ext_g_states: Dict<'a>, /// The raw dictionary of fonts. pub fonts: Dict<'a>, - /// The raw dictionary of properties. + /// The effective property dictionary selected at page or form entry. pub properties: Dict<'a>, /// The raw dictionary of color spaces. pub color_spaces: Dict<'a>, @@ -698,6 +700,26 @@ impl<'a> Resources<'a> { Self::new(resources, Some(parent), &ctx) } + // PdfCraft patch: page-tree inheritance stops at any present Resources dictionary. + fn for_page(resources: Option>, parent: Self) -> Self { + let properties = resources.as_ref().map_or_else(|| parent.properties.clone(), |dict| dict.get::>(PROPERTIES).unwrap_or_default()); + let mut result = Self::from_parent(resources.unwrap_or_default(), parent); + result.properties = properties.clone(); + result.page_properties = properties; + result + } + + /// Create form resources, using page Properties only when Resources is absent. + /// Other resource categories retain their existing parent-chain lookup. + // PdfCraft patch: ISO 32000-2, 7.8.3 and Table 93 require the page origin. + pub fn for_form(resources: Option>, parent: Self) -> Self { + let properties = + resources.as_ref().map_or_else(|| parent.page_properties.clone(), |dict| dict.get::>(PROPERTIES).unwrap_or_default()); + let mut result = Self::from_parent(resources.unwrap_or_default(), parent); + result.properties = properties; + result + } + /// Create a new `Resources` object. pub(crate) fn new(resources: Dict<'a>, parent: Option, ctx: &ReaderContext<'a>) -> Self { let ext_g_states = resources.get::>(EXT_G_STATE).unwrap_or_default(); @@ -708,6 +730,8 @@ impl<'a> Resources<'a> { let shadings = resources.get::>(SHADING).unwrap_or_default(); let properties = resources.get::>(PROPERTIES).unwrap_or_default(); + // PdfCraft patch: generic child resources retain the page origin. + let page_properties = parent.as_ref().map_or_else(|| properties.clone(), |parent| parent.page_properties.clone()); let parent = parent.map(Box::new); Self { @@ -716,6 +740,7 @@ impl<'a> Resources<'a> { fonts, color_spaces, properties, + page_properties, x_objects, patterns, shadings, diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 6333ed580..a5ce6c2a9 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -254,15 +254,20 @@ pub fn render_into<'a>( interpreter_settings.preview, ); - // PdfCraft patch (18): the page renders as one transparency group with the background - // painted *inside* it, as the initial backdrop. A group isolates what it wraps from what - // was painted before it, so with the background outside, a Multiply highlight blended - // only with the group's transparent backdrop and stayed its own colour — over a dark - // reading-colours background the whole quad became one flat bar and the words under it - // vanished. Inside the group the highlight multiplies with the background too: the words - // keep the highlight's colour and the band between them takes the background (white - // backgrounds are unaffected — multiplying with white is the identity). - device.push_transparency_group(1.0, None, BlendMode::Normal); + // PdfCraft patch (18): the page renders as one transparency group (the page group). Its + // objects composite with an initially transparent backdrop, and the group is then put on + // the paper (ISO 32000-2, page group), so a blend mode only blends where something was + // drawn: a /BM /Screen fill over an empty part of the page keeps its colour (#2970). + // Under reading colours the background is painted *inside* the group instead, as its + // initial backdrop, so a Multiply highlight blends with the dark background: outside it, + // the highlight kept its own flat colour and the words under it vanished into the band + // (#1548). Reading colours replace the page's colours anyway; for document colours the + // background stays outside, or every separable mode but Normal, Multiply, Darken and + // HardLight would blend with the white paper. + let backdrop_inside = render_settings.color_replacement.is_some(); + if backdrop_inside { + device.push_transparency_group(1.0, None, BlendMode::Normal); + } device.ctx.set_paint(render_settings.bg_color); device @@ -274,11 +279,19 @@ pub fn render_into<'a>( path: clip_path, fill: FillRule::NonZero, }); + if !backdrop_inside { + device.push_transparency_group(1.0, None, BlendMode::Normal); + } interpret_page(page, &mut state, &mut device); + if !backdrop_inside { + device.pop_transparency_group(); + } device.pop_clip_path(); - device.pop_transparency_group(); + if backdrop_inside { + device.pop_transparency_group(); + } let mut resources = vello_cpu::Resources::default(); device.ctx.render_to_buffer( @@ -675,3 +688,137 @@ mod color_replacement_render_tests { assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]); } } + +/// PdfCraft patch (19): a mesh shading paints its triangles alone (`EncodedShadingPattern::extent`), +/// and one with no triangle on the canvas paints nothing. +#[cfg(test)] +mod mesh_extent_tests { + use super::{InterpreterSettings, Pdf, Pixmap, RenderCache, RenderSettings, render}; + use vello_cpu::color::palette::css::WHITE; + + /// A one-page 200×200 pt PDF whose content is `body`, with a type 4 (free-form triangle) + /// shading `/Sh0`: one red triangle with corners (x0, y0), (x1, y0) and (x0, y1) in user + /// space, and an ExtGState `/Half` with 50% alpha. + fn fixture(body: &str, (x0, y0, x1, y1): (i32, i32, i32, i32)) -> Vec { + // Three vertices: a flag, x and y (8 bits each, decoded into the ranges) and RGB. + let mesh: [u8; 18] = [ + 0, 0, 0, 255, 0, 0, // + 0, 255, 0, 255, 0, 0, // + 0, 0, 255, 255, 0, 0, + ]; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> \ + /ExtGState << /Half << /ca 0.5 /CA 0.5 >> >> >> >>" + .to_vec(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(), + ]; + let mut shading = format!( + "<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 \ + /BitsPerFlag 8 /Decode [{x0} {x1} {y0} {y1} 0 1 0 1 0 1] /Length {} >>\nstream\n", + mesh.len() + ) + .into_bytes(); + shading.extend_from_slice(&mesh); + shading.extend_from_slice(b"\nendstream"); + objs.push(shading); + + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice( + format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes(), + ); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", + objs.len() + 1 + ) + .as_bytes(), + ); + out + } + + fn render_at(pdf: Vec, scale: f32) -> Pixmap { + let pdf = Pdf::new(pdf).unwrap(); + let cache = RenderCache::new(); + let settings = RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: WHITE, + ..RenderSettings::default() + }; + render( + pdf.pages().get(0).expect("the fixture has one page"), + &cache, + &InterpreterSettings::default(), + &settings, + ) + } + + fn px(pixmap: &Pixmap, x: u16, y: u16) -> [u8; 4] { + let p = pixmap.sample(x, y); + [p.r, p.g, p.b, p.a] + } + + fn inked(pixmap: &Pixmap) -> usize { + (0..pixmap.height()) + .flat_map(|y| (0..pixmap.width()).map(move |x| (x, y))) + .filter(|&(x, y)| px(pixmap, x, y) != [255, 255, 255, 255]) + .count() + } + + /// The triangle (100, 100)–(110, 100)–(100, 110) in a 200 pt page: in device space (y + /// down) the pixel (102, 97) is inside it, (108, 91) past its hypotenuse, and the rest of + /// the page is nowhere near it. + #[test] + fn a_mesh_paints_its_triangles_alone() { + let white = [255, 255, 255, 255]; + for scale in [1.0_f32, 4.0] { + let p = render_at(fixture("/Sh0 sh", (100, 100, 110, 110)), scale); + let at = |x: f32, y: f32| ((x * scale) as u16, (y * scale) as u16); + let (x, y) = at(102.5, 97.5); + assert_eq!(px(&p, x, y), [255, 0, 0, 255], "scale {scale}: inside the triangle"); + let (x, y) = at(108.5, 91.5); + assert_eq!(px(&p, x, y), white, "scale {scale}: past the hypotenuse"); + let (x, y) = at(150.0, 50.0); + assert_eq!(px(&p, x, y), white, "scale {scale}: far from it"); + let (x, y) = at(10.0, 190.0); + assert_eq!(px(&p, x, y), white, "scale {scale}: the texture is not repeated"); + // A 10 × 10 pt right triangle covers 50 pt²; the sampler counts the pixels whose + // corner lies inside it (36 at scale 1, nearer 50 as the scale grows), and nothing + // beyond its own bounding box is touched. + let area = inked(&p) as f32 / (scale * scale); + assert!((30.0..=60.0).contains(&area), "scale {scale}: {area} pt² inked"); + } + } + + /// The alpha of the graphics state still applies: red at 50% over white is pink. + #[test] + fn a_mesh_keeps_the_graphics_state_alpha() { + let p = render_at(fixture("/Half gs /Sh0 sh", (100, 100, 110, 110)), 1.0); + let [r, g, b, a] = px(&p, 102, 97); + assert!(r == 255 && (120..=136).contains(&g) && g == b && a == 255, "{:?}", [r, g, b, a]); + } + + /// A mesh whose triangle lies off the canvas paints nothing, and costs no texture. + #[test] + fn a_mesh_off_the_canvas_paints_nothing() { + let p = render_at(fixture("/Sh0 sh", (300, 300, 310, 310)), 1.0); + assert_eq!(inked(&p), 0); + // One straddling the edge paints its part on the canvas. + let p = render_at(fixture("/Sh0 sh", (195, 100, 205, 110)), 1.0); + assert_eq!(px(&p, 196, 98), [255, 0, 0, 255]); + assert_eq!(px(&p, 150, 50), [255, 255, 255, 255]); + } +} diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 6aa53716a..7f6eb88bd 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -427,6 +427,15 @@ fn snap_minified_image( (w.abs_diff(planned.0) <= 1 && h.abs_diff(planned.1) <= 1).then_some((w, h, x0, y0)) } +/// PdfCraft patch (19): what `Renderer::set_paint` found out: the clip path to apply while +/// painting (if any), or that the paint touches no pixel of the canvas, so the caller skips +/// the fill or stroke (a mesh shading whose triangles all lie outside the path or canvas). +#[must_use] +enum PaintSetup { + Clip(Option), + Nothing, +} + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -435,6 +444,13 @@ pub(crate) struct Renderer { /// PdfCraft patch (17): while the page's annotations are being drawn, paints keep their /// colours — reading colours replace document colours, never annotation colours. in_annotations: bool, + /// PdfCraft patch (22): one entry per transparency group pushed, whether it is a knockout + /// group (`Device::set_knockout`). + knockout: Vec, + /// PdfCraft patch (22): inside a knockout erase pass (`Device::begin_knockout_erase`), and + /// how many transparency groups were skipped in it (nothing is layered while erasing). + erasing: bool, + erase_groups: usize, pub(crate) inside_pattern: bool, /// Soft masks drawn during this render. PdfCraft note: it belongs to one `Renderer` (one /// render, every nested renderer starting empty) and so to one `preview` setting: a mask @@ -458,6 +474,11 @@ enum ImagePixelFormat { } impl Renderer { + /// PdfCraft patch (22): whether the innermost transparency group is a knockout group. + fn in_knockout(&self) -> bool { + self.knockout.last().copied().unwrap_or(false) + } + pub(crate) fn new( width: u16, height: u16, @@ -470,6 +491,9 @@ impl Renderer { ctx: RenderContext::new_with(width, height, settings), color_replacement, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: cache.outline_cache.clone(), @@ -571,6 +595,9 @@ impl Renderer { // original values, so it is never remapped by the reading-colours setting. color_replacement: None, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -735,6 +762,40 @@ impl Renderer { } } + /// The size an image of `width` x `height` samples is decoded at to be drawn with + /// `transform`, or `None` if `draw_image` skips it: off the canvas (PdfCraft patch: see + /// `may_paint_rect`; checked before decoding, so a tile no longer decodes every image on its + /// page), or, in a preview, covering only a few device pixels (PdfCraft patch; final renders + /// always draw them). The tiny-image test measures axis lengths: the size returned maps a + /// point, so it includes the translation and only suits the decode hint. + fn image_draw_size(&self, width: u32, height: u32, transform: Affine) -> Option<(u32, u32)> { + if !may_paint_rect( + Rect::new(0.0, 0.0, f64::from(width), f64::from(height)), + transform, + self.ctx.width(), + self.ctx.height(), + ) { + return None; + } + let target_width = (transform * Point::new(width as f64, 0.0)) + .to_vec2() + .length() + .ceil() as u32; + let target_height = (transform * Point::new(0.0, height as f64)) + .to_vec2() + .length() + .ceil() as u32; + if self.preview { + let c = transform.as_coeffs(); + let tiny_w = (f64::from(width) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; + let tiny_h = (f64::from(height) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; + if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { + return None; + } + } + Some((target_width, target_height)) + } + fn draw_image(&mut self, image_data: ImageData, alpha_data: Option) { let cur_transform = *self.ctx.transform(); let (x_scale, y_scale) = { @@ -1039,9 +1100,26 @@ impl Renderer { } #[must_use] - fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> Option { + fn set_paint(&mut self, paint: &Paint<'_>, path: &BezPath, is_stroke: bool) -> PaintSetup { + // PdfCraft patch (22): in a knockout erase pass every paint clears what is under its + // shape (a transparent fill with `Compose::Copy`, weighted by coverage). + if self.erasing { + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + // A shading's `/BBox` clips what it paints, so it clips what it clears too. + return PaintSetup::Clip(match paint { + Paint::Pattern(p) => match p.as_ref() { + Pattern::Shading(s) => s.shading.clip_path.clone(), + _ => None, + }, + _ => None, + }); + } let mut paint_transform = Affine::IDENTITY; let mut clip_path = None; + // PdfCraft patch (19): `Extend::Repeat` tiles the texture over the path; a mesh texture + // covers its triangles alone and must not repeat outside them. + let mut extend = peniko::Extend::Repeat; let paint: PaintType = match paint.clone() { Paint::Color(c) => { @@ -1074,6 +1152,28 @@ impl Renderer { // pixel around it: texels at a fractional edge look one pixel further). let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0))); + // PdfCraft patch (19): a mesh paints only where its triangles are. The + // texture below used to cover the whole path (the canvas, for `sh`) + // and was sampled pixel by pixel: 310 one-triangle meshes on a plan + // cost 310 full tiles per tile, and the watchdog gave the page up. It + // now covers the triangles alone (a pixel around them, which stays + // transparent and is what `Extend::Pad` repeats), and a mesh with no + // triangle on the canvas paints nothing at all. + if let Some(extent) = encoded.extent { + // An empty extent (no triangle in the area) is `Rect::ZERO`, which + // sits at the canvas origin: test it before intersecting, or a + // 1 × 1 transparent texture would still be painted over the whole + // path (310 of them cost 230 ms per tile). + if extent.width() <= 0.0 || extent.height() <= 0.0 { + return PaintSetup::Nothing; + } + bbox = bbox.intersect(extent.inflate(1.0, 1.0)); + if bbox.width() <= 0.0 || bbox.height() <= 0.0 { + return PaintSetup::Nothing; + } + extend = peniko::Extend::Pad; + } + // PdfCraft patch (backport of hayro 0.8): axial and radial shadings // become native gradients instead of a texture sampled pixel by pixel. // Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already @@ -1089,7 +1189,7 @@ impl Renderer { self.ctx .set_paint_transform(path_transform.inverse() * native.transform); self.ctx.set_paint(native_gradient(native)); - return clip_path; + return PaintSetup::Clip(clip_path); } let (image, width, height, transform, may_have_transparency) = @@ -1108,8 +1208,8 @@ impl Renderer { let image = Image { image: ImageSource::Pixmap(Arc::new(pixmap)), sampler: ImageSampler { - x_extend: peniko::Extend::Repeat, - y_extend: peniko::Extend::Repeat, + x_extend: extend, + y_extend: extend, quality: ImageQuality::Medium, alpha: 1.0, }, @@ -1163,6 +1263,9 @@ impl Renderer { ), color_replacement: None, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1192,7 +1295,7 @@ impl Renderer { }; self.ctx.set_paint_transform(paint_transform); self.ctx.set_paint(PaintType::Image(image)); - return clip_path; + return PaintSetup::Clip(clip_path); } // PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the // u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells). @@ -1217,6 +1320,9 @@ impl Renderer { // content is never remapped by the reading-colours setting. color_replacement: None, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, cur_mask: None, inside_pattern: true, soft_mask_cache: HashMap::default(), @@ -1267,7 +1373,7 @@ impl Renderer { self.ctx.set_paint_transform(paint_transform); self.ctx.set_paint(paint); - clip_path + PaintSetup::Clip(clip_path) } fn stroke_path( @@ -1287,7 +1393,7 @@ impl Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, is_text); - let clip_path = self.set_paint(&paint, path, true); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, true) else { return }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, FillRule::NonZero); } @@ -1316,7 +1422,7 @@ impl Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, is_text); - let clip_path = self.set_paint(&paint, path, false); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, path, false) else { return }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, fill_rule); } @@ -1410,40 +1516,19 @@ impl Renderer { impl<'a> Device<'a> for Renderer { fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) { - // PdfCraft patch: see `may_paint_rect`. Checked before decoding: off-canvas images - // cost nothing, so a tile no longer decodes every image on its page. - if !may_paint_rect( - Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())), - transform, - self.ctx.width(), - self.ctx.height(), - ) { + // PdfCraft patch (22): an erase pass paints nothing; an unmasked image's rectangle was + // cleared by `knockout_shape`, and a masked image's shape is left alone. + if self.erasing { return; } + let Some((target_width, target_height)) = + self.image_draw_size(image.width(), image.height(), transform) + else { + return; + }; self.ctx.set_paint_transform(Affine::IDENTITY); self.ctx.set_aliasing_threshold(Some(1)); - let target_width = (transform * Point::new(image.width() as f64, 0.0)) - .to_vec2() - .length() - .ceil() as u32; - let target_height = (transform * Point::new(0.0, image.height() as f64)) - .to_vec2() - .length() - .ceil() as u32; - - // PdfCraft patch: previews skip images that would cover only a few device pixels. - // Final renders always draw them. (Measured as axis lengths: the `target_*` above - // map a point, so they include the translation and only suit the decode hint.) - if self.preview { - let c = transform.as_coeffs(); - let tiny_w = (f64::from(image.width()) * (c[0] * c[0] + c[1] * c[1]).sqrt()).ceil() as u32; - let tiny_h = (f64::from(image.height()) * (c[2] * c[2] + c[3] * c[3]).sqrt()).ceil() as u32; - if tiny_w.saturating_mul(tiny_h) < PREVIEW_MIN_IMAGE_AREA { - return; - } - } - match image { hayro_interpret::Image::Stencil(s) => { s.with_stencil( @@ -1533,6 +1618,9 @@ impl<'a> Device<'a> for Renderer { // the reading-colours setting. color_replacement: None, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, soft_mask_cache: HashMap::default(), outline_cache: self.outline_cache.clone(), @@ -1562,14 +1650,19 @@ impl<'a> Device<'a> for Renderer { ); self.ctx.set_transform(transform); - let clip_path = - self.set_paint(paint, &stencil_rect.to_path(0.1), true); - if let Some(clip_path) = clip_path.as_ref() { - self.push_clip_path_inner(clip_path, FillRule::NonZero); - } - self.ctx.fill_rect(&stencil_rect); - if clip_path.is_some() { - self.ctx.pop_clip_path(); + // PdfCraft patch: a stencil mask is painted with the fill + // (non-stroking) paint; `true` drew an uncoloured tiling pattern + // in its stroke colour (black by default) (#2975). + if let PaintSetup::Clip(clip_path) = + self.set_paint(paint, &stencil_rect.to_path(0.1), false) + { + if let Some(clip_path) = clip_path.as_ref() { + self.push_clip_path_inner(clip_path, FillRule::NonZero); + } + self.ctx.fill_rect(&stencil_rect); + if clip_path.is_some() { + self.ctx.pop_clip_path(); + } } self.ctx.pop_layer(); @@ -1605,6 +1698,11 @@ impl<'a> Device<'a> for Renderer { mask: Option>, blend_mode: BlendMode, ) { + // PdfCraft patch (22): nothing is layered in a knockout erase pass. + if self.erasing { + self.erase_groups += 1; + return; + } let settings = *self.ctx.render_settings(); let force_images_may_have_transparency = mask.is_some() || blend_mode != BlendMode::Normal @@ -1631,6 +1729,7 @@ impl<'a> Device<'a> for Renderer { ); self.image_transparency_stack .push(force_images_may_have_transparency); + self.knockout.push(false); } fn pop_clip_path(&mut self) { @@ -1638,10 +1737,86 @@ impl<'a> Device<'a> for Renderer { } fn pop_transparency_group(&mut self) { + if self.erasing && self.erase_groups > 0 { + self.erase_groups -= 1; + return; + } self.image_transparency_stack.pop(); + self.knockout.pop(); self.ctx.pop_layer(); } + /// PdfCraft patch (22): the group just pushed is a knockout group. Its elements are drawn + /// with `Compose::Copy`, which (weighted by coverage) replaces what earlier elements drew + /// where the element's shape is, so each element composites with the group's initial + /// backdrop (transparent: an isolated group, and the non-isolated approximation every + /// group layer here already makes) instead of with the elements before it. Against a + /// transparent backdrop the element's own blend mode has nothing to blend with, so the + /// mix is Normal. This covers paths, strokes, glyph outlines and shadings. Images and + /// nested groups are drawn into a layer of their own, which can't be copied (it covers the + /// whole canvas, transparent where nothing was drawn): what is under their shape is cleared + /// first (`knockout_shape`, the knockout erase pass) and the layer composites over it. + fn set_knockout(&mut self) { + if let Some(top) = self.knockout.last_mut() { + *top = true; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + } + + /// PdfCraft patch (22): in a knockout group, clear what the group drew under `shape` (a + /// transparent fill with `Compose::Copy`, weighted by coverage) before an element drawn into + /// a layer of its own composites there, so the element replaces it. + fn knockout_erase_wanted(&mut self) -> bool { + self.in_knockout() && !self.erasing + } + + fn begin_knockout_erase(&mut self) { + self.erasing = true; + self.erase_groups = 0; + } + + fn end_knockout_erase(&mut self) { + self.erasing = false; + self.erase_groups = 0; + self.ctx.set_blend_mode(knockout_blend_mode()); + } + + fn in_knockout_group(&mut self) -> bool { + self.in_knockout() + } + + fn knockout_image(&mut self, image: &hayro_interpret::RasterImage<'_>, transform: Affine) { + if !self.in_knockout() { + return; + } + // Only an image `draw_image` will draw, decoded at the size it draws it (the same + // decode, so a cached decode serves both). + let Some(size) = self.image_draw_size(image.width(), image.height(), transform) else { + return; + }; + let mut decodes = false; + image.with_rgba(|_, _| decodes = true, Some(size)); + if decodes { + let rect = Rect::new(0.0, 0.0, f64::from(image.width()), f64::from(image.height())); + self.knockout_shape(&(transform * rect.to_path(0.1))); + } + } + + fn knockout_shape(&mut self, shape: &BezPath) { + if !self.in_knockout() { + return; + } + let old_transform = *self.ctx.transform(); + let old_rule = *self.ctx.fill_rule(); + self.ctx.set_transform(Affine::IDENTITY); + self.ctx.set_fill_rule(Fill::NonZero); + self.ctx.set_blend_mode(knockout_blend_mode()); + self.ctx.set_paint(AlphaColor::::new([0.0, 0.0, 0.0, 0.0])); + self.ctx.fill_path(shape); + self.ctx.set_fill_rule(old_rule); + self.ctx.set_transform(old_transform); + } + fn set_soft_mask(&mut self, mask: Option>) { let settings = *self.ctx.render_settings(); self.cur_mask = mask.map(|m| { @@ -1706,7 +1881,7 @@ impl<'a> Device<'a> for Renderer { // PdfCraft patch: apply the reading-colours mapping before painting. let paint = self.replaced(paint, false); - let clip_path = self.set_paint(&paint, &path, false); + let PaintSetup::Clip(clip_path) = self.set_paint(&paint, &path, false) else { return }; if let Some(clip_path) = clip_path.as_ref() { self.push_clip_path_inner(clip_path, *fill_rule); } @@ -1745,7 +1920,13 @@ impl<'a> Device<'a> for Renderer { } fn set_blend_mode(&mut self, blend_mode: BlendMode) { - self.ctx.set_blend_mode(convert_blend_mode(blend_mode)); + // PdfCraft patch (22): inside a knockout group every element replaces what it covers. + let mode = if self.in_knockout() { + knockout_blend_mode() + } else { + convert_blend_mode(blend_mode) + }; + self.ctx.set_blend_mode(mode); } } @@ -1829,6 +2010,9 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei // content is never remapped by the reading-colours setting. color_replacement: None, in_annotations: false, + knockout: Vec::new(), + erasing: false, + erase_groups: 0, inside_pattern: false, cur_mask: None, soft_mask_cache: HashMap::default(), @@ -1923,6 +2107,11 @@ fn convert_fill_rule(fill_rule: FillRule) -> Fill { } } +/// PdfCraft patch (22): how an element of a knockout group is drawn (see `set_knockout`). +fn knockout_blend_mode() -> peniko::BlendMode { + peniko::BlendMode::new(Mix::Normal, Compose::Copy) +} + fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode { let mix = match blend_mode { BlendMode::Normal => Mix::Normal,