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