diff --git a/Cargo.lock b/Cargo.lock index 0d49dc34..120bfaf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1129,7 +1129,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ecolor" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "eframe" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1171,7 +1171,7 @@ dependencies = [ [[package]] name = "egui" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1191,7 +1191,7 @@ dependencies = [ [[package]] name = "egui-wgpu" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,7 +1210,7 @@ dependencies = [ [[package]] name = "egui-winit" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "block2 0.6.2", "egui", @@ -1229,7 +1229,7 @@ dependencies = [ [[package]] name = "egui_extras" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1243,7 +1243,7 @@ dependencies = [ [[package]] name = "egui_kittest" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1266,7 +1266,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1359,7 +1359,7 @@ dependencies = [ [[package]] name = "epaint" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1386,7 +1386,7 @@ dependencies = [ [[package]] name = "epaint_default_fonts" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -5962,7 +5962,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "android-activity", "atomic-waker", @@ -6413,19 +6413,19 @@ dependencies = [ [[patch.unused]] name = "egui_demo_lib" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_glow" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_inspection" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[patch.unused]] name = "egui_system_font_provider" version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index f4aa6873..953e5b68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,18 +60,18 @@ hayro-syntax = { path = "vendor/hayro-syntax" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } hayro-cmap = { path = "vendor/hayro-cmap" } hayro-postscript = { path = "vendor/hayro-postscript" } -ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } -winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/crates/pdf/README.md b/crates/pdf/README.md index 657a7fda..8e654138 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -119,6 +119,12 @@ serialization uses the same backend version as before extraction. It provides: postprocessing. App errors pass through; serialization errors retain backend diagnostics. Caught panic messages retain up to 1024 Unicode scalar values; the installed panic hook remains responsible for logging source locations and full diagnostics. +- `write::uri_target`: the Krilla link target for a URI link, or `None` when `safe_url` + refuses it. Apps build URI links with it instead of `backend::action::LinkAction`, so a + link target from someone else's document becomes a PDF link only when it is an `http`/`https` + URL with a host or a `mailto` address. A refused target writes no link annotation. +- `safe_url` (crate root, any feature): the same rule, for apps that open links themselves, + such as a slide show. ### Print color diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e540157a..9814c469 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -6,8 +6,10 @@ #[cfg(feature = "interpret")] pub use hayro_interpret as interpret; +mod link; #[cfg(feature = "read")] pub use hayro_syntax as syntax; +pub use link::safe_url; #[cfg(feature = "read")] mod read; diff --git a/crates/pdf/src/link.rs b/crates/pdf/src/link.rs new file mode 100644 index 00000000..f4c7b6a7 --- /dev/null +++ b/crates/pdf/src/link.rs @@ -0,0 +1,84 @@ +//! Which link targets from a document may become clickable links in a written PDF. + +/// Whether a link target from a (possibly untrusted) document may be opened by whoever opens +/// the file: `http`/`https` URLs with a host, and `mailto` with an address. +/// +/// Everything else is refused: `file:`, `javascript:`, `data:`, relative paths, network +/// shares (`\\host\share`, `//host/share`, `smb:`) and other schemes, which an operating system +/// may hand to a protocol handler. Leading and trailing whitespace is ignored; schemes compare +/// case-insensitively. +pub fn safe_url(url: &str) -> bool { + let u = url.trim(); + let Some((scheme, rest)) = u.split_once(':') else { return false }; + match scheme.to_ascii_lowercase().as_str() { + "http" | "https" => rest.strip_prefix("//").is_some_and(|r| !host(r).is_empty()), + "mailto" => has_recipient(rest), + _ => false, + } +} + +/// The host of an authority-first URL remainder: before the path, query or fragment, after any +/// userinfo, without the port. +fn host(rest: &str) -> &str { + let authority = rest.split(['/', '?', '#', '\\']).next().unwrap_or(""); + let host_port = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + match host_port.strip_prefix('[') { + Some(v6) => v6.split_once(']').map_or("", |(h, _)| h), + None => host_port.split(':').next().unwrap_or(""), + } +} + +/// Whether a `mailto:` remainder names an address, before its headers or in a `to` header. +fn has_recipient(rest: &str) -> bool { + let (to, headers) = rest.split_once('?').unwrap_or((rest, "")); + !to.trim().is_empty() || headers.split('&').any(|h| h.split_once('=').is_some_and(|(k, v)| k.eq_ignore_ascii_case("to") && !v.trim().is_empty())) +} + +#[cfg(test)] +mod tests { + use super::safe_url; + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn only_web_and_mail_links_are_safe_to_open() { + for url in [ + "https://example.com", + " HTTP://example.com/x", + "https://user@example.com:8443/x?q#f", + "https://[::1]/", + "mailto:someone@example.com", + "mailto:?to=someone@example.com&subject=hi", + ] { + assert!(safe_url(url), "{url}"); + } + let refused = [ + "https://", + "https://?q=x", + "https:///x", + "https://user@/x", + "https://:80/", + "mailto:", + "mailto:?subject=hello", + "file:///C:/notes.pdf", + "notes.pdf", + "javascript:alert(1)", + "\t JavaScript:alert(1)", + "java\nscript:alert(1)", + "vbscript:MsgBox(1)", + "data:text/html,", + "ms-msdt:/id PCWDiagnostic", + "ms-word:ofe|u|\\\\attacker.example\\share\\x.docx", + "ldap://attacker.example/x", + "//attacker.example/share", + "\\\\attacker.example\\share", + "file://attacker.example/share/x", + "file:////attacker.example/share/x", + "smb://attacker.example/share", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + "search:query=x&crumb=location:\\\\attacker.example\\share", + ]; + for url in refused { + assert!(!safe_url(url), "{url}"); + } + } +} diff --git a/crates/pdf/src/write.rs b/crates/pdf/src/write.rs index 3731a99b..057e1559 100644 --- a/crates/pdf/src/write.rs +++ b/crates/pdf/src/write.rs @@ -139,6 +139,14 @@ impl Writer { } } +/// The link target that opens `url`, or `None` when [`safe_url`](crate::safe_url) refuses it. +/// Build URI links with this instead of `backend::action::LinkAction` so a refused target +/// writes no link annotation; the page content stays as drawn. The URI is written trimmed. +pub fn uri_target(url: &str) -> Option { + let url = url.trim(); + crate::safe_url(url).then(|| backend::annotation::Target::Action(backend::action::LinkAction::new(url.to_owned()).into())) +} + fn check_font_input(size: usize, variations: Option<&[([u8; 4], f32)]>) -> Result<(), WriteError> { if size > MAX_FONT_BYTES { return Err(WriteError::FontLimit); diff --git a/crates/pdf/tests/link_annotations.rs b/crates/pdf/tests/link_annotations.rs new file mode 100644 index 00000000..e637b3c7 --- /dev/null +++ b/crates/pdf/tests/link_annotations.rs @@ -0,0 +1,66 @@ +//! Link annotations, destinations and page boxes whose geometry has no finite place on the +//! page make `Writer::finish` return an error instead of panicking (krilla patch 13). +#![cfg(feature = "write")] +#![allow(clippy::unwrap_used, clippy::panic)] + +use backend::action::LinkAction; +use backend::annotation::{LinkAnnotation, Target}; +use backend::destination::XyzDestination; +use backend::error::KrillaError; +use backend::geom::{Point, Quadrilateral, Rect}; +use backend::page::PageSettings; +use craft_pdf::write::{Metadata, WriteError, Writer, backend}; + +fn link() -> Target { + Target::Action(LinkAction::new("https://example.com".into()).into()) +} + +fn quad(x: f32, y: f32) -> Quadrilateral { + Quadrilateral([Point::from_xy(x, y); 4]) +} + +fn write(settings: PageSettings, annotation: Option) -> Result, KrillaError> { + let mut writer = Writer::new(Default::default(), Metadata::default()); + let mut page = writer.document_mut().start_page_with(settings); + if let Some(annotation) = annotation { + page.add_annotation(annotation.into()); + } + page.finish(); + match writer.finish() { + Ok(pdf) => Ok(pdf), + Err(WriteError::Serialization(error)) => Err(*error), + Err(error) => panic!("unexpected error: {error}"), + } +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn unplaceable_geometry_is_an_error() { + let page = || PageSettings::from_wh(40.0, 20.0).unwrap(); + let big_page = || PageSettings::from_wh(40.0, 2e38).unwrap(); + + let ok = write(page(), Some(LinkAnnotation::new_with_quad_points(vec![quad(1.0, 2.0)], link()))); + assert!(ok.is_ok(), "{ok:?}"); + + let quads = [vec![], vec![quad(f32::NAN, f32::NAN)], vec![quad(1.0, 2.0), quad(f32::INFINITY, 2.0)], vec![quad(-3e38, 0.0), quad(3e38, 0.0)]]; + for (case, quads) in quads.into_iter().enumerate() { + let result = write(page(), Some(LinkAnnotation::new_with_quad_points(quads, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "case {case}: {result:?}"); + } + + // Finite, but 2e38 + 2e38 overflows when flipped into PDF coordinates. + let far = Rect::from_ltrb(0.0, -2e38, 10.0, -1e38).unwrap(); + let result = write(big_page(), Some(LinkAnnotation::new(far, link()))); + assert!(matches!(result, Err(KrillaError::InvalidAnnotationGeometry(None))), "{result:?}"); + + let result = write(big_page().with_crop_box(Some(far)), None); + assert!(matches!(result, Err(KrillaError::InvalidPageBox(0))), "{result:?}"); + + let missing_page = Target::Destination(XyzDestination::new(5, Point::from_xy(1.0, 1.0)).into()); + let result = write(page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), missing_page))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(5))), "{result:?}"); + + let far_point = Target::Destination(XyzDestination::new(0, Point::from_xy(1.0, -3e38)).into()); + let result = write(big_page(), Some(LinkAnnotation::new(Rect::from_xywh(0.0, 0.0, 5.0, 5.0).unwrap(), far_point))); + assert!(matches!(result, Err(KrillaError::InvalidDestination(0))), "{result:?}"); +} diff --git a/crates/pdf/tests/plates.rs b/crates/pdf/tests/plates.rs index d7f0da0d..41e6d653 100644 --- a/crates/pdf/tests/plates.rs +++ b/crates/pdf/tests/plates.rs @@ -515,3 +515,25 @@ fn a_knockout_group_separates_with_only_the_top_object_in_the_overlap() { let blended = plates(&group(""), &inks(&[]), true, None); assert_eq!(blended.at(20, 20), [25, 50, 0, 0], "both squares without /K"); } + +/// PdfCraft #3018: `/DefaultCMYK` and `/DefaultGray` change how device color looks, not the +/// inks it prints with. Under them DeviceCMYK still separates as its components (and keeps the +/// nonzero overprint mode), a DeviceCMYK image sample by sample, and DeviceGray on black alone, +/// with or without a simulation whose profile differs from the default. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn default_color_spaces_leave_device_color_on_its_plates() { + let image = stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceCMYK", &[51, 102, 153, 204]); + let objects = [stream("/N 4", CGATS), stream("/N 1", &synthetic_icc::gray_profile("Gray 2.2")), image]; + let content = "0 0 0 1 k 0 0 10 40 re f 1 0 0 0 k 10 0 10 40 re f /M1 gs 0 1 0 0 k 10 0 10 40 re f \ + q 10 0 0 40 20 0 cm /Im1 Do Q 0.25 g 30 0 10 40 re f"; + let resources = "/ExtGState << /M1 << /OP true /OPM 1 >> >> /XObject << /Im1 7 0 R >>"; + let calibrated = format!("{resources} /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >>"); + let expected: [[u32; 4]; 4] = [[0, 0, 0, 100], [100, 100, 0, 0], [20, 40, 60, 80], [0, 0, 0, 75]]; + for simulation in [None, Some(press_simulation())] { + let s = plates(&page_pdf(content, &calibrated, &objects), &inks(&[]), true, simulation.as_ref()); + for (x, want) in [5, 15, 25, 35].into_iter().zip(expected) { + assert_eq!(s.at(x, 20), want, "x {x}, simulated: {}", simulation.is_some()); + } + } +} diff --git a/crates/pdf/tests/upstream_compatibility.rs b/crates/pdf/tests/upstream_compatibility.rs index 38ffa0c7..d8aface5 100644 --- a/crates/pdf/tests/upstream_compatibility.rs +++ b/crates/pdf/tests/upstream_compatibility.rs @@ -280,6 +280,7 @@ mod painting { GlyphDrawMode::Fill => 'F', GlyphDrawMode::Stroke(_) => 'S', GlyphDrawMode::Invisible => 'I', + GlyphDrawMode::Clip => 'C', }; self.glyphs.push(GlyphEvent { text, mode, rgba: rgba(paint), origin: glyph_transform * Point::ORIGIN }); } diff --git a/crates/pdf/tests/upstream_raster.rs b/crates/pdf/tests/upstream_raster.rs index 554e129b..44170098 100644 --- a/crates/pdf/tests/upstream_raster.rs +++ b/crates/pdf/tests/upstream_raster.rs @@ -1,10 +1,12 @@ //! Portable regressions adapted from PdfCraft 7437bde378f43856eada40af25b983812e615e8a and //! a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0). #![cfg(feature = "raster")] +use craft_pdf::interpret::InterpreterSettings; use craft_pdf::{ open_slice, raster::{self, Options, RenderCache}, }; +use std::sync::Arc; struct Pixels { width: u32, @@ -428,3 +430,724 @@ trailer << /Root 1 0 R >> } } } + +/// A one-page PDF: `page` holds the page dictionary's entries besides its tree links and +/// contents (the media box, resources, annotations), and `objects` the objects from 5 on. +fn one_page(page: &str, content: &[u8], objects: &[u8]) -> Vec { + let mut pdf = format!( + "%PDF-1.7\n1 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 {page} /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n", + content.len() + ) + .into_bytes(); + pdf.extend_from_slice(content); + pdf.extend_from_slice(b"\nendstream endobj\n"); + pdf.extend_from_slice(objects); + pdf.extend_from_slice(b"\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// PdfCraft ledger hayro-interpret (46) and the hayro stencil paint (#2975): a stencil mask is painted in the +/// fill color at the fill opacity, once: at `ca 0.5` it is as opaque as a rectangle filled at +/// `ca 0.5` beside it. The same holds when the fill is a pattern, an uncolored tiling pattern +/// (drawn in its fill color, not its stroke color) or a shading pattern. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn stencil_masks_are_drawn_at_the_fill_opacity_once() { + let fills = [("color", "0 0 1 rg"), ("uncolored 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 page = format!( + "/MediaBox [0 0 300 200] /Resources << /ExtGState << /A << /ca {alpha} >> >> /XObject << /Msk 5 0 R >> \ + /ColorSpace << /CsP [/Pattern /DeviceRGB] >> /Pattern << /Tp 6 0 R /Sp 7 0 R >> >>" + ); + let objects = b"5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Length 2 >> stream\n\0\0\nendstream endobj\n\ +6 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length 14 >> stream\n0 0 10 10 re f\nendstream endobj\n\ +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"; + let p = render(&one_page(&page, content.as_bytes(), objects)); + // Pixel rows run top down: PDF (70, 100) is in the mask, (210, 100) in the rectangle. + let (mask, rect) = (pixel(&p, 70, 100), pixel(&p, 210, 100)); + assert!(rect[..3] != [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:?}"); + } + } +} + +/// PdfCraft #3246 (#3176, with ledger hayro-interpret (46)): `CA` and `ca` belong to the graphics +/// state at paint time, so a `gs` after `SCN` or `scn` still fades a shading pattern, and a +/// stroke takes the stroke alpha. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn shading_pattern_uses_the_alpha_current_at_paint_time() { + let content = b"/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 page = "/MediaBox [0 0 100 100] /Resources << /ExtGState << /S1 << /CA 0.25 >> /F1 << /ca 0.25 >> >> /Pattern << /P0 5 0 R >> >>"; + let objects = b"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"; + let p = render(&one_page(page, content, objects)); + // A quarter red over white: (255, 191, 191). + for (what, y) in [("stroke", 25), ("fill", 75)] { + let px = pixel(&p, 50, y); + assert!(px[0] == 255 && (185..=197).contains(&px[1]) && (185..=197).contains(&px[2]), "{what}: {px:?}"); + } +} + +fn type4_mesh_page(data: &[u8]) -> Pixels { + let mut objects = format!( + "5 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() + ) + .into_bytes(); + objects.extend_from_slice(data); + objects.extend_from_slice(b"\nendstream endobj"); + let page = render(&one_page("/MediaBox [0 0 50 50] /Resources << /Shading << /Sh0 5 0 R >> >>", b"/Sh0 sh", &objects)); + assert_eq!((page.width, page.height), (50, 50)); + page +} + +/// PdfCraft ledger hayro-interpret (36) (#3035): two triangles of a mesh that share an edge cover it +/// together. Testing only each pixel's corner, strictly inside a triangle, left the shared +/// diagonal white, and a pixel row or column along the mesh's own sides. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_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 centers 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 page = type4_mesh_page(&data); + let (mut white_inside, mut red_outside) = (Vec::new(), Vec::new()); + for y in 0..50 { + for x in 0..50 { + let p = pixel(&page, x, y); + 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 center, 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 page = type4_mesh_page(&data); + let (mut spilled, mut missing) = (Vec::new(), Vec::new()); + for y in 10..40 { + for x in 10..40 { + let p = pixel(&page, x, y); + 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:?}"); +} + +/// PdfCraft ledger hayro-interpret (40) (#2987): 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`. A malformed +/// expression, one naming an object that isn't a listed group, or one nested too deep falls +/// back to `/OCGs`. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn visibility_expressions_decide_what_a_membership_dictionary_shows() { + let render = |ocmd: &str, off: &[i32]| { + let catalog = "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>"; + let objects = format!("5 0 obj << /Type /OCG /Name (A) >> endobj\n6 0 obj << /Type /OCG /Name (B) >> endobj\n7 0 obj {ocmd} endobj"); + let pdf = String::from_utf8(one_page( + "/MediaBox [0 0 100 100] /Resources << /Properties << /M1 7 0 R >> >>", + b"/OC /M1 BDC 1 0 0 rg 0 0 100 100 re f EMC", + objects.as_bytes(), + )) + .unwrap() + .replacen("<< /Type /Catalog /Pages 2 0 R >>", catalog, 1); + let doc = open_slice(pdf.as_bytes(), None).unwrap(); + let settings = InterpreterSettings { ocg_overrides: Arc::new(off.iter().map(|n| (*n, 0, false)).collect()), ..Default::default() }; + let options = Options { background: [255; 4], scale: [0.5, 0.5], ..Default::default() }; + let raster = raster::render(&doc.pages()[0], &RenderCache::new(), &settings, &options).unwrap(); + raster.bytes()[..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"); + let both = "<< /Type /OCMD /OCGs [5 0 R] /P /AllOn /VE [/Not 5 0 R] >>"; + assert!(render(both, &[5]), "the expression, not /P, decides"); + // Malformed: an unknown operator, a wrong operand count, a bare group, a string operand, + // a missing object, and the page instead of a group. + 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"); + } + 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"); +} + +/// PdfCraft ledger hayro (18) (#2972): a page's objects composite with a transparent backdrop +/// and the page group is then put on the paper, so a blend mode only blends where something +/// was drawn. A green fill with `/BM /Screen` (or Lighten, Difference…) half over a red square +/// takes the mode's color over the red and stays plain green over the empty page. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn blend_modes_over_an_empty_part_of_the_page_keep_their_color() { + 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 keep the source color. + ("Hue", None), + ("Saturation", None), + ("Color", None), + ("Luminosity", None), + ] { + let page = format!("/MediaBox [0 0 300 200] /Resources << /ExtGState << /G1 << /Type /ExtGState /BM /{mode} >> >> >>"); + let p = render(&one_page(&page, b"1 0 0 rg 20 20 150 150 re f /G1 gs 0 1 0 rg 100 50 150 120 re f", b"")); + // Pixel rows run top down: PDF (210, 100) and (130, 100) are rows 100 from the top. + assert_eq!(pixel(&p, 210, 100), [0, 255, 0, 255], "{mode} over the empty page"); + if let Some(over_red) = over_red { + assert_eq!(pixel(&p, 130, 100)[..3], over_red, "{mode} over the red square"); + } + assert_eq!(pixel(&p, 280, 10), [255, 255, 255, 255], "{mode}: the paper stays white"); + } +} + +/// PdfCraft #3018 (ISO 32000-2 §8.6.5.6): the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` +/// color spaces of a resource dictionary stand in for the device spaces its content selects, +/// with color operators, images and shadings. The defaults are synthetic ICC profiles that send +/// device color somewhere it cannot go uncalibrated: red to green (or blue), black to mid gray, +/// black ink to blue. +mod default_color_spaces { + use super::{Pixels, render}; + + const WIDTH: usize = 130; + pub(super) const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 255, 0]; + pub(super) 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_GRAY: [u8; 3] = [188, 188, 188]; + /// Full black ink through `cmyk_profile`: CIELAB (40, 20, -60) relative to D50. + const INK_BLUE: [u8; 3] = [67, 86, 194]; + const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>"; + + /// 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 of device class `class` for the color space `space`, with `tags` + /// (signature, data) in that order. The device model stays blank: hayro skips the transform + /// of a profile whose model reads `sRGB`. + 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"); + 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 + } + + 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. + 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. + pub(super) 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, so `0 g` is mid gray. + fn gray_profile() -> Vec { + icc(b"mntr", b"GRAY", b"XYZ ", &[(*b"kTRC", curve(&[0x8000, 0xffff]))]) + } + + /// A CMYK profile (`lut8Type` into CIELAB) 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(); + 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)); + } + for _ in 0..4 { + lut.extend(0..=255_u8); + } + // Two grid points per ink, black varying fastest; Lab encoded as L* · 2.55, a* + 128, b* + 128. + for point in 0..16 { + lut.extend_from_slice(if point % 2 == 1 { &[102, 148, 68] } else { &[255, 128, 128] }); + } + for _ in 0..3 { + lut.extend(0..=255_u8); + } + icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)]) + } + + pub(super) fn profile_stream(n: u8, profile: Vec) -> (String, Vec) { + (format!("<< /N {n} /Length {} >>", profile.len()), profile) + } + + /// A form XObject covering the page, with the dictionary entries `extra`. + fn form(extra: &str, content: &str) -> (String, Vec) { + (format!("<< /Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra} /Length {} >>", content.len()), content.as_bytes().to_vec()) + } + + /// A 130 × 70 pt page that draws `content` with `resources`, whose objects 5, 6, … are the + /// streams `objects`. + pub(super) fn page_pdf(content: &str, resources: &str, objects: &[(String, Vec)]) -> Vec { + let mut pdf = format!( + "%PDF-1.4\n1 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 130 70] /Contents 4 0 R /Resources {resources} >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", + content.len() + ) + .into_bytes(); + for (i, (dict, data)) in objects.iter().enumerate() { + pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).bytes()); + pdf.extend_from_slice(data); + pdf.extend_from_slice(b"\nendstream endobj\n"); + } + pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF"); + pdf + } + + /// Draw `content` with `resources` on a 130 × 70 pt page whose objects 5, 6, … are the + /// streams `objects`, and give the color at each of `points` (from the top left). + pub(super) fn colors(content: &str, resources: &str, objects: &[(String, Vec)], points: &[(usize, usize)]) -> Vec<[u8; 3]> { + let Pixels { width, height, rgba } = render(&page_pdf(content, resources, objects)); + assert_eq!((width as usize, height), (WIDTH, 70)); + points.iter().map(|&(x, y)| rgba[(y * WIDTH + x) * 4..][..3].try_into().unwrap()).collect() + } + + #[track_caller] + pub(super) 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 draw in the + /// page's DefaultRGB; without one they draw device red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_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 = colors(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 = colors(content, "<< /ColorSpace << /CS0 /DeviceRGB >> >>", &objects, &points); + for (got, what) in device.into_iter().zip(what) { + assert_eq!(got, RED, "{what} without DefaultRGB"); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_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 = colors(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 colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_dark(got, &format!("{what} without DefaultCMYK")); + } + } + + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_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 = colors(content, "<< /ColorSpace << /DefaultGray [/ICCBased 5 0 R] >> >>", &objects, &points); + for (got, what) in calibrated.into_iter().zip(what) { + assert_near(got, MID_GRAY, &format!("{what} with DefaultGray")); + } + for (got, what) in colors(content, "<< >>", &objects, &points).into_iter().zip(what) { + assert_eq!(got, BLACK, "{what} without DefaultGray"); + } + } + + /// Image XObjects and inline images (whose `/RGB` and `/G` abbreviate the device spaces, or + /// that name a `/ColorSpace` resource that is DeviceRGB) in a device space draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_images_draw_in_the_default_color_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 defaults = "/DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] /DefaultCMYK [/ICCBased 7 0 R]"; + let calibrated = colors(content, &format!("<< /ColorSpace << {defaults} /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points); + assert_near(calibrated[0], GREEN, "DeviceRGB image"); + assert_near(calibrated[1], MID_GRAY, "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_GRAY, "inline /G image"); + + let device = colors(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 in a device space, painted by `sh` or as a shading pattern, with colors from a + /// function (axial) or from their vertices (a Type 4 mesh), draw in its default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn device_shadings_draw_in_the_default_color_spaces() { + let axial = |space: &str, color: &str| { + format!( + "<< /ShadingType 2 /ColorSpace /{space} /Coords [0 0 130 0] /Extend [true true] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [{color}] /C1 [{color}] /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 = colors( + 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_GRAY, "sh, an axial shading in DeviceGray"); + + let device = colors(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 keys the default. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn an_image_drawn_with_and_without_a_default_keeps_both_colors() { + let objects = [ + profile_stream(3, rgb_profile([1, 2, 0])), + (ONE_PIXEL_RGB.to_owned(), vec![255, 0, 0]), + form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> /XObject << /R 6 0 R >> >>", "q 40 0 0 70 70 0 cm /R Do Q"), + ]; + let got = colors( + "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 color in the defaults of the resources in its scope: the + /// page's when it has no `/Resources` (directly or nested in another form), else only its + /// own, as for every other resource here. PdfCraft, Poppler and MuPDF also fall back to the + /// page's default from a form with `/Resources` of its own. A form whose own default is a + /// device space or not a usable default draws device color. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn forms_draw_in_the_default_color_spaces_of_their_resource_scope() { + 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 would otherwise match the first. + (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 = colors( + "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_eq!(got[2], RED, "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_eq!(got[7], RED, "a form whose /ColorSpace has no DefaultRGB"); + } + + /// A default that cannot stand in for its device space (Lab, Indexed, Pattern, another device + /// space, the wrong number of components, or no color space) leaves device color as it is, + /// and the dictionary's other defaults still apply. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn unusable_defaults_leave_device_color_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 = colors( + "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_GRAY, &format!("DefaultRGB {default}: DefaultGray still applies")); + } + } +} + +/// PdfCraft ledger hayro-interpret (48) and the ICC profile cache key (#3020): two `/ICCBased` +/// profiles whose stream dictionaries are byte for byte the same (`/N 3` and one `/Length`) +/// each draw through their own profile. hayro cached the color 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 linear sRGB and the same profile with red and blue swapped, +/// which draws red as blue; they have the same length. +mod icc_profile_cache { + use super::default_color_spaces::{BLUE, RED, assert_near, colors, page_pdf, profile_stream, rgb_profile}; + use craft_pdf::interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, font::Glyph, + }; + use kurbo::{Affine, BezPath, Rect}; + + /// An `/ICCBased` space whose profile stream is written directly in the space's array, not + /// as an object of its own: the profile as ASCIIHex digits, each a token of its own, in + /// upper case (a lone `f` reads as a broken `false`). 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()) + } + + /// Six 20 pt squares, side by side, filled with `1 0 0 sc` (red) in `/A` (object 5, linear + /// sRGB), `/B` (object 6, red and blue swapped), `/A2` (object 5 again, through an array of + /// other bytes), `/A3` (object 7, a copy of object 5), `/C` and `/D` (the two profiles + /// again, each written directly in its array). Objects 5, 6 and 7 have the same dictionary, + /// and so do the two direct streams. + fn page() -> (String, String, Vec<(String, 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 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"); + (content, resources, vec![profile_stream(3, srgb.clone()), profile_stream(3, swapped), profile_stream(3, srgb)]) + } + + /// Each square is drawn through its own profile, whether the profile is an object or + /// written in its array, and object 5 looked up again and its copy still draw red. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_with_identical_dictionaries_draw_their_own_colors() { + let (content, resources, objects) = page(); + let points: Vec<_> = (0..6).map(|i| (12 + 21 * i, 35)).collect(); + let got = colors(&content, &resources, &objects, &points); + 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 (got, (what, want)) in got.into_iter().zip(expected) { + assert_near(got, want, what); + } + } + + /// Draws nothing: only the color 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) {} + } + + /// 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 pass through the same cache reads none. + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn icc_profiles_used_again_are_read_once() { + let (content, resources, objects) = page(); + let bytes = page_pdf(&content, &resources, &objects); + let pdf = craft_pdf::open_slice(&bytes, None).unwrap(); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + for pass 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()); + craft_pdf::interpret_page(page, &mut ctx, &mut Nothing).unwrap(); + assert_eq!(cache.icc_profile_reads(), 4, "after the {pass} pass: each different profile is read once"); + } + } +} diff --git a/crates/pdf/tests/upstream_text.rs b/crates/pdf/tests/upstream_text.rs index 471370cd..1e6fdacd 100644 --- a/crates/pdf/tests/upstream_text.rs +++ b/crates/pdf/tests/upstream_text.rs @@ -1,5 +1,6 @@ //! Text contracts adapted from PdfCraft a2ec27b4775f53c40cad3456936d9406611e3a8d (MIT OR Apache-2.0): -//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names. +//! Annex D.2 alias codes, glyph names outside the Adobe Glyph List, and `/ToUnicode` identity names; +//! and from PdfCraft 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e, clipping-only text. #![cfg(feature = "interpret")] #![allow(clippy::unwrap_used, clippy::panic)] @@ -10,10 +11,11 @@ use craft_pdf::interpret::{ use hayro_cmap::BfString; use kurbo::{Affine, BezPath, Rect}; -/// The text of each glyph drawn, `None` where the font gives none. +/// The text of each glyph drawn, `None` where the font gives none, and how it was drawn. #[derive(Default)] struct TextDevice { glyphs: Vec>, + modes: Vec, } impl Device<'_> for TextDevice { @@ -22,7 +24,13 @@ impl Device<'_> for TextDevice { fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {} fn push_clip_path(&mut self, _: &ClipPath) {} fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} - fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) { + fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, mode: &GlyphDrawMode) { + self.modes.push(match mode { + GlyphDrawMode::Fill => 'F', + GlyphDrawMode::Stroke(_) => 'S', + GlyphDrawMode::Clip => 'C', + GlyphDrawMode::Invisible => 'I', + }); self.glyphs.push(glyph.as_unicode().map(|text| match text { BfString::Char(c) => c.to_string(), BfString::String(s) => s, @@ -33,8 +41,8 @@ impl Device<'_> for TextDevice { fn pop_transparency_group(&mut self) {} } -/// The glyph texts of a one-page PDF whose font `/F1` is object 5; `extra` holds further objects. -fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { +/// The glyphs a one-page PDF draws with its font `/F1`, object 5; `extra` holds further objects. +fn drawn_glyphs(font: &str, extra: &str, content: &[u8]) -> TextDevice { let mut pdf = format!( "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ @@ -52,7 +60,11 @@ fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 300.0, 300.0), &cache, page.xref(), InterpreterSettings::default()); let mut device = TextDevice::default(); craft_pdf::interpret_page(page, &mut context, &mut device).unwrap(); - device.glyphs + device +} + +fn glyph_texts(font: &str, extra: &str, content: &[u8]) -> Vec> { + drawn_glyphs(font, extra, content).glyphs } fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec { @@ -121,3 +133,18 @@ fn identity_to_unicode_names_give_text() { assert_eq!(texts, ["Z", "\u{fc}", "r", "i", "c", "h", " ", "\u{20ac}", "", ""], "{name}"); } } + +/// PdfCraft #3109 / #3116: text in mode 7 (clip only) reaches the device as +/// `GlyphDrawMode::Clip`, apart from mode 3 (invisible OCR text), so extraction that leaves out +/// OCR text keeps a heading drawn through its clip. +#[cfg_attr(not(target_arch = "wasm32"), test)] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +fn clipping_only_text_reaches_the_device_apart_from_invisible_text() { + let font = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + for (mode, expected) in [(0, 'F'), (3, 'I'), (7, 'C')] { + let content = format!("q BT /F1 24 Tf {mode} Tr 30 200 Td (Hi) Tj ET 0 0 1 rg 0 0 300 300 re f Q"); + let drawn = drawn_glyphs(font, "", content.as_bytes()); + assert_eq!(drawn.glyphs, [Some("H".into()), Some("i".into())], "{mode} Tr"); + assert_eq!(drawn.modes, [expected; 2], "{mode} Tr"); + } +} diff --git a/crates/pdf/tests/writing.rs b/crates/pdf/tests/writing.rs index da611b61..9d4e0fe9 100644 --- a/crates/pdf/tests/writing.rs +++ b/crates/pdf/tests/writing.rs @@ -317,3 +317,40 @@ fn empty_language_is_absent_and_keeps_required_language_validation() { } assert_eq!(outputs[0], outputs[1], "empty language and absent language must serialize identically"); } + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn only_web_and_mail_link_targets_become_uri_links() { + use backend::annotation::{Annotation, LinkAnnotation}; + use hayro_syntax::object::{Array, Dict, String as PdfString}; + let urls = [ + " https://example.com/deck ", + "mailto:someone@example.com", + "javascript:alert(1)", + "data:text/html,", + "file://attacker.example/share/x", + "\\\\attacker.example\\share\\x", + "//attacker.example/share/x", + "ms-msdt:/id PCWDiagnostic", + "search-ms:query=x&crumb=location:\\\\attacker.example\\share", + ]; + let settings = backend::SerializeSettings { configuration: write::configuration(PdfVersion::Pdf17, None).unwrap(), ..Default::default() }; + let mut writer = Writer::new(settings, Metadata::default()); + let mut page = writer.document_mut().start_page_with(PageSettings::new(Size::from_wh(200.0, 400.0).unwrap())); + for (i, url) in (0u8..).zip(urls) { + let rect = Rect::from_xywh(10.0, 10.0 + 30.0 * f32::from(i), 100.0, 20.0).unwrap(); + if let Some(target) = write::uri_target(url) { + page.add_annotation(Annotation::new_link(LinkAnnotation::new(rect, target), None)); + } + } + page.finish(); + let bytes = writer.finish().unwrap(); + let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let annotations = pdf.pages()[0].raw().get::>(b"Annots").unwrap(); + let uris: Vec = annotations + .iter::>() + .filter_map(|a| a.get::>(b"A")?.get::>(b"URI")) + .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) + .collect(); + assert_eq!(uris, ["https://example.com/deck", "mailto:someone@example.com"]); +} diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 1f8f9da7..7ed8278e 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1,23 +1,12 @@ # egui 0.37 migration plan for craft-libs -Status: local implementation and immutable dependency preparation, 2026-10-24. Reconciled on merged -PR 199 (`3832c47ef68a0e6214f2c1e85f3526b6adcb686e`). Screenshot completion and read-only text -alignment helpers, regression coverage and the headless performance harness are implemented. -The root manifest and lock select the reviewed egui fork -`173cc7375d35dc5fbc5b5d0994fdf787f547c5d1` and winit fork -`ae48c756a3b81125eb796c0cc6dccf60990d1181` using canonical GitHub URLs. +Status: final landing integration, 2026-10-25. The reviewed shared UI includes screenshot completion, shaped-text alignment, keymap support and regression coverage. The manifest and lock select egui `6d518fc799681571963842f95b55a6b68431b15a` and winit `a517b3846ee8457d84ee4975110bfda6797c44dc` through canonical GitHub URLs. -Current checks: canonical lock/source audit passed; all 118 craft-ui tests passed (112 unit, -1 docking and 5 visual tests), strict craft-ui all-target clippy passed, and workspace formatting -passed after applying rustfmt. These are local mirror and package checks. Full workspace tests -and clippy, platform/native acceptance, accepted comparative performance measurements and a clean -remote fetch after approved publication remain separate gates. The harness is not a performance -acceptance result. +The integration of migration ancestor `25da1314d443171b4adeaad2cd9454b273f35c6f` with main `2ab6049d9031bc8fae7f7df669b5fccaf5b9966f` passed formatting, strict workspace all-target clippy, workspace tests and the PDF all-feature suite in managed job `egui037-craft-libs-current-main-gates-20261024-2021`. Source and lock hashes were checked against that job before the local integration checkpoint. -No publication approval exists. Every future push or new pull request needs explicit human review -of the exact commit range and validation first. The baseline tables and detailed work plan below -retain the 2026-10-22 planning snapshot for provenance; this current-status record supersedes their -statements about pending implementation and the old PR 199 conflict. +New main `6e7f28518dd1cb57e1624bdc269c3c14316b13ae` adds docking, native floating windows and other shared-library changes. Its integration is separately planned and reviewed; its compiled, runtime and performance checks remain pending. Earlier test counts below are historical snapshots, not acceptance for that newer tree. App-pinned migration ancestors remain reachable. The prepared benchmark protocol has no accepted timing result; the completed egui text benchmark remains separately inconclusive. + +Every push or new PR requires approval of its exact commit range and validation. Human review and merge are required. The baseline tables below preserve the original planning record. This plan covers only `storytold/craft-libs`. It records the shared-library work needed before the Craft applications move to the egui 0.37 family. Application-specific UI migrations, their document @@ -323,15 +312,20 @@ it is not authorized or required by the git-based suite migration. The only vendored packages are PDF readers/writer, not egui: - `vendor/krilla` (0.8.2), `vendor/hayro` (0.7.1), `vendor/hayro-interpret` (0.7.0), - `vendor/hayro-syntax` (0.7.2) and `vendor/hayro-jbig2` (0.3.0). -- Provenance and patch ledger: `vendor/README.craft-reader.md`, transferred from PdfCraft - `7437bde378f43856eada40af25b983812e615e8a`; each source manifest records blob and SHA-256. + `vendor/hayro-syntax` (0.7.2), `vendor/hayro-jbig2` (0.3.0), `vendor/hayro-cmap` (0.1.0) + and `vendor/hayro-postscript` (0.1.0). +- Reader provenance and patch ledger: `vendor/README.craft-reader.md`. The first reader transfer + was from PdfCraft `7437bde378f43856eada40af25b983812e615e8a`; the ledger records subsequent + transfers and fixes. Writer provenance and patches are recorded separately in + `vendor/krilla/README.craft.md`. Each source manifest records the relevant source hashes. - Equivalence decision: keep the 0.7 backend and its documented compatibility patches. They are not - replaced by the egui migration. Do not update, rebase, delete or re-vendor them as part of this - work. + replaced by the egui migration. When reconciling current main, preserve its reader/writer fixes + and provenance byte-for-byte; do not independently re-vendor or remove them. - Removal/test condition: only a separate PDF backend migration may change them; their existing `cargo test -p craft-pdf --all-features` and `scripts/test-pdf-backends.sh` evidence remain the - gate. The root `[patch.crates-io]` must keep these entries while `craft-pdf` uses them. + gate. Run these checks again after a main reconciliation changes the vendors; default-feature + workspace tests alone do not qualify the PDF backend. The root `[patch.crates-io]` must keep + these entries while `craft-pdf` uses them. `crates/pdf/read-only-contract` is a nested workspace with its own `Cargo.lock` and a single `hayro-syntax` patch. It does not depend on egui and is unaffected by the workspace egui lock; do not diff --git a/vendor/README.craft-reader.md b/vendor/README.craft-reader.md index 1a560e73..2c0093cb 100644 --- a/vendor/README.craft-reader.md +++ b/vendor/README.craft-reader.md @@ -175,6 +175,136 @@ comma. The unit tests of all these items come with them; the CCITT, Type 3 and c raster tests are adapted in `tests/upstream_raster.rs` and the text contracts in `tests/upstream_text.rs`. (P8)'s PdfCraft test needs a Japanese substitute font and is not ported. +- A CCITT image with `/Rows 0` (unknown height) decodes at most the rows that fit the stream's + output limit (counted in 1-bit rows) and 2^28 pixels; a known height is clamped the same way. + Before, it decoded to the end of the data, and a bit of 2D code can repeat a whole row. +- A JPEG 2000 image over 2^28 pixels, or over its output limit (at least 64 MiB), by the + codestream's own dimensions is refused before decoding, and its buffer is reserved fallibly: + the image dictionary's smaller size no longer stands in for the size the decoder allocates. + +Paint fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +each marked `PdfCraft patch` in the source: hayro-interpret (46) with its hayro counterpart +(PdfCraft #2977), a stencil mask is painted with the fill paint, not the stroke paint, at the +fill opacity once, and a pattern paint drops the opacity it captured at `scn` +(`Pattern::without_opacity`); the unledgered PdfCraft #3246, a shading pattern takes the alpha +current when it is painted (`Pattern::set_alpha`, also on a plate), so a `gs` after `scn` or +`SCN` still applies; (36), marked `PdfCraft patch (#3035)` because the plate-mode code uses +(35) and (36) for #2770's own numbers, a mesh pixel whose corner no triangle holds takes the +triangle covering its center (`Triangle::covers`), so shared edges leave no seam; and (51), +the saved-state byte and count sums saturate, so a huge dash array or clip list stays over +the bound on 32-bit targets instead of wrapping. The PdfCraft raster tests for (46), #3246 and +(36) are adapted in `tests/upstream_raster.rs`. + +Font and text fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +each marked `PdfCraft patch` in the source: hayro-interpret (49) (PdfCraft #2949 and its +follow-up), `OpenTypeFontBlob::new` accepts a `post` table version 1.0, 2.0 or 3.0 written +with its 16-bit halves swapped (`00 00 00 03`, as ScanSoft PDF Create! embeds every font), and +`glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 +table's names serve `/Differences`; any other unknown version is still refused; (50) +(PdfCraft #3958), `CffFontBlob::glyph_index_by_name` looks names up in a map built on first +use and shared between clones instead of searching the charset for every character code, and +the first glyph with a name still wins; and the unledgered PdfCraft #3116 (#3109), marked +`PdfCraft patch (#3116)`, text in mode 7 (clip only) installs its clip and reaches the device +as `GlyphDrawMode::Clip`, which hayro does not paint, so extraction that leaves out invisible +OCR text (mode 3) keeps a heading drawn through its clip. The unit tests +`font::blob::tests::{a_post_version_with_swapped_halves_is_read_as_the_version_it_means, +glyph_names_resolve_to_the_first_glyph_with_that_name}` come with (49) and (50); the #3116 +contract is in `tests/upstream_text.rs`. + +Fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +by its item ids, each marked `PdfCraft patch` in the source. hayro-syntax (24), PdfCraft +#2400: `xref::prev_offset` reads a trailer `/Prev` written as a reference `N G R` as the +offset that object holds when the section's own entries place it (iText's +`indirectPrev.pdf`), and otherwise as offset N (tiff2pdf), so the earlier sections' pages +are seen. Each section's entries are read before its `Prev` is followed; the local +xref-stream bounds above apply to them unchanged. Tests: +`xref::tests::prev_as_reference_reaches_the_earlier_section` and +`xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`. +hayro-syntax (P5), PdfCraft #3184: `filter::jpx::decode` applies a JP2 palette (`pclr`) when +the image dictionary has no `/ColorSpace`, or one that does not resolve, since the colors then +come from the JP2 header (ISO 32000-2 §7.4.9). With a `/ColorSpace` the raw indices are kept, +as an `/Indexed` space needs. Resolved palette colors are 8-bit samples whatever the +precision of the indices; an image without a palette keeps its own. Tests: +`filter::jpx::tests::jpx_palette_is_applied_without_a_color_space`, +`filter::jpx::tests::jpx_palette_indices_stay_raw_for_an_indexed_color_space` and +`filter::jpx::tests::palette_colours_are_eight_bit_and_other_samples_keep_their_precision`. +PdfCraft's raster test `a_jp2_palette_keeps_its_colours_whatever_the_index_precision` is not +ported: the unit tests show the same behavior. + +From [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e](https://github.com/storytold/pdfcraft/tree/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor), +unledgered and marked `PdfCraft patch` in the source: hayro-syntax #3438 (issue #3107), with +its look-ahead bound from 01af6cba3. An inline image without a filter ends at the byte +length its `/W`, `/H`, `/CS`, `/BPC` and `/IM` give (rows padded to whole bytes) when white +space and `EI` follow there, so an `EI` inside its samples does not end it. Conflicting +long and short keys, filters and other color spaces keep the `EI` look-ahead. When the +computed end lies past the content window, the tokenizer asks for more data only while the +image, its header and the terminator fit within `MAX_DECODED_STREAM`, and otherwise falls +back to the look-ahead. Its unit tests in `content::tests` come with it. PdfCraft's raster +test is not ported: the windowed tokenizer tests cover the same content streams. + +Rendering fixes from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +each marked `PdfCraft patch` in the source: hayro-interpret (40) (PdfCraft #2987), an optional +content membership dictionary with a visibility expression (`/VE`: `/And`, `/Or` and `/Not` +over groups the document lists in `/OCProperties /OCGs`) shows its content when the +expression is true, ahead of `/OCGs` and `/P`; a malformed expression, or one nested deeper +than 32 levels, is ignored and `/OCGs` and `/P` decide; and hayro (18) (PdfCraft #2972), the +page renders as one transparency group over a transparent backdrop that is then put on the +background, so a blend mode over an empty part of the page keeps its color; with a color +replacement (reading colors) the background stays inside the group as its backdrop. A plate +render (`InterpreterSettings::plate`) also keeps the background inside, marked `Craft patch`, +so blend modes on a separation's film blend with the paper. The PdfCraft raster tests of both +are adapted in `tests/upstream_raster.rs`. + +Default color spaces from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md): +hayro-interpret (38) (PdfCraft #3018), marked `PdfCraft patch (#3018)` because the plate-mode +code labels #2770's changes by that pull request's own patch numbers. The `/DefaultGray`, +`/DefaultRGB` and `/DefaultCMYK` color spaces of the resources in scope (ISO 32000-2 +§8.6.5.6) replace the device space that `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` select (by +name, or through a `/ColorSpace` resource that is a device space), and the device +`/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). A default that is +a device, Lab, Indexed or Pattern space, or has the wrong number of components, is ignored. +Decoded images and shadings are cached per default. Two local differences: a form XObject +with `/Resources` of its own sees only its own defaults, as it sees only its own other +resources here (`Resources::from_scope`), where PdfCraft falls back to the page's; and on a +plate (patch 35) no default applies, so DeviceCMYK still separates as its components with +the nonzero overprint mode, and DeviceGray on black alone, rather than through a DefaultCMYK +or DefaultGray profile and the CMYK simulation of patch 36. PdfCraft's eight raster tests are +adapted in `tests/upstream_raster.rs` (`default_color_spaces`); the plate case is +`default_color_spaces_leave_device_color_on_its_plates` in `tests/plates.rs`. + +A rendering speedup from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md), +hayro-interpret (35) with hayro (19), PdfCraft #3296, marked `PdfCraft patch (#3296)` because +the plate-mode code uses (35) for #2770's own numbers. `EncodedShadingPattern::extent` is the +device-space rectangle of the pixels a mesh shading's triangles touched when it was encoded +(`None` for function-based, axial and radial shadings, and for a mesh with a `/Background`). +`Renderer::set_paint` returns `PaintSetup`: a mesh texture covers that extent (and a +transparent pixel around it, padded instead of repeated) rather than the whole path, and a +mesh with no triangle on the canvas returns `PaintSetup::Nothing`, which the fill, stroke, +rect and stencil callers skip. The rendered output is the same. The knockout erase pass +returns before the extent is used, so it still clears under the whole path. Tests: +`mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, +a_mesh_off_the_canvas_paints_nothing}` in hayro. + +An ICC profile cache fix from the [PdfCraft revision 0b670caf4a090e9d83a7f41a84b4cb2144a2d33e ledger](https://github.com/storytold/pdfcraft/blob/0b670caf4a090e9d83a7f41a84b4cb2144a2d33e/vendor/README.md): +hayro-interpret (48) (PdfCraft #3020), marked `PdfCraft patch (#3020)`. The color space read +from an `/ICCBased` profile stream is cached by `IccProfileKey`, not by the stream +dictionary's bytes alone, so two profiles with the same `/N` and `/Length` no longer share +whichever was read first. A stream object is looked up by its object identifier without +reading it, and on a miss by its dictionary key and raw (decrypted, undecoded) bytes, so +copies of a profile are read once; a stream written directly in its array has only the +content key. `InterpreterCache::icc_profile_reads` counts the reads. The CMYK simulation of +patch 36 doesn't use this cache: it is built from the output intent's bytes by the caller, +and the digest that tells its profile from a document profile is still a hash of the +decoded profile bytes. PdfCraft's two raster tests are adapted in `tests/upstream_raster.rs` +(`icc_profile_cache`). + +Local fixes on top of these ports, marked `Craft patch` or in the comments of the ported +code: an inflated patch-mesh triangle is rebuilt with `Triangle::new`, so its stored dot +products match its vertices (moving the points alone skewed barycentric coordinates by about +5 %, in `covers` and in interpolation); a stencil image puts the fill alpha back itself, since +`restore_state` doesn't when the saved-state bound skipped the save; and a resolved JP2 +palette decodes to 8 bits whatever `/BitsPerComponent` the dictionary gives. This remains the patched 0.7 backend. It retains the existing Bradford Lab conversion, floating component precision and codec versions, rather than claiming full 0.8 diff --git a/vendor/hayro-interpret/SOURCE.json b/vendor/hayro-interpret/SOURCE.json index 957b7220..b1a9faac 100644 --- a/vendor/hayro-interpret/SOURCE.json +++ b/vendor/hayro-interpret/SOURCE.json @@ -176,19 +176,19 @@ "path": "src/cache.rs", "source_sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42", "source_git_blob": "e253a7b8c7dbfbfa2f7dc1f6c582559962060b94", - "sha256": "76b5dc066330666a50a32124856306b72c98e646346263bd8a269bc99e5a0d42" + "sha256": "c63ed890ead39cc6205746063f90689b7a617e8c14a35e6ee1b955550d1230a5" }, { "path": "src/color.rs", "source_sha256": "799ce29bb5e59c8fc94db202873e48c2047dd35962e6f26efd215156c47158c1", "source_git_blob": "56bb04f24c04ba33ab6bf99c02a24735670959ad", - "sha256": "031a4615051020c34eacb28f656464db0769e2b1b8ff6f32ed328e62a8ee3d05" + "sha256": "85391b386b0c34b6bb1d6757c3d083ca07764062d085e0324456a377d6d520fc" }, { "path": "src/context.rs", "source_sha256": "dc2be553958ee3f5e5e644a77a0301723bae9ca027a352c13b9caaf549cc0e56", "source_git_blob": "d6772baa15ebb95d3ba2d1f54ce5c250ad1e8137", - "sha256": "c2c2206bed7fb375c60beaa838ad364fb7bd92323488d6841c0afc285b889e82" + "sha256": "98ed66aab25f1e3b8b9dfd9b6837dc0d9519a9fd7f5429b695865879abc24f6d" }, { "path": "src/convert.rs", @@ -206,13 +206,13 @@ "path": "src/encode.rs", "source_sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6", "source_git_blob": "b3f830ba2dcdb7069ea73706be43c55be398f670", - "sha256": "674a97c48cc62e9a218f843246776528173c36c00aa96a1e4c291c1d912c29c6" + "sha256": "46c9773f05c329cad20f3126394f89a551ee393569c58911b0a921be8c020376" }, { "path": "src/font/blob.rs", "source_sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575", "source_git_blob": "6eabc26b9bc30bdc51f0b5fdd5e4b556d7583ec5", - "sha256": "30a9dadf498829ee03eb1ddc6deaeb09a06ee42415f288d46f4a67640582c575" + "sha256": "109170b39c6e2c8c95a973c73a174e749b0d25e78b4c1e40c8a3ae4dfb220db7" }, { "path": "src/font/cid.rs", @@ -362,13 +362,13 @@ "path": "src/interpret/mod.rs", "source_sha256": "2b002323055bc19aa497e92c81ad8e06dc60ee03686cad89366df4ac7c7a06b2", "source_git_blob": "fe08fc284ba7c914d1414b68a5a346674a6f910f", - "sha256": "cf79add26a1b39c188c00ba5c6aeb199d5f26297c4ce199b823118079fb2a742" + "sha256": "c83663220d27ecf7f80419aab07d44a7ab1894d4932b71b9f5dee83e7e2ba221" }, { "path": "src/interpret/path.rs", "source_sha256": "a5defbc4b883b262662f7e7474a00ec1f97dbef8c03b8669859f624f5d02344b", "source_git_blob": "838ea099f11d918d6e333e32503368be864689f8", - "sha256": "3104a301f331bf1902318ad35a1ea2f2d8e2a1c84ec352ea66658732afc9ef06" + "sha256": "2731da799d456cbdddc18518f468ba202356b5f255a116fc7801f65a4aedb9b0" }, { "path": "src/interpret/state.rs", @@ -380,7 +380,7 @@ "path": "src/interpret/text.rs", "source_sha256": "35eafa80147aea635bdef127c33ace20e1695e11e87c7bfe1e035c886806ff30", "source_git_blob": "ddec0400ea7a639c14769ac9ee7e137d000b3efd", - "sha256": "239ca1ac3ad2a34bfce3d70935ea3483dbb5c5a689c44cd7c5a22693dab51b75" + "sha256": "34911b1b423e24638863c1c9b173a4d1ccddd3bf08789dab226765adc072c10f" }, { "path": "src/lib.rs", @@ -398,19 +398,19 @@ "path": "src/ocg.rs", "source_sha256": "439455f65b8ad6ed9b0306536c2f75fa5f3fdda42ed4ca351ec3d340c38dc330", "source_git_blob": "c59b70df4be6a83485360dacd76bead9f80c7f94", - "sha256": "439455f65b8ad6ed9b0306536c2f75fa5f3fdda42ed4ca351ec3d340c38dc330" + "sha256": "802fd4c330f5066bcb2070aa0986dc869cd6f1f443e8e92857a598f7336266b7" }, { "path": "src/pattern.rs", "source_sha256": "17b49a465275c20e6645dea9443f2cb3a64c594925fd860e74b80b44d5344e37", "source_git_blob": "9295f51709f8915951d7b9afca9411651d65a1f8", - "sha256": "ae362a5074b40482f2c5e16ea66a0398b27ec866a7b4adb8f45fe7221c960ae1" + "sha256": "e57d4de60126eafa0da2113af12bbbde1aabd15554f1ee26f6551b8219f4ede5" }, { "path": "src/shading.rs", "source_sha256": "5d541a2d52f69c88e87a40580bf28afa4b8a6a2d62a3385009087264384ead32", "source_git_blob": "2e619e47f2800d7fc7cbdae77c66c58f012c84c2", - "sha256": "c6008d05330877ebc2b3b5eccc269aedc0242c1f81b1e522c42ecd4ba897349d" + "sha256": "861ad2a40d1d3f09dc1fdb4a240a36f2873ffe2b5bedf54bb601f6ac8e5b05bb" }, { "path": "src/soft_mask.rs", @@ -422,7 +422,7 @@ "path": "src/types.rs", "source_sha256": "0e66569b8a7dc243b0a384cf1d3615ca62bf660b5324236f2594609bdfddfd46", "source_git_blob": "64d2b849bdd3fdcd4ebfd76845edcef77b625fbd", - "sha256": "542fafbfcf8a472466377cacd580eda2fc2d40b834a8d5ecd1ad35d75445f532" + "sha256": "9b5cc9485c0e4104aee99be773706490e6a33f3c03e796f27940571041864125" }, { "path": "src/util.rs", @@ -434,7 +434,7 @@ "path": "src/x_object.rs", "source_sha256": "e1c51e96a335c32c122011b2d05c5020c36a33c2c73342ba173c28a3e2fe0219", "source_git_blob": "21b134ce8cd9604399cd0776febf923d69c02673", - "sha256": "8ddb1ceb10fe80f62c4a01af23842a92947f5fac8d9b0fe54b3761af3e290d74" + "sha256": "6adbf88e6bcdde2321c517525c1291ce7ead55a7989c930757ed384c309589d4" } ], "compatibility_reference": { diff --git a/vendor/hayro-interpret/src/cache.rs b/vendor/hayro-interpret/src/cache.rs index e253a7b8..d2be7543 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 (#3020): 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 (#3020): count one ICC profile read, the color 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 (#3020): 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 color space drawn in place of the image's device space, if any (the cache +/// key of its object, PdfCraft patch #3018): 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 5a7d5ec7..e85fd17e 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -10,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, ProfileText, RenderingIntent, 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}; @@ -122,6 +124,34 @@ pub(crate) enum ColorSpaceType { Plate(PlateSpace), } +/// PdfCraft patch (#3020): what the color 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 colors 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 (#3020): 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) @@ -140,34 +170,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 (#3020): 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 => { @@ -361,6 +407,149 @@ impl ColorSpace { self.to_alpha_color(c, opacity, manual_scale) .unwrap_or(AlphaColor::BLACK) } + + /// PdfCraft patch (#3018): whether this space can be the default color 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 (#3018): a device color space (ISO 32000-2 §8.6.4), which the resources' +/// default color 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 color 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 color + /// 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 (#3018): the default color 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 the resources in scope: a form XObject without `/Resources` draws in its page's +/// defaults, one with `/Resources` only in its own. +/// +/// On a plate (patch 35) device color keeps its device space: plates separate DeviceCMYK +/// by its components, with nonzero overprint mode, and a DefaultCMYK profile would route it +/// through the CMYK simulation (patch 36) instead. Defaults change how device color looks, +/// not which inks it is printed with. +pub(crate) struct DefaultColorSpaces<'r, 'a> { + resources: &'r Resources<'a>, + cache: Cache, + on_plate: bool, + gray: OnceCell>, + rgb: OnceCell>, + cmyk: OnceCell>, +} + +impl<'r, 'a> DefaultColorSpaces<'r, 'a> { + pub(crate) fn new(resources: &'r Resources<'a>, cache: &Cache, on_plate: bool) -> Self { + Self { + resources, + cache: cache.clone(), + on_plate, + 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 color space that can stand in for + /// the device space, or on a plate: device color is then drawn as it is. + pub(crate) fn get(&self, family: DeviceFamily) -> Option<(ColorSpace, u128)> { + if self.on_plate { + return None; + } + 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 color 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 { @@ -1231,6 +1420,16 @@ pub struct Color { } impl Color { + /// PdfCraft patch (46): the same color, fully opaque (see `Pattern::without_opacity`). A + /// fully transparent color stays so: on a plate it is the paint that leaves the plate as it + /// is (`for_plate`), and making it opaque would knock the plate out. + pub(crate) fn opaque(mut self) -> Self { + if self.opacity != 0.0 { + 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 b4dc8bf0..e4d84130 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -156,6 +156,13 @@ impl<'a> InterpreterCache<'a> { pub fn decoded_image_decodes(&self) -> usize { self.decoded_cache.decodes() } + + /// PdfCraft patch (#3020): 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`]. @@ -185,10 +192,19 @@ const MAX_SAVED_STATES: usize = 4096; const MAX_SAVED_STATE_BYTES: usize = 64 << 20; /// Roughly what a saved copy of `state` holds. +/// PdfCraft patch (51): the sums saturate, so a huge dash array or clip list counts as over the +/// bound rather than wrapping on a 32-bit target. fn state_bytes(state: &State<'_>) -> usize { size_of::>() - + state.graphics_state.stroke_props.dash_array.len() * size_of::() - + state.clips.len() * size_of::() + .saturating_add( + state + .graphics_state + .stroke_props + .dash_array + .len() + .saturating_mul(size_of::()), + ) + .saturating_add(state.clips.len().saturating_mul(size_of::())) } impl<'a> Context<'a> { @@ -269,7 +285,7 @@ impl<'a> Context<'a> { } let cur = cur.clone(); - self.saved_bytes += bytes; + self.saved_bytes = self.saved_bytes.saturating_add(bytes); self.states.push(cur); } @@ -446,7 +462,7 @@ impl<'a> Context<'a> { pub(crate) fn num_states(&self) -> usize { // PdfCraft patch: saves not kept still count, so callers restore back past them. - self.states.len() + self.skipped_saves.len() + self.states.len().saturating_add(self.skipped_saves.len()) } pub(crate) fn nesting_depth(&self) -> u32 { diff --git a/vendor/hayro-interpret/src/encode.rs b/vendor/hayro-interpret/src/encode.rs index b3f830ba..314d133f 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 (#3296): 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 (#3296): 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,13 @@ 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 +268,8 @@ struct TriangleSampler { map: FxHashMap<(u16, u16), ColorComponents>, area: kurbo::Rect, visits_left: u64, + /// PdfCraft patch (#3296): the pixels sampled so far, as `(x0, y0, x1, y1)` inclusive. + touched: Option<(u16, u16, u16, u16)>, } impl TriangleSampler { @@ -263,6 +284,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 (#3296): 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 +357,29 @@ 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 (#3035): 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 center, edges included. Testing only the + // corner left a white seam along every shared edge. Such a pixel takes the + // color at its corner, as every other pixel does (where MuPDF's colors + // 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/font/blob.rs b/vendor/hayro-interpret/src/font/blob.rs index 6eabc26b..8df3611a 100644 --- a/vendor/hayro-interpret/src/font/blob.rs +++ b/vendor/hayro-interpret/src/font/blob.rs @@ -13,7 +13,7 @@ use skrifa::raw::{FontData as ReadFontData, FontRead}; use skrifa::{FontRef, GlyphId, MetadataProvider, OutlineGlyphCollection}; use std::collections::HashMap; use std::fmt::{Debug, Formatter}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use yoke::{Yoke, Yokeable}; type FontData = Arc + Send + Sync>; @@ -50,7 +50,10 @@ impl Type1FontBlob { /// A font blob for CFF-based fonts. #[derive(Clone)] -pub(crate) struct CffFontBlob(Arc); +pub(crate) struct CffFontBlob(Arc, Arc>); + +/// PdfCraft patch (50): glyph name to glyph id, built on first use. +type GlyphNames = HashMap, GlyphId>; impl Debug for CffFontBlob { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -84,7 +87,7 @@ impl CffFontBlob { }); let _ = (cff, charset, subfonts); - Some(Self(Arc::new(yoke))) + Some(Self(Arc::new(yoke), Arc::new(OnceLock::new()))) } pub(crate) fn font_data(&self) -> FontData { @@ -134,11 +137,23 @@ impl CffFontBlob { } pub(crate) fn glyph_index_by_name(&self, name: &str) -> Option { - // TODO: This is probably slow to do repeatedly? - self.charset()?.iter().find_map(|(gid, sid)| { - let bytes = self.0.as_ref().get().cff.string(sid)?; - (bytes == name.as_bytes()).then_some(gid) - }) + // PdfCraft patch (50): called for every character code of a font, so the charset is + // read once. The first glyph of a name wins, as it did when the charset was searched + // in order. + self.1 + .get_or_init(|| { + let mut names = GlyphNames::new(); + if let Some(charset) = self.charset() { + for (gid, sid) in charset.iter() { + if let Some(bytes) = self.0.as_ref().get().cff.string(sid) { + names.entry(bytes.into()).or_insert(gid); + } + } + } + names + }) + .get(name.as_bytes()) + .copied() } pub(crate) fn glyph_index_by_cid(&self, cid: u16) -> Option { @@ -179,11 +194,14 @@ impl OpenTypeFontBlob { let f = FontRef::from_index(data.as_ref().as_ref(), index).ok()?; // Reject fonts with invalid post table version, fixes pdf.js issue 9462. Not sure if there // is a better fix, for some reason skrifa accepts the font which is completely invalid. + // PdfCraft patch (49): also accept 1.0, 2.0 and 3.0 with the two 16-bit halves swapped + // (`00 00 00 03` for 3.0), as ScanSoft PDF Create! writes it into every font it embeds + // (#2946). Rejecting those fonts drew their text with a substitute that has no such glyphs. let invalid = f.post().is_ok_and(|p| { !matches!( p.version().to_major_minor(), (1, 0) | (2, 0) | (2, 5) | (3, 0) - ) + ) && !matches!(p.version().to_be_bytes(), [0, 0, 0, 1..=3]) }); if invalid { @@ -253,6 +271,27 @@ impl OpenTypeFontBlob { return glyph_names; }; + // PdfCraft patch (49): a version written with its halves swapped (`00 00 00 02`, + // accepted in `new`) is read as the version it means, from a copy of the table with the + // version written correctly: read-fonts reads a 2.0 table's name index only under + // version 2.0, so otherwise its glyph names were never found (#2946). + let repaired = match post.version().to_be_bytes() { + [0, 0, 0, n @ 1..=3] => self + .font_ref() + .table_data(skrifa::raw::types::Tag::new(b"post")) + .map(|data| { + let mut bytes = data.as_bytes().to_vec(); + if let Some(version) = bytes.get_mut(..4) { + version.copy_from_slice(&[0, n, 0, 0]); + } + bytes + }), + _ => None, + }; + let repaired_post = repaired + .as_deref() + .and_then(|b| skrifa::raw::tables::post::Post::read(ReadFontData::new(b)).ok()); + let post = repaired_post.as_ref().unwrap_or(&post); match post.version().to_major_minor() { (1, 0) => { for (gid, name) in DEFAULT_GLYPH_NAMES.iter().enumerate() { @@ -333,3 +372,146 @@ struct CFFYoke<'a> { charset: Option>, subfonts: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + /// A CFF font with glyphs `.notdef`, `Alpha`, `Alpha` (a duplicate name) and `Beta`. + fn cff_with_names() -> Vec { + fn index(items: &[&[u8]]) -> Vec { + let mut out = (items.len() as u16).to_be_bytes().to_vec(); + if items.is_empty() { + return out; + } + out.push(1); + let mut offset = 1u8; + out.push(offset); + for item in items { + offset += item.len() as u8; + out.push(offset); + } + for item in items { + out.extend_from_slice(item); + } + out + } + // Operands use the 5-byte form so the Top DICT has a fixed size. + fn int(value: usize) -> Vec { + let mut out = vec![29]; + out.extend_from_slice(&(value as u32).to_be_bytes()); + out + } + fn build_top(charset: usize, char_strings: usize, private: usize) -> Vec { + let mut dict = Vec::new(); + dict.extend(int(charset)); + dict.push(15); + dict.extend(int(char_strings)); + dict.push(17); + dict.extend(int(0)); + dict.extend(int(private)); + dict.push(18); + dict + } + let header = vec![1, 0, 4, 1]; + let names = index(&[b"T"]); + let strings = index(&[b"Alpha", b"Beta"]); + let subrs = index(&[]); + // Standard strings take SIDs 0..=390, so the first custom string is 391. + let charset = vec![0, 0x01, 0x87, 0x01, 0x87, 0x01, 0x88]; + let char_strings = index(&[&[14], &[14], &[14], &[14]]); + // The Top DICT has a fixed size because every operand takes the 5-byte form. + let top_len = index(&[&build_top(0, 0, 0)]).len(); + let charset_at = header.len() + names.len() + top_len + strings.len() + subrs.len(); + let char_strings_at = charset_at + charset.len(); + let private_at = char_strings_at + char_strings.len(); + let dict = build_top(charset_at, char_strings_at, private_at); + let top = index(&[&dict]); + let mut out = header; + for part in [names, top, strings, subrs, charset, char_strings] { + out.extend(part); + } + out + } + + /// The charset search `glyph_index_by_name` replaced. + fn linear(blob: &CffFontBlob, name: &str) -> Option { + blob.charset()?.iter().find_map(|(gid, sid)| { + let bytes = blob.0.as_ref().get().cff.string(sid)?; + (bytes == name.as_bytes()).then_some(gid) + }) + } + + #[test] + fn glyph_names_resolve_to_the_first_glyph_with_that_name() { + let data: FontData = Arc::new(cff_with_names()); + let blob = CffFontBlob::new(data).expect("the test font parses"); + assert!(blob.1.get().is_none(), "the name map is built on first use"); + assert_eq!(blob.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + assert_eq!(blob.glyph_index_by_name("Beta"), Some(GlyphId::new(3))); + assert_eq!(blob.glyph_index_by_name("Missing"), None); + assert_eq!(blob.glyph_index_by_name(""), None); + for name in ["Alpha", "Beta", "Missing", ".notdef", ""] { + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + // The second lookup is answered from the stored map. + assert_eq!( + blob.glyph_index_by_name(name), + linear(&blob, name), + "{name:?}" + ); + } + assert!(blob.1.get().is_some(), "the lookups built the name map"); + let shared = blob.clone(); + assert!(Arc::ptr_eq(&blob.1, &shared.1), "clones share one name map"); + assert_eq!(shared.glyph_index_by_name("Alpha"), Some(GlyphId::new(1))); + } + + /// An sfnt with only a `post` table of the given version, naming three glyphs `.notdef`, + /// `A` (a standard name) and `W.cv07` (a name of its own) when the version is 2.0. + fn font_with_post(version: [u8; 4]) -> FontData { + let mut post = version.to_vec(); + post.extend_from_slice(&[0; 28]); + post.extend_from_slice(&3u16.to_be_bytes()); + for index in [0u16, 36, 258] { + post.extend_from_slice(&index.to_be_bytes()); + } + post.push(6); + post.extend_from_slice(b"W.cv07"); + let mut font = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + font.extend_from_slice(b"post"); + font.extend_from_slice(&[0; 4]); + font.extend_from_slice(&28u32.to_be_bytes()); + font.extend_from_slice(&(post.len() as u32).to_be_bytes()); + font.extend(post); + Arc::new(font) + } + + #[test] + fn a_post_version_with_swapped_halves_is_read_as_the_version_it_means() { + for (swapped, correct) in [([0, 0, 0, 1], [0, 1, 0, 0]), ([0, 0, 0, 3], [0, 3, 0, 0])] { + assert!(OpenTypeFontBlob::new(font_with_post(correct), 0).is_some()); + assert!( + OpenTypeFontBlob::new(font_with_post(swapped), 0).is_some(), + "{swapped:?} is accepted" + ); + } + let names = |version| { + OpenTypeFontBlob::new(font_with_post(version), 0) + .unwrap() + .glyph_names() + }; + let correct = names([0, 2, 0, 0]); + assert_eq!(correct.get("A"), Some(&GlyphId::new(1))); + assert_eq!(correct.get("W.cv07"), Some(&GlyphId::new(2))); + assert_eq!( + names([0, 0, 0, 2]), + correct, + "a swapped 2.0 table names its glyphs" + ); + assert!(OpenTypeFontBlob::new(font_with_post([0, 9, 0, 0]), 0).is_none()); + } +} diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 8cd80a08..44cbdc0c 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; @@ -347,6 +347,30 @@ fn draw_highlight_without_appearance<'a>( context.restore_state(device); } +/// PdfCraft patch (#3018): the color 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`). @@ -356,6 +380,13 @@ fn interpret_window<'a>( context: &mut Context<'a>, device: &mut impl Device<'a>, ) { + // PdfCraft patch (#3018): the resources' DefaultGray, DefaultRGB and DefaultCMYK replace the + // device spaces that color operators and images select (ISO 32000-2 §8.6.5.6). + let defaults = DefaultColorSpaces::new( + resources, + &context.interpreter_cache.object_cache, + context.settings.plate.is_some(), + ); 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)) { @@ -364,18 +395,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 (#3018): 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 (#3018): 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 (#3018): 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; @@ -439,18 +473,24 @@ 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 (#3018): 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 (#3018): 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 (#3018): 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; @@ -539,13 +579,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 (#3018): 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; @@ -554,13 +590,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 (#3018): 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; @@ -583,7 +615,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) => { @@ -592,7 +624,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) => { @@ -793,6 +825,8 @@ fn interpret_window<'a>( &cache, Some(&decoded_cache), transfer_function.clone(), + // PdfCraft patch (#3018): an image in a device space draws in its default. + &defaults, ) }) { draw_xobject(&x_object, resources, context, device); @@ -803,14 +837,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 (#3018): 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); } @@ -829,7 +866,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 (#3018): 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 2850ddab..3fbd1129 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -178,6 +178,9 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> pattern.set_transfer_function(tf.clone()); } + // PdfCraft patch (#3246): `CA`/`ca` can be set by a `gs` after `scn`/`SCN`, so the + // pattern takes the alpha current at paint time. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) @@ -232,6 +235,8 @@ fn plate_paint<'a>( } Pattern::Tiling(t) => t.for_plate(plate, overprint, overprint_mode), } + // PdfCraft patch (#3246): the alpha current at paint time, as in `get_paint`. + pattern.set_alpha(data.alpha); pattern.pre_concat_transform(context.root_transform()); Paint::Pattern(Box::new(pattern)) } diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index 65a9a279..0bf5a996 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -189,6 +189,16 @@ fn show_glyph<'a>( } TextRenderingMode::Clip => { clip_glyph(ctx, glyph, glyph_transform); + // PdfCraft patch (#3116): report clipping text separately from invisible OCR text. + // Extraction filters must retain text that is filled through its + // clipping path without rendering devices drawing it directly. + device.draw_glyph( + glyph, + ctx.get().ctm, + glyph_transform, + &get_paint(ctx, false), + &GlyphDrawMode::Clip, + ); } TextRenderingMode::FillAndClip => { clip_glyph(ctx, glyph, glyph_transform); diff --git a/vendor/hayro-interpret/src/ocg.rs b/vendor/hayro-interpret/src/ocg.rs index c59b70df..156bda2e 100644 --- a/vendor/hayro-interpret/src/ocg.rs +++ b/vendor/hayro-interpret/src/ocg.rs @@ -1,9 +1,14 @@ -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 +16,7 @@ impl OcgState { fn dummy() -> Self { Self { inactive_ocgs: HashSet::default(), + known_ocgs: HashSet::default(), visibility_stack: vec![], } } @@ -25,6 +31,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 +76,7 @@ impl OcgState { Self { inactive_ocgs: inactive, + known_ocgs: known, visibility_stack: Vec::new(), } } @@ -72,6 +88,17 @@ 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 +131,48 @@ 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 +209,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 b916a381..00686161 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, Plate}; +use crate::color::{Color, ColorSpace, DefaultColorSpaces, Plate}; 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 (#3018): `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,29 @@ 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 (#3246): set the alpha of the graphics state in effect when the pattern is + /// painted. A `gs` 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 +119,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 +140,8 @@ impl ShadingPattern { Some(Self { shading: Arc::new(shading), - opacity, + // PdfCraft patch (#3246): `get_paint` sets the alpha current at paint time. + opacity: 1.0, matrix, transfer_function: None, }) diff --git a/vendor/hayro-interpret/src/shading.rs b/vendor/hayro-interpret/src/shading.rs index 22f5cf77..98cd68f3 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; @@ -144,7 +144,7 @@ impl Shading { pub(crate) fn for_plate(&mut self, plate: &crate::color::Plate) { self.color_space = self.color_space.plate(plate); let simulation = plate.simulation.as_ref().map(|s| s.digest()); - self.cache_key = crate::util::hash128(&( + self.cache_key = hash128(&( self.cache_key, &plate.ink, simulation, @@ -152,12 +152,24 @@ impl Shading { )); } - pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option { - let cache_key = dict.cache_key(); - + /// PdfCraft patch (#3018): a shading in a device space is drawn in its default color 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 => { @@ -388,6 +400,24 @@ impl Triangle { self.kurbo_tri.winding(pos) != 0 } + /// PdfCraft patch (#3035): 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 colors + /// 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() @@ -798,13 +828,22 @@ where // Converting to triangles is lossy and can lead to gaps. To make this a bit better, // we slightly inflate the triangles. - let inflate = |mut triangle: Triangle| { + // Craft patch: the inflated triangle is built anew, so its stored dot products + // (and `kurbo_tri`) match its vertices; moving the points alone skewed the + // barycentric coordinates by about 5 %. + let inflate = |triangle: Triangle| { let mid = triangle.kurbo_tri.centroid(); - triangle.p0.point = inflate_point(triangle.p0.point, mid); - triangle.p1.point = inflate_point(triangle.p1.point, mid); - triangle.p2.point = inflate_point(triangle.p2.point, mid); + let Triangle { + mut p0, + mut p1, + mut p2, + .. + } = triangle; + p0.point = inflate_point(p0.point, mid); + p1.point = inflate_point(p1.point, mid); + p2.point = inflate_point(p2.point, mid); - triangle + Triangle::new(p0, p1, p2) }; // PdfCraft patch (33): the second triangle takes the vertices the first copied. diff --git a/vendor/hayro-interpret/src/types.rs b/vendor/hayro-interpret/src/types.rs index ff7776f3..af540247 100644 --- a/vendor/hayro-interpret/src/types.rs +++ b/vendor/hayro-interpret/src/types.rs @@ -284,6 +284,8 @@ pub enum GlyphDrawMode { Fill, /// Draw using a stroke. Stroke(StrokeProps), + /// PdfCraft patch (#3116): text that shapes a clip; report it without painting a glyph. + Clip, /// Invisible text (for text extraction but not visual rendering). Invisible, } diff --git a/vendor/hayro-interpret/src/x_object.rs b/vendor/hayro-interpret/src/x_object.rs index a7bc2320..60af0748 100644 --- a/vendor/hayro-interpret/src/x_object.rs +++ b/vendor/hayro-interpret/src/x_object.rs @@ -1,11 +1,13 @@ use crate::cache::{Cache, DecodedImageCache, DecodedImageKey}; -use crate::color::{ColorComponents, ColorSpace, InkUse, Plate, ToRgb}; +use crate::color::{ + ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, InkUse, Plate, 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 +38,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 +50,7 @@ impl<'a> XObject<'a> { decoded_cache, false, transfer_function, + Some(defaults), )?)), FORM => Some(Self::FormXObject(FormXObject::new(stream)?)), _ => None, @@ -336,10 +340,20 @@ 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 color) must not carry it again: at `ca 0.5` a mask was drawn at a + // quarter of its opacity (#2975). The alpha is put back below: `restore_state` doesn't + // when the saved-state bound skipped the save. + let fill_alpha = context.get().graphics_state.non_stroke_alpha; + 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 { @@ -349,6 +363,7 @@ pub(crate) fn draw_image_xobject<'a, 'b>( device.draw_image(image, transform); device.pop_transparency_group(); + context.get_mut().graphics_state.non_stroke_alpha = fill_alpha; context.restore_state(device); if has_oc { @@ -387,6 +402,9 @@ pub(crate) struct ImageXObject<'a> { warning_sink: WarningSinkFn, /// PdfCraft patch (35): the plate the image is drawn on, if any. plate: Option, + /// PdfCraft patch (#3018): the cache key of the default color space drawn in place of the + /// image's device space, if one is. + default_space: Option, } impl<'a> ImageXObject<'a> { @@ -401,6 +419,9 @@ impl<'a> ImageXObject<'a> { } } + /// PdfCraft patch (#3018): an image in a device space is drawn in its default color space + /// from `defaults`. Masks have none to replace, and `resolve_cs` (an inline image's named + /// color space) substitutes its own. pub(crate) fn new( stream: &Stream<'a>, resolve_cs: impl FnOnce(&Name<'_>) -> Option, @@ -409,6 +430,7 @@ impl<'a> ImageXObject<'a> { decoded_cache: Option<&DecodedImageCache>, mut is_mask: bool, transfer_function: Option, + defaults: Option<&DefaultColorSpaces<'_, '_>>, ) -> Option { let dict = stream.dict(); @@ -418,6 +440,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()) @@ -426,15 +449,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 @@ -462,6 +495,7 @@ impl<'a> ImageXObject<'a> { is_mask, is_stencil_mask, plate: None, + default_space, }) } @@ -525,6 +559,8 @@ impl<'a> ImageXObject<'a> { is_mask: self.is_mask, is_stencil_mask: self.is_stencil_mask, interpolate: self.interpolate, + // PdfCraft patch (#3018): the same image drawn in a default color space or without one. + default_space: self.default_space, // PdfCraft patch (32): see `decode_reads_target`. target: if self.decode_reads_target() { target_dimension @@ -1001,7 +1037,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) { @@ -1061,7 +1097,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/SOURCE.json b/vendor/hayro-syntax/SOURCE.json index d2234ad3..5e9fb9b1 100644 --- a/vendor/hayro-syntax/SOURCE.json +++ b/vendor/hayro-syntax/SOURCE.json @@ -68,7 +68,7 @@ "path": "src/content/mod.rs", "source_sha256": "b0f36badb2688c90691c27a4e5dcdfec7639858081553242744dd7bc39ffc0d0", "source_git_blob": "8e47324d0b0bd90ed15efe5ff1e9de0cb46e75d9", - "sha256": "aa0883ba92dd05655bfd8d54195584bab2c0b28111d76ec73c5381a0f23b6181" + "sha256": "ae0b0596d2304adc4109294cc7af681698071060d9e8cd6d44f0bb46baffec6b" }, { "path": "src/content/ops.rs", @@ -146,7 +146,7 @@ "path": "src/filter/ccitt.rs", "source_sha256": "45d7eb89934d28cff97a00c7b264010bccfd14f3d511e1307ad50a9fef3b53c8", "source_git_blob": "685d680d2fbadce608a9bd3ca925c2ccc6c5d641", - "sha256": "b0fef2271417c3009a1de569c9573b05dd464ff76c1a5f7220f634bb5feb44e2" + "sha256": "67d17a339639eb0fef4cc6c745b579c6ba9ae3f2f67fd3bacb1bf1389aa5ffab" }, { "path": "src/filter/dct.rs", @@ -162,9 +162,9 @@ }, { "path": "src/filter/jpx.rs", - "source_sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82", + "source_sha256": "c9f068629c9cbe79af6793c3c125df4631ca1455691cb1335b44ff43cb1b4056", "source_git_blob": "74efd8b31e2b2935ff2bbceda2c1ffd36ea9c20d", - "sha256": "f0a2b01c692c086d30a26562d7548f9e837af166be737d2c543de7c886a45f82" + "sha256": "dc3742454cfde9a994ce48984e8fcb7b18851efb36ce2d62e31629efea063a1b" }, { "path": "src/filter/lzw_flate.rs", @@ -176,7 +176,7 @@ "path": "src/filter/mod.rs", "source_sha256": "0af7145ab16b15b7a60c4b57efecb9bd5fd0cfdc9bb5f9388abb695386e63e49", "source_git_blob": "06bda29487d62c25dc39c1ee109091cb17e3867f", - "sha256": "e33e74ee55dcc850db9eb64811c56b4ffe1932908ab9684dfba07f4277b1eea9" + "sha256": "e43d7d309a4993f4a1f698208997122431b4a8b0db838bb07d4358ccbc00f02f" }, { "path": "src/filter/run_length.rs", @@ -338,7 +338,7 @@ "path": "src/xref.rs", "source_sha256": "51ffeeb7d4ac035bae2cd5ba9288c180088ae06cdcefe2b29dfeda560a793aa0", "source_git_blob": "20e33a410489d42e438a3f2b87d70c7bb8d66eae", - "sha256": "fa8d24280df15ffe5628057fdcfa2a3d2a9f42539b6f9096b2feb836ac54ac30" + "sha256": "98fb56eb5c2da8b0c40202ed30b2f464874c1143436e72eda4e49f2333dd7c7f" }, { "path": "src/decode_limits_tests.rs", diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index ee749cd9..5b19d509 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -35,7 +35,7 @@ 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; @@ -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, PR #3438). + 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") { @@ -858,9 +884,175 @@ 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); @@ -1081,4 +1273,193 @@ 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/ccitt.rs b/vendor/hayro-syntax/src/filter/ccitt.rs index b83e2c41..a7921a5d 100644 --- a/vendor/hayro-syntax/src/filter/ccitt.rs +++ b/vendor/hayro-syntax/src/filter/ccitt.rs @@ -8,10 +8,12 @@ use alloc::vec::Vec; use core::iter; use hayro_ccitt::{DecodeSettings, Decoder, DecoderContext, EncodingMode}; +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). pub(crate) fn decode( data: &[u8], params: &Dict<'_>, image_params: &ImageDecodeParams, + limit: usize, ) -> Option> { let k = params.get::(K).unwrap_or(0); @@ -52,6 +54,16 @@ pub(crate) fn decode( if !super::ccitt_size_ok(settings.columns, settings.rows) { return None; } + // PdfCraft patch: the decoder stops after `rows` rows; with `/Rows 0` (unknown) it ran to the + // end of the data, and a bit of 2D code can repeat a whole row, so a small stream produced + // gigabytes. Decode at most the rows that fit the output limit and the pixel cap. The limit + // counts the stream's own 1-bit rows (`decode_limit`), whatever depth the rows are widened to. + let mut settings = settings; + let fit = super::ccitt_max_rows(settings.columns, settings.columns.div_ceil(8), limit); + settings.rows = if settings.rows == 0 { fit } else { settings.rows.min(fit) }; + if settings.rows == 0 { + return None; + } // Whenever possible (if we don't have an indexed color space), we convert // the data as 8-bit instead of 1-bit, so that it can be easier converted @@ -181,7 +193,7 @@ mod tests { #[test] fn columns_past_u32_max_are_refused_not_wrapped() { let params = ImageDecodeParams { width: 8, height: 4, ..Default::default() }; - let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms); + let decoded = |dict: &[u8]| decode(&ALL_BLACK_8X4, &Dict::from_bytes(dict).unwrap(), ¶ms, 1 << 20); assert!(decoded(b"<< /K -1 /Columns 8 >>").is_some()); // 2^32 + 8 would wrap to 8 columns. assert!(decoded(b"<< /K -1 /Columns 4294967304 >>").is_none()); diff --git a/vendor/hayro-syntax/src/filter/jpx.rs b/vendor/hayro-syntax/src/filter/jpx.rs index 74efd8b3..d04f3990 100644 --- a/vendor/hayro-syntax/src/filter/jpx.rs +++ b/vendor/hayro-syntax/src/filter/jpx.rs @@ -17,11 +17,25 @@ impl ImageColorSpace { } } -pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option> { +/// PdfCraft patch: the most pixels a JPEG 2000 image may decode to, as for JBIG2. +const MAX_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the least output `limit` a JPEG 2000 image is held to: the limit comes from the +/// image dictionary's size, which a decoder may round differently from the codestream's. +const MIN_LIMIT: usize = 64 << 20; + +/// PdfCraft patch: `limit` is the most bytes the filter may produce (see `decode_limit`). +pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams, limit: usize) -> Option> { use crate::object::stream::ImageColorSpace; let settings = DecodeSettings { - resolve_palette_indices: false, + // PdfCraft patch: (PdfCraft #3184) with no /ColorSpace in the image dictionary the colors + // come from the JP2 header (ISO 32000-2 §7.4.9), so its palette (`pclr`) is applied here; + // with a /ColorSpace the header color data is ignored, and an /Indexed space takes the raw + // indices. A /ColorSpace that does not resolve also leaves `num_components` unset: the + // header's colors are then the only ones there are, so the palette is applied too (raw + // indices would be drawn as gray). + resolve_palette_indices: params.num_components.is_none(), strict: false, target_resolution: params.target_dimension, }; @@ -41,7 +55,17 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option ImageColorSpace::Gray, ColorSpace::RGB => ImageColorSpace::Rgb, @@ -58,7 +82,23 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option MAX_PIXELS { + return None; + } + let channels = usize::from(cs.num_components()) + usize::from(has_alpha); + let len = (width as usize).checked_mul(height as usize)?.checked_mul(channels)?; + let output_limit = limit.max(MIN_LIMIT); + if len > output_limit { + return None; + } + let mut bitmap = Vec::new(); + bitmap.try_reserve_exact(len).ok()?; + bitmap.resize(len, 0); + let mut context = hayro_jpeg2000::DecoderContext::default(); + image.decode_into(&mut bitmap, &mut context).ok()?; let (mut data, mut alpha) = if !has_alpha { (bitmap, None) @@ -82,6 +122,18 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option output_limit { + return None; + } data = scale(&data, bpc, cs.num_components(), width, height)?; alpha = alpha.and_then(|alpha| scale(&alpha, bpc, cs.num_components(), width, height)); } @@ -98,6 +150,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]; @@ -198,7 +259,9 @@ fn scale( .checked_mul(num_components as usize)? .checked_mul(bit_per_component as usize)?; let input_len = bits_per_row.div_ceil(8).checked_mul(height as usize)?; - let mut input = vec![0; input_len]; + let mut input = Vec::new(); + input.try_reserve_exact(input_len).ok()?; + input.resize(input_len, 0); let mut writer = BitWriter::new(&mut input, bit_per_component)?; let components_per_row = (num_components as usize).checked_mul(width as usize)?; @@ -221,8 +284,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 +422,126 @@ 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, + ]; + + /// PdfCraft #3179: with no `/ColorSpace` in the image dictionary, the JP2 palette gives the + /// colors. Raw indices (0 and 1) would 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, usize::MAX).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` color 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, usize::MAX).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 + } + + /// PdfCraft #3184: resolved palette colors 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, usize::MAX) + .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)); + let one_bit = ImageDecodeParams { + bpc: Some(1), + ..params.clone() + }; + let palette_bpc = decode(&one_bit_jp2(&palette), &one_bit, usize::MAX) + .and_then(|r| r.image_data) + .map(|d| d.bits_per_component); + assert_eq!(palette_bpc, Some(8), "/BitsPerComponent 1 with a palette"); + } } diff --git a/vendor/hayro-syntax/src/filter/mod.rs b/vendor/hayro-syntax/src/filter/mod.rs index e3622795..c6ee6ed2 100644 --- a/vendor/hayro-syntax/src/filter/mod.rs +++ b/vendor/hayro-syntax/src/filter/mod.rs @@ -110,11 +110,11 @@ impl Filter { #[cfg(feature = "images")] Self::DctDecode => dct::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::CcittFaxDecode => ccitt::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), + Self::CcittFaxDecode => ccitt::decode(data, params, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] Self::Jbig2Decode => jbig2::decode(data, params, image_params).ok_or(DecodeFailure::ImageDecode), #[cfg(feature = "images")] - Self::JpxDecode => jpx::decode(data, image_params).ok_or(DecodeFailure::ImageDecode), + Self::JpxDecode => jpx::decode(data, image_params, limit).ok_or(DecodeFailure::ImageDecode), #[cfg(not(feature = "images"))] Self::DctDecode | Self::CcittFaxDecode | Self::Jbig2Decode | Self::JpxDecode => { warn!("image decoding is not supported (enable the `images` feature)"); @@ -168,6 +168,30 @@ pub(crate) fn decode_limit(params: &ImageDecodeParams, last: bool, limits: crate /// real fax lines are a few thousand pixels wide. pub fn ccitt_size_ok(columns: u32, rows: u32) -> bool { const MAX_COLUMNS: u32 = 1 << 20; - const MAX_PIXELS: u64 = 1 << 28; - columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_PIXELS + columns > 0 && columns <= MAX_COLUMNS && u64::from(columns) * u64::from(rows.max(1)) <= MAX_CCITT_PIXELS +} + +/// PdfCraft patch: the most pixels a CCITT image may decode to. +const MAX_CCITT_PIXELS: u64 = 1 << 28; + +/// PdfCraft patch: the most rows of `columns` pixels, `row_bytes` bytes each once decoded, that fit +/// both `limit` bytes and the pixel cap; at least one when one row fits. +pub(crate) fn ccitt_max_rows(columns: u32, row_bytes: u32, limit: usize) -> u32 { + let by_pixels = MAX_CCITT_PIXELS / u64::from(columns.max(1)); + let by_bytes = u64::try_from(limit).unwrap_or(u64::MAX) / u64::from(row_bytes.max(1)); + u32::try_from(by_pixels.min(by_bytes)).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod ccitt_tests { + use super::ccitt_max_rows; + + /// PdfCraft patch: an image of unknown height decodes at most the rows that fit both the output + /// limit (in 1-bit rows) and the 2^28-pixel cap. + #[test] + fn unknown_ccitt_rows_fit_the_limit_and_the_pixel_cap() { + assert_eq!(ccitt_max_rows(1728, 216, 216 * 100), 100); + assert_eq!(ccitt_max_rows(1 << 20, 1 << 17, usize::MAX), 1 << 8); + assert_eq!(ccitt_max_rows(1728, 216, 100), 0); + } } diff --git a/vendor/hayro-syntax/src/xref.rs b/vendor/hayro-syntax/src/xref.rs index 15858ac4..4f9de535 100644 --- a/vendor/hayro-syntax/src/xref.rs +++ b/vendor/hayro-syntax/src/xref.rs @@ -759,6 +759,34 @@ fn populate_xref_impl<'a>(data: &'a [u8], pos: usize, xref_map: &mut XrefMap, li populate_xref_impl_inner(data, pos, xref_map, &mut visited, limits) } +/// The offset of the previous xref section (`Prev`). +// PdfCraft patch: (PdfCraft #2400) some writers give `Prev` as a reference `N G R`. When this +// section's own entries (`own`) place that object and it holds an offset (iText's +// `indirectPrev.pdf`), that is the offset; otherwise its object number is (tiff2pdf, in JHOVE's +// PDF-HUL-157 files), as other readers take it. +fn prev_offset(data: &[u8], trailer: &Dict<'_>, own: &XrefMap) -> Option { + if let Some(prev) = trailer.get::(PREV) { + return usize::try_from(prev).ok(); + } + + let r = trailer.get_ref(PREV)?; + let id = ObjectIdentifier::new(r.obj_number, r.gen_number); + let held = match own.get(&id) { + Some(EntryType::Normal(at)) => { + let mut reader = Reader::new(data); + reader.jump(*at); + reader + .read_with_context::>(&ReaderContext::dummy()) + .filter(|o| o.id() == &id) + .map(|o| o.get()) + .filter(|p| *p >= 0) + } + _ => None, + }; + + usize::try_from(held.unwrap_or(r.obj_number)).ok() +} + /// Maximum number of allowed xref `Prev` pointers before we abort. const MAX_XREF_CHAIN_DEPTH: usize = 256; @@ -832,17 +860,8 @@ fn populate_from_xref_table<'a>( reader.skip_white_spaces(); let mut max_obj = 0; - - if let Some(prev) = trailer.get::(PREV) { - // First insert the entries from any previous xref tables. - populate_xref_impl_inner(data, prev as usize, insert_map, visited, limits)?; - } - - // In hybrid files, entries in `XRefStm` should have higher priority, therefore we insert them - // after looking at `PREV`. - if let Some(xref_stm) = trailer.get::(XREF_STM) { - populate_xref_impl_inner(data, xref_stm as usize, insert_map, visited, limits)?; - } + // PdfCraft patch: (PdfCraft #2400) this section's entries are read first, for `prev_offset`. + let mut own = XrefMap::default(); while let Some(header) = reader.read_without_context::() { reader.skip_white_spaces(); @@ -858,7 +877,7 @@ fn populate_from_xref_table<'a>( // Specification says we should ignore any object number > SIZE, but probably // not important? if entry.used { - insert_map.insert( + own.insert( ObjectIdentifier::new(obj_number as i32, entry.gen_number), EntryType::Normal(entry.offset), ); @@ -866,6 +885,19 @@ fn populate_from_xref_table<'a>( } } + if let Some(prev) = prev_offset(data, &trailer, &own) { + // First insert the entries from any previous xref tables. + populate_xref_impl_inner(data, prev, insert_map, visited, limits)?; + } + + // In hybrid files, entries in `XRefStm` should have higher priority, therefore we insert them + // after looking at `PREV`. + if let Some(xref_stm) = trailer.get::(XREF_STM) { + populate_xref_impl_inner(data, xref_stm as usize, insert_map, visited, limits)?; + } + + insert_map.extend(own); + Some(trailer.data()) } @@ -878,11 +910,6 @@ fn populate_from_xref_stream<'a>( ) -> Option<&'a [u8]> { let stream = reader.read_with_context::>>(&ReaderContext::dummy_with_limits(limits))?.get(); - if let Some(prev) = stream.dict().get::(PREV) { - // First insert the entries from any previous xref tables. - let _ = populate_xref_impl_inner(data, prev as usize, insert_map, visited, limits)?; - } - let size = stream.dict().get::(SIZE)?; let [f1_len, f2_len, f3_len] = stream.dict().get::<[u8; 3]>(W)?; @@ -895,6 +922,8 @@ fn populate_from_xref_stream<'a>( } let xref_data = stream.decoded().ok()?; + // PdfCraft patch: (PdfCraft #2400) this section's entries are read first, for `prev_offset`. + let mut own = XrefMap::default(); let mut xref_reader = Reader::new(xref_data.as_ref()); if let Some(arr) = stream.dict().get::>(INDEX) { @@ -908,21 +937,20 @@ fn populate_from_xref_stream<'a>( f1_len, f2_len, f3_len, - insert_map, + &mut own, )?; } } else { - xref_stream_subsection( - &mut xref_reader, - 0, - size, - f1_len, - f2_len, - f3_len, - insert_map, - )?; + xref_stream_subsection(&mut xref_reader, 0, size, f1_len, f2_len, f3_len, &mut own)?; } + if let Some(prev) = prev_offset(data, stream.dict(), &own) { + // First insert the entries from any previous xref tables. + let _ = populate_xref_impl_inner(data, prev, insert_map, visited, limits)?; + } + + insert_map.extend(own); + Some(stream.dict().data()) } @@ -1450,6 +1478,90 @@ mod tests { let _result = populate_xref_impl(pdf.as_ref(), xref_pos, &mut xref_map, DecodeLimits::default()); } + #[test] + fn prev_as_reference_reaches_the_earlier_section() { + let mut pdf = b"%PDF-1.0\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_vec(); + let first_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 2\n\ + 0000000000 65535 f \n\ + 0000000009 00000 n \n\ + trailer\n<< /Size 2 /Root 1 0 R >>\n\ + startxref\n{first_xref_pos}\n%%EOF\n" + ) + .as_bytes(), + ); + let second_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 1\n\ + 0000000000 65535 f \n\ + trailer\n<< /Size 2 /Root 1 0 R /Prev {first_xref_pos} 0 R >>\n\ + startxref\n{second_xref_pos}\n%%EOF" + ) + .as_bytes(), + ); + + let mut xref_map = FxHashMap::default(); + let xref_pos = find_last_xref_pos(pdf.as_ref()).unwrap(); + assert_eq!(xref_pos, second_xref_pos); + populate_xref_impl( + pdf.as_ref(), + xref_pos, + &mut xref_map, + DecodeLimits::default(), + ) + .unwrap(); + assert!(xref_map.contains_key(&ObjectIdentifier::new(1, 0))); + } + + #[test] + fn prev_as_reference_to_an_object_holding_the_offset_reads_that_offset() { + let mut pdf = b"%PDF-1.0\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_vec(); + let first_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 0 2\n\ + 0000000000 65535 f \n\ + 0000000009 00000 n \n\ + trailer\n<< /Size 2 /Root 1 0 R >>\n\ + startxref\n{first_xref_pos}\n%%EOF\n" + ) + .as_bytes(), + ); + // Object 3 holds the offset; read as offset 3, `Prev` would point inside the header. + let held_pos = pdf.len(); + pdf.extend_from_slice(format!("3 0 obj\n{first_xref_pos}\nendobj\n").as_bytes()); + let second_xref_pos = pdf.len(); + pdf.extend_from_slice( + format!( + "xref\n\ + 3 1\n\ + {held_pos:010} 00000 n \n\ + trailer\n<< /Size 4 /Root 1 0 R /Prev 3 0 R >>\n\ + startxref\n{second_xref_pos}\n%%EOF" + ) + .as_bytes(), + ); + + let mut xref_map = FxHashMap::default(); + let xref_pos = find_last_xref_pos(pdf.as_ref()).unwrap(); + assert_eq!(xref_pos, second_xref_pos); + populate_xref_impl( + pdf.as_ref(), + xref_pos, + &mut xref_map, + DecodeLimits::default(), + ) + .unwrap(); + assert!(xref_map.contains_key(&ObjectIdentifier::new(1, 0))); + assert!(xref_map.contains_key(&ObjectIdentifier::new(3, 0))); + } + #[test] fn find_last_xref_uses_last_startxref() { let pdf = b"%PDF-1.0\nstartxref\n5\n%%EOF\nstartxref\n42\n%%EOF"; diff --git a/vendor/hayro/SOURCE.json b/vendor/hayro/SOURCE.json index d7d97f1e..1ea22629 100644 --- a/vendor/hayro/SOURCE.json +++ b/vendor/hayro/SOURCE.json @@ -62,13 +62,13 @@ "path": "src/lib.rs", "source_sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078", "source_git_blob": "6333ed5808ae0cf9cf158e4730e973c1d7089bab", - "sha256": "682db2a32fbf4d875c0e9dd7ac5dc875a6b09ade375efbce6f104de9197de078" + "sha256": "8b17f78900e6f15b0d458629d69c954e436a3e8b97c01563c884456b87379d9f" }, { "path": "src/renderer.rs", "source_sha256": "f7ff7bd088ceb43823ede33151138384af331cf71403f7d23f7b2937212efc3b", "source_git_blob": "6aa53716ad2d0203a8058b2f7271515b4fc1db22", - "sha256": "d762789ffe04fae9dc37fb5cf5b81ef59a61742286f4a6b5eb64e52e0c76c39c" + "sha256": "02d256f43d9b8bceb68cee9f9f9ed0bcb48bb70d7068f2d1c6d6a9d85e3f4e64" } ] } diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 6333ed58..bd6468c8 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -254,15 +254,22 @@ 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 color (#2970). + // Under reading colors 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 color and the words under it vanished into the band + // (#1548). Reading colors replace the page's colors anyway; for document colors the + // background stays outside, or every separable mode but Normal, Multiply, Darken and + // HardLight would blend with the white paper. Craft patch: a plate render keeps the + // background inside as well, so blend modes on a separation's film blend with the paper. + let backdrop_inside = + render_settings.color_replacement.is_some() || interpreter_settings.plate.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 +281,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 +690,152 @@ mod color_replacement_render_tests { assert_eq!(sample(&no_line_art, 40, 25), [0, 0, 0, 255]); } } + +/// PdfCraft patch (#3296): 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 d8e8010c..ea329b81 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 (#3296): 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 @@ -1093,23 +1102,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 { // Knockout patch (pdfcraft #2974): 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 match paint { + 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 (#3296): `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) => { @@ -1142,6 +1154,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 (#3296): a mesh paints only where its triangles are. + // The texture covers the triangles alone (and a pixel around them, + // which stays transparent and is what `Extend::Pad` repeats), not the + // whole path (the canvas, for `sh`): sampled pixel by pixel, 310 + // one-triangle meshes on a plan cost 310 full tiles per tile, and the + // watchdog gave the page up. 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 @@ -1157,7 +1191,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) = @@ -1176,8 +1210,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, }, @@ -1263,7 +1297,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). @@ -1341,7 +1375,7 @@ impl Renderer { self.ctx.set_paint_transform(paint_transform); self.ctx.set_paint(paint); - clip_path + PaintSetup::Clip(clip_path) } fn stroke_path( @@ -1361,7 +1395,9 @@ 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); } @@ -1390,7 +1426,9 @@ 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); } @@ -1618,14 +1656,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 uncolored tiling pattern + // in its stroke color (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(); @@ -1844,7 +1887,9 @@ 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); } @@ -1876,8 +1921,8 @@ impl<'a> Device<'a> for Renderer { GlyphDrawMode::Stroke(s) => { Self::stroke_glyph(self, glyph, transform, glyph_transform, paint, s); } - GlyphDrawMode::Invisible => { - // Don't render invisible text for visual output + GlyphDrawMode::Invisible | GlyphDrawMode::Clip => { + // PdfCraft patch (#3116): neither mode paints; the interpreter installs the clip. } } } diff --git a/vendor/krilla/README.craft.md b/vendor/krilla/README.craft.md index 3b55e756..c47779c7 100644 --- a/vendor/krilla/README.craft.md +++ b/vendor/krilla/README.craft.md @@ -22,6 +22,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi | `patches/10-mesh-gradients.patch` | Mesh gradients | `content.rs`, `error.rs`, `graphics/{mask,paint,shading_function}.rs`, `serialize.rs`, `surface.rs` | | `patches/11-png-decode-cap.patch` | PNG decode cap | `graphics/image.rs` | | `patches/12-optional-content.patch` | Optional content | `chunk_container.rs`, `configure/{PDF_A1,PDF_A2,PDF_A4,PDF_UA1}.md`, `configure/validate.rs`, `content.rs`, `error.rs`, `graphics/{mod,optional_content}.rs`, `lib.rs`, `resource.rs`, `serialize.rs`, `surface.rs` | +| `patches/13-annotation-bounds.patch` | Annotation and destination bounds | `error.rs`, `interactive/{annotation,destination}.rs`, `page.rs`, `serialize.rs` | Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`) @@ -336,3 +337,22 @@ get small hooks. `CRAFT_OPTIONAL_CONTENT` marks the patch; craft-pdf requires it at compile time. Tests: `crates/pdf/tests/optional_content.rs`. + +## 13: annotation and destination bounds + +Upstream panics on link geometry that has no finite place on the page, which aborts a WASM +app. `Document::finish` returns an error instead and writes nothing for the object: + +| Input | Upstream | Error | +|---|---|---| +| `LinkAnnotation::new_with_quad_points` with no quads, a non-finite point, or a bounding box whose width or height overflows | `assert!` or `unwrap` | `InvalidAnnotationGeometry(location)` | +| A link rect or quad bounding box that overflows when flipped into PDF coordinates (`page_height - y`) | `unwrap` | `InvalidAnnotationGeometry(location)` | +| A media, crop, bleed, trim or art box that overflows the same way | `unwrap` | `InvalidPageBox(page_index)` | +| An `XyzDestination` page index past the last page | `panic!` | `InvalidDestination(page_index)` | +| An `XyzDestination` point that maps to a non-finite coordinate | writes `inf` or `NaN` | `InvalidDestination(page_index)` | + +The constructors keep their signatures: `LinkAnnotation` holds an `Option` and the check +runs at serialization, before the annotation object starts. Refusing a single annotation +instead of skipping it keeps the indices of tagged annotations in the structure tree valid. + +Tests: `crates/pdf/tests/link_annotations.rs`. diff --git a/vendor/krilla/SOURCE.json b/vendor/krilla/SOURCE.json index ce4d6ed2..be542dd6 100644 --- a/vendor/krilla/SOURCE.json +++ b/vendor/krilla/SOURCE.json @@ -137,7 +137,7 @@ { "path": "src/error.rs", "upstream_sha256": "656b260bd99d250535099f17f2db8e6e6135e72c74fb6dff304371db9f646be5", - "sha256": "fadd496a1d3e782ef8dfc40a59bc0c070406846d8a24b8b21eaa6130d4c58f5a" + "sha256": "8d06efd85136668d1a491f2f04ba2d69e82347a89f275d9299998445f28172ac" }, { "path": "src/geom.rs", @@ -232,12 +232,12 @@ { "path": "src/interactive/annotation.rs", "upstream_sha256": "45b01acfef124a5ab9b6799ab4c83734f929b3573fdb28c218b108e220afad39", - "sha256": "45b01acfef124a5ab9b6799ab4c83734f929b3573fdb28c218b108e220afad39" + "sha256": "50f2a4bdc99d82bda275533e3a269eca6e7ae3ecb68b781203160122c744e55e" }, { "path": "src/interactive/destination.rs", "upstream_sha256": "fbe7ce81ce195dbc86502e8555268c026a7c795cfe8a94a975e23cab16b657c3", - "sha256": "fbe7ce81ce195dbc86502e8555268c026a7c795cfe8a94a975e23cab16b657c3" + "sha256": "3043468f3caafe0fd0d3cac6cdd6bafe5ccbd89c4c3a5817ce505d5fe9403c36" }, { "path": "src/interactive/mod.rs", @@ -302,7 +302,7 @@ { "path": "src/page.rs", "upstream_sha256": "ef85a771013896b185c9393414175ee91e5541c9a3fad4760c161764088ac177", - "sha256": "0c85a6b7be5c1e07180cea54f62b53e5a9e6c5c37790122f2ef5c9f8adc4efd7" + "sha256": "9822a623229000c1017d636867971a9f7aa3a4cc3094ba3057786ee146ee3dcd" }, { "path": "src/pdf.rs", @@ -317,7 +317,7 @@ { "path": "src/serialize.rs", "upstream_sha256": "4f2c439dbd20006eea112e056b2f1a5ba98a1ede9227a45f66a6df46fb1a07e8", - "sha256": "99cda1c62910281776726de63b1600830484be0972b8d60adbca2d9b341cd49b" + "sha256": "f5ccb6b92f546e1068e51e0791dbac8b802f07f29df0c2a6ccd1dcf8a1bdc938" }, { "path": "src/stream.rs", diff --git a/vendor/krilla/patches/13-annotation-bounds.patch b/vendor/krilla/patches/13-annotation-bounds.patch new file mode 100644 index 00000000..f7945f15 --- /dev/null +++ b/vendor/krilla/patches/13-annotation-bounds.patch @@ -0,0 +1,299 @@ +diff --git a/src/error.rs b/src/error.rs +index a19ebf6..7158052 100644 +--- a/src/error.rs ++++ b/src/error.rs +@@ -43,6 +43,15 @@ pub enum KrillaError { + /// Optional content marks were not balanced: an end without a start, a mark still open + /// when its stream finished, or a mark overlapping a tagged section. + UnbalancedOptionalContent(Option), ++ /// A link annotation has no finite bounding box on its page: its quad points are empty ++ /// or not finite, or its rect doesn't map to finite page coordinates. ++ InvalidAnnotationGeometry(Option), ++ /// An XYZ destination targets a page the document doesn't have (the index is ++ /// zero-based), or a point that isn't finite on that page. ++ InvalidDestination(usize), ++ /// A page's media, crop, bleed, trim or art box doesn't map to finite PDF coordinates. ++ /// Holds the zero-based page index. ++ InvalidPageBox(usize), + /// An error while attempting to embed a font. + Font(Font, String), + /// A list of validation errors. Can only occur if you set the `validator` in +@@ -96,6 +105,20 @@ impl Display for KrillaError { + f.write_str("optional content marks are not balanced")?; + write_location(f, *location) + } ++ KrillaError::InvalidAnnotationGeometry(location) => { ++ f.write_str("a link annotation has no finite bounding box")?; ++ write_location(f, *location) ++ } ++ KrillaError::InvalidDestination(page_index) => write!( ++ f, ++ "a destination on page {} is not in the document or not finite", ++ page_index.saturating_add(1) ++ ), ++ KrillaError::InvalidPageBox(page_index) => write!( ++ f, ++ "a page box on page {} doesn't map to finite coordinates", ++ page_index.saturating_add(1) ++ ), + KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), + KrillaError::Validation(errors) => { + let count = errors.len(); +diff --git a/src/interactive/annotation.rs b/src/interactive/annotation.rs +index 91c4cff..f2121f1 100644 +--- a/src/interactive/annotation.rs ++++ b/src/interactive/annotation.rs +@@ -14,7 +14,7 @@ use pdf_writer::{Finish, Name, Ref, TextStr}; + use crate::chunk_container::ChunkContainer; + use crate::color::Color; + use crate::configure::{PdfVersion, ValidationError}; +-use crate::error::KrillaResult; ++use crate::error::{KrillaError, KrillaResult}; + use crate::geom::{Quadrilateral, Rect}; + use crate::interactive::action::Action; + use crate::interactive::destination::Destination; +@@ -70,15 +70,20 @@ impl Annotation { + root_ref: Ref, + page_height: f32, + ) -> KrillaResult<()> { ++ let AnnotationType::Link(l) = &self.annotation_type; ++ // Checked before the object starts, so a refused annotation writes nothing. ++ let rect = l ++ .page_rect(page_height) ++ .ok_or(KrillaError::InvalidAnnotationGeometry(self.location))?; ++ + let chunk = &mut chunk_container.non_stream.annotations; + let mut annotation = chunk + .indirect(root_ref) + .start::(); + + self.annotation_type +- .serialize_type(sc, &mut annotation, page_height)?; ++ .serialize_type(sc, &mut annotation, page_height, rect)?; + +- let AnnotationType::Link(l) = &self.annotation_type; + // Only set the print flag when really necessary (only PDF/A). Don't + // set it by default, so annotations with color borders will be shown + // on a screen but not printed. +@@ -124,9 +129,10 @@ impl AnnotationType { + sc: &mut SerializeContext, + annotation: &mut pdf_writer::writers::Annotation, + page_height: f32, ++ rect: Rect, + ) -> KrillaResult<()> { + match self { +- AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height), ++ AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height, rect), + } + } + } +@@ -157,7 +163,8 @@ impl LinkBorder { + + /// A link annotation. + pub struct LinkAnnotation { +- pub(crate) rect: Rect, ++ /// `None` when the quad points have no finite bounding box. ++ pub(crate) rect: Option, + pub(crate) quad_points: Option>, + pub(crate) target: Target, + pub(crate) border: Option, +@@ -170,7 +177,7 @@ impl LinkAnnotation { + /// `target`: The target of the link annotation. + pub fn new(rect: Rect, target: Target) -> Self { + Self { +- rect, ++ rect: Some(rect), + quad_points: None, + target, + border: None, +@@ -183,8 +190,16 @@ impl LinkAnnotation { + /// `quad_points`: An array of quadrilaterals that define where the link + /// annotation should be activated. This is useful if you for example have + /// a link annotation that is broken to one or multiple lines. ++ /// ++ /// An empty list, or a point that isn't finite, makes [`Document::finish`] return ++ /// [`KrillaError::InvalidAnnotationGeometry`]. ++ /// ++ /// [`Document::finish`]: crate::Document::finish + pub fn new_with_quad_points(quad_points: Vec, target: Target) -> Self { +- assert!(!quad_points.is_empty()); ++ let finite = quad_points ++ .iter() ++ .flat_map(|q| q.0) ++ .all(|p| p.x.is_finite() && p.y.is_finite()); + + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; +@@ -202,13 +217,16 @@ impl LinkAnnotation { + // that sometimes prevents the quadpoints from being used if the quad + // points lie exactly on the bounding rectangle. + const EPSILON: f32 = 0.001; +- let rect = Rect::from_ltrb( +- min_x - EPSILON, +- min_y - EPSILON, +- max_x + EPSILON, +- max_y + EPSILON, +- ) +- .unwrap(); ++ let rect = finite ++ .then(|| { ++ Rect::from_ltrb( ++ min_x - EPSILON, ++ min_y - EPSILON, ++ max_x + EPSILON, ++ max_y + EPSILON, ++ ) ++ }) ++ .flatten(); + + Self { + rect, +@@ -227,19 +245,23 @@ impl LinkAnnotation { + } + } + ++ /// The rect in PDF page coordinates, or `None` when there is no rect or it doesn't ++ /// map to finite coordinates. The quad points lie inside the rect, so they map to ++ /// finite coordinates whenever it does. ++ fn page_rect(&self, page_height: f32) -> Option { ++ self.rect?.transform(page_root_transform(page_height)) ++ } ++ + fn serialize_type( + &self, + sc: &mut SerializeContext, + annotation: &mut pdf_writer::writers::Annotation, + page_height: f32, ++ rect: Rect, + ) -> KrillaResult<()> { + annotation.subtype(pdf_writer::types::AnnotationType::Link); + +- let actual_rect = self +- .rect +- .transform(page_root_transform(page_height)) +- .unwrap(); +- annotation.rect(actual_rect.to_pdf_rect()); ++ annotation.rect(rect.to_pdf_rect()); + annotation.border( + 0.0, + 0.0, +diff --git a/src/interactive/destination.rs b/src/interactive/destination.rs +index de40085..ec50951 100644 +--- a/src/interactive/destination.rs ++++ b/src/interactive/destination.rs +@@ -117,7 +117,10 @@ impl From for Destination { + impl XyzDestination { + /// Create a new XYZ destination. `page_index` should be the index (i.e. number) of the + /// target page, and point indicates the specific location on that page that should be +- /// targeted. If the `page_index` is out of range, export will panic. ++ /// targeted. A `page_index` out of range, or a point that isn't finite, makes ++ /// [`Document::finish`] return [`KrillaError::InvalidDestination`]. ++ /// ++ /// [`Document::finish`]: crate::Document::finish + pub fn new(page_index: usize, point: Point) -> Self { + Self(Arc::new(XyzDestRepr { page_index, point })) + } +@@ -127,17 +130,9 @@ impl XyzDestination { + sc: &mut SerializeContext, + chunk_container: &mut ChunkContainer, + root_ref: Ref, +- ) { +- let chunk = &mut chunk_container.non_stream.destinations; +- let destination = chunk.destination(root_ref); +- +- let page_info = sc.page_infos().get(self.0.page_index).unwrap_or_else(|| { +- panic!( +- "attempted to link to page {}, but document only has {} pages", +- self.0.page_index + 1, +- sc.page_infos().len() +- ) +- }); ++ ) -> KrillaResult<()> { ++ let invalid = || KrillaError::InvalidDestination(self.0.page_index); ++ let page_info = sc.page_infos().get(self.0.page_index).ok_or_else(invalid)?; + + let (ref_, surface_size) = match page_info { + PageInfo::Krilla { +@@ -153,10 +148,17 @@ impl XyzDestination { + // Convert to PDF coordinates + let invert_transform = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, page_size); + invert_transform.map_point(&mut mapped_point); ++ if !(mapped_point.x.is_finite() && mapped_point.y.is_finite()) { ++ return Err(invalid()); ++ } + +- destination ++ chunk_container ++ .non_stream ++ .destinations ++ .destination(root_ref) + .page(page_ref) + .xyz(mapped_point.x, mapped_point.y, None); ++ Ok(()) + } + } + +diff --git a/src/page.rs b/src/page.rs +index 3b3227b..c596677 100644 +--- a/src/page.rs ++++ b/src/page.rs +@@ -12,7 +12,7 @@ use crate::chunk_container::ChunkContainer; + use crate::configure::validate::VersionedFeature; + use crate::configure::ValidationError; + use crate::content::ContentBuilder; +-use crate::error::KrillaResult; ++use crate::error::{KrillaError, KrillaResult}; + use crate::geom::{Rect, Size, Transform}; + use crate::graphics::xobject::write_group_color_space; + use crate::interactive::annotation::Annotation; +@@ -437,31 +437,31 @@ impl InternalPage { + rect.transform(page_root_transform( + self.page_settings.surface_size().height(), + )) +- .unwrap() ++ .ok_or(KrillaError::InvalidPageBox(self.page_index)) + }; + + // media box is mandatory, so we need to fall back to the default bbox +- let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox)); ++ let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox))?; + page.media_box(media_box.to_pdf_rect()); + + // the remaining type of box are not mandatory, so we only set them if they are present + if let Some(crop_box) = self.page_settings.crop_box() { +- let crop_box = transform_rect(crop_box); ++ let crop_box = transform_rect(crop_box)?; + page.crop_box(crop_box.to_pdf_rect()); + } + + if let Some(bleed_box) = self.page_settings.bleed_box() { +- let bleed_box = transform_rect(bleed_box); ++ let bleed_box = transform_rect(bleed_box)?; + page.bleed_box(bleed_box.to_pdf_rect()); + } + + if let Some(trim_box) = self.page_settings.trim_box() { +- let trim_box = transform_rect(trim_box); ++ let trim_box = transform_rect(trim_box)?; + page.trim_box(trim_box.to_pdf_rect()); + } + + if let Some(art_box) = self.page_settings.art_box() { +- let art_box = transform_rect(art_box); ++ let art_box = transform_rect(art_box)?; + page.art_box(art_box.to_pdf_rect()); + } + +diff --git a/src/serialize.rs b/src/serialize.rs +index 4f4af4c..f2986fc 100644 +--- a/src/serialize.rs ++++ b/src/serialize.rs +@@ -1019,7 +1019,7 @@ impl SerializeContext { + ) -> KrillaResult<()> { + let xyz_destinations = self.global_objects.xyz_destinations.take(); + for (ref_, dest) in &xyz_destinations { +- dest.serialize(self, chunk_container, *ref_); ++ dest.serialize(self, chunk_container, *ref_)?; + } + + Ok(()) diff --git a/vendor/krilla/src/error.rs b/vendor/krilla/src/error.rs index a19ebf62..71580526 100644 --- a/vendor/krilla/src/error.rs +++ b/vendor/krilla/src/error.rs @@ -43,6 +43,15 @@ pub enum KrillaError { /// Optional content marks were not balanced: an end without a start, a mark still open /// when its stream finished, or a mark overlapping a tagged section. UnbalancedOptionalContent(Option), + /// A link annotation has no finite bounding box on its page: its quad points are empty + /// or not finite, or its rect doesn't map to finite page coordinates. + InvalidAnnotationGeometry(Option), + /// An XYZ destination targets a page the document doesn't have (the index is + /// zero-based), or a point that isn't finite on that page. + InvalidDestination(usize), + /// A page's media, crop, bleed, trim or art box doesn't map to finite PDF coordinates. + /// Holds the zero-based page index. + InvalidPageBox(usize), /// An error while attempting to embed a font. Font(Font, String), /// A list of validation errors. Can only occur if you set the `validator` in @@ -96,6 +105,20 @@ impl Display for KrillaError { f.write_str("optional content marks are not balanced")?; write_location(f, *location) } + KrillaError::InvalidAnnotationGeometry(location) => { + f.write_str("a link annotation has no finite bounding box")?; + write_location(f, *location) + } + KrillaError::InvalidDestination(page_index) => write!( + f, + "a destination on page {} is not in the document or not finite", + page_index.saturating_add(1) + ), + KrillaError::InvalidPageBox(page_index) => write!( + f, + "a page box on page {} doesn't map to finite coordinates", + page_index.saturating_add(1) + ), KrillaError::Font(_, message) => write!(f, "failed to embed font: {message}"), KrillaError::Validation(errors) => { let count = errors.len(); diff --git a/vendor/krilla/src/interactive/annotation.rs b/vendor/krilla/src/interactive/annotation.rs index 91c4cff6..f2121f1a 100644 --- a/vendor/krilla/src/interactive/annotation.rs +++ b/vendor/krilla/src/interactive/annotation.rs @@ -14,7 +14,7 @@ use pdf_writer::{Finish, Name, Ref, TextStr}; use crate::chunk_container::ChunkContainer; use crate::color::Color; use crate::configure::{PdfVersion, ValidationError}; -use crate::error::KrillaResult; +use crate::error::{KrillaError, KrillaResult}; use crate::geom::{Quadrilateral, Rect}; use crate::interactive::action::Action; use crate::interactive::destination::Destination; @@ -70,15 +70,20 @@ impl Annotation { root_ref: Ref, page_height: f32, ) -> KrillaResult<()> { + let AnnotationType::Link(l) = &self.annotation_type; + // Checked before the object starts, so a refused annotation writes nothing. + let rect = l + .page_rect(page_height) + .ok_or(KrillaError::InvalidAnnotationGeometry(self.location))?; + let chunk = &mut chunk_container.non_stream.annotations; let mut annotation = chunk .indirect(root_ref) .start::(); self.annotation_type - .serialize_type(sc, &mut annotation, page_height)?; + .serialize_type(sc, &mut annotation, page_height, rect)?; - let AnnotationType::Link(l) = &self.annotation_type; // Only set the print flag when really necessary (only PDF/A). Don't // set it by default, so annotations with color borders will be shown // on a screen but not printed. @@ -124,9 +129,10 @@ impl AnnotationType { sc: &mut SerializeContext, annotation: &mut pdf_writer::writers::Annotation, page_height: f32, + rect: Rect, ) -> KrillaResult<()> { match self { - AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height), + AnnotationType::Link(l) => l.serialize_type(sc, annotation, page_height, rect), } } } @@ -157,7 +163,8 @@ impl LinkBorder { /// A link annotation. pub struct LinkAnnotation { - pub(crate) rect: Rect, + /// `None` when the quad points have no finite bounding box. + pub(crate) rect: Option, pub(crate) quad_points: Option>, pub(crate) target: Target, pub(crate) border: Option, @@ -170,7 +177,7 @@ impl LinkAnnotation { /// `target`: The target of the link annotation. pub fn new(rect: Rect, target: Target) -> Self { Self { - rect, + rect: Some(rect), quad_points: None, target, border: None, @@ -183,8 +190,16 @@ impl LinkAnnotation { /// `quad_points`: An array of quadrilaterals that define where the link /// annotation should be activated. This is useful if you for example have /// a link annotation that is broken to one or multiple lines. + /// + /// An empty list, or a point that isn't finite, makes [`Document::finish`] return + /// [`KrillaError::InvalidAnnotationGeometry`]. + /// + /// [`Document::finish`]: crate::Document::finish pub fn new_with_quad_points(quad_points: Vec, target: Target) -> Self { - assert!(!quad_points.is_empty()); + let finite = quad_points + .iter() + .flat_map(|q| q.0) + .all(|p| p.x.is_finite() && p.y.is_finite()); let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; @@ -202,13 +217,16 @@ impl LinkAnnotation { // that sometimes prevents the quadpoints from being used if the quad // points lie exactly on the bounding rectangle. const EPSILON: f32 = 0.001; - let rect = Rect::from_ltrb( - min_x - EPSILON, - min_y - EPSILON, - max_x + EPSILON, - max_y + EPSILON, - ) - .unwrap(); + let rect = finite + .then(|| { + Rect::from_ltrb( + min_x - EPSILON, + min_y - EPSILON, + max_x + EPSILON, + max_y + EPSILON, + ) + }) + .flatten(); Self { rect, @@ -227,19 +245,23 @@ impl LinkAnnotation { } } + /// The rect in PDF page coordinates, or `None` when there is no rect or it doesn't + /// map to finite coordinates. The quad points lie inside the rect, so they map to + /// finite coordinates whenever it does. + fn page_rect(&self, page_height: f32) -> Option { + self.rect?.transform(page_root_transform(page_height)) + } + fn serialize_type( &self, sc: &mut SerializeContext, annotation: &mut pdf_writer::writers::Annotation, page_height: f32, + rect: Rect, ) -> KrillaResult<()> { annotation.subtype(pdf_writer::types::AnnotationType::Link); - let actual_rect = self - .rect - .transform(page_root_transform(page_height)) - .unwrap(); - annotation.rect(actual_rect.to_pdf_rect()); + annotation.rect(rect.to_pdf_rect()); annotation.border( 0.0, 0.0, diff --git a/vendor/krilla/src/interactive/destination.rs b/vendor/krilla/src/interactive/destination.rs index de40085d..ec509511 100644 --- a/vendor/krilla/src/interactive/destination.rs +++ b/vendor/krilla/src/interactive/destination.rs @@ -117,7 +117,10 @@ impl From for Destination { impl XyzDestination { /// Create a new XYZ destination. `page_index` should be the index (i.e. number) of the /// target page, and point indicates the specific location on that page that should be - /// targeted. If the `page_index` is out of range, export will panic. + /// targeted. A `page_index` out of range, or a point that isn't finite, makes + /// [`Document::finish`] return [`KrillaError::InvalidDestination`]. + /// + /// [`Document::finish`]: crate::Document::finish pub fn new(page_index: usize, point: Point) -> Self { Self(Arc::new(XyzDestRepr { page_index, point })) } @@ -127,17 +130,9 @@ impl XyzDestination { sc: &mut SerializeContext, chunk_container: &mut ChunkContainer, root_ref: Ref, - ) { - let chunk = &mut chunk_container.non_stream.destinations; - let destination = chunk.destination(root_ref); - - let page_info = sc.page_infos().get(self.0.page_index).unwrap_or_else(|| { - panic!( - "attempted to link to page {}, but document only has {} pages", - self.0.page_index + 1, - sc.page_infos().len() - ) - }); + ) -> KrillaResult<()> { + let invalid = || KrillaError::InvalidDestination(self.0.page_index); + let page_info = sc.page_infos().get(self.0.page_index).ok_or_else(invalid)?; let (ref_, surface_size) = match page_info { PageInfo::Krilla { @@ -153,10 +148,17 @@ impl XyzDestination { // Convert to PDF coordinates let invert_transform = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, page_size); invert_transform.map_point(&mut mapped_point); + if !(mapped_point.x.is_finite() && mapped_point.y.is_finite()) { + return Err(invalid()); + } - destination + chunk_container + .non_stream + .destinations + .destination(root_ref) .page(page_ref) .xyz(mapped_point.x, mapped_point.y, None); + Ok(()) } } diff --git a/vendor/krilla/src/page.rs b/vendor/krilla/src/page.rs index 3b3227b8..c5966776 100644 --- a/vendor/krilla/src/page.rs +++ b/vendor/krilla/src/page.rs @@ -12,7 +12,7 @@ use crate::chunk_container::ChunkContainer; use crate::configure::validate::VersionedFeature; use crate::configure::ValidationError; use crate::content::ContentBuilder; -use crate::error::KrillaResult; +use crate::error::{KrillaError, KrillaResult}; use crate::geom::{Rect, Size, Transform}; use crate::graphics::xobject::write_group_color_space; use crate::interactive::annotation::Annotation; @@ -437,31 +437,31 @@ impl InternalPage { rect.transform(page_root_transform( self.page_settings.surface_size().height(), )) - .unwrap() + .ok_or(KrillaError::InvalidPageBox(self.page_index)) }; // media box is mandatory, so we need to fall back to the default bbox - let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox)); + let media_box = transform_rect(self.page_settings.media_box().unwrap_or(self.bbox))?; page.media_box(media_box.to_pdf_rect()); // the remaining type of box are not mandatory, so we only set them if they are present if let Some(crop_box) = self.page_settings.crop_box() { - let crop_box = transform_rect(crop_box); + let crop_box = transform_rect(crop_box)?; page.crop_box(crop_box.to_pdf_rect()); } if let Some(bleed_box) = self.page_settings.bleed_box() { - let bleed_box = transform_rect(bleed_box); + let bleed_box = transform_rect(bleed_box)?; page.bleed_box(bleed_box.to_pdf_rect()); } if let Some(trim_box) = self.page_settings.trim_box() { - let trim_box = transform_rect(trim_box); + let trim_box = transform_rect(trim_box)?; page.trim_box(trim_box.to_pdf_rect()); } if let Some(art_box) = self.page_settings.art_box() { - let art_box = transform_rect(art_box); + let art_box = transform_rect(art_box)?; page.art_box(art_box.to_pdf_rect()); } diff --git a/vendor/krilla/src/serialize.rs b/vendor/krilla/src/serialize.rs index 4f4af4c9..f2986fc8 100644 --- a/vendor/krilla/src/serialize.rs +++ b/vendor/krilla/src/serialize.rs @@ -1019,7 +1019,7 @@ impl SerializeContext { ) -> KrillaResult<()> { let xyz_destinations = self.global_objects.xyz_destinations.take(); for (ref_, dest) in &xyz_destinations { - dest.serialize(self, chunk_container, *ref_); + dest.serialize(self, chunk_container, *ref_)?; } Ok(())