diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 40a2770e5..daf4a25ad 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -4,8 +4,9 @@ //! parameters (text, font, size, colour, alignment, box; or image and box) kept in the stream //! dictionary under `/PCAdded`. That keeps the item editable later (move, resize, retype, //! reformat, delete) without content-stream surgery, while every viewer sees ordinary page -//! content. Boxes are in display space (origin at the bottom-left of the page as shown, after -//! `/Rotate`), so items stay upright on rotated pages. +//! content. Boxes are in display space (points, origin at the bottom-left of the page as shown, +//! after `/UserUnit` and `/Rotate`), so items stay upright on rotated pages; an item records the +//! `/UserUnit` it was written for. use std::collections::HashMap; @@ -170,6 +171,15 @@ impl Content { } } + /// The same item with its box and text size scaled by `factor`. + fn scaled(self, factor: f64) -> Content { + match self { + _ if factor == 1.0 => self, + Content::Text(t) => Content::Text(AddedText { rect: t.rect.map(|v| v * factor), size: t.size * factor, ..t }), + Content::Image(i) => Content::Image(AddedImage { rect: i.rect.map(|v| v * factor), ..i }), + } + } + /// The same item moved/resized to `rect` (text keeps its computed height). pub fn with_rect(&self, rect: [f64; 4]) -> Content { match self { @@ -762,6 +772,7 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> R check(&[page], all.len())?; let p = &all[page]; let view = p.view_matrix(doc); + let user_unit = p.user_unit(doc); // The page gets its own copy of its resources with the item's fonts and images added, and // without the Arabic fonts of the version it replaces. let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); @@ -780,7 +791,11 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> R doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(pres)))?; let mut sd = Dict::new(); sd.set(b"PCMark".to_vec(), Object::name(TAG)); - sd.set(b"PCAdded".to_vec(), Object::Dict(params(c))); + let mut added = params(c); + if user_unit != 1.0 { + added.set(b"UserUnit".to_vec(), Object::Real(user_unit)); + } + sd.set(b"PCAdded".to_vec(), Object::Dict(added)); let stream = Stream::flate(sd, &content); match obj { Some(r) => { @@ -817,8 +832,17 @@ pub fn list_added(doc: &Document) -> Vec { if d.name(b"PCMark") != Some(TAG.as_bytes()) { continue; } - if let Some(c) = d.get(b"PCAdded").and_then(|p| doc.resolve(p).as_dict().cloned()).and_then(|p| parse(doc, &p)) { - out.push(Added { page: pi, obj: r, content: c }); + let Some(params) = d.get(b"PCAdded").and_then(|o| doc.resolve(o).as_dict().cloned()) else { continue }; + if let Some(c) = parse(doc, ¶ms) { + // Display points depend on the page's /UserUnit. An item records the one it was + // written for; none means 1, as for items written before display space followed + // /UserUnit. + let written = params + .get(b"UserUnit") + .and_then(Object::as_f64) + .filter(|u| u.is_finite() && *u >= 1.0) + .map_or(1.0, |u| u.min(pdfcraft_model::MAX_USER_UNIT)); + out.push(Added { page: pi, obj: r, content: c.scaled(p.user_unit(doc) / written) }); } } } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 4378eb259..54aecc759 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -175,6 +175,39 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() { assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark"); } +/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many +/// user-space units. Items record the `/UserUnit` they were written for; one without it was +/// written when display space was in user-space units, and reads back scaled to points. +#[test] +fn added_items_follow_the_page_user_unit() { + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[2].obj; + doc.update_dict(page, |d| d.set(b"UserUnit".to_vec(), Object::Int(2))).unwrap(); + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Scaled".into(), size: 14.0, ..AddedText::default() }; + add_content(&mut doc, 2, &Content::Text(text.clone())).unwrap(); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + let doc = reopen(&doc); + let all = list_added(&doc); + let on = |page: usize| all.iter().find(|a| a.page == page).unwrap(); + let Content::Text(t) = &on(2).content else { panic!() }; + assert_eq!((t.rect[0], t.rect[2], t.rect[3], t.size), (72.0, 300.0, 700.0, 14.0), "read back as written"); + assert!(streams(&doc, 2).iter().any(|s| s.starts_with("q 0.5 0 0 0.5 0 0 cm")), "display points to user space"); + let recorded = |doc: &Document, a: &Added| { + doc.get(a.obj).as_dict().and_then(|d| d.get(b"PCAdded").cloned()).and_then(|p| p.as_dict().and_then(|p| p.get(b"UserUnit").cloned())) + }; + assert!(recorded(&doc, on(0)).is_none(), "a page without /UserUnit records nothing"); + assert_eq!(recorded(&doc, on(2)).and_then(|u| u.as_f64()), Some(2.0)); + // The same item as an earlier version wrote it: no record, box and size in user-space units. + let mut legacy = doc.clone(); + let Object::Stream(mut s) = (*legacy.get(on(2).obj)).clone() else { panic!() }; + let mut params = s.dict.get(b"PCAdded").and_then(|p| p.as_dict().cloned()).unwrap(); + params.remove(b"UserUnit"); + s.dict.set(b"PCAdded".to_vec(), Object::Dict(params)); + legacy.set(on(2).obj, Object::Stream(s)); + let Content::Text(old) = &list_added(&legacy).into_iter().find(|a| a.page == 2).unwrap().content else { panic!() }; + assert_eq!((old.rect[0], old.rect[2], old.rect[3], old.size), (144.0, 600.0, 1400.0, 28.0), "scaled to points"); +} + #[test] fn added_text_and_images_are_page_content_that_stays_editable() { let mut doc = fixture(); diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index fbd9684e0..067f6fa80 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -470,8 +470,7 @@ pub fn scale_at(doc: &Document, page_index: usize, at: Point) -> Result { } } } - let user_unit = p.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0); - Scale::new(user_unit / 72.0, "in", 2) + Scale::new(p.user_unit(doc) / 72.0, "in", 2) } /// Add a named rectangular viewport. Existing viewports are preserved in drawing order. pub fn set_scale(doc: &mut Document, page_index: usize, bbox: [f64; 4], name: &str, scale: &Scale) -> Result<()> { @@ -531,25 +530,17 @@ mod tests; pub fn view_to_user(doc: &Document, page_index: usize, at: Point) -> Result { check_points(&[at])?; let page = page(doc, page_index)?; - let unit = page.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0); - if !unit.is_finite() || unit <= 0.0 || unit > 75000.0 { - return Err(invalid("invalid page UserUnit")); - } let (_, height) = page.display_size(doc); let m = pdfcraft_content::Matrix(page.view_matrix(doc)); - let (x, y) = m.apply(at[0] / unit, height - at[1] / unit); + let (x, y) = m.apply(at[0], height - at[1]); check_points(&[[x, y]])?; Ok([x, y]) } pub fn user_to_view(doc: &Document, page_index: usize, at: Point) -> Result { check_points(&[at])?; let page = page(doc, page_index)?; - let unit = page.dict.get(b"UserUnit").and_then(Object::as_f64).unwrap_or(1.0); - if !unit.is_finite() || unit <= 0.0 || unit > 75000.0 { - return Err(invalid("invalid page UserUnit")); - } let (_, height) = page.display_size(doc); let m = pdfcraft_content::Matrix(page.view_matrix(doc)).invert().ok_or_else(|| invalid("invalid page transform"))?; let (x, y) = m.apply(at[0], at[1]); - Ok([x * unit, (height - y) * unit]) + Ok([x, height - y]) } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 1a563dd68..8cd701d05 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -319,3 +319,16 @@ fn indirect_viewport_array_stays_indirect() { assert_eq!(d.get(r).as_array().map(Vec::len), Some(1)); close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, 1.0); } + +#[test] +fn user_unit_is_read_as_acrobat_reads_it() { + // Values below 1 and values that aren't numbers count as 1, values over 75,000 as 75,000. + for (attrs, unit) in [("/UserUnit 0", 1.0), ("/UserUnit 0.5", 1.0), ("/UserUnit (2)", 1.0), ("/UserUnit 100000", 75_000.0)] { + let d = fixture("", attrs, &[]); + close(scale_at(&d, 0, [50.0, 50.0]).unwrap().x, unit / 72.0); + let user = view_to_user(&d, 0, [36.0, 72.0]).unwrap(); + let view = user_to_view(&d, 0, user).unwrap(); + close(view[0], 36.0); + close(view[1], 72.0); + } +} diff --git a/crates/model/README.md b/crates/model/README.md index 19b457b61..06c30b039 100644 --- a/crates/model/README.md +++ b/crates/model/README.md @@ -5,9 +5,11 @@ L3/L4 crates need: - `pages(doc)`: leaf pages in order, each with its inherited attributes (`Resources`, `MediaBox`, `CropBox`, `Rotate`, §7.7.3.4) resolved; -- `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the - matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`) - to user space, so content can be placed the way the reader sees the page. +- `Page::crop`, `Page::rotation`, `Page::user_unit` and `Page::view_matrix`: the displayed page's + geometry, and the matrix from "display space" (points, origin at the bottom-left of the page as + shown, after `/UserUnit` and `/Rotate`) to user space, so content can be placed the way the + reader sees the page. `/UserUnit` is read as Acrobat reads it: the page's own entry, a number + from 1 up, at most 75,000; anything else counts as 1. `view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a comment's picture upright inside its user-space `/Rect` on a turned page. diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index ff9e70c00..4e4f3f53b 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -7,6 +7,9 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object}; /// Attributes a page inherits from its ancestors (ISO 32000-2 §7.7.3.4). pub const INHERITABLE: [&[u8]; 4] = [b"Resources", b"MediaBox", b"CropBox", b"Rotate"]; +/// The largest `/UserUnit` honoured, as in Acrobat (a larger value counts as this one). +pub const MAX_USER_UNIT: f64 = 75_000.0; + /// A leaf page and its effective (inherited) attributes. #[derive(Clone, Debug, PartialEq)] pub struct Page { @@ -39,17 +42,26 @@ impl Page { self.dict.get(b"Rotate").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).rem_euclid(360) / 90 * 90 } - /// The displayed size (width, height) in points. + /// The size of the page's user-space unit in points (`/UserUnit`; it isn't inherited), read + /// as Acrobat reads it: a number from 1 up, at most [`MAX_USER_UNIT`]; anything else is 1. + pub fn user_unit(&self, doc: &Document) -> f64 { + self.dict.get(b"UserUnit").and_then(|o| doc.resolve(o).as_f64()).filter(|u| *u >= 1.0).map_or(1.0, |u| u.min(MAX_USER_UNIT)) + } + + /// The displayed size (width, height) in points, after `/UserUnit` and `/Rotate`. pub fn display_size(&self, doc: &Document) -> (f64, f64) { let c = self.crop(doc); - let (w, h) = (c[2] - c[0], c[3] - c[1]); + let u = self.user_unit(doc); + let (w, h) = ((c[2] - c[0]) * u, (c[3] - c[1]) * u); if self.rotation(doc) % 180 == 0 { (w, h) } else { (h, w) } } - /// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as - /// shown, y up, after `/Rotate`) to user space. + /// The matrix `[a b c d e f]` from display space (points, origin at the bottom-left of the + /// page as shown, y up, after `/UserUnit` and `/Rotate`) to user space. pub fn view_matrix(&self, doc: &Document) -> [f64; 6] { - view_matrix_for(self.rotation(doc), self.crop(doc)) + let m = view_matrix_for(self.rotation(doc), self.crop(doc)); + let u = self.user_unit(doc); + if u == 1.0 { m } else { [m[0] / u, m[1] / u, m[2] / u, m[3] / u, m[4], m[5]] } } } @@ -117,10 +129,15 @@ mod tests { use super::*; fn doc(rotate: i64) -> Document { + doc_with(rotate, "", "") + } + + /// `pages` and `page` add entries to the page tree node and to the page. + fn doc_with(rotate: i64, pages: &str, page: &str) -> Document { let objs = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), - format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> >>"), - "<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] >>".to_string(), + format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << /Font << >> >> {pages} >>"), + format!("<< /Type /Page /Parent 2 0 R /CropBox [10 20 210 320] {page} >>"), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -166,4 +183,33 @@ mod tests { assert_eq!(tl, expect, "rotation {rotate}"); } } + + /// Display space is in points: `/UserUnit 2` doubles it. As in Acrobat, the value isn't + /// inherited, values below 1 or not numbers count as 1, and values over 75,000 as 75,000. + #[test] + fn user_unit_scales_display_space() { + for rotate in [0, 90, 180, 270] { + let d = doc_with(rotate, "", "/UserUnit 2"); + let p = &pages(&d)[0]; + let (w, h) = p.display_size(&d); + assert_eq!((w, h), if rotate % 180 == 0 { (400.0, 600.0) } else { (600.0, 400.0) }, "{rotate}"); + let plain = doc(rotate); + let unscaled = pages(&plain)[0].view_matrix(&plain); + for (x, y) in [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h), (37.0, 81.0)] { + assert_eq!(apply(p.view_matrix(&d), x, y), apply(unscaled, x / 2.0, y / 2.0), "{rotate}: ({x}, {y})"); + } + } + for (pages_attrs, page_attrs, unit) in [ + ("/UserUnit 2", "", 1.0), + ("", "/UserUnit 1.5", 1.5), + ("", "/UserUnit 0.5", 1.0), + ("", "/UserUnit 0", 1.0), + ("", "/UserUnit -2", 1.0), + ("", "/UserUnit /Two", 1.0), + ("", "/UserUnit 100000", MAX_USER_UNIT), + ] { + let d = doc_with(0, pages_attrs, page_attrs); + assert_eq!(pages(&d)[0].user_unit(&d), unit, "{pages_attrs} {page_attrs}"); + } + } } diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index d81ffd7ee..908bc2adf 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3840,6 +3840,76 @@ trailer << /Root 1 0 R >> assert!(px.rgba[((50 * px.width + 25) * 4) as usize] < 64, "the glyph is drawn"); } + /// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on + /// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with + /// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in + /// Acrobat, values below 1, values that aren't numbers and a value on a parent `/Pages` + /// node count as 1, and values over 75,000 count as 75,000. + #[test] + fn user_unit_scales_the_page_and_everything_on_it() { + let content = "1 0 0 rg 20 10 30 20 re f BT /F1 18 Tf 15 50 Td (Hg) Tj ET q 16 0 0 8 60 30 cm BI /W 2 /H 2 /CS /RGB /BPC 8 /F /AHx ID 0000FFFF000000FF00FFFF00> EI Q"; + let pdf = |page: &str, parent: &str| { + format!( + "%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 {parent} >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 80] /CropBox [10 6 90 76] {page} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +6 0 obj 2 endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ) + .into_bytes() + }; + let render = |bytes: Vec, scale: f32| { + let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0 }); + assert!(p.error.is_none(), "{:?}", p.error); + p + }; + let glyphs = |bytes: Vec| { + let parsed = Pdf::new(Arc::new(bytes)).expect("parses"); + crate::text::extract_page(&parsed, 0, &InterpreterSettings::default()).expect("text").glyphs + }; + let size = |bytes: Vec| { + let p = &crate::inspect(Arc::new(bytes), None).expect("opens").pages[0]; + (p.width, p.height) + }; + for rotate in [0, 90, 180, 270] { + let turned = format!("/Rotate {rotate}"); + let (w, h) = if rotate % 180 == 0 { (80.0, 70.0) } else { (70.0, 80.0) }; + let plain = render(pdf(&turned, ""), 2.0); + let one = render(pdf(&turned, ""), 1.0); + let plain_glyphs = glyphs(pdf(&turned, "")); + for unit in ["/UserUnit 2", "/UserUnit 6 0 R"] { + let page = format!("{turned} {unit}"); + assert_eq!(size(pdf(&page, "")), (w * 2.0, h * 2.0), "{page}"); + let doubled = render(pdf(&page, ""), 1.0); + assert_eq!((doubled.width, doubled.height), (plain.width, plain.height), "{page}"); + assert!(doubled.rgba == plain.rgba, "{page}: the page renders as at twice the scale"); + let doubled_glyphs = glyphs(pdf(&page, "")); + assert_eq!(doubled_glyphs.len(), plain_glyphs.len(), "{page}"); + for (d, p) in doubled_glyphs.iter().zip(&plain_glyphs) { + assert_eq!(d.rect, p.rect.map(|v| v * 2.0), "{page}: text layer"); + } + } + for unit in ["/UserUnit 1", "/UserUnit 0", "/UserUnit -1", "/UserUnit 0.5", "/UserUnit /Two", "/UserUnit (2)"] { + let page = format!("{turned} {unit}"); + assert_eq!(size(pdf(&page, "")), (w, h), "{page}"); + assert!(render(pdf(&page, ""), 1.0).rgba == one.rgba, "{page} counts as 1"); + } + assert_eq!(size(pdf(&turned, "/UserUnit 2")), (w, h), "not inherited from /Pages"); + assert!(render(pdf(&turned, "/UserUnit 2"), 1.0).rgba == one.rgba, "not inherited from /Pages"); + for unit in ["/UserUnit 75000", "/UserUnit 100000"] { + assert_eq!(size(pdf(&format!("{turned} {unit}"), "")), (w * 75_000.0, h * 75_000.0), "{unit}"); + } + } + } + #[test] fn tiles_match_full_render() { let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c722197b2..9f1ade7de 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -698,8 +698,8 @@ milestone = "M2" status = "partial" commands = [] tools = ["page_render"] -evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/automation.rs::open_inspect_render_and_find"] -notes = "hayro bootstrap renderer; own DisplayList devices in M2.6" +evidence = ["crates/render/src/raster.rs::renders_a_blue_rectangle", "crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/render/src/raster.rs::user_unit_scales_the_page_and_everything_on_it"] +notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value" [[feature]] id = "view.render-shadings-patterns" diff --git a/vendor/README.md b/vendor/README.md index 9657a69c2..429d68dc3 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -12,7 +12,7 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001). | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices` | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | diff --git a/vendor/hayro-syntax/src/page.rs b/vendor/hayro-syntax/src/page.rs index dd60f8193..2aed89633 100644 --- a/vendor/hayro-syntax/src/page.rs +++ b/vendor/hayro-syntax/src/page.rs @@ -112,6 +112,10 @@ impl<'a> Deref for Pages<'a> { /// PdfCraft patch: maximum page-tree depth (direct dictionaries have no id to track). const MAX_PAGE_TREE_DEPTH: usize = 256; +/// PdfCraft patch: the largest `/UserUnit` honoured, as in Acrobat (a larger value counts as +/// this one). +const MAX_USER_UNIT: f64 = 75_000.0; + fn resolve_pages<'a>( pages_dict: &Dict<'a>, entries: &mut Vec>, @@ -185,6 +189,7 @@ pub struct Page<'a> { media_box: Rect, crop_box: Rect, rotation: Rotation, + user_unit: f32, page_streams: OnceLock>>, resources: Resources<'a>, ctx: ReaderContext<'a>, @@ -223,6 +228,13 @@ impl<'a> Page<'a> { _ => Rotation::None, }; + // PdfCraft patch: the page's own `/UserUnit` (it isn't inherited), read as Acrobat reads + // it: a number from 1 up, at most `MAX_USER_UNIT`; anything else counts as 1. + let user_unit = dict + .get::(USER_UNIT) + .filter(|u| *u >= 1.0) + .map_or(1.0, |u| u.min(MAX_USER_UNIT) as f32); + let ctx = resources.ctx.clone(); let resources = Resources::from_parent( dict.get::>(RESOURCES).unwrap_or_default(), @@ -234,6 +246,7 @@ impl<'a> Page<'a> { media_box, crop_box, rotation, + user_unit, page_streams: OnceLock::new(), resources, ctx, @@ -313,6 +326,11 @@ impl<'a> Page<'a> { self.crop_box } + /// PdfCraft patch: the size of the page's user-space unit in points (`/UserUnit`). + pub fn user_unit(&self) -> f32 { + self.user_unit + } + /// Return the intersection of crop box and media box. pub fn intersected_crop_box(&self) -> Rect { self.crop_box().intersect(self.media_box()) @@ -339,7 +357,10 @@ impl<'a> Page<'a> { /// Depending on the document, it is either based on the media box or the crop box /// of the page. In addition to that, it also takes the rotation of the page into account. pub fn render_dimensions(&self) -> (f32, f32) { - let (mut base_width, mut base_height) = self.base_dimensions(); + let (base_width, base_height) = self.base_dimensions(); + // PdfCraft patch: in points, after `/UserUnit`. + let (mut base_width, mut base_height) = + (base_width * self.user_unit, base_height * self.user_unit); if matches!( self.rotation(), @@ -379,6 +400,8 @@ impl<'a> Page<'a> { pub fn initial_transform(&self, invert_y: bool) -> Transform { let crop_box = self.intersected_crop_box(); let (_, base_height) = self.base_dimensions(); + // PdfCraft patch: the page in points, after `/UserUnit`. + let base_height = base_height * self.user_unit; let (width, height) = self.render_dimensions(); let horizontal_t = Transform::ROTATE_CW_90 * Transform::translate((0.0, -width as f64)); @@ -412,9 +435,16 @@ impl<'a> Page<'a> { Transform::IDENTITY }; - rotation_transform - * inversion_transform - * Transform::translate((-crop_box.x0, -crop_box.y0)) + let user_space = Transform::translate((-crop_box.x0, -crop_box.y0)); + // PdfCraft patch: user space in units of `/UserUnit` points. A page without it keeps + // exactly the transform it had. + let user_space = if self.user_unit == 1.0 { + user_space + } else { + Transform::scale(f64::from(self.user_unit)) * user_space + }; + + rotation_transform * inversion_transform * user_space } }