mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 07:39:23 +00:00
egui037: Reconcile shared UI pins with reviewed PDF updates
This commit is contained in:
commit
b282cb2d29
45 files changed
+3398
-299
No files matched your search
Generated
+15
-15
@@ -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"
|
||||
+15
-15
@@ -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" }
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,<script>alert(1)</script>",
|
||||
"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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<backend::annotation::Target> {
|
||||
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);
|
||||
|
||||
@@ -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<LinkAnnotation>) -> Result<Vec<u8>, 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:?}");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8>)]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
[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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8>) -> (String, Vec<u8>) {
|
||||
(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<u8>) {
|
||||
(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<u8>)]) -> Vec<u8> {
|
||||
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<u8>)], 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<u8> = 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<u8>)>) {
|
||||
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::<Vec<_>>()
|
||||
.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<SoftMask<'a>>) {}
|
||||
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<SoftMask<'a>>, _: 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Option<String>>,
|
||||
modes: Vec<char>,
|
||||
}
|
||||
|
||||
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<SoftMask<'_>>, _: 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<Option<String>> {
|
||||
/// 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<Option<String>> {
|
||||
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<Option<String>> {
|
||||
drawn_glyphs(font, extra, content).glyphs
|
||||
}
|
||||
|
||||
fn helvetica_texts(encoding: &str, content: &[u8]) -> Vec<String> {
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,<script>alert(1)</script>",
|
||||
"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::<Array<'_>>(b"Annots").unwrap();
|
||||
let uris: Vec<String> = annotations
|
||||
.iter::<Dict<'_>>()
|
||||
.filter_map(|a| a.get::<Dict<'_>>(b"A")?.get::<PdfString<'_>>(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"]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+130
@@ -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
|
||||
|
||||
Vendored
+13
-13
@@ -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": {
|
||||
|
||||
Vendored
+21
-3
@@ -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<u128, Option<Box<dyn Any + Send + Sync>>>;
|
||||
/// 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<Mutex<CacheMap>>);
|
||||
pub(crate) struct Cache(Arc<Mutex<CacheMap>>, Arc<AtomicUsize>);
|
||||
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<T: Clone + Send + Sync + 'static>(
|
||||
@@ -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<u128>,
|
||||
pub(crate) target: Option<(u32, u32)>,
|
||||
pub(crate) preview: bool,
|
||||
}
|
||||
|
||||
Vendored
+226
-27
@@ -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<object::ObjectIdentifier> {
|
||||
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> {
|
||||
Self::new_inner(object, cache)
|
||||
@@ -140,34 +170,50 @@ impl ColorSpaceType {
|
||||
let dict = icc_stream.dict();
|
||||
let num_components = dict.get::<usize>(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::<Object<'_>>(ALTERNATE)
|
||||
.and_then(|o| Self::new(o, cache))
|
||||
})
|
||||
.or_else(|| match dict.get::<u8>(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::<Object<'_>>(ALTERNATE)
|
||||
.and_then(|o| Self::new(o, cache))
|
||||
})
|
||||
.or_else(|| match dict.get::<u8>(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<Self> {
|
||||
match object {
|
||||
Object::Name(name) => Self::of_name(name),
|
||||
Object::Array(array) => array
|
||||
.flex_iter()
|
||||
.next::<Name<'_>>()
|
||||
.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<Self> {
|
||||
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<Option<(ColorSpace, u128)>>,
|
||||
rgb: OnceCell<Option<(ColorSpace, u128)>>,
|
||||
cmyk: OnceCell<Option<(ColorSpace, u128)>>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+20
-4
@@ -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<'_>>()
|
||||
+ state.graphics_state.stroke_props.dash_array.len() * size_of::<f32>()
|
||||
+ state.clips.len() * size_of::<ClipType>()
|
||||
.saturating_add(
|
||||
state
|
||||
.graphics_state
|
||||
.stroke_props
|
||||
.dash_array
|
||||
.len()
|
||||
.saturating_mul(size_of::<f32>()),
|
||||
)
|
||||
.saturating_add(state.clips.len().saturating_mul(size_of::<ClipType>()))
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Vendored
+59
-3
@@ -18,6 +18,13 @@ pub struct EncodedShadingPattern {
|
||||
pub(crate) shading_type: EncodedShadingType,
|
||||
pub(crate) opacity: f32,
|
||||
pub(crate) transfer_function: Option<ActiveTransferFunction>,
|
||||
/// 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<kurbo::Rect>,
|
||||
}
|
||||
|
||||
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<kurbo::Rect>) -> 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+191
-9
@@ -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<dyn AsRef<[u8]> + Send + Sync>;
|
||||
@@ -50,7 +50,10 @@ impl Type1FontBlob {
|
||||
|
||||
/// A font blob for CFF-based fonts.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CffFontBlob(Arc<CffFontYoke>);
|
||||
pub(crate) struct CffFontBlob(Arc<CffFontYoke>, Arc<OnceLock<GlyphNames>>);
|
||||
|
||||
/// PdfCraft patch (50): glyph name to glyph id, built on first use.
|
||||
type GlyphNames = HashMap<Box<[u8]>, 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<GlyphId> {
|
||||
// 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<GlyphId> {
|
||||
@@ -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<Charset<'a>>,
|
||||
subfonts: Vec<Subfont>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A CFF font with glyphs `.notdef`, `Alpha`, `Alpha` (a duplicate name) and `Beta`.
|
||||
fn cff_with_names() -> Vec<u8> {
|
||||
fn index(items: &[&[u8]]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<GlyphId> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+68
-25
@@ -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<ColorSpace> {
|
||||
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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+10
@@ -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);
|
||||
|
||||
Vendored
+74
-2
@@ -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<ObjectIdentifier>,
|
||||
/// 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<ObjectIdentifier>,
|
||||
visibility_stack: Vec<bool>,
|
||||
}
|
||||
|
||||
@@ -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<ObjectIdentifier> = oc_properties
|
||||
.get::<Array<'_>>(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::<Name<'_>>(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::<Array<'_>>(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::<Name<'_>>(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<bool> {
|
||||
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::<Name<'_>>(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,
|
||||
|
||||
+36
-5
@@ -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<Self> {
|
||||
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<Self> {
|
||||
pub(crate) fn new(
|
||||
dict: &Dict<'_>,
|
||||
cache: &Cache,
|
||||
defaults: &DefaultColorSpaces<'_, '_>,
|
||||
) -> Option<Self> {
|
||||
let shading = dict.get::<Object<'_>>(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,
|
||||
})
|
||||
|
||||
+51
-12
@@ -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<Self> {
|
||||
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<Self> {
|
||||
let shading_num = dict.get::<u8>(SHADING_TYPE)?;
|
||||
|
||||
let color_space = ColorSpace::new(dict.get(COLORSPACE)?, cache)?;
|
||||
let color_space_obj = dict.get::<Object<'_>>(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.
|
||||
|
||||
Vendored
+2
@@ -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,
|
||||
}
|
||||
|
||||
+50
-14
@@ -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<ActiveTransferFunction>,
|
||||
defaults: &DefaultColorSpaces<'_, '_>,
|
||||
) -> Option<Self> {
|
||||
let dict = stream.dict();
|
||||
match dict.get::<Name<'_>>(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<Plate>,
|
||||
/// 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<u128>,
|
||||
}
|
||||
|
||||
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<ColorSpace>,
|
||||
@@ -409,6 +430,7 @@ impl<'a> ImageXObject<'a> {
|
||||
decoded_cache: Option<&DecodedImageCache>,
|
||||
mut is_mask: bool,
|
||||
transfer_function: Option<ActiveTransferFunction>,
|
||||
defaults: Option<&DefaultColorSpaces<'_, '_>>,
|
||||
) -> Option<Self> {
|
||||
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::<Object<'_>>(CS)
|
||||
.or_else(|| dict.get::<Object<'_>>(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::<Stream<'_>>(SMASK)
|
||||
.or_else(|| dict.get::<Stream<'_>>(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::<SmallVec<[u16; 4]>>(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))
|
||||
|
||||
Vendored
+6
-6
@@ -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",
|
||||
|
||||
+383
-2
@@ -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<usize> {
|
||||
for key in [b"Filter".as_slice(), b"F"] {
|
||||
if let Some(filter) = dict.get::<Object<'_>>(key)
|
||||
&& !matches!(filter, Object::Null(_))
|
||||
&& !matches!(&filter, Object::Array(filters) if filters.iter::<Object<'_>>().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::<Object<'_>>(full), dict.get::<Object<'_>>(short))
|
||||
&& full != short
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let get = |full: &[u8], short: &[u8]| {
|
||||
dict.get::<Object<'_>>(full)
|
||||
.or_else(|| dict.get::<Object<'_>>(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::<Object<'_>>();
|
||||
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<Vec<u8>> {
|
||||
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::<crate::object::dict::InlineImageDict<'_>>()
|
||||
.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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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<FilterResult<'static>> {
|
||||
let k = params.get::<i32>(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());
|
||||
|
||||
+192
-6
@@ -17,11 +17,25 @@ impl ImageColorSpace {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterResult<'static>> {
|
||||
/// 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<FilterResult<'static>> {
|
||||
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<FilterRe
|
||||
|
||||
let width = image.width();
|
||||
let height = image.height();
|
||||
let bpc = params.bpc.unwrap_or(image.original_bit_depth());
|
||||
// PdfCraft patch: (PdfCraft #3184) a resolved palette gives colors, decoded to 8 bits like
|
||||
// every sample; the codestream's precision is that of the indices (1 bit, say), and rescaling
|
||||
// the colors to it kept only 0 or 255 per channel.
|
||||
let palette_resolved = settings.resolve_palette_indices && has_palette(data);
|
||||
// A `/BitsPerComponent` the dictionary gives (JPXDecode ignores it) can't apply to
|
||||
// resolved colors either.
|
||||
let bpc = if palette_resolved {
|
||||
8
|
||||
} else {
|
||||
params.bpc.unwrap_or(image.original_bit_depth())
|
||||
};
|
||||
let cs = match image.color_space() {
|
||||
ColorSpace::Gray => ImageColorSpace::Gray,
|
||||
ColorSpace::RGB => ImageColorSpace::Rgb,
|
||||
@@ -58,7 +82,23 @@ pub(crate) fn decode(data: &[u8], params: &ImageDecodeParams) -> Option<FilterRe
|
||||
},
|
||||
};
|
||||
let has_alpha = image.has_alpha();
|
||||
let bitmap = image.decode().ok()?;
|
||||
// PdfCraft patch: the decoder sizes its output from the codestream's own dimensions, not the
|
||||
// image dictionary's, and allocates it infallibly. Refuse more than `MAX_PIXELS` pixels or
|
||||
// more than `limit` bytes before decoding, and reserve the buffer fallibly.
|
||||
if u64::from(width) * u64::from(height) > 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<FilterRe
|
||||
// The decoded image is always 8-bit, so if necessary we have to rescale
|
||||
// ourselves.
|
||||
if bpc != 8 {
|
||||
// PdfCraft patch: the rescaled buffer can be larger than the 8-bit bitmap (up to 32 bits
|
||||
// per component), so bound it, with alpha, by the same output limit.
|
||||
let bits_per_row = (width as usize)
|
||||
.checked_mul(usize::from(cs.num_components()))?
|
||||
.checked_mul(usize::from(bpc))?;
|
||||
let scaled_len = bits_per_row
|
||||
.div_ceil(8)
|
||||
.checked_mul(height as usize)?
|
||||
.checked_mul(if alpha.is_some() { 2 } else { 1 })?;
|
||||
if scaled_len > 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<FilterRe
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: (PdfCraft #3184) whether JP2 file `data` has a palette box (`pclr`) in its
|
||||
/// header box (`jp2h`, ISO 15444-1 I.5.3.4). A raw codestream has none.
|
||||
fn has_palette(data: &[u8]) -> 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<u8> = [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<u8> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+139
-27
@@ -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<usize> {
|
||||
if let Some(prev) = trailer.get::<i32>(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::<IndirectObject<i32>>(&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::<i32>(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::<i32>(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::<SubsectionHeader>() {
|
||||
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::<i32>(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::<IndirectObject<Stream<'_>>>(&ReaderContext::dummy_with_limits(limits))?.get();
|
||||
|
||||
if let Some(prev) = stream.dict().get::<i32>(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::<u32>(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::<Array<'_>>(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";
|
||||
|
||||
Vendored
+2
-2
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+174
-10
@@ -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<u8> {
|
||||
// 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<u8>> = 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<u8>, 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]);
|
||||
}
|
||||
}
|
||||
Vendored
+66
-21
@@ -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<BezPath>),
|
||||
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<BezPath> {
|
||||
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::<Srgb>::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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+20
@@ -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<Rect>` 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`.
|
||||
Vendored
+5
-5
@@ -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",
|
||||
|
||||
+299
@@ -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<Location>),
|
||||
+ /// 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<Location>),
|
||||
+ /// 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::<pdf_writer::writers::Annotation>();
|
||||
|
||||
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<Rect>,
|
||||
pub(crate) quad_points: Option<Vec<Quadrilateral>>,
|
||||
pub(crate) target: Target,
|
||||
pub(crate) border: Option<LinkBorder>,
|
||||
@@ -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<Quadrilateral>, 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<Rect> {
|
||||
+ 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<XyzDestination> 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(())
|
||||
Vendored
+23
@@ -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<Location>),
|
||||
/// 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<Location>),
|
||||
/// 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();
|
||||
|
||||
+41
-19
@@ -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::<pdf_writer::writers::Annotation>();
|
||||
|
||||
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<Rect>,
|
||||
pub(crate) quad_points: Option<Vec<Quadrilateral>>,
|
||||
pub(crate) target: Target,
|
||||
pub(crate) border: Option<LinkBorder>,
|
||||
@@ -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<Quadrilateral>, 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<Rect> {
|
||||
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,
|
||||
|
||||
+15
-13
@@ -117,7 +117,10 @@ impl From<XyzDestination> 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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
-7
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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(())
|
||||
|
||||
Reference in new issue
Block a user