+ 'static>(img: &image::ImageBuffer>, w: u32, h: u32) -> image::ImageBuffer
> {
+ use image::imageops::{FilterType, resize};
+ let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
+ let largest = (iw * ih).max(u64::from(w) * u64::from(h));
+ if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
+ resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
+ } else {
+ resize(img, w, h, FilterType::CatmullRom)
+ }
+}
+
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> {
let mut out = Vec::new();
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
@@ -1654,6 +1670,66 @@ mod dimension_tests {
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1, &mut ImageBudget::with_work(u64::MAX)).map(|p| p.len()), Some(1000 * 1000));
}
+
+ /// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
+ /// the first three shapes asked for 160 GB and more on the way, and that aborted the process.
+ /// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way
+ /// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions
+ /// resample as they always did.
+ #[test]
+ fn resampling_across_proportions_takes_the_memory_of_neither_product() {
+ let shapes = [
+ (100_000u32, 1u32, 1u32, 100_000u32, 1usize),
+ (1_000_000, 1, 4, 100_000, 1),
+ (100_000, 1, 1, 100_000, 3),
+ (1, 100_000, 100_000, 1, 1),
+ (1, 100_000, 100_000, 1, 3),
+ ];
+ for (w, h, nw, nh, n) in shapes {
+ let px = vec![0x60; (w * h) as usize * n];
+ let out = resample(&px, n, w, h, nw, nh).unwrap();
+ assert_eq!(out.len(), (nw * nh) as usize * n);
+ assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
+ }
+ let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
+ let px: Vec = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
+ let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
+ let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
+ assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
+ }
+
+ /// The long way round is for proportions far apart: up to a quarter over the larger picture the
+ /// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
+ /// out the same either way.
+ #[test]
+ fn proportions_up_to_the_limit_resample_as_they_did() {
+ let noise = |w: u32, h: u32| -> Vec {
+ let mut seed = 0x2545_F491_4F6C_DD1Du64;
+ (0..w * h)
+ .map(|_| {
+ seed ^= seed << 13;
+ seed ^= seed >> 7;
+ seed ^= seed << 17;
+ seed as u8
+ })
+ .collect()
+ };
+ let direct = |px: &[u8], w, h, nw, nh| {
+ let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
+ image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
+ };
+ // Each exactly a quarter over the larger: the source's size, then the target's.
+ for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
+ let px = noise(w, h);
+ assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
+ }
+ // A step past it goes the long way, which is how to tell.
+ let px = noise(25, 15);
+ assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
+ // A picture of the size asked for comes through as it is.
+ let px = noise(20, 10);
+ assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px);
+ }
}
#[cfg(test)]
diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs
index 0254d64a34..8eec68c841 100644
--- a/crates/optimize/tests/optimize.rs
+++ b/crates/optimize/tests/optimize.rs
@@ -1775,7 +1775,8 @@ fn monochrome_images_keep_what_they_should() {
optimize(&mut doc, &Settings::default()).unwrap();
assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey");
assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default");
- assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller");
+ assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate");
+ assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image");
let (mut doc, [_, wrapped, scan, _, _]) = build();
diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs
index 31e062d12f..7a426db076 100644
--- a/crates/render/src/raster/tests.rs
+++ b/crates/render/src/raster/tests.rs
@@ -802,6 +802,472 @@ fn indexed_palettes_over_lab_keep_their_colours() {
}
assert!(indexed[0] > 180, "the palette red must not render near black: {indexed:?}");
}
+
+/// A Pattern space's underlying space is any other, named or by reference as well as a direct array
+/// (ISO 32000-2 §8.7.3.3). It was skipped when it was a name or a reference, so an uncoloured tiling
+/// pattern took its components as RGB: a CMYK cyan drew red, and a grey of 0.5 dark red. Each is
+/// compared with the same colour painted without the pattern.
+#[test]
+fn an_uncoloured_pattern_uses_its_underlying_space_in_every_form() {
+ use super::*;
+ let render = |space: &str, content: &str| -> [u8; 4] {
+ let pattern = "0 0 10 10 re f";
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> /Pattern << /P 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >> /Length {} >> stream\n{pattern}\nendstream endobj\n\
+ 6 0 obj /DeviceCMYK endobj\n\
+ 7 0 obj /DeviceGray endobj\n\
+ 8 0 obj [/Indexed /DeviceRGB 1 <00FF00FF0000>] endobj\n\
+ 9 0 obj [/Pattern /DeviceCMYK] endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ pattern.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{space}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let fill = "0 0 200 200 re f";
+ let cyan = render("/DeviceCMYK", &format!("/DeviceCMYK cs 1 0 0 0 sc {fill}"));
+ let grey = render("/DeviceGray", &format!("/DeviceGray cs 0.5 sc {fill}"));
+ let green = render("8 0 R", &format!("/C cs 0 sc {fill}"));
+ assert_ne!(cyan[0], 255, "cyan has no red");
+ for (space, colour, want) in [
+ ("[/Pattern /DeviceCMYK]", "1 0 0 0", cyan),
+ ("[/Pattern 6 0 R]", "1 0 0 0", cyan),
+ ("9 0 R", "1 0 0 0", cyan),
+ ("[/Pattern /DeviceGray]", ".5", grey),
+ ("[/Pattern 7 0 R]", ".5", grey),
+ ("[/Pattern 8 0 R]", "0", green),
+ ("[/Pattern [/Indexed /DeviceRGB 1 <00FF00FF0000>]]", "0", green),
+ ] {
+ assert_eq!(render(space, &format!("/C cs {colour} /P scn {fill}")), want, "{space}");
+ }
+}
+
+/// A colour space's parts can refer back to the space, now that a Pattern's underlying space is
+/// followed as an Indexed space's base always was. A file made so ends as a space that can't be
+/// read, and the page draws what it can.
+#[test]
+fn a_colour_space_that_refers_to_itself_ends() {
+ use super::*;
+ let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f";
+ for (space, objects) in [
+ ("5 0 R", "5 0 obj [/Pattern 5 0 R] endobj"),
+ ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Pattern 5 0 R] endobj"),
+ ("5 0 R", "5 0 obj [/Pattern 6 0 R] endobj 6 0 obj [/Indexed 5 0 R 1 <0011>] endobj"),
+ ("5 0 R", "5 0 obj [/Indexed 5 0 R 1 <0011>] endobj"),
+ ] {
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{objects}: {:?}", page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{objects}: what comes before it drew");
+ }
+}
+
+/// A chain of colour spaces repeats no object, so what ends a cycle lets it through. Each way one
+/// space names another ends, at a depth that leaves room on the stack, and the page draws what it
+/// can.
+#[test]
+fn a_chain_of_colour_spaces_ends() {
+ use super::*;
+ const LINKS: u32 = 20_000;
+ let content = "1 0 0 rg 0 0 100 100 re f /C cs 0 sc 100 100 100 100 re f";
+ let link: [(&str, &dyn Fn(u32) -> String); 5] = [
+ ("[/Pattern 10 0 R]", &|next| format!("[/Pattern {next} 0 R]")),
+ ("10 0 R", &|next| format!("[/Indexed {next} 0 R 1 <0011>]")),
+ ("10 0 R", &|next| format!("[/Separation /A {next} 0 R 5 0 R]")),
+ ("10 0 R", &|next| format!("[/DeviceN [/A] {next} 0 R 5 0 R]")),
+ ("[/ICCBased 10 0 R]", &|next| format!("<< /N 1 /Alternate [/ICCBased {next} 0 R] /Length 1 >> stream\nx\nendstream")),
+ ];
+ for (space, link) in link {
+ let objects: String = (0..LINKS).map(|i| format!("{} 0 obj {} endobj\n", 10 + i, link(11 + i))).collect();
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C {space} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ {objects}{} 0 obj /DeviceGray endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ 10 + LINKS
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{}: {:?}", link(11), page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", link(11));
+ }
+}
+
+/// A file nests colour spaces five levels at most, and the limit is well past that: 31 spaces over
+/// a device space are read and draw their colour, and with one more the space isn't read.
+#[test]
+fn colour_spaces_are_read_to_the_limit_of_their_nesting() {
+ use super::*;
+ let content = "/C cs 1 scn 0 0 200 200 re f";
+ let green = |levels: u32| {
+ let objects: String = (0..levels - 1).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect();
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C 10 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\
+ {objects}{} 0 obj [/Separation /A /DeviceRGB 6 0 R] endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ 10 + levels - 1
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{levels}: {:?}", page.error);
+ page.rgba.get(..4) == Some(&[0, 255, 0, 255][..])
+ };
+ assert!(green(1), "a space on its own");
+ assert!(green(5), "as deep as a legal file goes");
+ assert!(green(31), "the deepest that is read");
+ assert!(!green(32), "one past it");
+}
+
+/// A page with the colour space resources `spaces` and the other objects `objects`, drawn with
+/// `content`. Objects 5 and 6 are tint transforms: the tint itself, and paper to green.
+fn page_with_colour_spaces(spaces: &str, objects: &str, content: &str) -> super::RenderedPage {
+ use super::*;
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << {spaces} >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [0] /C1 [1] >> endobj\n\
+ 6 0 obj << /FunctionType 2 /Domain [0 1] /N 1 /C0 [1 1 1] /C1 [0 1 0] >> endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ })
+}
+
+/// What an ICC profile reads as is kept. If what it falls back to were kept with it, a page
+/// selecting the last of a chain first would build each space on the one before, and no read
+/// would be deep: 20,000 levels past the limit, for whatever walks the space to paint with it.
+#[test]
+fn a_chain_built_on_spaces_already_read_ends() {
+ const LINKS: u32 = 20_000;
+ let selected: String = (0..LINKS).rev().map(|i| format!("/C{i} cs\n")).collect();
+ let content = format!("1 0 0 rg 0 0 100 100 re f\n{selected}0 sc 100 100 100 100 re f");
+ let spaces: String = (0..LINKS).map(|i| format!("/C{i} [/ICCBased {} 0 R] ", 10 + i)).collect();
+ let around: [&dyn Fn(&str) -> String; 4] = [
+ &|next| format!("[/Pattern {next}]"),
+ &|next| format!("[/Indexed {next} 1 <0011>]"),
+ &|next| format!("[/Separation /A {next} 5 0 R]"),
+ &|next| format!("[/DeviceN [/A] {next} 5 0 R]"),
+ ];
+ for around in around {
+ let mut objects: String = (0..LINKS)
+ .map(|i| {
+ let alternate = around(&format!("[/ICCBased {} 0 R]", 11 + i));
+ format!("{} 0 obj << /N 1 /Alternate {alternate} /Length 1 >> stream\nx\nendstream endobj\n", 10 + i)
+ })
+ .collect();
+ objects.push_str(&format!("{} 0 obj << /N 1 /Length 1 >> stream\nx\nendstream endobj\n", 10 + LINKS));
+ let page = page_with_colour_spaces(&spaces, &objects, &content);
+ assert!(page.error.is_none(), "{}: {:?}", around("next"), page.error);
+ let start = ((150 * page.width as usize) + 50) * 4;
+ assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..]), "{}: what comes before it drew", around("next"));
+ }
+}
+
+/// A profile met at the limit can't read its alternate there, which says nothing of a read nearer
+/// the top: the space kept from the first read isn't what the second gets.
+#[test]
+fn a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top() {
+ let chain: String = (0..31).map(|i| format!("{} 0 obj [/Separation /A {} 0 R 5 0 R] endobj\n", 10 + i, 11 + i)).collect();
+ let objects = format!(
+ "{chain}41 0 obj [/ICCBased 7 0 R] endobj\n\
+ 7 0 obj << /N 1 /Alternate [/Separation /A /DeviceRGB 6 0 R] /Length 1 >> stream\nx\nendstream endobj\n"
+ );
+ let page = page_with_colour_spaces("/Deep 10 0 R /Near 41 0 R", &objects, "/Deep cs /Near cs 1 scn 0 0 200 200 re f");
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!(page.rgba.get(..4), Some(&[0, 255, 0, 255][..]));
+}
+
+/// An image's `/Decode` pairs can have a reference at the start of a pair. Its read took that for
+/// the whole pair, found no pair there, and ended the array, so the image didn't draw at all.
+#[test]
+fn a_decode_array_with_references_in_it_is_read() {
+ use super::*;
+ let render = |width_of: &str, decode: &str, space: &str, data: &str| -> [u8; 4] {
+ let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q";
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width {width_of} /Height 1 /BitsPerComponent 8 /ColorSpace {space} /Decode {decode} /Filter /ASCIIHexDecode /Length {} >> stream\n{data}\nendstream endobj\n\
+ 6 0 obj 1 endobj\n\
+ 7 0 obj 0 endobj\n\
+ 8 0 obj 8 0 R endobj\n\
+ 9 0 obj 10 0 R endobj\n\
+ 10 0 obj 9 0 R endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ data.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{decode}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let grey = render("1", "[1 0]", "/DeviceGray", "40>");
+ assert_eq!(grey, [191, 191, 191, 255], "the direct form inverts the sample");
+ for decode in ["[6 0 R 0]", "[1 7 0 R]", "[6 0 R 7 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceGray", "40>"), grey, "{decode}");
+ }
+ let rgb = render("1", "[1 0 1 0 1 0]", "/DeviceRGB", "204060>");
+ for decode in ["[6 0 R 0 1 0 1 0]", "[1 0 6 0 R 0 1 0]", "[1 0 1 0 6 0 R 0]", "[6 0 R 7 0 R 6 0 R 7 0 R 6 0 R 7 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceRGB", "204060>"), rgb, "{decode}");
+ }
+ // A reference that only leads back to itself holds no number, and reads as one that names
+ // nothing does.
+ let missing = render("1", "[99 0 R 0]", "/DeviceGray", "40>");
+ for decode in ["[8 0 R 0]", "[1 8 0 R]", "[9 0 R 0]", "[1 9 0 R]", "[8 0 R 9 0 R]"] {
+ assert_eq!(render("1", decode, "/DeviceGray", "40>"), missing, "{decode}");
+ }
+}
+
+/// An object that holds only a reference stands for what that names, stored on its own or in an
+/// object stream. Read as a number it was the reference's own object number: here 8 names 7, 7
+/// names 6 and 6 holds 1, and that read as 7, which drew white. A chain past the limit, and so a
+/// cycle, reads as a missing object does.
+#[test]
+fn a_reference_to_a_reference_is_what_it_names() {
+ use super::*;
+ let file = |decode: &str, objects: &str| -> Vec {
+ let content = "1 0 0 rg 0 0 200 200 re f q 200 0 0 200 0 0 cm /Im Do Q";
+ 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 /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\n40>\nendstream endobj\n\
+ {objects}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes()
+ };
+ let draw = |bytes: Vec| -> [u8; 4] {
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let grey = draw(file("[1 0]", ""));
+ assert_eq!(grey, [191, 191, 191, 255]);
+
+ let chain = "6 0 obj 1 endobj 7 0 obj 6 0 R endobj 8 0 obj 7 0 R endobj";
+ assert_eq!(draw(file("[8 0 R 0]", chain)), grey, "stored on their own");
+ let doc = pdfcraft_cos::Document::open(Arc::new(file("[8 0 R 0]", chain))).unwrap();
+ let packed = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
+ assert!(packed.windows(7).any(|w| w == b"/ObjStm"), "the save packs them");
+ assert_eq!(draw(packed), grey, "stored in an object stream");
+ assert_eq!(draw(file("9 0 R", "9 0 obj 10 0 R endobj 10 0 obj [1 0] endobj")), grey, "not only numbers");
+ let commented = "6 0 obj 1 endobj 7 0 obj 6 %c\n0 R endobj 8 0 obj 7 0 %c\nR endobj";
+ assert_eq!(draw(file("[8 %c\n0 R 0]", commented)), grey, "a comment counts as white space");
+
+ let links = |n: usize| -> String {
+ let links: String = (0..n).map(|i| format!("{} 0 obj {} 0 R endobj\n", 10 + i, 11 + i)).collect();
+ format!("{links}{} 0 obj 1 endobj", 10 + n)
+ };
+ let missing = draw(file("[99 0 R 0]", ""));
+ assert_ne!(missing, grey);
+ assert_eq!(draw(file("[10 0 R 0]", &links(32))), grey, "32 references are followed");
+ assert_eq!(draw(file("[10 0 R 0]", &links(33))), missing, "33 aren't");
+ assert_eq!(draw(file("[6 0 R 0]", "6 0 obj 6 0 R endobj")), missing, "nor is a cycle");
+}
+
+/// A function's `/Encode`, `/Decode`, `/Domain` and `/Range` are read a pair at a time too, so they
+/// lost a reference at the start of a pair the same way, and the array with it. Each form is compared
+/// with the direct one, which differs from the identity so the array is shown to count.
+#[test]
+fn a_functions_arrays_can_start_with_a_reference() {
+ use super::*;
+ let render = |function: String| -> [u8; 4] {
+ let content = "/C cs 0.25 sc 0 0 200 200 re f";
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /C [/Separation /X /DeviceRGB 9 0 R] >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj 1 endobj\n6 0 obj 0 endobj\n\
+ 7 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 0 0] /C1 [1 0 0] /N 1 >> endobj\n\
+ 8 0 obj << /FunctionType 2 /Domain [0 1] /C0 [0 1 0] /C1 [0 0 1] /N 1 >> endobj\n\
+ 9 0 obj {function} endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{function}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let sampled = |encode: &str| {
+ format!(
+ "<< /FunctionType 0 /Size [2] /BitsPerSample 8 /Domain [0 1] /Range [0 1 0 1 0 1] /Encode {encode} /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF8040>\nendstream"
+ )
+ };
+ let stitched = |encode: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [7 0 R 8 0 R] /Bounds [.5] /Encode {encode} >>");
+ assert_ne!(render(sampled("[1 0]")), render(sampled("[0 1]")), "/Encode counts");
+ assert_eq!(render(sampled("[5 0 R 0]")), render(sampled("[1 0]")), "a sampled function");
+ assert_ne!(render(stitched("[1 0 0 1]")), render(stitched("[0 1 0 1]")), "/Encode counts");
+ assert_eq!(render(stitched("[5 0 R 0 0 1]")), render(stitched("[1 0 0 1]")), "a stitching function");
+}
+
+/// The lookup table of an `/Indexed` space is a string or a stream, and any part of the space can
+/// be indirect (ISO 32000-2 §8.6.6.3, §7.3.10). A table in an indirect string was lost: reading it
+/// as a stream used up the reference, so the space couldn't be read and drew in grey.
+#[test]
+fn an_indexed_lookup_table_is_read_in_every_legal_form() {
+ use super::*;
+ let render = |space: &str, content: &str, extra: &str| -> [u8; 4] {
+ let bytes = 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 /MediaBox [0 0 200 200] /Resources << /ColorSpace << /I {space} >> /XObject << /Im 9 0 R >> >> /Contents 4 0 R >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj <000000FF0000> endobj\n\
+ 6 0 obj << /Filter /ASCIIHexDecode /Length 13 >> stream\n000000FF0000>\nendstream endobj\n\
+ 7 0 obj /DeviceRGB endobj\n\
+ 8 0 obj 1 endobj\n\
+ {extra}\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len()
+ )
+ .into_bytes();
+ let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest {
+ page: 0,
+ kind: RequestKind::Pixels,
+ tile: None,
+ scale: 1.0,
+ tag: 0,
+ preview: false,
+ });
+ assert!(page.error.is_none(), "{space}: {:?}", page.error);
+ let start = (100 * page.width as usize + 100) * 4;
+ let mut pixel = [0; 4];
+ pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel"));
+ pixel
+ };
+ let paint = "/I cs 1 sc 0 0 200 200 re f";
+ // One byte of index 1, as an image in the same space: the image path reads it too.
+ let image = "9 0 obj << /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 1 5 0 R] /Filter /ASCIIHexDecode /Length 3 >> stream\n01>\nendstream endobj";
+ for space in [
+ "[/Indexed /DeviceRGB 1 <000000FF0000>]",
+ "[/Indexed /DeviceRGB 1 5 0 R]",
+ "[/Indexed /DeviceRGB 1 6 0 R]",
+ "[/Indexed 7 0 R 8 0 R 5 0 R]",
+ "[/I /DeviceRGB 1 5 0 R]",
+ ] {
+ assert_eq!(render(space, paint, ""), [255, 0, 0, 255], "{space}");
+ }
+ let indirect = "10 0 obj [/Indexed /DeviceRGB 1 5 0 R] endobj";
+ assert_eq!(render("10 0 R", paint, indirect), [255, 0, 0, 255], "the whole space by reference");
+ assert_eq!(render("/DeviceRGB", "q 200 0 0 200 0 0 cm /Im Do Q", image), [255, 0, 0, 255], "an image's table by reference");
+ // What's neither table form leaves the space unreadable, as a missing table does.
+ for space in ["[/Indexed /DeviceRGB 1 8 0 R]", "[/Indexed /DeviceRGB 1 99 0 R]", "[/Indexed /DeviceRGB 1]"] {
+ let _ = render(space, paint, "");
+ }
+ // So does a table that only leads back to itself, and the read of it ends.
+ let cycle = "11 0 obj 11 0 R endobj 12 0 obj 13 0 R endobj 13 0 obj 12 0 R endobj";
+ for space in ["[/Indexed /DeviceRGB 1 11 0 R]", "[/Indexed /DeviceRGB 1 12 0 R]"] {
+ let _ = render(space, paint, cycle);
+ }
+}
#[test]
fn watchdog_skips_a_stuck_page_and_keeps_rendering() {
use super::*;
@@ -4671,6 +5137,86 @@ fn jbig2_symbol_dictionaries_that_never_advance_terminate() {
}
}
+/// A JBIG2 stream can put a region, a reference or a symbol at the very end of what an `i32` holds,
+/// where adding to it overflowed: a panic wherever overflow is checked, a build's own tests included.
+/// A region or a symbol out there is off the page, so nothing draws. Vendored hayro-jbig2 patch: the arithmetic wraps, as
+/// a release build's did, or in `Bitmap::combine` saturates, which comes to the same. Three of the text
+/// streams are arithmetic-coded: one symbol on a strip of -2^31 + 1, with its bottom left as the corner
+/// placed, one on a strip of -2^31, and one refined by an offset of `i32::MIN`, which reads nothing of
+/// the old symbol and still draws. Two are Huffman-coded, each with its first S at an offset of
+/// 0x7FFFFFFF on a table's 32-bit range line, past what an `i32` holds, and are refused further on.
+#[test]
+fn jbig2_placements_at_the_end_of_an_i32_draw_nothing() {
+ const FAR_CORNER: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdc3ce0205df55c36bfffac00000002062001010000001f000000100000000c000000000000000004000000000001969747cbe88bffac";
+ const STRIP: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda25e46956d3e0d3fffac00000002062001010000001f000000100000000c000000000000000004001000000001969747cbe5dbffac";
+ const UPPER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003f7fffffff0000000000000000";
+ const LOWER_LINE: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001d000003fffdff02fefefe00000001000000014fdaea38c99641e0c8ffac000000020620010100000038000000100000000c00000000000000000400110000000000010100000000000000000000000000000000003e7fffffff0000000000000000";
+ const OFFSET: &str = "0000000030000100000013000000100000000c0000000000000000420000000000010000010000001e000003fffdff02fefefe00000001000000014fdda9172f614a037ddfffac0000000206200101000000260000000800000006000000000000000004801200000001a9695690b3e5f44a2f105e30d3ffac";
+ let segment = |number: u32, kind: u8, data: &[u8]| {
+ let mut out = number.to_be_bytes().to_vec();
+ out.extend_from_slice(&[kind, 0, 1]);
+ out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
+ out.extend_from_slice(data);
+ out
+ };
+ let side = 8u32.to_be_bytes();
+ let page = |width: u32| {
+ let mut page = [width.to_be_bytes(), side].concat();
+ page.extend_from_slice(&[0; 8]); // resolution
+ page.extend_from_slice(&[0, 0, 0]); // flags, striping
+ segment(0, 48, &page)
+ };
+ let region = |width: u32, kind: u8, x: u32, y: u32, header: &[u8]| {
+ let mut region = [width.to_be_bytes(), side, x.to_be_bytes(), y.to_be_bytes()].concat();
+ region.push(0); // combination operator
+ region.extend_from_slice(header);
+ region.extend_from_slice(&[0x5a; 64]);
+ [page(width), segment(1, kind, ®ion)].concat()
+ };
+ let generic = |x, y| region(8, 38, x, y, &[0, 3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // template 0, AT pixels
+ // Immediate refinements of the page: template 0 with its usual AT pixels, with others, with typical
+ // prediction, and template 1.
+ let refined = |width, x, y, form: usize| {
+ region(width, 42, x, y, [&[0, 0xff, 0xff, 0xff, 0xff][..], &[0, 0xff, 0xff, 127, 127], &[2, 0xff, 0xff, 0xff, 0xff], &[1]][form])
+ };
+ let hex = |text: &str| (0..text.len() / 2).map(|i| u8::from_str_radix(&text[i * 2..i * 2 + 2], 16).unwrap()).collect::>();
+ let ink = |jbig2: Vec, width: u32, height: u32| {
+ let content = b"q 20 0 0 20 5 5 cm /Im1 Do Q";
+ let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n".to_vec();
+ pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n", content.len()).as_bytes());
+ pdf.extend_from_slice(content);
+ pdf.extend_from_slice(b"\nendstream endobj\n");
+ pdf.extend_from_slice(
+ format!("5 0 obj << /Type /XObject /Subtype /Image /Width {width} /Height {height} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len())
+ .as_bytes(),
+ );
+ pdf.extend_from_slice(&jbig2);
+ pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
+ let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
+ let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] < 128).count()
+ };
+ assert!(ink(generic(2, 0), 8, 8) > 0, "a region on the page draws");
+ for form in 0..4 {
+ assert!(ink(refined(8, 0, 0, form), 8, 8) > 0, "a refinement on the page draws: {form}");
+ }
+ let end = 0x7FFF_FFFFu32;
+ for (x, y) in [(end, 0), (0, end), (end - 7, 0), (0, end - 7), (end - 129, 0), (0, end - 129)] {
+ assert_eq!(ink(generic(x, y), 8, 8), 0, "generic at {x:#x}, {y:#x}");
+ for form in 0..4 {
+ assert_eq!(ink(refined(8, x, y, form), 8, 8), 0, "refinement {form} at {x:#x}, {y:#x}");
+ }
+ }
+ // Past a 32-bit word of the row, where the reference is read again.
+ assert_eq!(ink(refined(64, 0, end, 0), 64, 8), 0, "a wide refinement");
+ assert_eq!(ink(hex(FAR_CORNER), 16, 12), 0, "a symbol's far side past the end");
+ assert_eq!(ink(hex(STRIP), 16, 12), 0, "a strip of -2^31");
+ assert!(ink(hex(OFFSET), 16, 12) > 0, "a symbol refined by an offset of i32::MIN");
+ assert_eq!(ink(hex(UPPER_LINE), 16, 12), 0, "an upper range line past an i32");
+ assert_eq!(ink(hex(LOWER_LINE), 16, 12), 0, "a lower range line past an i32");
+}
+
/// Item 1 of storytold/pdfcraft#623: a page's content used to be cut at `MAX_DECODED_STREAM`,
/// so everything after the cut was lost (the text and the title block of the A1 sample). Content
/// that expands by a few times over is read to its end, however large it is.
diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs
index 6248c1e9e1..0cac252c33 100644
--- a/crates/sign/src/der.rs
+++ b/crates/sign/src/der.rs
@@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec {
/// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and
/// never panics on malformed input.
pub fn try_oid(dotted: &str) -> Result, SignError> {
- let mut arcs = dotted.split('.').map(|a| a.parse::().map_err(|_| bad("invalid OID arc")));
+ let mut arcs = dotted.split('.').map(|a| {
+ if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) {
+ return Err(bad("invalid OID arc"));
+ }
+ a.parse::().map_err(|_| bad("invalid OID arc"))
+ });
let first = arcs.next().ok_or_else(|| bad("empty OID"))??;
let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??;
if first > 2 || (first < 2 && second >= 40) {
@@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec {
mod tests {
use super::*;
+ #[test]
+ fn try_oid_rejects_signs_and_empty_arcs() {
+ for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] {
+ assert!(try_oid(bad).is_err(), "accepted {bad}");
+ }
+ assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549"));
+ }
+
#[test]
fn round_trips() {
let o = oid("1.2.840.113549.1.7.2");
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index 804b5ea22a..7f164991d5 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -20,6 +20,7 @@ craft-quit = { workspace = true, features = ["egui"] }
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
+pdfcraft-content = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
@@ -96,6 +97,7 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
skrifa = "0.44"
+proptest = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 425c2e26ce..4de70f3d9e 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -73,6 +73,7 @@ impl PdfCraftApp {
default_mode: Mode::AllTools,
view_defaults: Default::default(),
units: Default::default(),
+ blank_paper: crate::paper::Paper::system(),
date_format_draft: None,
mode_override: None,
left: LeftPanel::AllTools,
@@ -91,6 +92,7 @@ impl PdfCraftApp {
// A fresh install follows the desktop's appearance (#1248); saved settings override.
theme_preference: ThemePreference::System,
reading_colors: Default::default(),
+ show_page_edge_shadows: true,
#[cfg(target_os = "linux")]
desktop_theme: None,
#[cfg(target_os = "linux")]
@@ -117,6 +119,7 @@ impl PdfCraftApp {
all_tools_query: String::new(),
all_tools_tab: (None, false),
recent: Vec::new(),
+ recent_limit: crate::RECENT_FILES_DEFAULT,
reopen_last_session: false,
restore_last_view: true,
last_views: Vec::new(),
@@ -133,9 +136,15 @@ impl PdfCraftApp {
pending_opens: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
next_open_token: 0,
+ #[cfg(not(target_arch = "wasm32"))]
+ launch_layout: None,
+ #[cfg(not(target_arch = "wasm32"))]
+ launch_display_target: None,
#[cfg(all(test, not(target_arch = "wasm32")))]
open_prepare_gate: None,
pinned: Default::default(),
+ pinned_tools: Vec::new(),
+ pin_tool_search: String::new(),
toast: None,
toast_script: None,
script_messages: std::collections::VecDeque::new(),
@@ -223,7 +232,6 @@ impl PdfCraftApp {
last_copied_file: None,
system_clipboard: true,
clipboard_override: None,
- v_pressed: false,
attach_override: None,
duplicate_draft: None,
page_props_draft: None,
@@ -353,6 +361,46 @@ impl PdfCraftApp {
pub fn render_pending(&self) -> bool {
self.views.iter().any(|v| v.render_pending())
}
+
+ /// Check whether a tool group is pinned to Home.
+ pub fn is_tool_pinned(&self, id: &str) -> bool {
+ self.pinned_tools.iter().any(|t| t == id)
+ }
+
+ /// Pin a tool group to Home. Returns true if newly pinned.
+ pub fn pin_tool(&mut self, id: &str) -> bool {
+ if pdfcraft_engine::catalog::group(id).is_none() {
+ return false;
+ }
+ if !self.is_tool_pinned(id) {
+ self.pinned_tools.push(id.to_string());
+ if let Some(g) = pdfcraft_engine::catalog::group(id) {
+ self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]);
+ }
+ return true;
+ }
+ false
+ }
+
+ /// Unpin a tool group from Home.
+ pub fn unpin_tool(&mut self, id: &str) {
+ if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) {
+ self.pinned_tools.remove(pos);
+ if let Some(g) = pdfcraft_engine::catalog::group(id) {
+ self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]);
+ }
+ }
+ }
+
+ /// Toggle a tool group's pinned state on Home. Returns whether it is now pinned.
+ pub fn toggle_pin_tool(&mut self, id: &str) -> bool {
+ if self.is_tool_pinned(id) {
+ self.unpin_tool(id);
+ false
+ } else {
+ self.pin_tool(id)
+ }
+ }
}
impl eframe::App for PdfCraftApp {
@@ -362,6 +410,10 @@ impl eframe::App for PdfCraftApp {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// The button's release a window move or resize kept from egui.
window_controls::release_after_desktop_grab(ctx, raw);
+ // The wheel after Alt+Tab back, before the mouse has moved (#1518).
+ let known = ctx.input(|i| i.pointer.latest_pos().is_some());
+ let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size());
+ drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx));
self.prepare_raw_input(ctx, raw);
if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() {
return;
diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs
index 6641d93a86..9319368d29 100644
--- a/crates/ui-egui/src/app/clipboard.rs
+++ b/crates/ui-egui/src/app/clipboard.rs
@@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str {
tl!("The document's security settings don't allow printing")
}
+enum ClipboardPaste {
+ Text,
+ Image(std::sync::Arc),
+}
+
impl PdfCraftApp {
/// Whether text and images may be copied out of document `id`; says why not when they may
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
@@ -22,14 +27,83 @@ impl PdfCraftApp {
false
}
- /// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
- pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
- // ⌘C arrives as a Copy event on most platforms.
- let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
- if !copy || ctx.egui_wants_keyboard_input() {
+ /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
+ pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
+ use egui::{Key, KeyboardShortcut, Modifiers};
+ let pastes: Vec<_> = ctx.input(|input| {
+ input
+ .events
+ .iter()
+ .filter_map(|event| match event {
+ egui::Event::Paste(_) => Some(ClipboardPaste::Text),
+ egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
+ _ => None,
+ })
+ .collect()
+ });
+ let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
+ if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
+ self.receive_pastes(i, pastes, false);
return;
}
- self.copy_selected_text(i, ctx);
+ let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
+ || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
+ let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
+ || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
+ let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
+
+ if copy
+ && !self.copy_selected_text(i, ctx)
+ && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
+ && let Some(id) = self.views.get(i).map(|v| v.id)
+ && self.may_copy(id)
+ {
+ self.copy_page_image(sel.page, sel.index);
+ }
+ if cut
+ && let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
+ && let Some(id) = self.views.get(i).map(|v| v.id)
+ && self.may_copy(id)
+ {
+ self.cut_page_image(sel.page, sel.index);
+ }
+ if !pastes.is_empty() {
+ self.receive_pastes(i, pastes, true);
+ return;
+ }
+ if paste {
+ let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. }));
+ #[cfg(not(target_arch = "wasm32"))]
+ let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false };
+ #[cfg(target_arch = "wasm32")]
+ let system_has_text = false;
+
+ if !has_override_image && system_has_text {
+ self.last_copied_image = None;
+ self.last_copied_file = None;
+ return;
+ }
+
+ let page = self.views.get(i).map_or(0, |v| v.current);
+ self.paste_image(page, None);
+ }
+ }
+
+ fn receive_pastes(&mut self, view: usize, pastes: Vec, images_allowed: bool) {
+ let page = self.views.get(view).map_or(0, |view| view.current);
+ for paste in pastes {
+ match paste {
+ ClipboardPaste::Text => {
+ // Text fields may own the paste; the canvas cache still observes its contents.
+ self.last_copied_image = None;
+ self.last_copied_file = None;
+ }
+ ClipboardPaste::Image(image) if images_allowed => {
+ self.paste_received_image(page, &image);
+ }
+ ClipboardPaste::Image(_) => {}
+ }
+ }
}
/// Shared permission gate for copying selected page text from menus and keyboard events.
@@ -79,3 +153,222 @@ impl PdfCraftApp {
}
}
}
+
+#[cfg(test)]
+mod review_tests {
+ use super::*;
+ fn delivered() -> egui::Event {
+ egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)))
+ }
+
+ fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
+ egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }
+ }
+
+ fn image_app() -> PdfCraftApp {
+ let mut app = PdfCraftApp::new();
+ app.system_clipboard = false;
+ app.create_blank();
+ app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] });
+ app
+ }
+
+ fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec) -> usize {
+ ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
+ .drop_without_applying_deltas();
+ app.session.get(app.views[0].id).unwrap().page_images(0).len()
+ }
+
+ #[test]
+ fn delivered_image_pastes_for_either_modifier_release_order() {
+ for command_first in [true, false] {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]);
+ let releases = if command_first {
+ vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]
+ } else {
+ vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]
+ };
+ assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste");
+ assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste");
+ assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1);
+ }
+ }
+
+ #[test]
+ fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() {
+ for explicit_press in [false, true] {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()];
+ if explicit_press {
+ first.push(key(true, egui::Modifiers::COMMAND));
+ }
+ first.push(key(false, egui::Modifiers::COMMAND));
+ assert_eq!(frame(&mut app, &ctx, 1.0, first), 1);
+ // The integration drops Windows-key identity; Win+V can leave only this release.
+ assert_eq!(
+ frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]),
+ 1,
+ "opening history must not paste"
+ );
+ assert_eq!(
+ frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
+ 2,
+ "choosing an item produces exactly one intentional paste"
+ );
+ }
+ }
+
+ #[test]
+ fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
+ frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]);
+ assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
+ assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
+ assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0);
+ }
+
+ #[test]
+ fn delivered_image_is_authoritative_and_not_duplicated_by_keys() {
+ let mut app = image_app();
+ app.system_clipboard = true;
+ app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into()));
+ let ctx = egui::Context::default();
+ assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1);
+ assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]);
+ }
+
+ #[test]
+ fn text_then_image_batch_pastes_image_and_keeps_its_cache() {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1);
+ assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]);
+ }
+
+ #[test]
+ fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1);
+ assert!(app.last_copied_image.is_none());
+ assert!(app.last_copied_file.is_none());
+ }
+
+ #[test]
+ fn separate_delivered_images_in_one_batch_each_paste_once() {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ assert_eq!(
+ frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
+ 2
+ );
+ }
+
+ #[test]
+ fn explicit_control_shortcut_still_pastes_once_without_an_image_event() {
+ let mut app = image_app();
+ let ctx = egui::Context::default();
+ assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1);
+ assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1);
+ }
+
+ #[test]
+ fn delivered_image_obeys_focus_modal_and_permission_guards() {
+ let ctx = egui::Context::default();
+ let mut app = image_app();
+ app.palette_open = true;
+ assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0);
+ app.palette_open = false;
+ app.views[0].organize = true;
+ assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0);
+ app.views[0].organize = false;
+ app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
+ permissions_password: Some("owner".into()),
+ ..Default::default()
+ }));
+ let bytes = app.session.save_bytes(app.views[0].id).unwrap();
+ let mut restricted = PdfCraftApp::new();
+ restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
+ assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification());
+ assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0);
+ }
+
+ #[test]
+ fn review_an_image_less_release_is_silent_but_explicit_paste_explains() {
+ let mut app = PdfCraftApp::new();
+ app.system_clipboard = false;
+ app.create_blank();
+ app.toast = None;
+ let ctx = egui::Context::default();
+ ctx.run_ui(
+ egui::RawInput {
+ events: vec![egui::Event::Key {
+ key: egui::Key::V,
+ physical_key: None,
+ pressed: false,
+ repeat: false,
+ modifiers: egui::Modifiers::NONE,
+ }],
+ ..Default::default()
+ },
+ |ui| app.clipboard_keys(0, ui.ctx()),
+ )
+ .drop_without_applying_deltas();
+ assert!(app.toast.is_none());
+ assert!(!app.paste_image(0, None));
+ assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image");
+ }
+
+ #[test]
+ fn review_text_paste_release_is_silent_and_later_image_paste_still_works() {
+ let mut app = PdfCraftApp::new();
+ app.system_clipboard = false;
+ app.create_blank();
+ app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255]));
+ app.palette_open = true; // A text field owns the first event.
+ let ctx = egui::Context::default();
+ ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
+ .drop_without_applying_deltas();
+ assert!(app.last_copied_image.is_none());
+ app.palette_open = false;
+ app.toast = None;
+ ctx.run_ui(
+ egui::RawInput {
+ events: vec![egui::Event::Key {
+ key: egui::Key::V,
+ physical_key: None,
+ pressed: false,
+ repeat: false,
+ modifiers: egui::Modifiers::NONE,
+ }],
+ ..Default::default()
+ },
+ |ui| app.clipboard_keys(0, ui.ctx()),
+ )
+ .drop_without_applying_deltas();
+ assert!(app.toast.is_none(), "the text gesture's key release must be silent");
+ app.clipboard_override = None;
+ let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)));
+ ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
+ .drop_without_applying_deltas();
+ let id = app.active_ids().unwrap().1;
+ assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works");
+ }
+
+ #[test]
+ fn review_failed_keyboard_cut_keeps_the_image_selection() {
+ let mut app = PdfCraftApp::new();
+ app.system_clipboard = false;
+ app.create_blank();
+ app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0));
+ let ctx = egui::Context::default();
+ ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
+ .drop_without_applying_deltas();
+ assert!(app.views[0].image_selection.is_some());
+ }
+}
diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs
index 79bd2fa258..26e20f8df5 100644
--- a/crates/ui-egui/src/app/control.rs
+++ b/crates/ui-egui/src/app/control.rs
@@ -32,6 +32,9 @@ impl PdfCraftApp {
};
self.set_theme_preference(preference);
}
+ ("show_page_edge_shadows", _) => {
+ self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?;
+ }
("scroll_speed", _) => {
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
}
@@ -167,6 +170,12 @@ impl PdfCraftApp {
_ => return Err("default-page-gaps must be on or off".into()),
};
}
+ ("recent-files", _) => {
+ let max = crate::RECENT_FILES_MAX;
+ let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?;
+ self.recent_limit = limit;
+ self.recent.truncate(limit);
+ }
("cover", Some(v)) => {
let on = match value {
"on" => true,
diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs
index 3c68dc5619..29e6978461 100644
--- a/crates/ui-egui/src/app/files.rs
+++ b/crates/ui-egui/src/app/files.rs
@@ -117,6 +117,7 @@ impl PdfCraftApp {
after_open: request.after_open,
recovered: request.recovered,
then: request.then,
+ display_override: None,
});
Ok(token)
}
@@ -147,6 +148,7 @@ impl PdfCraftApp {
// earlier waiting replies, without consuming a blocked receiver's result.
waiting.append(&mut self.pending_opens);
self.pending_opens = waiting;
+ self.settle_launch_layout();
}
#[cfg(not(target_arch = "wasm32"))]
@@ -182,6 +184,12 @@ impl PdfCraftApp {
if let Some(destination) = pending.after_open {
self.apply_remote_destination(destination);
}
+ if let Some((layout, continuous)) = pending.display_override
+ && let Some(view) = self.views.iter_mut().find(|view| view.id == id)
+ {
+ view.set_display(layout, continuous);
+ self.launch_display_target = Some(id);
+ }
if let Some(meta) = &pending.recovered {
self.finish_recovery(meta);
}
@@ -205,6 +213,7 @@ impl PdfCraftApp {
recovered: pending.recovered,
then: pending.then,
after_open: pending.after_open,
+ display_override: pending.display_override,
});
}
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]),
@@ -239,6 +248,7 @@ impl PdfCraftApp {
recovered: None,
then: None,
after_open: None,
+ display_override: None,
});
return Ok(());
}
@@ -323,7 +333,7 @@ impl PdfCraftApp {
macos_association::repair(std::path::Path::new(&p));
self.recent.retain(|r| r.path != p);
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
- self.recent.truncate(12);
+ self.recent.truncate(self.recent_limit);
}
// What the form's scripts said while it opened (messages, errors) shows now, not with
// the next edit.
@@ -416,6 +426,8 @@ impl PdfCraftApp {
if let Some(meta) = p.recovered {
self.recoverable.push(meta);
}
+ #[cfg(not(target_arch = "wasm32"))]
+ self.settle_launch_layout();
return;
};
#[cfg(not(target_arch = "wasm32"))]
@@ -431,6 +443,11 @@ impl PdfCraftApp {
recovered: p.recovered.clone(),
then: p.then,
});
+ if let Ok(token) = &result
+ && let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == *token)
+ {
+ pending.display_override = p.display_override;
+ }
if let Err(error) = result {
let mut prompt = p;
prompt.error = Some(error);
@@ -458,6 +475,15 @@ impl PdfCraftApp {
if let Some(destination) = after_open {
self.apply_remote_destination(destination);
}
+ if let Some((layout, continuous)) = p.display_override
+ && let Some(view) = self.views.last_mut()
+ {
+ view.set_display(layout, continuous);
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ self.launch_display_target = Some(view.id);
+ }
+ }
if let Some(meta) = &p.recovered {
self.finish_recovery(meta);
}
@@ -472,9 +498,12 @@ impl PdfCraftApp {
prompt.recovered = p.recovered;
prompt.then = p.then;
prompt.after_open = after_open;
+ prompt.display_override = p.display_override;
}
}
}
+ #[cfg(not(target_arch = "wasm32"))]
+ self.settle_launch_layout();
}
/// Ask for the active document's permissions password, then run `then` (if any).
@@ -533,17 +562,37 @@ impl PdfCraftApp {
/// the file opened, focused its tab, was handed to the background worker, or failed.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
+ self.open_path_request(path, then, None)
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome {
// One file, one tab (#2166): a file that is open already comes to the front instead,
// and the tool it was opened for runs on it. One still being opened is left to that.
if let Some(i) = self.tab_showing(path) {
self.startup_superseded = true;
self.active = Some(i);
+ if let Some((layout, continuous)) = display_override
+ && let Some(view) = self.views.get_mut(i)
+ {
+ view.set_display(layout, continuous);
+ self.launch_display_target = Some(view.id);
+ }
if let Some(command) = then {
self.execute(command);
}
return OpenOutcome::Focused;
}
- if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
+ if let Some(pending) = self.pending_opens.iter_mut().find(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
+ if display_override.is_some() {
+ pending.display_override = display_override;
+ }
+ return OpenOutcome::Pending;
+ }
+ if display_override.is_some()
+ && let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path)))
+ {
+ prompt.display_override = display_override;
return OpenOutcome::Pending;
}
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
@@ -552,18 +601,29 @@ impl PdfCraftApp {
// Before the first frame there is no interaction to keep responsive; this also preserves
// deterministic inline startup and test flows. Once live, reading and preparation use
// the bounded worker and commit on the UI thread.
- if self.ctx.is_none() || self.run_inline {
+ // Keep flagged launch files behind an existing password prompt or picker.
+ if (self.ctx.is_none() || self.run_inline) && !(display_override.is_some() && (self.modal_open() || self.pickers.pending())) {
match std::fs::read(path) {
Ok(bytes) => {
let before = self.views.len();
+ let previous_prompt = self.password_prompt.as_ref().map(|p| p.bytes.clone());
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
return OpenOutcome::Failed;
}
- if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
+ if let Some(prompt) = self.password_prompt.as_mut().filter(|p| {
+ p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes))
+ }) {
// Encrypted: the tool runs once the password is in.
prompt.then = then;
+ prompt.display_override = display_override;
} else if self.views.len() > before {
+ if let Some((layout, continuous)) = display_override
+ && let Some(view) = self.views.last_mut()
+ {
+ view.set_display(layout, continuous);
+ self.launch_display_target = Some(view.id);
+ }
if let Some(doc) = self.views.last().map(|view| view.id) {
self.watch.known_as(doc, stamp);
}
@@ -595,7 +655,12 @@ impl PdfCraftApp {
recovered: None,
then,
}) {
- Ok(_) => OpenOutcome::Pending,
+ Ok(token) => {
+ if let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == token) {
+ pending.display_override = display_override;
+ }
+ OpenOutcome::Pending
+ }
Err(error) => {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
OpenOutcome::Failed
@@ -604,6 +669,38 @@ impl PdfCraftApp {
}
}
+ /// Apply an explicit launch layout after the command-line display overrides settle.
+ #[cfg(not(target_arch = "wasm32"))]
+ pub fn set_launch_layout(&mut self, value: &str) -> Result<(), String> {
+ let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?;
+ self.launch_layout = Some((layout, continuous));
+ self.settle_launch_layout();
+ Ok(())
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ fn settle_launch_layout(&mut self) {
+ if self.pending_opens.iter().any(|open| open.display_override.is_some())
+ || self.password_prompt.as_ref().is_some_and(|prompt| prompt.display_override.is_some())
+ {
+ return;
+ }
+ // Follow the existing completion/activation order, not a guessed tab index or path.
+ // Failed or cancelled opens cannot cause the layout to land on a restored document.
+ if let Some((layout, continuous)) = self.launch_layout.take()
+ && let Some(id) = self.launch_display_target
+ && let Some(view) = self.views.iter_mut().find(|view| view.id == id)
+ {
+ view.set_display(layout, continuous);
+ }
+ }
+
+ /// Open one native file with a transient display override, including after a password prompt.
+ #[cfg(not(target_arch = "wasm32"))]
+ pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) {
+ self.open_path_request(path, None, Some((layout, continuous)));
+ }
+
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs
index 6021af64d8..c014453806 100644
--- a/crates/ui-egui/src/app/keys.rs
+++ b/crates/ui-egui/src/app/keys.rs
@@ -3,7 +3,6 @@ use super::*;
impl PdfCraftApp {
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
use egui::Key;
- let pasted = self.paste_key_released(ctx);
// "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document
// properties; Escape cancels it rather than clearing a selection), and nothing may run
// underneath it. They are read here, before the canvas can consume them.
@@ -78,8 +77,7 @@ impl PdfCraftApp {
prepare::nudge_keys(ctx, &mut self.views[i], doc);
}
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
- self.copy_key(i, ctx);
- self.paste_image_key(i, ctx, pasted);
+ self.clipboard_keys(i, ctx);
self.image_pick_keys(i, ctx);
}
}
diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs
index e5eaae5863..c2a7de31dc 100644
--- a/crates/ui-egui/src/app/prefs.rs
+++ b/crates/ui-egui/src/app/prefs.rs
@@ -78,7 +78,7 @@ impl PdfCraftApp {
let mut v = serde_json::json!({
"side_panels": self.side_panels.to_json(self.left_open),
"docking_right": self.right,
- "recent": self.recent,
+ "recent": self.recent.iter().take(self.recent_limit).collect::>(),
"reopen_last_session": self.reopen_last_session,
"restore_last_view": self.restore_last_view,
"last_views": self.last_views_to_save(),
@@ -139,10 +139,13 @@ impl PdfCraftApp {
});
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
+ v["recent_limit"] = serde_json::json!(self.recent_limit);
v["fill_text_color"] = serde_json::json!(self.fill_text_color);
v["redact_prefs"] = self.redact_defaults.to_json();
+ v["pinned_tools"] = serde_json::json!(self.pinned_tools);
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
+ v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
@@ -179,6 +182,11 @@ impl PdfCraftApp {
let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
self.recent = r;
}
+ // Nonsense keeps the limit it had; settings from before the limit have none.
+ if let Some(limit) = v["recent_limit"].as_u64() {
+ self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX));
+ }
+ self.recent.truncate(self.recent_limit);
self.restore_layout_prefs(&v);
self.restore_behavior_prefs(&v);
self.restore_view_prefs(&v);
@@ -206,6 +214,18 @@ impl PdfCraftApp {
self.show_secured_notice = on;
}
self.pinned.restore(&v["pinned_folders"]);
+ if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
+ let mut tools = Vec::new();
+ for item in items {
+ if let Some(id) = item.as_str()
+ && pdfcraft_engine::catalog::group(id).is_some()
+ && !tools.iter().any(|t| t == id)
+ {
+ tools.push(id.to_string());
+ }
+ }
+ self.pinned_tools = tools;
+ }
if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) {
self.set_theme_preference(preference);
}
@@ -267,6 +287,9 @@ impl PdfCraftApp {
{
self.fill_text_color = color;
}
+ if let Some(show) = v["show_page_edge_shadows"].as_bool() {
+ self.show_page_edge_shadows = show;
+ }
if let Some(prefs) = RedactPrefs::from_json(&v["redact_prefs"]) {
self.redact_prefs = prefs.clone();
self.redact_defaults = prefs;
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 8b280ba75d..7a0976644b 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -84,6 +84,175 @@ pub enum Fit {
None,
}
+use crate::edit_text_ui::ImageHandle;
+
+/// Extension trait on `egui::Painter` to draw polygons.
+pub trait PainterExt {
+ fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx;
+}
+
+impl PainterExt for egui::Painter {
+ fn polygon(&self, points: Vec, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx {
+ self.add(egui::Shape::convex_polygon(points, fill, stroke))
+ }
+}
+
+/// User-space selectable bounding box for an image object.
+/// If the image object has an active clipping path applied to it (the crop box),
+/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
+pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
+ if let Some(clip) = im.clip_rect {
+ let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
+ if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
+ return r;
+ }
+ }
+ im.visible_rect.unwrap_or(im.rect)
+}
+
+/// Screen-space selectable bounding box for an image object.
+/// If the image object has an active clipping path applied to it (the crop box),
+/// the final selectable `egui::Rect` is the mathematical intersection of the image's
+/// full CTM-derived rect and the clipping path's rect.
+pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
+ let full = xf.user_rect_f64(info, page, im.rect);
+ if let Some(clip) = im.clip_rect {
+ let clip_r = xf.user_rect_f64(info, page, clip);
+ let inter = full.intersect(clip_r);
+ if inter.is_positive() {
+ return inter;
+ }
+ }
+ if let Some(vr) = im.visible_rect {
+ let r = xf.user_rect_f64(info, page, vr);
+ if r.is_positive() {
+ return r;
+ }
+ }
+ full
+}
+
+/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
+/// with 8 resize handles and rotated local axes.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ImageObb {
+ pub s00: Pos2,
+ pub s10: Pos2,
+ pub s11: Pos2,
+ pub s01: Pos2,
+ pub s_bc: Pos2,
+ pub s_rc: Pos2,
+ pub s_tc: Pos2,
+ pub s_lc: Pos2,
+ pub s_rot: Pos2,
+ pub screen_center: Pos2,
+ pub u_axis: (f64, f64),
+ pub v_axis: (f64, f64),
+ pub is_rotated: bool,
+}
+
+impl ImageObb {
+ pub(crate) fn handle_cursor(&self, handle: ImageHandle) -> egui::CursorIcon {
+ let direction = self.handle_pos(handle) - self.screen_center;
+ match handle {
+ ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
+ if direction.x * direction.y > 0.0 {
+ egui::CursorIcon::ResizeNwSe
+ } else {
+ egui::CursorIcon::ResizeNeSw
+ }
+ }
+ _ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
+ _ => egui::CursorIcon::ResizeVertical,
+ }
+ }
+
+ pub(crate) fn contains(&self, visible: Rect, p: Pos2) -> bool {
+ (visible.is_positive() && visible.contains(p)) || crate::edit_text_ui::quad_contains(self.corners(), p)
+ }
+
+ pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
+ let m = crate::edit_text_ui::Matrix(matrix);
+ let p00 = m.apply(0.0, 0.0);
+ let p10 = m.apply(1.0, 0.0);
+ let p11 = m.apply(1.0, 1.0);
+ let p01 = m.apply(0.0, 1.0);
+
+ let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1);
+ let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1);
+ let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1);
+ let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1);
+
+ let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5);
+ let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5);
+ let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5);
+ let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5);
+
+ let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25);
+ let out_dir = s_tc - screen_center;
+ let out_len = out_dir.length().max(1e-4);
+ let out_norm = out_dir / out_len;
+ let s_rot = s_tc + out_norm * 24.0;
+
+ let [a, b, c, d, ..] = matrix;
+ let w_u = (a * a + b * b).sqrt().max(1e-6);
+ let h_v = (c * c + d * d).sqrt().max(1e-6);
+ let u_axis = (a / w_u, b / w_u);
+ let v_axis = (c / h_v, d / h_v);
+ // The fast rectangle path interprets labels in screen space. Use matrix
+ // resizing whenever the image's user-space axes are turned or reflected
+ // on screen, including page /Rotate and View > Rotate View.
+ let u = s10 - s00;
+ let v = s01 - s00;
+ let is_rotated = u.y.abs() > 1e-4 || v.x.abs() > 1e-4 || u.x < 0.0 || v.y > 0.0;
+
+ Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center, u_axis, v_axis, is_rotated }
+ }
+
+ pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self {
+ let [a, b, c, d, ..] = im.matrix;
+ let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4 || a < 0.0 || d < 0.0;
+ let bounds = image_selectable_bounds(im);
+ let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] {
+ [bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]]
+ } else {
+ im.matrix
+ };
+ Self::from_matrix(matrix, xf, info, page)
+ }
+
+ pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
+ [
+ ImageHandle::TopLeft,
+ ImageHandle::TopCenter,
+ ImageHandle::TopRight,
+ ImageHandle::RightCenter,
+ ImageHandle::BottomRight,
+ ImageHandle::BottomCenter,
+ ImageHandle::BottomLeft,
+ ImageHandle::LeftCenter,
+ ]
+ .map(|h| (h, self.handle_pos(h)))
+ }
+
+ pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
+ match handle {
+ ImageHandle::TopLeft => self.s01,
+ ImageHandle::TopCenter => self.s_tc,
+ ImageHandle::TopRight => self.s11,
+ ImageHandle::RightCenter => self.s_rc,
+ ImageHandle::BottomRight => self.s10,
+ ImageHandle::BottomCenter => self.s_bc,
+ ImageHandle::BottomLeft => self.s00,
+ ImageHandle::LeftCenter => self.s_lc,
+ }
+ }
+
+ pub fn corners(&self) -> [Pos2; 4] {
+ [self.s00, self.s10, self.s11, self.s01]
+ }
+}
+
/// The page display as it was before full screen, put back on leaving it
/// ([`DocView::enter_presentation`]).
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -569,6 +738,104 @@ pub enum ViewAction {
CameraNext(&'static str),
}
+/// Default page right-click menu actions.
+#[derive(Clone, Debug, PartialEq)]
+pub(crate) enum PageMenuAction {
+ RotateCCW,
+ RotateCW,
+ Print,
+ SelectTool,
+ HandTool,
+ MarqueeZoom,
+ Copy,
+ Paste(usize, [f64; 2]),
+ AddComment(usize, [f64; 2]),
+ AddBookmark,
+ TakeSnapshot,
+ EditPdf,
+ AddText,
+ AddImage,
+ DeletePage(usize),
+ ExportPdf,
+ Find,
+ DocProperties,
+ FlattenComments,
+}
+
+pub(crate) struct PageMenuPerms {
+ pub can_modify: bool,
+ pub can_assemble: bool,
+ pub can_annotate: bool,
+ pub can_print: bool,
+ pub can_copy: bool,
+ pub can_copy_items: bool,
+ pub can_paste: bool,
+ pub has_annotations: bool,
+}
+
+pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option) {
+ use PageMenuAction as A;
+ // Takes `choice` when `entry` was clicked, and closes the menu.
+ let mut pick = |entry: egui::Response, ui: &mut egui::Ui, choice: PageMenuAction| {
+ if entry.clicked() {
+ *action = Some(choice);
+ ui.close();
+ }
+ };
+ let icon = |ui: &mut egui::Ui, name: &str, tip: &str| crate::icons::button(ui, name, 24.0, false, tip);
+ // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
+ ui.horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 4.0;
+ pick(icon(ui, "rotate-ccw", tl!("Rotate view counterclockwise")), ui, A::RotateCCW);
+ ui.add_enabled_ui(perms.can_print, |ui| pick(icon(ui, "printer", tl!("Print")), ui, A::Print));
+ pick(icon(ui, "mouse-pointer-2", tl!("Select Tool")), ui, A::SelectTool);
+ pick(icon(ui, "hand", tl!("Hand Tool")), ui, A::HandTool);
+ pick(icon(ui, "zoom-in", tl!("Marquee Zoom")), ui, A::MarqueeZoom);
+ });
+
+ ui.separator();
+
+ pick(
+ ui.add_enabled(
+ perms.can_copy_items,
+ egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)),
+ ),
+ ui,
+ A::Copy,
+ );
+ pick(
+ ui.add_enabled(
+ perms.can_paste,
+ egui::Button::new(tl!("Paste")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), pdfcraft_engine::commands::Shortcut::cmd("V"))),
+ ),
+ ui,
+ A::Paste(page, at),
+ );
+
+ ui.separator();
+
+ pick(ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))), ui, A::AddComment(page, at));
+ if perms.has_annotations {
+ pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))), ui, A::FlattenComments);
+ }
+ pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))), ui, A::AddBookmark);
+ pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))), ui, A::TakeSnapshot);
+
+ ui.separator();
+
+ pick(ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())), ui, A::EditPdf);
+ pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))), ui, A::AddText);
+ pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))), ui, A::AddImage);
+ pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))), ui, A::DeletePage(page));
+ pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))), ui, A::ExportPdf);
+
+ ui.separator();
+
+ pick(ui.button(tl!("Find text or tools")), ui, A::Find);
+ pick(ui.button(tl!("Rotate view clockwise")), ui, A::RotateCW);
+ pick(ui.button(tl!("Document properties…")), ui, A::DocProperties);
+}
+
impl DocView {
/// Whether a middle-button scrolling gesture is active (tests and automation).
pub fn auto_scrolling(&self) -> bool {
@@ -862,6 +1129,7 @@ struct PageEnv<'a> {
modal: bool,
navigation_enabled: bool,
paper: Color32,
+ show_page_edge_shadows: bool,
ppp: f32,
precise_scroll: Vec2,
prefs: &'a comments::CommentPrefs,
@@ -885,6 +1153,7 @@ struct PageEnv<'a> {
fill_text_size: f64,
fill_text_color: [f64; 3],
today: (i64, u32, u32),
+ can_paste_image: bool,
}
/// What the page-scroll closure produces for the rest of the frame.
@@ -902,6 +1171,7 @@ struct PageOut {
open_initials: bool,
open_signature: bool,
open_table: Option<(usize, [f64; 4])>,
+ page_action: Option,
primary_down: bool,
refused: Option,
stamp_placed: bool,
@@ -945,6 +1215,70 @@ struct PageSlot {
/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders
/// it wants.
+#[allow(clippy::too_many_arguments)]
+fn paint_image_suppressed(
+ xf: &PageXform,
+ painter: &egui::Painter,
+ tex: egui::TextureId,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+ suppress: Option<[f32; 4]>,
+ is_full_page_tex: bool,
+) {
+ let Some([su0, sv0, su1, sv1]) = suppress else {
+ if is_full_page_tex {
+ xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
+ } else {
+ xf.paint_image(painter, tex, u0, v0, u1, v1);
+ }
+ return;
+ };
+ let iu0 = u0.max(su0);
+ let iu1 = u1.min(su1);
+ let iv0 = v0.max(sv0);
+ let iv1 = v1.min(sv1);
+ if iu0 >= iu1 || iv0 >= iv1 {
+ if is_full_page_tex {
+ xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
+ } else {
+ xf.paint_image(painter, tex, u0, v0, u1, v1);
+ }
+ return;
+ }
+ let map_uv = |pu: f32, pv: f32| -> (f32, f32) {
+ if is_full_page_tex {
+ (pu, pv)
+ } else {
+ let du = (u1 - u0).max(1e-6);
+ let dv = (v1 - v0).max(1e-6);
+ ((pu - u0) / du, (pv - v0) / dv)
+ }
+ };
+ let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| {
+ if pu1 <= pu0 || pv1 <= pv0 {
+ return;
+ }
+ let (tu0, tv0) = map_uv(pu0, pv0);
+ let (tu1, tv1) = map_uv(pu1, pv1);
+ xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1);
+ };
+
+ if iv0 > v0 {
+ draw_strip(u0, v0, u1, iv0);
+ }
+ if v1 > iv1 {
+ draw_strip(u0, iv1, u1, v1);
+ }
+ if iu0 > u0 {
+ draw_strip(u0, iv0, iu0, iv1);
+ }
+ if u1 > iu1 {
+ draw_strip(iu1, iv0, u1, iv1);
+ }
+}
+
fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) {
let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
@@ -952,7 +1286,7 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
let info = frame.env.info;
let painter = ui.painter();
// Shadows would darken the preceding page when pages touch.
- if view.show_page_gaps {
+ if view.show_page_gaps && frame.env.show_page_edge_shadows {
painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO));
}
painter.rect_filled(r, CornerRadius::ZERO, paper);
@@ -987,13 +1321,33 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)),
None => frame.wanted.push((i, want_scale, want_tag, None)),
}
+ let doc = frame.env.doc;
+ let active_drag_suppress = if let Some(sel) = view.image_selection.clone()
+ && sel.page == i
+ && sel.drag.is_some()
+ {
+ let images = cached_edit_images(view, doc, i);
+ if let Some(img) = images.get(sel.index) {
+ let obb = ImageObb::from_image(img, &xf, info, i);
+ let uvs = obb.corners().map(|c| xf.screen_to_norm(c));
+ let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0);
+ let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0);
+ let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0);
+ let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0);
+ if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
match view.pages.get(&i) {
Some(p) => {
if !tiled && p.tag == want_tag {
- xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
+ paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
} else {
// A backdrop, or a raster at an older scale until the new one arrives.
- xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
+ paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
}
}
None => {
@@ -1004,14 +1358,21 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
}
}
if tiled && r.intersects(visible) {
- paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side));
+ paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress);
}
}
}
/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched,
/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing.
-fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) {
+fn paint_page_tiles(
+ ui: &egui::Ui,
+ view: &mut DocView,
+ frame: &mut PageFrame<'_>,
+ slot: &PageSlot,
+ (pw_pt, ph_pt, max_side): (f32, f32, f32),
+ active_drag_suppress: Option<[f32; 4]>,
+) {
let PageEnv { ppp, scale, tag, .. } = frame.env;
let PageSlot { i, r, xf } = *slot;
let visible = frame.visible;
@@ -1040,7 +1401,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
let [w, h] = tex.size();
let (x, y) = (ox as f32, oy as f32);
- xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
+ paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false);
}
// Visible tiles, nearest the viewport centre first: each tile costs a full
// page interpret, so the workers sharpen what is looked at before the edges.
@@ -1065,13 +1426,16 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
let (fw, fh) = (dw as f32, dh as f32);
- xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
+ paint_image_suppressed(
+ &xf.texel_aligned([dw as usize, dh as usize], ppp),
painter,
tex.id(),
x as f32 / fw,
y as f32 / fh,
(x + w) as f32 / fw,
(y + h) as f32 / fh,
+ active_drag_suppress,
+ false,
);
}
}
@@ -1326,9 +1690,18 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
info,
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
};
- let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action);
+ let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
!others_own_pointer && (text_used || image_used)
};
+ if tool == QuickTool::EditText {
+ if others_own_pointer {
+ if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
+ view.image_selection = None;
+ }
+ } else {
+ crate::content_ui::sync_image_selection(view, i, &images, added);
+ }
+ }
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
if tool == QuickTool::Select
&& resp.secondary_clicked()
@@ -1366,17 +1739,20 @@ fn edit_inventory(
l
}
};
- let images = match view.edit_images.get(&i) {
- Some((g, l)) if *g == generation => l.clone(),
- _ => {
- let l = doc.page_images(i);
- view.edit_images.insert(i, (generation, l.clone()));
- l
- }
- };
+ let images = cached_edit_images(view, doc, i).to_vec();
(lines, images)
}
+/// Images parsed once per page and document edit generation, shared by painting and menus.
+fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
+ let generation = doc.edit_generation();
+ let entry = view.edit_images.entry(page).or_insert_with(|| (generation, doc.page_images(page)));
+ if entry.0 != generation {
+ *entry = (generation, doc.page_images(page));
+ }
+ &entry.1
+}
+
/// What the page modules have under a point on one page, for the page router.
struct PageHits<'a> {
page: usize,
@@ -1425,6 +1801,19 @@ impl PageHits<'_> {
images,
clip,
} = *self;
+ // Crop mode owns presses on its page, including the controls beside its
+ // frame. Underlying paragraphs and the empty-page marquee must not take
+ // a crop button's press before the image module receives it.
+ if tool == QuickTool::EditText
+ && can_modify
+ && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())
+ {
+ return vec![crate::page_router::Hit::new(
+ crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body },
+ crate::page_cursor::Layer::Handle,
+ egui::CursorIcon::Default,
+ )];
+ }
let fields = if preparing {
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
} else if tool == QuickTool::Select {
@@ -1934,6 +2323,7 @@ fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer)
}
}
+#[allow(clippy::cognitive_complexity)]
fn draw_pages(
ui: &mut egui::Ui,
viewport: Rect,
@@ -1967,6 +2357,8 @@ fn draw_pages(
prefs,
preparing,
selection_wheel,
+ ppp,
+ rects,
single_paging,
tool,
unobstructed,
@@ -2033,6 +2425,38 @@ fn draw_pages(
if resp.secondary_clicked() {
// Set again below when the right-click lands on a link.
view.links.context = None;
+ let mut over_image = false;
+ if tool == QuickTool::EditText {
+ let pointer_pos = ptr.pos;
+ if let Some(p) = pointer_pos {
+ for &page_idx in visible_pages {
+ let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
+ if !r.intersects(visible) {
+ continue;
+ }
+ let xf =
+ PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
+ let images = cached_edit_images(view, doc, page_idx).to_vec();
+ for (idx, img) in images.iter().enumerate().rev() {
+ let obb = ImageObb::from_image(img, &xf, info, page_idx);
+ let b = image_screen_rect(img, &xf, info, page_idx);
+ if obb.contains(b, p) {
+ over_image = true;
+ if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
+ view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
+ }
+ break;
+ }
+ }
+ if over_image {
+ break;
+ }
+ }
+ }
+ }
+ if !over_image && tool == QuickTool::EditText {
+ view.image_selection = None;
+ }
}
let mut frame = PageFrame {
env: *env,
@@ -2062,20 +2486,22 @@ fn draw_pages(
let PageOut {
mut canvas_action,
clicked_link,
- mut copy_selected,
+ copy_selected,
mut field_menu,
field_placed,
field_props,
hover_text: _,
- image_action,
+ mut image_action,
open_boxes,
open_initials,
open_signature,
open_table,
+ page_action: _,
primary_down: _,
refused,
stamp_placed,
} = page_out;
+ let mut page_action: Option = None;
let painter = ui.painter();
if calc_order {
view.calc_order.set_hover_page(calc_hover);
@@ -2195,20 +2621,33 @@ fn draw_pages(
}
return;
}
- if view.comments.selected.is_none() {
- let selected = view.selected_text().is_some_and(|t| !t.is_empty());
- let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
- if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() {
- copy_selected = true;
- ui.close();
- }
- ui.separator();
+ let can_paste_image = env.can_paste_image;
+ let is_image_menu = tool == QuickTool::EditText
+ && (ui.ctx().data_mut(|d| d.get_temp::(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
+ || view.image_selection.clone().is_some_and(|sel| {
+ let pointer_pos = ptr.pos;
+ let Some(p) = pointer_pos else { return false };
+ let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
+ let images = cached_edit_images(view, doc, sel.page).to_vec();
+ let Some(img) = images.get(sel.index) else { return false };
+ let obb = ImageObb::from_image(img, &xf, info, sel.page);
+ let b = image_screen_rect(img, &xf, info, sel.page);
+ obb.contains(b, p)
+ }));
+ if is_image_menu && let Some(sel) = view.image_selection.clone() {
+ let images = cached_edit_images(view, doc, sel.page).to_vec();
+ let at = view.comments.context_at.map(|(_, at)| at);
+ let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
+ crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
+ return;
+ }
+ if view.comments.selected.is_some() {
+ canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
+ return;
}
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
let mut copy_image = None;
- if view.comments.selected.is_none()
- && let Some((page, at)) = view.comments.context_at
- {
+ if let Some((page, at)) = view.comments.context_at {
// The page's content is parsed once per right-click, not on every frame the menu is open.
let key = egui::Id::unique("copy-image-under-menu");
let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at);
@@ -2232,9 +2671,28 @@ fn draw_pages(
ui.separator();
}
}
- canvas_action =
- comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image);
+ let has_copy_image = copy_image.is_some();
+ if copy_image.is_some() {
+ canvas_action = copy_image;
+ }
+ let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
+ let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image);
+ let can_paste = can_paste_image && can_modify;
+ let perms = PageMenuPerms {
+ can_modify,
+ can_assemble: doc.allows_assembly(),
+ can_annotate: allowed,
+ can_print: doc.allows_printing(),
+ can_copy,
+ can_copy_items,
+ can_paste,
+ has_annotations: !info.annotations.is_empty(),
+ };
+ default_page_context_menu(ui, page, at, perms, &mut page_action);
});
+ if !resp.context_menu_opened() {
+ ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
+ }
*out = PageOut {
canvas_action,
clicked_link,
@@ -2248,6 +2706,7 @@ fn draw_pages(
open_initials,
open_signature,
open_table,
+ page_action,
primary_down: ptr.primary_down,
refused,
stamp_placed,
@@ -2358,6 +2817,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging:
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
// scroll the page just shown away from its top (or bottom).
view.precise_scroll_until = now + 0.25;
+ view.fling.stop();
}
// Paging owns the vertical axis in single-page view; leave it to the pager.
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
@@ -2991,6 +3451,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry:
/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests.
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
+ let can_paste_key = egui::Id::unique("can-paste-image");
+ let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) {
+ let can = app.can_paste_image();
+ ui.data_mut(|d| d.insert_temp(can_paste_key, can));
+ can
+ } else {
+ ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false)
+ };
// Keyboard scrolling applies to this frame's page view only, never later.
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
if key_scroll != 0.0 {
@@ -3049,7 +3517,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if view.organize {
// One remembered grid size for every document, kept in the settings.
view.set_grid_zoom(organize_zoom);
- organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
+ organize_grid(view, doc, unobstructed, app.show_page_edge_shadows, ui, &t);
app.organize_zoom = view.grid_zoom();
return;
}
@@ -3204,6 +3672,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
modal,
navigation_enabled,
paper,
+ show_page_edge_shadows: app.show_page_edge_shadows,
ppp,
precise_scroll,
prefs,
@@ -3227,9 +3696,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
fill_text_size: app.fill_text_size,
fill_text_color: app.fill_text_color,
today,
+ can_paste_image,
};
let mut page_out = PageOut::default();
- let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| {
+ let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| {
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
});
let PageOut {
@@ -3245,6 +3715,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
open_initials,
open_signature,
open_table,
+ page_action,
primary_down,
mut refused,
stamp_placed,
@@ -3284,8 +3755,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if preparing {
crate::prepare::keys(ui.ctx(), view);
}
+ if tool != QuickTool::EditText {
+ view.image_selection = None;
+ crate::content_ui::release_image_selection(view, &added);
+ }
if editing_content {
- crate::content_ui::keys(ui.ctx(), view);
+ crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
}
if tool == QuickTool::Link {
crate::link_ui::keys(ui.ctx(), view);
@@ -3331,6 +3806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
field_props,
field_menu,
image_action,
+ page_action,
refused,
open_signature,
open_initials,
@@ -3352,6 +3828,7 @@ struct FrameRequests {
field_props: Option<(String, usize)>,
field_menu: Option,
image_action: Option,
+ page_action: Option,
refused: Option,
open_signature: bool,
open_initials: bool,
@@ -3366,12 +3843,14 @@ struct FrameRequests {
}
/// Opens the dialogs and runs the app commands the frame asked for.
+#[allow(clippy::cognitive_complexity)]
fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) {
let FrameRequests {
open_props,
field_props,
field_menu,
image_action,
+ page_action,
refused,
open_signature,
open_initials,
@@ -3435,12 +3914,109 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
}
Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index),
Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => {
- app.views[index].image_selection = None;
app.cut_page_image(page, image);
}
+ Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => {
+ if let Some(pt) = at {
+ app.paste_image_at(page, pt);
+ } else {
+ app.paste_image(page, None);
+ }
+ }
+ Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
+ app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
+ }
Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index),
Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page),
+ Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => {
+ if let Some(d) = app.session.get(app.views[index].id) {
+ let images = d.page_images(page);
+ if let Some(img) = images.get(i) {
+ let r = img.visible_rect.unwrap_or(img.rect);
+ let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0];
+ app.views[index].comments.context_at = Some((page, center));
+ app.views[index].comments.composer = Some(crate::comments::Composer {
+ page,
+ at: center,
+ kind: crate::comments::ComposerKind::Note,
+ text: String::new(),
+ focus: true,
+ });
+ }
+ }
+ }
+ None => {}
+ }
+ match page_action {
+ Some(PageMenuAction::Copy) => {
+ if let Some(sel) = app.views[index].image_selection.clone() {
+ app.copy_page_image(sel.page, sel.index);
+ } else if app.views[index].selected_text().is_some() {
+ app.execute("edit.copy");
+ } else if let Some((page, at)) = app.views[index].comments.context_at
+ && let Some(doc) = app.session.get(app.views[index].id)
+ && let Some(image) = doc.page_image_at(page, at)
+ {
+ app.copy_page_image(page, image);
+ }
+ }
+ Some(PageMenuAction::Paste(page, at)) => {
+ app.paste_image_at(page, at);
+ }
+ Some(PageMenuAction::RotateCCW) => {
+ app.views[index].rotate_view(false);
+ }
+ Some(PageMenuAction::RotateCW) => {
+ app.views[index].rotate_view(true);
+ }
+ Some(PageMenuAction::Print) => {
+ app.open_print();
+ }
+ Some(PageMenuAction::SelectTool) => {
+ app.quick_tool = QuickTool::Select;
+ }
+ Some(PageMenuAction::HandTool) => {
+ app.quick_tool = QuickTool::Hand;
+ }
+ Some(PageMenuAction::MarqueeZoom) => {
+ app.quick_tool = QuickTool::MarqueeZoom;
+ }
+ Some(PageMenuAction::AddComment(page, at)) => {
+ app.views[index].comments.composer =
+ Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true });
+ }
+ Some(PageMenuAction::FlattenComments) => {
+ app.views[index].comments.selected = None;
+ app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false });
+ }
+ Some(PageMenuAction::AddBookmark) => {
+ app.bookmark_action(crate::panels::BmAction::New);
+ }
+ Some(PageMenuAction::TakeSnapshot) => {
+ app.quick_tool = QuickTool::Snapshot;
+ }
+ Some(PageMenuAction::EditPdf) => {
+ app.quick_tool = QuickTool::EditText;
+ }
+ Some(PageMenuAction::AddText) => {
+ app.quick_tool = QuickTool::AddText;
+ }
+ Some(PageMenuAction::AddImage) => {
+ app.add_image_dialog();
+ }
+ Some(PageMenuAction::DeletePage(page)) => {
+ app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] });
+ }
+ Some(PageMenuAction::ExportPdf) => {
+ app.select_tool("export");
+ }
+ Some(PageMenuAction::Find) => {
+ app.views[index].open_find();
+ }
+ Some(PageMenuAction::DocProperties) => {
+ app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description));
+ }
None => {}
}
if let Some(why) = refused {
diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs
index 068661ea5f..0d934dc8c9 100644
--- a/crates/ui-egui/src/canvas/area.rs
+++ b/crates/ui-egui/src/canvas/area.rs
@@ -32,14 +32,22 @@ pub(super) const SUPPRESS_SECS: f64 = 0.25;
/// Short rolling extension while egui still has an unprocessed wheel tail.
pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05;
-// Keep the viewer's scroll identity tied to the document instead of its parent UI.
+// Keep the viewer's scroll identity tied to the document instead of its parent UI. Without
+// `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only.
pub(super) fn page_scroll(
ui: &mut egui::Ui,
scroll: egui::ScrollArea,
id: egui::Id,
+ fades: bool,
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
) -> egui::scroll_area::ScrollAreaOutput {
- ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner
+ let strength = ui.spacing().scroll.fade.strength;
+ if !fades {
+ ui.spacing_mut().scroll.fade.strength = 0.0;
+ }
+ let out = ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner;
+ ui.spacing_mut().scroll.fade.strength = strength;
+ out
}
/// Run a push button's action (the ones that need no JavaScript engine).
diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs
index a0ad8fb335..4b6f6c2e42 100644
--- a/crates/ui-egui/src/canvas/cache.rs
+++ b/crates/ui-egui/src/canvas/cache.rs
@@ -239,6 +239,12 @@ impl DocView {
self.texts.get(&page).cloned()
}
+ /// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and
+ /// find matches on a page all wait for it (tests and automation).
+ pub fn has_page_text(&self, page: usize) -> bool {
+ self.texts.contains_key(&page)
+ }
+
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
self.thumbs.get(&page).map(|t| &t.tex)
}
diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs
index 403e9836c0..cc938170da 100644
--- a/crates/ui-egui/src/canvas/image_pick.rs
+++ b/crates/ui-egui/src/canvas/image_pick.rs
@@ -46,8 +46,16 @@ pub(crate) fn page_input(
let u = info_page.view_to_user(vx, vy);
let at = [f64::from(u[0]), f64::from(u[1])];
let generation = doc.edit_generation();
- view.picked_image =
- doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation }));
+ view.picked_image = doc.page_image_at(page, at).and_then(|index| {
+ doc.page_images(page).get(index).and_then(|i| {
+ let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
+ if screen_rect.is_positive() && screen_rect.contains(p) {
+ Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation })
+ } else {
+ None
+ }
+ })
+ });
}
/// Forget the selected picture once the document has been edited since it was picked: Edit text &
@@ -71,7 +79,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
}
/// A file name without characters file systems refuse.
-fn safe_name(s: &str) -> String {
+pub(crate) fn safe_name(s: &str) -> String {
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
let cleaned = cleaned.trim().trim_matches('.').to_string();
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
@@ -171,7 +179,8 @@ impl PdfCraftApp {
return;
}
// The document may have changed since the click: the same picture must still be there.
- let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect));
+ let current =
+ self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds));
if current == Some(picked.rect) {
self.copy_page_image_file(picked.page, picked.index);
} else {
@@ -195,6 +204,7 @@ impl PdfCraftApp {
let copied = file.and_then(|(ext, bytes)| {
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
self.put_file_on_clipboard(&name, &bytes)?;
+ self.last_copied_image = None;
self.last_copied_file = Some((name, bytes));
Ok(ext)
});
@@ -213,7 +223,7 @@ impl PdfCraftApp {
}
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
let path = write_clipboard_file(&dir, name, bytes)?;
- arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string())
+ crate::os_clipboard::with(|c| c.set().file_list(&[path])).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs
index 7f333b3e46..48c7e82d8f 100644
--- a/crates/ui-egui/src/canvas/organize.rs
+++ b/crates/ui-egui/src/canvas/organize.rs
@@ -267,7 +267,17 @@ fn request_sharper(
}
}
-pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) {
+pub(super) fn organize_grid(
+ view: &mut DocView,
+ doc: &pdfcraft_engine::Document,
+ unobstructed: bool,
+ show_page_edge_shadows: bool,
+ ui: &mut egui::Ui,
+ t: &Tokens,
+) {
+ let info = &doc.info;
+ let editable = doc.allows_assembly();
+ let dirty = doc.dirty;
let ppp = ui.ctx().pixels_per_point();
// The page image scales with the zoom; the padding and the page number don't.
let cell = vec2(146.0 * view.grid_zoom + 44.0, 194.0 * view.grid_zoom + 88.0);
@@ -290,6 +300,10 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
let (mut in_view, mut sharper): (Vec<(usize, f32)>, Vec) = (Vec::new(), Vec::new());
// The source cell can scroll out of the virtualized rows during a drag.
let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released());
+ let fade_strength = ui.spacing().scroll.fade.strength;
+ if !show_page_edge_shadows {
+ ui.spacing_mut().scroll.fade.strength = 0.0;
+ }
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| {
apply_auto_scroll(ui, middle_gesture, auto_delta);
ui.add_space(20.0);
@@ -329,7 +343,9 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
if view.selected.contains(&i) {
ui.painter().rect_stroke(c.shrink(6.0), CornerRadius::same(8), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
- ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
+ if show_page_edge_shadows {
+ ui.painter().rect_filled(pr.translate(vec2(0.0, 1.5)), CornerRadius::same(1), t.page_shadow);
+ }
ui.painter().rect_filled(pr, CornerRadius::ZERO, Color32::WHITE);
// A page drawn larger than its thumbnail gets a render at the size drawn, while
// it is in view; until that arrives the thumbnail stands in.
@@ -507,6 +523,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool,
view.org_drag = None;
}
});
+ ui.spacing_mut().scroll.fade.strength = fade_strength;
// Files dragged in from outside go to the gap under the pointer. Some platforms don't say
// where the pointer is until the files are dropped; then the whole grid is the target.
let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|p| viewport.contains(*p));
diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs
index f0df1d92f1..02cb2208ed 100644
--- a/crates/ui-egui/src/canvas/page_pointer.rs
+++ b/crates/ui-egui/src/canvas/page_pointer.rs
@@ -56,18 +56,17 @@ impl PagePointer {
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
// it stops: on the frame one opens egui still gives the pages a press that lands under
// it, and its click.
- let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
- // `contains_pointer` alone also holds a few points under the edge of an Area over the
- // pages (egui's interact radius), so the top layer there must be the pages' own. A
- // dialog covers them from the frame it opens, before egui knows its layer.
let layer = ui.layer_id();
let on_top =
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
+ let press_origin = origin.or_else(|| first_press(&edges));
+ let held =
+ resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
let pos = at.filter(|p| held || on_top(p));
if !held {
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
}
- Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
+ Self { pos, origin: press_origin, edges, primary_down, any_down }
}
/// Where the pointer is, when it is inside `r`.
diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs
index 06d3c48568..8b91cc865c 100644
--- a/crates/ui-egui/src/canvas/page_router.rs
+++ b/crates/ui-egui/src/canvas/page_router.rs
@@ -42,8 +42,7 @@ pub(crate) enum Owner {
pub(crate) enum PicturePart {
/// The picture: a click selects it, a drag moves the selected one.
Body,
- /// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag
- /// resizes it.
+ /// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
Corner(usize),
/// The selected picture's rotate knob: a drag turns it.
Knob,
diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs
index 634a885111..4761584735 100644
--- a/crates/ui-egui/src/canvas/tests.rs
+++ b/crates/ui-egui/src/canvas/tests.rs
@@ -2002,3 +2002,38 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale()
h.state_mut().views[0].stale_tiles.clear();
h.state_mut().views[0].tiles.clear();
}
+
+#[test]
+fn image_handles_account_for_document_and_view_rotation() {
+ for page_rotation in [0, 90, 180, 270] {
+ for view_rotation in [0, 90, 180, 270] {
+ let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
+ let info = DocInfo {
+ pages: vec![pdfcraft_render::PageInfo {
+ width,
+ height,
+ label: String::new(),
+ crop: [0.0, 0.0, 300.0, 400.0],
+ rotation: page_rotation,
+ }],
+ ..Default::default()
+ };
+ let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) };
+ let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height };
+ let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0);
+ let total = (page_rotation + view_rotation) % 360;
+ assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned");
+ for (handle, point) in obb.handles() {
+ let direction = point - obb.screen_center;
+ let cursor = match handle {
+ ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
+ if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
+ }
+ _ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
+ _ => egui::CursorIcon::ResizeVertical,
+ };
+ assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}");
+ }
+ }
+ }
+}
diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs
index 91a3739d62..0f6249c8a8 100644
--- a/crates/ui-egui/src/canvas/xform.rs
+++ b/crates/ui-egui/src/canvas/xform.rs
@@ -17,7 +17,7 @@ impl DocView {
}
/// Where `page` is drawn this frame, with its coordinate mapping.
- pub(crate) fn page_xform(&self, page: usize) -> Option {
+ pub fn page_xform(&self, page: usize) -> Option {
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
}
}
@@ -128,6 +128,10 @@ impl PageXform {
Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1))
}
+ pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
+ self.user_rect(info, page, r.map(|v| v as f32))
+ }
+
/// This transform resized to a raster of `px` device pixels (width, height before the view
/// rotation), so each of its texels covers one screen pixel. The rectangle's corner must
/// already be on a whole pixel ([`snap_to_pixels`]).
@@ -148,4 +152,34 @@ impl PageXform {
mesh.add_triangle(0, 2, 3);
painter.add(egui::Shape::mesh(mesh));
}
+
+ /// Draw a sub-rectangle of a whole-page raster texture with specific texture UVs covering the normalised page region.
+ #[allow(clippy::too_many_arguments)]
+ pub fn paint_image_region(
+ &self,
+ painter: &egui::Painter,
+ tex: egui::TextureId,
+ pu0: f32,
+ pv0: f32,
+ pu1: f32,
+ pv1: f32,
+ tu0: f32,
+ tv0: f32,
+ tu1: f32,
+ tv1: f32,
+ ) {
+ let mut mesh = egui::Mesh::with_texture(tex);
+ let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)];
+ for (u, v, tu, tv) in corners {
+ mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
+ }
+ mesh.add_triangle(0, 1, 2);
+ mesh.add_triangle(0, 2, 3);
+ painter.add(egui::Shape::mesh(mesh));
+ }
+
+ /// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space.
+ pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
+ self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1);
+ }
}
diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs
index 5a0dcf6733..159adf2b7e 100644
--- a/crates/ui-egui/src/canvas/zoom_snap.rs
+++ b/crates/ui-egui/src/canvas/zoom_snap.rs
@@ -145,7 +145,7 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
if self.system_clipboard {
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
- arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
+ crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
}
self.last_snapshot = Some((w, h, out.rgba));
Ok((w, h))
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index 43c41889e5..c3c730ed68 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -28,6 +28,8 @@
//! comments, selecting text, moving comments). `ui.state` reports `pages_on_screen` to aim at.
//! - `ui.type {text}`, `ui.key {key, modifiers?}`: keyboard input to the focused widget / app.
//! - `ui.command {id}`: run a registry command (as the menu would). `ui.commands` lists them.
+//! - `ui.set {key: "show_page_edge_shadows", value: "true"|"false"}` controls page
+//! decoration app-wide; `ui.state.show_page_edge_shadows` reports the saved preference.
//! - `ui.set {key, value}`: view options (`--page`, `--zoom`, …), plus persistent preferences.
//! `select` accepts physical page numbers and ranges such as `1,12,16-18`; blank clears the
//! explicit selection. This is the same bounded parser as the Organize Pages field; invalid
diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs
index 1166c0e7ec..64b10b69fb 100644
--- a/crates/ui-egui/src/control/host.rs
+++ b/crates/ui-egui/src/control/host.rs
@@ -141,6 +141,7 @@ impl Host for crate::PdfCraftApp {
"palette_open": self.palette_open,
"theme": format!("{:?}", self.theme),
"theme_preference": self.theme_preference,
+ "show_page_edge_shadows": self.show_page_edge_shadows,
"language": self.language,
"notice": self.toast.as_ref().map(|t| t.0.clone()),
"progress": self.progress_notice.as_ref().map(|p| json!({ "label": p.label, "fraction": p.fraction })),
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index bfb0da686a..42bed83657 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -128,6 +128,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::About => 780.0,
+ Dialog::PinTool => 540.0,
_ => 520.0,
});
if dialog_body(ui, app, dialog, &mut req) {
@@ -446,6 +447,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
return false;
}
}
+ Dialog::PinTool => {
+ if !dialog_pin_tool(ui, app, req) {
+ return false;
+ }
+ }
}
true
}
@@ -1080,6 +1086,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
let d = &mut app.rotate_draft;
+ let mut typing_to = false;
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
ui.add_space(8.0);
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
@@ -1101,10 +1108,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
ui.horizontal(|ui| {
- widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
- ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n));
- ui.label(tl!("to"));
- ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n));
+ let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
+ ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id);
+ let to_label = ui.label(tl!("to"));
+ typing_to =
+ widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id));
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
});
});
@@ -1136,10 +1144,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
});
ui.end_row();
});
- d.to = d.to.max(d.from);
+ widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
+ // OK while the end is still being typed (a screen reader can press it).
+ widgets::keep_range_ordered(d.from, &mut d.to, false);
req.rotate_now = true;
req.close = true;
}
@@ -1161,17 +1171,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe
ui.add_space(8.0);
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
- ui.horizontal(|ui| {
- widgets::radio_value(ui, &mut d.all, false, tl!("From"));
- ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
- ui.label(tl!("to"));
- ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
- ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
- });
- d.to = d.to.max(d.from);
+ let typing_to = ui
+ .horizontal(|ui| {
+ widgets::radio_value(ui, &mut d.all, false, tl!("From"));
+ ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
+ ui.label(tl!("to"));
+ let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)));
+ ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
+ typing_to
+ })
+ .inner;
+ widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
ui.add_space(12.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
+ widgets::keep_range_ordered(d.from, &mut d.to, false);
let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
req.close = true;
@@ -1370,13 +1384,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
let d = &mut app.number_draft;
d.to = d.to.clamp(1, n);
- d.from = d.from.clamp(1, d.to);
+ d.from = d.from.clamp(1, n);
+ let mut typing_to = false;
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
ui.label(tl!("Pages"));
ui.horizontal(|ui| {
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
ui.label(tl!("to"));
- ui.add(egui::DragValue::new(&mut d.to).range(1..=n));
+ typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n)));
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
});
ui.end_row();
@@ -1403,18 +1418,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
ui.end_row();
});
- d.to = d.to.max(d.from);
+ widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
ui.add_space(8.0);
- let preview = if d.from == d.to {
+ // `to` can be below `from` while it is being typed.
+ let preview = if d.to <= d.from {
label(d.start)
} else {
- format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32))
+ format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX))))
};
ui.label(
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted),
);
- req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
+ // Ordered here too: the footer's OK can be pressed while the end is still being typed.
+ req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
true
}
@@ -1553,6 +1570,91 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
true
}
+fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
+ let t = Tokens::get(ui.ctx());
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
+ if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
+ req.close = true;
+ }
+ });
+ });
+ ui.add_space(4.0);
+ ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
+ ui.add_space(10.0);
+
+ ui.horizontal(|ui| {
+ let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
+ crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
+ let search_resp =
+ ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0));
+ search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…")));
+ if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
+ app.pin_tool_search.clear();
+ }
+ });
+ ui.add_space(8.0);
+ ui.separator();
+ ui.add_space(6.0);
+
+ let query = app.pin_tool_search.trim().to_lowercase();
+ egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
+ let mut toggle = None;
+ for g in pdfcraft_engine::catalog::TOOL_GROUPS {
+ let label = tl!(g.label);
+ let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::>().join(" • ")).unwrap_or_default();
+ if !query.is_empty()
+ && !label.to_lowercase().contains(&query)
+ && !blurb.to_lowercase().contains(&query)
+ && !g.id.to_lowercase().contains(&query)
+ {
+ continue;
+ }
+
+ let pinned = app.is_tool_pinned(g.id);
+ let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
+ let press = theme::Press::track(ui, &resp);
+ press.wash(ui, rect, 6, false);
+ let body = rect.translate(press.offset());
+
+ let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
+ crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
+
+ let btn_w = 78.0;
+ let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
+ let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click());
+ let btn_hovered = btn_resp.hovered();
+
+ let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
+ (t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
+ } else if btn_hovered {
+ (t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
+ } else {
+ (egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
+ };
+ let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
+ btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}")));
+
+ ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
+ ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
+
+ let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
+ widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
+ widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
+
+ if btn_resp.clicked() || resp.clicked() {
+ toggle = Some(g.id);
+ }
+ }
+ if let Some(id) = toggle {
+ app.toggle_pin_tool(id);
+ }
+ });
+ true
+}
+
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
ui.add_space(16.0);
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
@@ -1569,6 +1671,10 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req:
req.recover = Some(false);
req.close = true;
}
+ } else if dialog == Dialog::PinTool {
+ if widgets::pill_button(ui, tl!("Done"), true).clicked() {
+ req.close = true;
+ }
} else if dialog == Dialog::NumberPages {
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
req.apply_number = true;
@@ -1836,6 +1942,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
// Protect / Remove security replace the Properties dialog.
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
if modal_closed || req.close || replaces {
+ // Only now: the number in Preferences can pass through 0 on its way to the one wanted.
+ app.recent.truncate(app.recent_limit);
app.dialog = None;
app.props_draft = None;
app.view_draft = None;
diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs
index 8def569f58..d4520a15b4 100644
--- a/crates/ui-egui/src/editing.rs
+++ b/crates/ui-egui/src/editing.rs
@@ -31,7 +31,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
fn select_after_edit(
view: &mut crate::DocView,
- edit: Edit,
+ edit: &Edit,
info: &pdfcraft_render::DocInfo,
annots_before: &[pdfcraft_render::Annotation],
quick_tool: crate::QuickTool,
@@ -39,15 +39,15 @@ fn select_after_edit(
match edit {
Edit::MovePages { pages, to } => {
let n = pages.iter().collect::>().len();
- let to = to.min(info.pages.len() - n);
+ let to = (*to).min(info.pages.len() - n);
view.select_pages(&(to..to + n).collect::>());
}
Edit::DuplicatePages { pages, at: Some(at) } => {
let n = pages.iter().collect::>().len();
- let at = at.min(info.pages.len().saturating_sub(n));
+ let at = (*at).min(info.pages.len().saturating_sub(n));
view.select_pages(&(at..at + n).collect::>());
}
- Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
+ Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
// A stroke drawn with the pen stays unselected, so the selection box and
// author popup don't sit over the next stroke (#429).
@@ -71,7 +71,7 @@ fn select_after_edit(
view.comments.open_editor(a.page, n, String::new());
}
}
- Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)),
+ Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
_ => {}
}
}
@@ -112,6 +112,25 @@ impl PdfCraftApp {
pub fn apply_edit(&mut self, edit: Edit) -> bool {
let Some((i, id)) = self.active_ids() else { return false };
let label = edit.label();
+ let target_arrange_image = match &edit {
+ Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => {
+ let doc = self.session.get(id);
+ let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned());
+ let ambiguous = doc.is_some_and(|d| {
+ if let Some(t) = &target {
+ d.page_images(*page)
+ .iter()
+ .filter(|img| img.name == t.name && img.object == t.object && rect_matches(img.rect, t.rect))
+ .count()
+ > 1
+ } else {
+ false
+ }
+ });
+ target.map(|t| (t, ambiguous))
+ }
+ _ => None,
+ };
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
} else {
@@ -166,7 +185,11 @@ impl PdfCraftApp {
view.comments.comment_added();
}
// Keep the pages the user acted on selected, where they now are.
- select_after_edit(view, edit, info, &annots_before, self.quick_tool);
+ select_after_edit(view, &edit, info, &annots_before, self.quick_tool);
+ if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
+ let page_images = doc.page_images(*page);
+ re_resolve_image_selection(view, *page, &target, ambiguous_before, &page_images);
+ }
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
self.quick_tool = crate::QuickTool::Select;
}
@@ -187,6 +210,9 @@ impl PdfCraftApp {
if let Some(view) = self.views.get_mut(i) {
// A drawing whose annotation wasn't added must not stay painted.
view.comments.drawings_refused();
+ if matches!(&edit, Edit::EditPageImage { change, .. } if !matches!(change, pdfcraft_engine::ImageEdit::Arrange(_))) {
+ view.image_selection = None;
+ }
// A paragraph that couldn't be applied opens again with its typing, instead of
// the box the click moved to.
if let Edit::EditTextBlock { page, block, .. } = &edit
@@ -287,13 +313,34 @@ impl PdfCraftApp {
if !self.close_text_editors(i, id) {
return;
}
+ let target_image = self.views.get(i).and_then(|v| v.image_selection.as_ref()).and_then(|s| {
+ let doc = self.session.get(id)?;
+ let images = doc.page_images(s.page);
+ let target = images.get(s.index).cloned()?;
+ let ambiguous =
+ images.iter().filter(|img| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)).count() > 1;
+ Some((s.page, target, ambiguous))
+ });
+ let had_selection = self.views.get(i).and_then(|v| v.image_selection.as_ref()).is_some();
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
match result {
Ok(label) => {
- if let Some(doc) = self.session.get(id) {
- self.views[i].document_changed(&doc.info);
+ if let Some(doc) = self.session.get(id)
+ && let Some(view) = self.views.get_mut(i)
+ {
+ view.document_changed(&doc.info);
+ view.picked_image = None;
+ if let Some((page, target, ambiguous_before)) = target_image {
+ let page_images = doc.page_images(page);
+ re_resolve_image_selection(view, page, &target, ambiguous_before, &page_images);
+ } else if had_selection {
+ view.image_selection = None;
+ }
+ }
+ if let Some(view) = self.views.get_mut(i) {
+ view.comments.selected = None;
+ view.comments.history_stepped();
}
- self.views[i].comments.history_stepped();
let label = crate::i18n::edit_label(&label);
if undo {
self.notify_fmt("Undid {label}", &[("label", &label)]);
@@ -783,6 +830,48 @@ impl PdfCraftApp {
}
}
+fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool {
+ a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3)
+}
+
+fn re_resolve_image_selection(
+ view: &mut crate::DocView,
+ page: usize,
+ target: &pdfcraft_engine::PageImage,
+ ambiguous_before: bool,
+ page_images: &[pdfcraft_engine::PageImage],
+) {
+ let exact_matches: Vec = page_images
+ .iter()
+ .enumerate()
+ .filter(|(_, img)| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect))
+ .map(|(idx, _)| idx)
+ .collect();
+ let new_idx = if !ambiguous_before && exact_matches.len() == 1 {
+ exact_matches.first().copied()
+ } else if !ambiguous_before && exact_matches.is_empty() {
+ let name_obj_matches: Vec =
+ page_images.iter().enumerate().filter(|(_, img)| img.name == target.name && img.object == target.object).map(|(idx, _)| idx).collect();
+ if name_obj_matches.len() == 1 {
+ name_obj_matches.first().copied()
+ } else if name_obj_matches.is_empty() {
+ let name_matches: Vec = page_images.iter().enumerate().filter(|(_, img)| img.name == target.name).map(|(idx, _)| idx).collect();
+ if name_matches.len() == 1 { name_matches.first().copied() } else { None }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+ if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == page) {
+ if let Some(idx) = new_idx {
+ s.index = idx;
+ } else {
+ view.image_selection = None;
+ }
+ }
+}
+
/// The single page a content edit changes (`None` for other edits). Like comment edits,
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
fn content_page(edit: &Edit) -> Option {
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 390b059228..4f5ac9deba 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -20,6 +20,57 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre
}
}
+/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
+/// If `allow_unpin` is true, an unpin button is always accessible in the top right corner.
+fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> {
+ let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
+ let press = theme::Press::track(ui, &resp);
+ card_lift(ui, t, rect, press);
+ let fill = press.fill(t.card, t.hover, t.pressed);
+ ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
+ let body = rect.translate(press.offset());
+ let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
+ icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
+
+ let mut unpin_clicked = false;
+ if allow_unpin {
+ let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
+ let unpin_resp = ui.interact(unpin_rect, ui.scope_id().with(("unpin_card_btn", g.id)), Sense::click());
+ unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Unpin tool")));
+ if unpin_resp.hovered() {
+ ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
+ }
+ icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
+ if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
+ unpin_clicked = true;
+ }
+ }
+
+ let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
+ title_job.wrap = egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
+ let title = ui.fonts_mut(|f| f.layout_job(title_job));
+ let title_elided = title.elided;
+ ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
+ let blurb = g.sections.first().map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · ")).unwrap_or_default();
+ // Fixed-height cards must leave room for their action in every language.
+ let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
+ job.wrap.max_rows = 2;
+ let galley = ui.fonts_mut(|f| f.layout_job(job));
+ let blurb_elided = galley.elided;
+ ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
+ ui.painter().text(body.left_bottom() + vec2(14.0, -14.0), Align2::LEFT_CENTER, tl!("Use now"), theme::medium(12.0), t.accent_text);
+ let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
+ if unpin_clicked {
+ Some(g.id)
+ } else {
+ if theme::hand(resp).clicked() {
+ app.select_tool(g.id);
+ }
+ None
+ }
+}
+
/// Draw the Home page with the welcome text, community card and recent documents.
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
@@ -73,44 +124,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for id in RECOMMENDED {
- let Some(g) = catalog::group(id) else { continue };
- let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
- let press = theme::Press::track(ui, &resp);
- card_lift(ui, &t, rect, press);
- let fill = press.fill(t.card, t.hover, t.pressed);
- ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
- let body = rect.translate(press.offset());
- let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
- icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
- let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
- title_job.wrap =
- egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
- let title = ui.fonts_mut(|f| f.layout_job(title_job));
- let title_elided = title.elided;
- ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
- let blurb = g
- .sections
- .first()
- .map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::>().join(" · "))
- .unwrap_or_default();
- // Fixed-height cards must leave room for their action in every language.
- let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
- job.wrap.max_rows = 2;
- let galley = ui.fonts_mut(|f| f.layout_job(job));
- let blurb_elided = galley.elided;
- ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
- ui.painter().text(
- body.left_bottom() + vec2(14.0, -14.0),
- Align2::LEFT_CENTER,
- tl!("Use now"),
- theme::medium(12.0),
- t.accent_text,
- );
- let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
- if theme::hand(resp).clicked() {
- app.select_tool(g.id);
+ if app.is_tool_pinned(id) {
+ continue;
}
+ let Some(g) = catalog::group(id) else { continue };
+ tool_card(app, ui, &t, g, false);
}
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file")));
@@ -132,31 +150,64 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
+ ui.add_space(26.0);
+ let mut unpin_tool = None;
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
+ app.dialog = Some(crate::Dialog::PinTool);
+ }
+ });
+ });
+ ui.add_space(4.0);
+ if app.pinned_tools.is_empty() {
+ ui.label(egui::RichText::new(tl!("Pin your favorite tools here for quick access.")).color(t.text_muted));
+ } else {
+ ui.add_space(6.0);
+ let pinned = app.pinned_tools.clone();
+ ui.horizontal_wrapped(|ui| {
+ ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
+ for id in &pinned {
+ let Some(g) = catalog::group(id) else { continue };
+ if let Some(to_unpin) = tool_card(app, ui, &t, g, true) {
+ unpin_tool = Some(to_unpin);
+ }
+ }
+ });
+ }
+ if let Some(id) = unpin_tool {
+ app.unpin_tool(id);
+ }
+
#[cfg(not(target_arch = "wasm32"))]
{
ui.add_space(26.0);
crate::folders_ui::section(app, ui);
}
- ui.add_space(26.0);
let mut clear = false;
- ui.horizontal(|ui| {
- ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
- if !app.recent.is_empty() {
- ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
- let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
- resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
- clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
- });
+ // Preferences can turn the list off (#5572).
+ if app.recent_limit > 0 {
+ ui.add_space(26.0);
+ ui.horizontal(|ui| {
+ ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
+ if !app.recent.is_empty() {
+ ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+ let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
+ clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
+ });
+ }
+ });
+ ui.add_space(8.0);
+ if app.recent.is_empty() {
+ ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
}
- });
- ui.add_space(8.0);
- if app.recent.is_empty() {
- ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
}
let mut open = None;
let mut remove = None;
- for r in &app.recent {
+ for r in app.recent.iter().take(app.recent_limit) {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));
let press = theme::Press::track(ui, &resp);
@@ -229,6 +280,41 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
mod tests {
use super::*;
+ fn review_home_harness() -> egui_kittest::Harness<'static, PdfCraftApp> {
+ crate::i18n::set_current(crate::i18n::Lang::EN);
+ let mut app = PdfCraftApp::new();
+ app.pinned_tools = vec!["edit".into()];
+ let mut fonts = false;
+ egui_kittest::Harness::builder().with_size(vec2(1500.0, 1200.0)).build_ui_state(
+ move |ui, app| {
+ if !fonts {
+ ui.ctx().set_fonts(theme::font_definitions());
+ fonts = true;
+ }
+ show(app, ui);
+ },
+ app,
+ )
+ }
+
+ #[test]
+ fn review_pinned_recommended_tools_appear_once() {
+ use egui_kittest::kittest::Queryable;
+ let mut h = review_home_harness();
+ h.run_steps(3);
+ assert_eq!(h.get_all_by_label("Edit a PDF").count(), 1);
+ }
+
+ #[test]
+ fn review_a_pinned_tool_can_be_unpinned_without_hover() {
+ use egui_kittest::kittest::Queryable;
+ let mut h = review_home_harness();
+ h.run_steps(3);
+ h.get_by_label("Unpin tool").click();
+ h.run_steps(2);
+ assert!(h.state().pinned_tools.is_empty());
+ }
+
#[test]
fn translated_card_description_leaves_room_for_the_action() {
let de = crate::i18n::Lang::from_code("de").unwrap();
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
index 3f478b637f..3ac16d622c 100644
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -16,7 +16,7 @@
//! placeholders, plural forms) pick it up from the registry.
//!
//! # Looking strings up
-//! - [`tl!`](crate::tl) / [`t`]: a plain string in the current language. [`tr`]: in a given one.
+//! - `tl!` / [`t`]: a plain string in the current language. [`tr`]: in a given one.
//! - [`tr_ctx`] (`tl_ctx!` in UI code): when one English word needs different translations.
//! - [`tr_id`]: a command-id keyed string with the English label as fallback (menu items), so a
//! translation survives rewording of the English text and can differ per command.
diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv
index 56f62b7734..1b08929307 100644
--- a/crates/ui-egui/src/i18n/ar.tsv
+++ b/crates/ui-egui/src/i18n/ar.tsv
@@ -20,6 +20,10 @@
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu القائمة
File ملف
Edit تحرير
@@ -2792,6 +2796,25 @@ certificate Version الإصدار
Make properties default جعل الخصائص افتراضية
Pages selected الصفحات المحددة
Page properties خصائص الصفحة
+ Clear search مسح البحث
+ Pin تثبيت
+ Pin a tool… تثبيت أداة…
+ Pin to Home تثبيت في الصفحة الرئيسية
+ Pin tools to Home تثبيت الأدوات في الصفحة الرئيسية
+ Pin your favorite tools here for quick access. ثبّت أدواتك المفضلة هنا للوصول السريع.
+ Pinned مثبت
+ Pinned Tools الأدوات المثبتة
+ Search tools… البحث عن الأدوات…
+ Select tools to display on your Home page for quick access. حدد الأدوات المراد عرضها على صفحتك الرئيسية للوصول السريع.
+ Unpin from Home إلغاء التثبيت من الصفحة الرئيسية
+ Unpin tool إلغاء تثبيت الأداة
+ Transform image تحويل الصورة
+ Select Tool أداة التحديد
+ Hand Tool أداة اليد
+ Marquee Zoom تكبير بالتحديد
+ Edit this PDF تحرير هذا الملف PDF
+ Export this PDF تصدير هذا الملف PDF
+ Find text or tools البحث عن نص أو أدوات
Create & Edit الإنشاء والتحرير
Review & Compare المراجعة والمقارنة
Forms & Signatures النماذج والتوقيعات
@@ -2838,6 +2861,13 @@ certificate Version الإصدار
field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name}
two cells would both be named {name} ستحمل خليتان الاسم نفسه {name}
a field named {name} already exists يوجد حقل باسم {name} بالفعل
+ Arrange image ترتيب الصورة
+ Bring forward إحضار للأمام
+ Bring to front إحضار إلى المقدمة
+ Crop قص
+ Remove crop إزالة القص
+ Send backward إرسال للخلف
+ Send to back إرسال إلى المؤخرة
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today اليوم
@@ -2854,3 +2884,11 @@ certificate Version الإصدار
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply خيارات هذا الرد
Export value قيمة التصدير
+ Show Page Edge Shadows إظهار ظلال حواف الصفحات
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… بحث…
+ Image pasted تم لصق الصورة
+ No image on clipboard to paste لا توجد صورة في الحافظة للصقها
+ Pinned {tool} to Home تم تثبيت {tool} في الرئيسية
+ Unpinned {tool} from Home تم إلغاء تثبيت {tool} من الرئيسية
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
index ceccdf9582..e269febf18 100644
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -7,6 +7,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
About За програмата
Contributors Сътрудници
Models Модели
@@ -2432,7 +2436,7 @@ animation effects Off Изключени
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Разглеждате редакция {rev} на „{from}“. Това е моментна снимка: промените тук няма да засегнат оригинала.
Turn upside down (180°) Обърни наопаки (180°)
4 x 6 in label (101.6 x 152.4 mm) Етикет 4 × 6 in (101,6 × 152,4 mm)
- Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната ѝ област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
+ Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Изрязва всяка страница до отпечатаната и област, завърта я изправено и я побира в листа без разтягане. Един етикет на лист.
Find untagged content Намиране на немаркирано съдържание
No untagged content was found. Не е намерено немаркирано съдържание.
Found {n} untagged content problems. Открити са {n} проблема с немаркирано съдържание.
@@ -2478,6 +2482,26 @@ animation effects Off Изключени
Make properties default Задаване като свойства по подразбиране
Pages selected Избрани страници
Page properties Свойства на страницата
+ Clear search Изчистване на търсенето
+ Pin Закачане
+ Pin a tool… Закачане на инструмент…
+ Pin to Home Закачане към Начало
+ Pin tools to Home Закачане на инструменти към Начало
+ Pin your favorite tools here for quick access. Закачете любимите си инструменти тук за бърз достъп.
+ Pinned Закачено
+ Pinned Tools Закачени инструменти
+ Search tools… Търсене на инструменти…
+ Select tools to display on your Home page for quick access. Изберете инструменти за показване на началната страница за бърз достъп.
+ Unpin from Home Откачане от Начало
+ Unpin tool Откачане на инструмент
+ Transform image Трансформиране на изображение
+ Select Tool Инструмент за избор
+ Hand Tool Инструмент „Ръка“
+ Marquee Zoom Мащабиране с рамка
+ Paste Постави
+ Edit this PDF Редактирай този PDF
+ Export this PDF Експортирай този PDF
+ Find text or tools Търсене на текст или инструменти
Create & Edit Създаване и редактиране
Review & Compare Преглед и сравнение
Forms & Signatures Формуляри и подписи
@@ -2525,6 +2549,13 @@ animation effects Off Изключени
field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name}
two cells would both be named {name} две клетки биха имали едно и също име {name}
a field named {name} already exists вече съществува поле с име {name}
+ Arrange image Подреждане на изображението
+ Bring forward Преместване напред
+ Bring to front Преместване най-отпред
+ Crop Изрязване
+ Remove crop Премахване на изрязването
+ Send backward Преместване назад
+ Send to back Преместване най-отзад
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Днес
@@ -2541,3 +2572,11 @@ animation effects Off Изключени
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Опции за този отговор
Export value Стойност за експортиране
+ Show Page Edge Shadows Показване на сенки по краищата на страниците
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Търсене…
+ Image pasted Изображението е поставено
+ No image on clipboard to paste Няма изображение в клипборда за поставяне
+ Pinned {tool} to Home {tool} е закачен в Начало
+ Unpinned {tool} from Home {tool} е откачен от Начало
diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv
index 83750ba124..7ff0f83fee 100644
--- a/crates/ui-egui/src/i18n/ca.tsv
+++ b/crates/ui-egui/src/i18n/ca.tsv
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menú
File Fitxer
Edit Edita
@@ -2815,6 +2819,33 @@ theme Appearance Aspecte
field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name}
two cells would both be named {name} dues cel·les es dirien {name}
a field named {name} already exists ja existeix un camp anomenat {name}
+ Bring forward Porta endavant
+ Bring to front Porta al capdavant
+ Crop image Retalla la imatge
+ Remove crop Elimina el retall
+ Send backward Envia enrere
+ Send to back Envia al fons
+ Clear search Neteja la cerca
+ Edit this PDF Edita aquest PDF
+ Export this PDF Exporta aquest PDF
+ Find text or tools Cerca text o eines
+ Hand Tool Eina Mà
+ Marquee Zoom Zoom d'enquadrament
+ Pin Fixa
+ Pin a tool… Fixa una eina…
+ Pin to Home Fixa a l'Inici
+ Pin tools to Home Fixa eines a l'Inici
+ Pin your favorite tools here for quick access. Fixeu aquí les vostres eines preferides per a un accés ràpid.
+ Pinned Fixat
+ Pinned Tools Eines fixades
+ Search tools… Cerca eines…
+ Select Tool Eina Selecció
+ Select tools to display on your Home page for quick access. Seleccioneu les eines que es mostraran a la pàgina d'Inici per a un accés ràpid.
+ Transform image Transforma la imatge
+ Unpin from Home Desenganxa de l'Inici
+ Unpin tool Desenganxa l'eina
+ Arrange image Organitza la imatge
+ Crop Escapça
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Avui
@@ -2838,3 +2869,10 @@ theme Appearance Aspecte
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opcions d'aquesta resposta
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Cerca…
+ Image pasted Imatge enganxada
+ No image on clipboard to paste No hi ha cap imatge al porta-retalls per enganxar
+ Pinned {tool} to Home {tool} fixat a Inici
+ Unpinned {tool} from Home {tool} desfixat d’Inici
diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv
index f6d91e3f42..3b6a7671ed 100644
--- a/crates/ui-egui/src/i18n/ckb.tsv
+++ b/crates/ui-egui/src/i18n/ckb.tsv
@@ -20,6 +20,10 @@
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu پێڕست
File فایل
Edit دەستکاریکردن
@@ -2735,6 +2739,13 @@ certificate Version وەشان
Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی
Pages selected پەڕە هەڵبژێردراوەکان
Page properties تایبەتمەندییەکانی پەڕە
+ Arrange image ڕێکخستنی وێنە
+ Bring forward هێنانە پێشەوە
+ Bring to front هێنانە پێشەوەی سەرەکی
+ Crop بڕینەوە
+ Remove crop لابردنی بڕینەوە
+ Send backward ناردنە دواوە
+ Send to back ناردنە دواوەی سەرەکی
Create & Edit دروستکردن و دەستکاریکردن
Review & Compare پێداچوونەوە و بەراوردکردن
Forms & Signatures فۆڕمەکان و واژووەکان
@@ -2770,3 +2781,32 @@ certificate Version وەشان
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… گەڕان…
+ Image pasted وێنەکە لکێنرا
+ No image on clipboard to paste هیچ وێنەیەک لە کلیپبۆرد نییە بۆ لکاندن
+ Pinned {tool} to Home {tool} لە ماڵەوە جێگیرکرا
+ Unpinned {tool} from Home {tool} لە ماڵەوە لابرا
+
+# Home tools (AI-assisted; not separately native-reviewed)
+ Clear search پاککردنەوەی گەڕان
+ Pin جێگیرکردن
+ Pin a tool… جێگیرکردنی ئامراز…
+ Pin to Home جێگیرکردن لە ماڵەوە
+ Pin tools to Home جێگیرکردنی ئامرازەکان لە ماڵەوە
+ Pin your favorite tools here for quick access. ئامرازە دڵخوازەکانت لێرە جێگیر بکە بۆ دەستگەیشتنی خێرا.
+ Pinned جێگیرکراو
+ Pinned Tools ئامرازە جێگیرکراوەکان
+ Search tools… گەڕان بەدوای ئامرازەکان…
+ Select tools to display on your Home page for quick access. ئامرازەکان هەڵبژێرە بۆ پیشاندان لە ماڵەوە و دەستگەیشتنی خێرا.
+ Unpin from Home لابردنی جێگیرکردن لە ماڵەوە
+ Unpin tool لابردنی جێگیرکردنی ئامراز
+ Transform image گۆڕینی شێوەی وێنە
+ Select Tool ئامرازی هەڵبژاردن
+ Hand Tool ئامرازی دەست
+ Marquee Zoom گەورەکردنی ناوچەی هەڵبژێردراو
+ Edit this PDF دەستکاریکردنی ئەم PDF ـە
+ Export this PDF هەناردەکردنی ئەم PDF ـە
+ Find text or tools دۆزینەوەی دەق یان ئامراز
+ Crop image بڕینەوەی وێنە
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
index 8bfde72a85..22f6e3e056 100644
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -19,6 +19,10 @@
# commands is kept. Messages with a count but no `@plural` entry use count-neutral wording
# ("Vybráno stránek: {n}"), because Czech has three plural forms.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Nabídka
File Soubor
Rotation: Otočení:
@@ -2589,6 +2593,26 @@ animation effects Off Vypnuto
Make properties default Nastavit vlastnosti jako výchozí
Pages selected Vybrané stránky
Page properties Vlastnosti stránky
+ Clear search Vymazat hledání
+ Pin Připnout
+ Pin a tool… Připnout nástroj…
+ Pin to Home Připnout na domovskou stránku
+ Pin tools to Home Připnout nástroje na domovskou stránku
+ Pin your favorite tools here for quick access. Připněte si sem oblíbené nástroje pro rychlý přístup.
+ Pinned Připnuto
+ Pinned Tools Připnuté nástroje
+ Search tools… Hledat nástroje…
+ Select tools to display on your Home page for quick access. Vyberte nástroje, které se mají zobrazovat na domovské stránce pro rychlý přístup.
+ Unpin from Home Odepnout z domovské stránky
+ Unpin tool Odepnout nástroj
+ Transform image Transformovat obrázek
+ Select Tool Nástroj pro výběr
+ Hand Tool Nástroj ručička
+ Marquee Zoom Zvětšit vybranou oblast
+ Paste Vložit
+ Edit this PDF Upravit toto PDF
+ Export this PDF Exportovat toto PDF
+ Find text or tools Hledat text nebo nástroje
Create & Edit Vytváření a úpravy
Review & Compare Revize a porovnání
Forms & Signatures Formuláře a podpisy
@@ -2793,7 +2817,6 @@ animation effects Off Vypnuto
Organize Uspořádat
Page {n} Stránka {n}
Page {n} of {total} Stránka {n} z {total}
- Paste Vložit
Previous Předchozí
Previous (⇧⌘G) Předchozí (⇧⌘G)
Print (⌘P) Tisk (⌘P)
@@ -2869,6 +2892,14 @@ certificate Version Verze
field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name}
two cells would both be named {name} dvě buňky by se jmenovaly {name}
a field named {name} already exists pole s názvem {name} už existuje
+ Bring forward Přenést blíž
+ Bring to front Přenést do popředí
+ Crop image Oříznout obrázek
+ Remove crop Odebrat oříznutí
+ Send backward Přenést dál
+ Send to back Přenést do pozadí
+ Arrange image Uspořádat obrázek
+ Crop Oříznout
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Dnes
@@ -2885,3 +2916,11 @@ certificate Version Verze
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Možnosti této odpovědi
Export value Exportní hodnota
+ Show Page Edge Shadows Zobrazovat stíny okrajů stránek
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Hledat…
+ Image pasted Obrázek vložen
+ No image on clipboard to paste Ve schránce není obrázek k vložení
+ Pinned {tool} to Home {tool} připnuto na úvodní stránku
+ Unpinned {tool} from Home {tool} odepnuto z úvodní stránky
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
index 83684c08ea..2c9ce7ae36 100644
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menü
File Datei
Edit Bearbeiten
@@ -2516,6 +2520,26 @@ animation effects Off Aus
Make properties default Eigenschaften als Standard festlegen
Pages selected Ausgewählte Seiten
Page properties Seiteneigenschaften
+ Clear search Suche löschen
+ Pin Anheften
+ Pin a tool… Werkzeug anheften…
+ Pin to Home An Start anheften
+ Pin tools to Home Werkzeuge an Start anheften
+ Pin your favorite tools here for quick access. Heften Sie Ihre bevorzugten Werkzeuge hier an, um schnell darauf zuzugreifen.
+ Pinned Angeheftet
+ Pinned Tools Angeheftete Werkzeuge
+ Search tools… Werkzeuge suchen…
+ Select tools to display on your Home page for quick access. Wählen Sie Werkzeuge für den Schnellzugriff auf Ihrer Startseite aus.
+ Unpin from Home Von Start lösen
+ Unpin tool Werkzeug lösen
+ Transform image Bild transformieren
+ Select Tool Auswahl-Werkzeug
+ Hand Tool Hand-Werkzeug
+ Marquee Zoom Auswahlrahmen-Zoom
+ Paste Einfügen
+ Edit this PDF Dieses PDF bearbeiten
+ Export this PDF Dieses PDF exportieren
+ Find text or tools Text oder Werkzeuge suchen
Create & Edit Erstellen & Bearbeiten
Review & Compare Prüfen & Vergleichen
Forms & Signatures Formulare & Signaturen
@@ -2563,6 +2587,13 @@ animation effects Off Aus
field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name}
two cells would both be named {name} zwei Zellen hätten beide den Namen {name}
a field named {name} already exists ein Feld namens {name} ist bereits vorhanden
+ Arrange image Bild anordnen
+ Bring forward Nach vorne bringen
+ Bring to front In den Vordergrund
+ Crop Zuschneiden
+ Remove crop Zuschnitt entfernen
+ Send backward Nach hinten senden
+ Send to back In den Hintergrund
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Heute
@@ -2579,3 +2610,11 @@ animation effects Off Aus
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Optionen für diese Antwort
Export value Exportwert
+ Show Page Edge Shadows Schatten an Seitenrändern anzeigen
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Suchen…
+ Image pasted Bild eingefügt
+ No image on clipboard to paste Kein Bild in der Zwischenablage zum Einfügen
+ Pinned {tool} to Home {tool} an Start angeheftet
+ Unpinned {tool} from Home {tool} von Start gelöst
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
index 576cdfcd6d..d83bf4aef7 100644
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -2,6 +2,11 @@
# Partial, clean-room initial contribution. Entries not yet translated fall back to English.
# Translate from English meanings; retain placeholders and trailing ellipses.
# Greek plural forms: one (1) | other (0, 2+).
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Μενού
File Αρχείο
Edit Επεξεργασία
@@ -2521,6 +2526,26 @@ animation effects Off Ανενεργά
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
Pages selected Επιλεγμένες σελίδες
Page properties Ιδιότητες σελίδας
+ Clear search Εκκαθάριση αναζήτησης
+ Pin Καρφίτσωμα
+ Pin a tool… Καρφίτσωμα εργαλείου…
+ Pin to Home Καρφίτσωμα στην Αρχική
+ Pin tools to Home Καρφίτσωμα εργαλείων στην Αρχική
+ Pin your favorite tools here for quick access. Καρφιτσώστε τα αγαπημένα σας εργαλεία εδώ για γρήγορη πρόσβαση.
+ Pinned Καρφιτσωμένο
+ Pinned Tools Καρφιτσωμένα εργαλεία
+ Search tools… Αναζήτηση εργαλείων…
+ Select tools to display on your Home page for quick access. Επιλέξτε εργαλεία για εμφάνιση στην Αρχική σας για γρήγορη πρόσβαση.
+ Unpin from Home Ξεκαρφίτσωμα από την Αρχική
+ Unpin tool Ξεκαρφίτσωμα εργαλείου
+ Transform image Μετασχηματισμός εικόνας
+ Select Tool Εργαλείο επιλογής
+ Hand Tool Εργαλείο χεριού
+ Marquee Zoom Μεγέθυνση επιλεγμένης περιοχής
+ Paste Επικόλληση
+ Edit this PDF Επεξεργασία αυτού του PDF
+ Export this PDF Εξαγωγή αυτού του PDF
+ Find text or tools Εύρεση κειμένου ή εργαλείων
Create & Edit Δημιουργία και επεξεργασία
Review & Compare Αναθεώρηση και σύγκριση
Forms & Signatures Φόρμες και υπογραφές
@@ -2568,6 +2593,13 @@ animation effects Off Ανενεργά
field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name}
two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name}
a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name}
+ Arrange image Διάταξη εικόνας
+ Bring forward Μεταφορά εμπρός
+ Bring to front Μεταφορά σε πρώτο πλάνο
+ Crop Περικοπή
+ Remove crop Κατάργηση περικοπής
+ Send backward Μεταφορά πίσω
+ Send to back Μεταφορά σε δεύτερο πλάνο
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Σήμερα
@@ -2584,3 +2616,11 @@ animation effects Off Ανενεργά
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Επιλογές για αυτήν την απάντηση
Export value Τιμή εξαγωγής
+ Show Page Edge Shadows Εμφάνιση σκιών στα άκρα των σελίδων
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Αναζήτηση…
+ Image pasted Η εικόνα επικολλήθηκε
+ No image on clipboard to paste Δεν υπάρχει εικόνα στο πρόχειρο για επικόλληση
+ Pinned {tool} to Home Το {tool} καρφιτσώθηκε στην Αρχική
+ Unpinned {tool} from Home Το {tool} ξεκαρφιτσώθηκε από την Αρχική
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
index 3216e4e448..131d2dacaf 100644
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menú
File Archivo
Edit Edición
@@ -2012,9 +2016,16 @@ action wizard Start Iniciar
Edit text Editar texto
Load document font Cargar fuente del documento
Move image Mover imagen
+ Transform image Transformar imagen
Rotate image Girar imagen
Flip image Voltear imagen
Delete image Eliminar imagen
+ Bring to front Traer al frente
+ Send to back Enviar al fondo
+ Bring forward Traer adelante
+ Send backward Enviar atrás
+ Remove crop Quitar recorte
+ Crop image Recortar imagen
Add header & footer Añadir encabezado y pie de página
Update header & footer Actualizar encabezado y pie de página
Update watermark Actualizar marca de agua
@@ -2679,6 +2690,25 @@ animation effects Off Desactivados
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original.
+ Clear search Borrar búsqueda
+ Pin Anclar
+ Pin a tool… Anclar una herramienta…
+ Pin to Home Anclar en Inicio
+ Pin tools to Home Anclar herramientas en Inicio
+ Pin your favorite tools here for quick access. Ancla tus herramientas favoritas aquí para un acceso rápido.
+ Pinned Anclado
+ Pinned Tools Herramientas ancladas
+ Search tools… Buscar herramientas…
+ Select tools to display on your Home page for quick access. Selecciona las herramientas que se mostrarán en la página de Inicio para un acceso rápido.
+ Unpin from Home Desanclar de Inicio
+ Unpin tool Desanclar herramienta
+ Select Tool Herramienta Selección
+ Hand Tool Herramienta Mano
+ Marquee Zoom Zoom por selección
+ Paste Pegar
+ Edit this PDF Editar este PDF
+ Export this PDF Exportar este PDF
+ Find text or tools Buscar texto o herramientas
Attach a file… Adjuntar un archivo…
Delete attachment Eliminar adjunto
Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web
@@ -2834,7 +2864,6 @@ theme Appearance Aspecto
Import Importar
Move Mover
Duplicate Duplicar
- Paste Pegar
Select all Seleccionar todo
Refresh Actualizar
Next Siguiente
@@ -2885,6 +2914,8 @@ theme Appearance Aspecto
field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name}
two cells would both be named {name} dos celdas se llamarían {name}
a field named {name} already exists ya existe un campo llamado {name}
+ Arrange image Organizar imagen
+ Crop Recortar
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hoy
@@ -2901,3 +2932,11 @@ theme Appearance Aspecto
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opciones de esta respuesta
Export value Valor de exportación
+ Show Page Edge Shadows Mostrar sombras en los bordes de las páginas
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Buscar…
+ Image pasted Imagen pegada
+ No image on clipboard to paste No hay ninguna imagen en el portapapeles para pegar
+ Pinned {tool} to Home {tool} fijado en Inicio
+ Unpinned {tool} from Home {tool} desfijado de Inicio
diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv
index 292b4fbdb3..4f9286b843 100644
--- a/crates/ui-egui/src/i18n/fa.tsv
+++ b/crates/ui-egui/src/i18n/fa.tsv
@@ -19,6 +19,10 @@
# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins
# compound words.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu منو
File فایل
Edit ویرایش
@@ -2816,3 +2820,10 @@ theme Appearance ظاهر
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply گزینههای این پاسخ
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… جستجو…
+ Image pasted تصویر چسبانده شد
+ No image on clipboard to paste تصویری در حافظهٔ موقت برای چسباندن نیست
+ Pinned {tool} to Home {tool} به خانه سنجاق شد
+ Unpinned {tool} from Home {tool} از خانه جدا شد
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
index 80d2a0f859..8c1a9baab9 100644
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -16,6 +16,11 @@
# Labels use sentence case. Product and technology names (PdfCraft, PDF, OCR, JavaScript ...)
# stay in Latin letters; the trailing "…" of dialog commands is kept. Calendar headings use the
# nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`).
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Valikko
File Tiedosto
Edit Muokkaa
@@ -2824,6 +2829,33 @@ theme Appearance Teema
field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name}
two cells would both be named {name} kahdella solulla olisi sama nimi {name}
a field named {name} already exists kenttä nimeltä {name} on jo olemassa
+ Bring forward Tuo eteenpäin
+ Bring to front Tuo etualalle
+ Crop image Rajaa kuva
+ Remove crop Poista rajaus
+ Send backward Vie taaksepäin
+ Send to back Vie taustalle
+ Clear search Tyhjennä haku
+ Edit this PDF Muokkaa tätä PDF-tiedostoa
+ Export this PDF Vie tämä PDF-tiedosto
+ Find text or tools Etsi tekstiä tai työkaluja
+ Hand Tool Käsityökalu
+ Marquee Zoom Alueen zoomaus
+ Pin Kiinnitä
+ Pin a tool… Kiinnitä työkalu…
+ Pin to Home Kiinnitä Aloitus-näkymään
+ Pin tools to Home Kiinnitä työkaluja Aloitus-näkymään
+ Pin your favorite tools here for quick access. Kiinnitä suosikkityökalusi tähän nopeaa käyttöä varten.
+ Pinned Kiinnitetty
+ Pinned Tools Kiinnitetyt työkalut
+ Search tools… Etsi työkaluja…
+ Select Tool Valintatyökalu
+ Select tools to display on your Home page for quick access. Valitse Aloitus-sivulla näytettävät työkalut nopeaa käyttöä varten.
+ Transform image Muunna kuvaa
+ Unpin from Home Irrota Aloitus-näkymästä
+ Unpin tool Irrota työkalu
+ Arrange image Järjestä kuva
+ Crop Rajaa
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Tänään
@@ -2847,3 +2879,10 @@ theme Appearance Teema
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Tämän vastauksen asetukset
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Hae…
+ Image pasted Kuva liitetty
+ No image on clipboard to paste Leikepöydällä ei ole liitettävää kuvaa
+ Pinned {tool} to Home {tool} kiinnitetty aloitussivulle
+ Unpinned {tool} from Home {tool} irrotettu aloitussivulta
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
index 6092231df3..1c22cf9d81 100644
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -9,6 +9,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menu
File Fichier
Edit Édition
@@ -2515,6 +2519,26 @@ animation effects Off Désactivés
Make properties default Définir comme propriétés par défaut
Pages selected Pages sélectionnées
Page properties Propriétés de la page
+ Clear search Effacer la recherche
+ Pin Épingler
+ Pin a tool… Épingler un outil…
+ Pin to Home Épingler à l'Accueil
+ Pin tools to Home Épingler des outils à l'Accueil
+ Pin your favorite tools here for quick access. Épinglez vos outils préférés ici pour un accès rapide.
+ Pinned Épinglé
+ Pinned Tools Outils épinglés
+ Search tools… Rechercher des outils…
+ Select tools to display on your Home page for quick access. Sélectionnez les outils à afficher sur votre page d'Accueil pour un accès rapide.
+ Unpin from Home Désépingler de l'Accueil
+ Unpin tool Désépingler l'outil
+ Transform image Transformer l'image
+ Select Tool Outil Sélection
+ Hand Tool Outil Main
+ Marquee Zoom Zoom par sélection
+ Paste Coller
+ Edit this PDF Modifier ce PDF
+ Export this PDF Exporter ce PDF
+ Find text or tools Rechercher du texte ou des outils
Create & Edit Créer et modifier
Review & Compare Réviser et comparer
Forms & Signatures Formulaires et signatures
@@ -2632,7 +2656,6 @@ animation effects Off Désactivés
Page {} has additional actions La page {} contient des actions supplémentaires
Panel layout could not be restored: {error} Impossible de restaurer la disposition des panneaux : {error}
Panel move was not applied: {error} Le déplacement du panneau n'a pas été appliqué : {error}
- Paste Coller
PdfCraft can't check this signature yet: {}. PdfCraft ne peut pas encore contrôler cette signature : {}.
Previous Précédent
Previous (⇧⌘G) Précédent (⇧⌘G)
@@ -2828,6 +2851,13 @@ certificate Version Version
field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name}
two cells would both be named {name} deux cellules porteraient le nom {name}
a field named {name} already exists un champ nommé {name} existe déjà
+ Arrange image Organiser l'image
+ Bring forward Avancer
+ Bring to front Mettre au premier plan
+ Crop Recadrer
+ Remove crop Supprimer le recadrage
+ Send backward Reculer
+ Send to back Mettre à l'arrière-plan
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Aujourd'hui
@@ -2844,3 +2874,11 @@ certificate Version Version
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Options de cette réponse
Export value Valeur d’exportation
+ Show Page Edge Shadows Afficher les ombres des bords des pages
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Rechercher…
+ Image pasted Image collée
+ No image on clipboard to paste Aucune image à coller dans le presse-papiers
+ Pinned {tool} to Home {tool} épinglé à l’Accueil
+ Unpinned {tool} from Home {tool} désépinglé de l’Accueil
diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv
index dfaa94b5d6..52c5450bb3 100644
--- a/crates/ui-egui/src/i18n/hi.tsv
+++ b/crates/ui-egui/src/i18n/hi.tsv
@@ -2,6 +2,11 @@
# context English source translation
# Clean-room translation written from the English source's meaning.
# License: MIT OR Apache-2.0.
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu मेनू
File फ़ाइल
Edit संपादित करें
@@ -2786,6 +2791,31 @@ theme Appearance स्वरूप
field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name}
two cells would both be named {name} दो सेल का नाम {name} हो जाएगा
a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है
+ Clear search खोज साफ़ करें
+ Edit this PDF इस PDF को संपादित करें
+ Export this PDF इस PDF को निर्यात करें
+ Find text or tools पाठ या उपकरण ढूंढें
+ Hand Tool हैंड टूल
+ Marquee Zoom मार्की ज़ूम
+ Pin पिन करें
+ Pin a tool… उपकरण पिन करें…
+ Pin to Home होम पर पिन करें
+ Pin tools to Home होम पर उपकरण पिन करें
+ Pin your favorite tools here for quick access. त्वरित पहुँच के लिए अपने पसंदीदा उपकरण यहाँ पिन करें।
+ Pinned पिन किया गया
+ Pinned Tools पिन किए गए उपकरण
+ Search tools… उपकरण खोजें…
+ Select Tool चयन उपकरण
+ Select tools to display on your Home page for quick access. त्वरित पहुँच के लिए अपने होम पेज पर प्रदर्शित करने हेतु उपकरण चुनें।
+ Unpin from Home होम से अनपिन करें
+ Unpin tool उपकरण अनपिन करें
+ Arrange image छवि व्यवस्थित करें
+ Bring forward आगे लाएँ
+ Bring to front सबसे आगे लाएँ
+ Crop क्रॉप करें
+ Remove crop क्रॉप हटाएँ
+ Send backward पीछे भेजें
+ Send to back सबसे पीछे भेजें
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today आज
@@ -2809,3 +2839,10 @@ theme Appearance स्वरूप
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply इस जवाब के विकल्प
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… खोजें…
+ Image pasted छवि चिपकाई गई
+ No image on clipboard to paste चिपकाने के लिए क्लिपबोर्ड में कोई छवि नहीं है
+ Pinned {tool} to Home {tool} को होम पर पिन किया गया
+ Unpinned {tool} from Home {tool} को होम से अनपिन किया गया
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
index 53b81062bb..47d9c24b40 100644
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -9,6 +9,11 @@
# consulted or copied; add entries the same way.
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menü
File Fájl
Edit Szerkesztés
@@ -2514,6 +2519,26 @@ animation effects Off Ki
Make properties default Tulajdonságok beállítása alapértelmezettként
Pages selected Kijelölt oldalak
Page properties Oldaltulajdonságok
+ Clear search Keresés törlése
+ Pin Rögzítés
+ Pin a tool… Eszköz rögzítése…
+ Pin to Home Rögzítés a Kezdőlapra
+ Pin tools to Home Eszközök rögzítése a Kezdőlapra
+ Pin your favorite tools here for quick access. Rögzítse ide kedvenc eszközeit a gyors eléréshez.
+ Pinned Rögzítve
+ Pinned Tools Rögzített eszközök
+ Search tools… Eszközök keresése…
+ Select tools to display on your Home page for quick access. Válassza ki a Kezdőlapon megjelenítendő eszközöket a gyors eléréshez.
+ Unpin from Home Rögzítés feloldása a Kezdőlapról
+ Unpin tool Eszköz rögzítésének feloldása
+ Transform image Kép transzformálása
+ Select Tool Kijelölő eszköz
+ Hand Tool Kéz eszköz
+ Marquee Zoom Kijelölő nagyítás
+ Paste Beillesztés
+ Edit this PDF Ezen PDF szerkesztése
+ Export this PDF Ezen PDF exportálása
+ Find text or tools Szöveg vagy eszközök keresése
Create & Edit Létrehozás és szerkesztés
Review & Compare Áttekintés és összehasonlítás
Forms & Signatures Űrlapok és aláírások
@@ -2561,6 +2586,13 @@ animation effects Off Ki
field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name}
two cells would both be named {name} két cella neve is {name} lenne
a field named {name} already exists már van {name} nevű mező
+ Arrange image Kép elrendezése
+ Bring forward Előrébb hozás
+ Bring to front Előrehozás
+ Crop Körülvágás
+ Remove crop Körülvágás eltávolítása
+ Send backward Hátrébb küldés
+ Send to back Hátraküldés
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Ma
@@ -2577,3 +2609,11 @@ animation effects Off Ki
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply A válasz beállításai
Export value Exportérték
+ Show Page Edge Shadows Oldalszéli árnyékok megjelenítése
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Keresés…
+ Image pasted Kép beillesztve
+ No image on clipboard to paste Nincs beilleszthető kép a vágólapon
+ Pinned {tool} to Home {tool} rögzítve a kezdőlapra
+ Unpinned {tool} from Home {tool} leválasztva a kezdőlapról
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
index 0eb02928f1..89152f62a4 100644
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -3,6 +3,11 @@
# Clean-room translations of PdfCraft's English UI labels. Use standard Indonesian computing
# terms; keep product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word...) unchanged.
# Indonesian does not inflect nouns for number, so @plural entries list one form.
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menu
Edit Edit
Pages Halaman
@@ -2787,6 +2792,31 @@ theme Appearance Tampilan
field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name}
two cells would both be named {name} dua sel akan sama-sama bernama {name}
a field named {name} already exists kolom bernama {name} sudah ada
+ Clear search Bersihkan pencarian
+ Edit this PDF Edit PDF ini
+ Export this PDF Ekspor PDF ini
+ Find text or tools Temukan teks atau alat
+ Hand Tool Alat Tangan
+ Marquee Zoom Zoom Bidang
+ Pin Sematkan
+ Pin a tool… Sematkan alat…
+ Pin to Home Sematkan ke Beranda
+ Pin tools to Home Sematkan alat ke Beranda
+ Pin your favorite tools here for quick access. Sematkan alat favorit Anda di sini untuk akses cepat.
+ Pinned Disematkan
+ Pinned Tools Alat yang Disematkan
+ Search tools… Cari alat…
+ Select Tool Alat Pemilih
+ Select tools to display on your Home page for quick access. Pilih alat untuk ditampilkan di halaman Beranda Anda untuk akses cepat.
+ Unpin from Home Lepas sematan dari Beranda
+ Unpin tool Lepas sematan alat
+ Arrange image Atur gambar
+ Bring forward Maju Selangkah
+ Bring to front Bawa ke Depan
+ Crop Pangkas
+ Remove crop Hapus pangkasan
+ Send backward Mundur Selangkah
+ Send to back Kirim ke Belakang
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hari ini
@@ -2810,3 +2840,10 @@ theme Appearance Tampilan
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opsi untuk balasan ini
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Cari…
+ Image pasted Gambar ditempel
+ No image on clipboard to paste Tidak ada gambar di papan klip untuk ditempel
+ Pinned {tool} to Home {tool} disematkan ke Beranda
+ Unpinned {tool} from Home {tool} dilepas dari Beranda
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
index a3dc999d6d..0cae8f3e98 100644
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menu
File File
Edit Modifica
@@ -2727,6 +2731,26 @@ certificate Key usage Utilizzo chiave
certificate Self-signed Autofirmato
certificate SHA-1 digest Impronta SHA-1
certificate SHA-256 digest Impronta SHA-256
+ Clear search Cancella ricerca
+ Pin Fissa
+ Pin a tool… Fissa uno strumento…
+ Pin to Home Fissa sulla Home
+ Pin tools to Home Fissa strumenti sulla Home
+ Pin your favorite tools here for quick access. Fissa qui i tuoi strumenti preferiti per un accesso rapido.
+ Pinned Fissato
+ Pinned Tools Strumenti fissati
+ Search tools… Cerca strumenti…
+ Select tools to display on your Home page for quick access. Seleziona gli strumenti da visualizzare nella pagina Home per un acceso rapido.
+ Unpin from Home Rimuovi da Home
+ Unpin tool Sblocca strumento dalla Home
+ Transform image Trasforma immagine
+ Select Tool Strumento Selezione
+ Hand Tool Strumento Mano
+ Marquee Zoom Ingrandimento selettivo
+ Paste Incolla
+ Edit this PDF Modifica questo PDF
+ Export this PDF Esporta questo PDF
+ Find text or tools Trova testo o strumenti
Create & Edit Crea e modifica
Review & Compare Revisione e confronto
Forms & Signatures Moduli e firme
@@ -2794,6 +2818,13 @@ certificate SHA-256 digest Impronta SHA-256
field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name}
two cells would both be named {name} due celle si chiamerebbero entrambe {name}
a field named {name} already exists esiste già un campo chiamato {name}
+ Arrange image Disponi immagine
+ Bring forward Porta avanti
+ Bring to front Porta in primo piano
+ Crop Ritaglia
+ Remove crop Rimuovi ritaglio
+ Send backward Porta indietro
+ Send to back Porta in secondo piano
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Oggi
@@ -2810,3 +2841,11 @@ certificate SHA-256 digest Impronta SHA-256
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opzioni per questa risposta
Export value Valore di esportazione
+ Show Page Edge Shadows Mostra le ombre ai bordi delle pagine
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Cerca…
+ Image pasted Immagine incollata
+ No image on clipboard to paste Nessuna immagine negli appunti da incollare
+ Pinned {tool} to Home {tool} fissato alla Home
+ Unpinned {tool} from Home {tool} rimosso dai preferiti della Home
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
index 5e64a1a8ce..abc616b817 100644
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu メニュー
File ファイル
Edit 編集
@@ -2515,6 +2519,26 @@ animation effects Off オフ
Make properties default プロパティをデフォルトに設定
Pages selected 選択したページ
Page properties ページのプロパティ
+ Clear search 検索をクリア
+ Pin ピン留め
+ Pin a tool… ツールをピン留め…
+ Pin to Home ホームにピン留め
+ Pin tools to Home ツールをホームにピン留め
+ Pin your favorite tools here for quick access. よく使うツールをここにピン留めして、すばやくアクセスできるようにします。
+ Pinned ピン留め済み
+ Pinned Tools ピン留めしたツール
+ Search tools… ツールを検索…
+ Select tools to display on your Home page for quick access. すばやくアクセスできるように、ホームに表示するツールを選択します。
+ Unpin from Home ホームからピン留めを解除
+ Unpin tool ツールのピン留めを解除
+ Transform image 画像を変換
+ Select Tool 選択ツール
+ Hand Tool 手のひらツール
+ Marquee Zoom 範囲指定ズーム
+ Paste 貼り付け
+ Edit this PDF この PDF を編集
+ Export this PDF この PDF を書き出す
+ Find text or tools テキストまたはツールを検索
Create & Edit 作成と編集
Review & Compare レビューと比較
Forms & Signatures フォームと署名
@@ -2562,6 +2586,13 @@ animation effects Off オフ
field names can't contain a period: {name} フィールド名にピリオドは使えません: {name}
two cells would both be named {name} 2 つのセルが同じ名前 {name} になります
a field named {name} already exists {name} という名前のフィールドは既にあります
+ Arrange image 画像を配置
+ Bring forward 前面へ移動
+ Bring to front 最前面へ移動
+ Crop 切り抜き
+ Remove crop 切り抜きを解除
+ Send backward 背面へ移動
+ Send to back 最背面へ移動
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today 今日
@@ -2578,3 +2609,11 @@ animation effects Off オフ
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply この返信のオプション
Export value 書き出し値
+ Show Page Edge Shadows ページ端の影を表示
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… 検索…
+ Image pasted 画像を貼り付けました
+ No image on clipboard to paste 貼り付ける画像がクリップボードにありません
+ Pinned {tool} to Home {tool} をホームにピン留めしました
+ Unpinned {tool} from Home {tool} のホームへのピン留めを解除しました
diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv
index 5c8e594759..fc6a2d783a 100644
--- a/crates/ui-egui/src/i18n/kk.tsv
+++ b/crates/ui-egui/src/i18n/kk.tsv
@@ -1,3 +1,7 @@
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Мәзір
File Файл
Edit Түзету
@@ -2513,6 +2517,26 @@ animation effects Off Өшірулі
Make properties default Қасиеттерді әдепкі ету
Pages selected Таңдалған беттер
Page properties Бет қасиеттері
+ Clear search Іздеуді тазалау
+ Pin Бекіту
+ Pin a tool… Құралды бекіту…
+ Pin to Home Басты бетке бекіту
+ Pin tools to Home Құралдарды басты бетке бекіту
+ Pin your favorite tools here for quick access. Жылдам қол жеткізу үшін таңдаулы құралдарды осында бекітіңіз.
+ Pinned Бекітілген
+ Pinned Tools Бекітілген құралдар
+ Search tools… Құралдарды іздеу…
+ Select tools to display on your Home page for quick access. Жылдам қол жеткізу үшін басты бетте көрсетілетін құралдарды таңдаңыз.
+ Unpin from Home Басты беттен алып тастау
+ Unpin tool Құралды бекітуден шығару
+ Transform image Кескінді түрлендіру
+ Select Tool Таңдау құралы
+ Hand Tool Қол құралы
+ Marquee Zoom Аймақты масштабтау
+ Paste Қою
+ Edit this PDF Осы PDF файлын түзету
+ Export this PDF Осы PDF файлын экспорттау
+ Find text or tools Мәтінді немесе құралдарды табу
Create & Edit Жасау және өңдеу
Review & Compare Шолу және салыстыру
Forms & Signatures Пішіндер және қолтаңбалар
@@ -2560,6 +2584,13 @@ animation effects Off Өшірулі
field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name}
two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді
a field named {name} already exists {name} атты өріс бар
+ Arrange image Суретті реттеу
+ Bring forward Алға жылжыту
+ Bring to front Алдыңғы планға шығару
+ Crop Қию
+ Remove crop Қиюды алып тастау
+ Send backward Артқа жылжыту
+ Send to back Артқы планға жіберу
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Бүгін
@@ -2576,3 +2607,11 @@ animation effects Off Өшірулі
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Осы жауаптың параметрлері
Export value Экспорт мәні
+ Show Page Edge Shadows Бет жиектерінің көлеңкелерін көрсету
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Іздеу…
+ Image pasted Сурет қойылды
+ No image on clipboard to paste Алмасу буферінде қоятын сурет жоқ
+ Pinned {tool} to Home {tool} басты бетке бекітілді
+ Unpinned {tool} from Home {tool} басты беттен ажыратылды
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
index 1e461bbdff..20315b5fcf 100644
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -2,6 +2,11 @@
# context English source translation
# Clean-room translation written from the English source's meaning.
# License: MIT OR Apache-2.0.
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu 메뉴
File 파일
Edit 편집
@@ -2786,6 +2791,31 @@ theme Appearance 모양
field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name}
two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다
a field named {name} already exists {name}(이)라는 필드가 이미 있습니다
+ Clear search 검색 지우기
+ Edit this PDF 이 PDF 편집
+ Export this PDF 이 PDF 내보내기
+ Find text or tools 텍스트 또는 도구 찾기
+ Hand Tool 손 도구
+ Marquee Zoom 영역 확대/축소
+ Pin 고정
+ Pin a tool… 도구 고정…
+ Pin to Home 홈에 고정
+ Pin tools to Home 홈에 도구 고정
+ Pin your favorite tools here for quick access. 빠른 액세스를 위해 즐겨찾는 도구를 여기에 고정하세요.
+ Pinned 고정됨
+ Pinned Tools 고정된 도구
+ Search tools… 도구 검색…
+ Select Tool 선택 도구
+ Select tools to display on your Home page for quick access. 빠른 액세스를 위해 홈 페이지에 표시할 도구를 선택하세요.
+ Unpin from Home 홈에서 고정 해제
+ Unpin tool 도구 고정 해제
+ Arrange image 이미지 정렬
+ Bring forward 앞으로 가져오기
+ Bring to front 맨 앞으로 가져오기
+ Crop 자르기
+ Remove crop 자르기 제거
+ Send backward 뒤로 보내기
+ Send to back 맨 뒤로 보내기
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today 오늘
@@ -2809,3 +2839,10 @@ theme Appearance 모양
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply 이 답글의 옵션
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… 검색…
+ Image pasted 이미지를 붙여넣었습니다
+ No image on clipboard to paste 클립보드에 붙여넣을 이미지가 없습니다
+ Pinned {tool} to Home {tool}을 홈에 고정했습니다
+ Unpinned {tool} from Home {tool}의 홈 고정을 해제했습니다
diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv
index c4d45c90d1..ea684f6d59 100644
--- a/crates/ui-egui/src/i18n/lo.tsv
+++ b/crates/ui-egui/src/i18n/lo.tsv
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "..." of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu ເມນູ
File ໄຟລ໌
Edit ແກ້ໄຂ
@@ -2451,6 +2455,13 @@ animation effects Off ປິດ
Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ
Pages selected ໜ້າທີ່ເລືອກ
Page properties ຄຸນສົມບັດໜ້າ
+ Arrange image ຈັດຮູບພາບ
+ Bring forward ນຳມາທາງໜ້າ
+ Bring to front ນຳມາໄວ້ໜ້າສຸດ
+ Crop ຕັດຂອບ
+ Remove crop ຍົກເລີກການຕັດຂອບ
+ Send backward ສົ່ງໄປທາງຫຼັງ
+ Send to back ສົ່ງໄປໄວ້ຫຼັງສຸດ
Create & Edit ສ້າງ & ແກ້ໄຂ
Review & Compare ກວດກາຄືນ & ປຽບທຽບ
Forms & Signatures ຟອມ & ລາຍເຊັນ
@@ -2481,3 +2492,33 @@ animation effects Off ປິດ
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… ຄົ້ນຫາ…
+ Image pasted ວາງຮູບແລ້ວ
+ No image on clipboard to paste ບໍ່ມີຮູບໃນຄລິບບອດເພື່ອວາງ
+ Pinned {tool} to Home ປັກໝຸດ {tool} ໄວ້ໜ້າຫຼັກແລ້ວ
+ Unpinned {tool} from Home ຖອນໝຸດ {tool} ຈາກໜ້າຫຼັກແລ້ວ
+
+# Home tools (AI-assisted; not separately native-reviewed)
+ Clear search ລ້າງການຄົ້ນຫາ
+ Pin ປັກໝຸດ
+ Pin a tool… ປັກໝຸດເຄື່ອງມື…
+ Pin to Home ປັກໝຸດໄວ້ໜ້າຫຼັກ
+ Pin tools to Home ປັກໝຸດເຄື່ອງມືໄວ້ໜ້າຫຼັກ
+ Pin your favorite tools here for quick access. ປັກໝຸດເຄື່ອງມືທີ່ມັກໄວ້ທີ່ນີ້ເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ.
+ Pinned ປັກໝຸດແລ້ວ
+ Pinned Tools ເຄື່ອງມືທີ່ປັກໝຸດ
+ Search tools… ຄົ້ນຫາເຄື່ອງມື…
+ Select tools to display on your Home page for quick access. ເລືອກເຄື່ອງມືໃຫ້ສະແດງໃນໜ້າຫຼັກເພື່ອເຂົ້າໃຊ້ໄດ້ໄວ.
+ Unpin from Home ຖອນໝຸດຈາກໜ້າຫຼັກ
+ Unpin tool ຖອນໝຸດເຄື່ອງມື
+ Transform image ປ່ຽນຮູບຮ່າງຮູບ
+ Select Tool ເຄື່ອງມືເລືອກ
+ Hand Tool ເຄື່ອງມືມື
+ Marquee Zoom ຊູມພື້ນທີ່ເລືອກ
+ Paste ວາງ
+ Edit this PDF ແກ້ໄຂ PDF ນີ້
+ Export this PDF ສົ່ງອອກ PDF ນີ້
+ Find text or tools ຊອກຫາຂໍ້ຄວາມ ຫຼື ເຄື່ອງມື
+ Crop image ຕັດຂອບຮູບພາບ
diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv
index 61fd9dc9a2..fd7f431154 100644
--- a/crates/ui-egui/src/i18n/lt.tsv
+++ b/crates/ui-egui/src/i18n/lt.tsv
@@ -4,6 +4,10 @@
# consult the Lietuvos Respublikos terminų bankas (terminai.vlkk.lt) when selecting terminology.
# Product and format names (PdfCraft, PDF, PDF/A, OCR, XFA, Word…) remain unchanged.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Meniu
Edit Redaguoti
Pages Puslapiai
@@ -2765,6 +2769,12 @@ key Space Tarpas
Make properties default Nustatyti ypatybes kaip numatytąsias
Pages selected Pasirinkti puslapiai
Page properties Puslapio ypatybės
+ Bring forward Perkelti į priekį
+ Bring to front Iškelti į priekį
+ Crop image Apkarpyti paveikslėlį
+ Remove crop Pašalinti apkarpymą
+ Send backward Perkelti atgal
+ Send to back Nukelti į galą
Create & Edit Kūrimas ir redagavimas
Review & Compare Peržiūra ir palyginimas
Forms & Signatures Formos ir parašai
@@ -2773,7 +2783,6 @@ key Space Tarpas
No tools match your search. Paieškos neatitinka joks įrankis.
Expand all tools Išskleisti visus įrankius
Collapse all tools Suskleisti visus įrankius
-
# AI-assisted additions for concurrently merged UI features (2026-10-15).
# These additions have not had separate native-speaker review.
Mouse wheel scroll speed Pelės ratuko slinkimo greitis
@@ -2781,6 +2790,25 @@ key Space Tarpas
The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti.
theme Appearance Išvaizda
+ Clear search Išvalyti paiešką
+ Pin Prisegti
+ Pin a tool… Prisegti įrankį…
+ Pin to Home Prisegti pradžioje
+ Pin tools to Home Prisegti įrankius pradžioje
+ Pin your favorite tools here for quick access. Prisekite mėgstamus įrankius čia, kad greitai pasiektumėte.
+ Pinned Prisegta
+ Pinned Tools Prisegti įrankiai
+ Search tools… Ieškoti įrankių…
+ Select tools to display on your Home page for quick access. Pasirinkite įrankius, kuriuos norite rodyti pradžios puslapyje.
+ Unpin from Home Atsegti nuo pradžios
+ Unpin tool Atsegti įrankį
+ Transform image Transformuoti paveikslėlį
+ Select Tool Pasirinkimo įrankis
+ Hand Tool Rankos įrankis
+ Marquee Zoom Didinti pažymėtą sritį
+ Edit this PDF Redaguoti šį PDF
+ Export this PDF Eksportuoti šį PDF
+ Find text or tools Rasti tekstą arba įrankius
Attach a file… Pridėti failą…
Delete attachment Ištrinti priedą
Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas
@@ -2823,6 +2851,8 @@ theme Appearance Išvaizda
a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių
the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0
+ Arrange image Tvarkyti vaizdą
+ Crop Apkarpyti
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Šiandien
@@ -2839,3 +2869,11 @@ theme Appearance Išvaizda
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Šio atsakymo parinktys
Export value Eksporto reikšmė
+ Show Page Edge Shadows Rodyti puslapių kraštų šešėlius
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Ieškoti…
+ Image pasted Paveikslėlis įklijuotas
+ No image on clipboard to paste Iškarpinėje nėra paveikslėlio įklijuoti
+ Pinned {tool} to Home {tool} prisegtas pradžios puslapyje
+ Unpinned {tool} from Home {tool} atsegtas nuo pradžios puslapio
diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv
index e81b84e341..b54b3e3526 100644
--- a/crates/ui-egui/src/i18n/nb.tsv
+++ b/crates/ui-egui/src/i18n/nb.tsv
@@ -7,6 +7,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Allow Tillat
Cancel Avbryt
Only allow this if you trust the source document. Tillat dette bare hvis du stoler på kildedokumentet.
@@ -2724,6 +2728,13 @@ key Space Mellomrom
# Page gaps (AI-assisted, not separately native-reviewed)
Show gaps between pages Vis mellomrom mellom sidene
Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke.
+ Arrange image Ranger bilde
+ Bring forward Flytt fremover
+ Bring to front Plasser lengst frem
+ Crop Beskjær
+ Remove crop Fjern beskjæring
+ Send backward Flytt bakover
+ Send to back Plasser lengst bak
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today I dag
@@ -2754,3 +2765,32 @@ key Space Mellomrom
@plural {n} more formula reads a missing field.|{n} more formulas read missing fields. {n} more formula reads a missing field.|{n} more formulas read missing fields.
@plural {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked. {fields} builds some field names as it runs, so those reads aren't checked.|{fields} build some field names as they run, so those reads aren't checked.
@plural {fields} is missing|{fields} are missing {fields} is missing|{fields} are missing
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Søk…
+ Image pasted Bilde limt inn
+ No image on clipboard to paste Ingen bilder på utklippstavlen å lime inn
+ Pinned {tool} to Home Festet {tool} til Hjem
+ Unpinned {tool} from Home Løsnet {tool} fra Hjem
+
+# Home tools (AI-assisted; not separately native-reviewed)
+ Clear search Tøm søk
+ Pin Fest
+ Pin a tool… Fest et verktøy…
+ Pin to Home Fest til Hjem
+ Pin tools to Home Fest verktøy til Hjem
+ Pin your favorite tools here for quick access. Fest favorittverktøyene dine her for rask tilgang.
+ Pinned Festet
+ Pinned Tools Festede verktøy
+ Search tools… Søk etter verktøy…
+ Select tools to display on your Home page for quick access. Velg verktøy som skal vises på hjemmesiden for rask tilgang.
+ Unpin from Home Løsne fra Hjem
+ Unpin tool Løsne verktøy
+ Transform image Transformer bilde
+ Select Tool Markeringsverktøy
+ Hand Tool Håndverktøy
+ Marquee Zoom Områdezoom
+ Edit this PDF Rediger denne PDF-en
+ Export this PDF Eksporter denne PDF-en
+ Find text or tools Finn tekst eller verktøy
+ Crop image Beskjær bilde
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
index 203cad58d8..208dd1c61e 100644
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -15,6 +15,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menu
File Plik
Edit Edycja
@@ -2491,6 +2495,26 @@ animation effects Off Wyłączone
Make properties default Ustaw właściwości jako domyślne
Pages selected Zaznaczone strony
Page properties Właściwości strony
+ Clear search Wyczyść wyszukiwanie
+ Pin Przypnij
+ Pin a tool… Przypnij narzędzie…
+ Pin to Home Przypnij do strony głównej
+ Pin tools to Home Przypnij narzędzia do strony głównej
+ Pin your favorite tools here for quick access. Przypnij tutaj ulubione narzędzia, aby mieć do nich szybki dostęp.
+ Pinned Przypięte
+ Pinned Tools Przypięte narzędzia
+ Search tools… Szukaj narzędzi…
+ Select tools to display on your Home page for quick access. Wybierz narzędzia do wyświetlenia na stronie głównej, aby mieć do nich szybki dostęp.
+ Unpin from Home Odepnij od strony głównej
+ Unpin tool Odepnij narzędzie
+ Transform image Przekształć obraz
+ Select Tool Narzędzie Zaznaczanie
+ Hand Tool Narzędzie Rączka
+ Marquee Zoom Powiększ zaznaczony obszar
+ Paste Wklej
+ Edit this PDF Edytuj ten plik PDF
+ Export this PDF Eksportuj ten plik PDF
+ Find text or tools Znajdź tekst lub narzędzia
Create & Edit Tworzenie i edycja
Review & Compare Recenzja i porównanie
Forms & Signatures Formularze i podpisy
@@ -2658,6 +2682,13 @@ animation effects Off Wyłączone
field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name}
two cells would both be named {name} dwie komórki miałyby nazwę {name}
a field named {name} already exists pole o nazwie {name} już istnieje
+ Arrange image Rozmieść obraz
+ Bring forward Przesuń do przodu
+ Bring to front Przesuń na wierzch
+ Crop Przytnij
+ Remove crop Usuń przycięcie
+ Send backward Przesuń do tyłu
+ Send to back Przesuń pod spód
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Dzisiaj
@@ -2674,3 +2705,11 @@ animation effects Off Wyłączone
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opcje tej odpowiedzi
Export value Wartość eksportowa
+ Show Page Edge Shadows Pokaż cienie krawędzi stron
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Szukaj…
+ Image pasted Wklejono obraz
+ No image on clipboard to paste Brak obrazu do wklejenia w schowku
+ Pinned {tool} to Home Przypięto {tool} do strony głównej
+ Unpinned {tool} from Home Odpięto {tool} od strony głównej
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
index 45f827171b..a172a03879 100644
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menu
File Arquivo
Rotation: Rotação:
@@ -2529,6 +2533,25 @@ animation effects Off Desativados
Make properties default Tornar propriedades padrão
Pages selected Páginas selecionadas
Page properties Propriedades da página
+ Clear search Limpar pesquisa
+ Pin Fixar
+ Pin a tool… Fixar uma ferramenta…
+ Pin to Home Fixar no Início
+ Pin tools to Home Fixar ferramentas no Início
+ Pin your favorite tools here for quick access. Fixe suas ferramentas favoritas aqui para acesso rápido.
+ Pinned Fixado
+ Pinned Tools Ferramentas fixadas
+ Search tools… Pesquisar ferramentas…
+ Select tools to display on your Home page for quick access. Selecione ferramentas para exibir na página Início para acesso rápido.
+ Unpin from Home Desafixar do Início
+ Unpin tool Desafixar ferramenta
+ Transform image Transformar imagem
+ Select Tool Ferramenta Seleção
+ Hand Tool Ferramenta Mão
+ Marquee Zoom Zoom por seleção
+ Edit this PDF Editar este PDF
+ Export this PDF Exportar este PDF
+ Find text or tools Localizar texto ou ferramentas
Create & Edit Criar e editar
Review & Compare Revisar e comparar
Forms & Signatures Formulários e assinaturas
@@ -2847,6 +2870,14 @@ theme Appearance Aparência
a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas
the first row number can be at most {max} o número da primeira linha pode ser no máximo {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0
+ Bring forward Avançar
+ Bring to front Trazer para a frente
+ Crop image Cortar imagem
+ Remove crop Remover corte
+ Send backward Recuar
+ Send to back Enviar para trás
+ Arrange image Organizar imagem
+ Crop Cortar
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hoje
@@ -2863,3 +2894,11 @@ theme Appearance Aparência
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opções desta resposta
Export value Valor de exportação
+ Show Page Edge Shadows Mostrar sombras nas bordas das páginas
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Pesquisar…
+ Image pasted Imagem colada
+ No image on clipboard to paste Nenhuma imagem na área de transferência para colar
+ Pinned {tool} to Home {tool} fixado no Início
+ Unpinned {tool} from Home {tool} desafixado do Início
diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv
index 6b23515b8e..0d0fbf16b4 100644
--- a/crates/ui-egui/src/i18n/ro.tsv
+++ b/crates/ui-egui/src/i18n/ro.tsv
@@ -9,6 +9,10 @@
# the trailing "…" of dialog commands is kept. Commands use the noun form ("Salvare", "Deschidere…"),
# messages address the user formally ("Alegeți", "dvs.").
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Meniu
Edit Editare
Pages Pagini
@@ -2815,6 +2819,33 @@ theme Appearance Aspect
field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name}
two cells would both be named {name} două celule s-ar numi amândouă {name}
a field named {name} already exists există deja un câmp numit {name}
+ Bring forward Aduce mai în față
+ Bring to front Aduce în prim-plan
+ Crop image Decupare imagine
+ Remove crop Elimină decuparea
+ Send backward Trimite mai în spate
+ Send to back Trimite în fundal
+ Clear search Golește căutarea
+ Edit this PDF Editează acest PDF
+ Export this PDF Exportă acest PDF
+ Find text or tools Găsește text sau instrumente
+ Hand Tool Instrumentul Mână
+ Marquee Zoom Zoom cadru
+ Pin Fixează
+ Pin a tool… Fixează un instrument…
+ Pin to Home Fixează la Pagina de pornire
+ Pin tools to Home Fixează instrumente la Pagina de pornire
+ Pin your favorite tools here for quick access. Fixează instrumentele preferate aici pentru acces rapid.
+ Pinned Fixat
+ Pinned Tools Instrumente fixate
+ Search tools… Caută instrumente…
+ Select Tool Instrumentul Selecție
+ Select tools to display on your Home page for quick access. Selectează instrumentele de afișat pe Pagina de pornire pentru acces rapid.
+ Transform image Transformare imagine
+ Unpin from Home Anulează fixarea la Pagina de pornire
+ Unpin tool Anulează fixarea instrumentului
+ Arrange image Aranjează imaginea
+ Crop Decupare
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Astăzi
@@ -2838,3 +2869,10 @@ theme Appearance Aspect
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opțiuni pentru acest răspuns
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Caută…
+ Image pasted Imagine lipită
+ No image on clipboard to paste Nicio imagine în clipboard de lipit
+ Pinned {tool} to Home {tool} fixat pe pagina principală
+ Unpinned {tool} from Home {tool} desprins de pe pagina principală
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
index 59c483526a..b45d73ecab 100644
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Меню
File Файл
Edit Редактирование
@@ -2428,6 +2432,26 @@ animation effects Off Выкл
Make properties default Сделать свойства стандартными
Pages selected Выделенные страницы
Page properties Свойства страницы
+ Clear search Очистить поиск
+ Pin Закрепить
+ Pin a tool… Закрепить инструмент…
+ Pin to Home Закрепить на Главной
+ Pin tools to Home Закрепить инструменты на Главной
+ Pin your favorite tools here for quick access. Закрепите здесь избранные инструменты для быстрого доступа.
+ Pinned Закреплено
+ Pinned Tools Закрепленные инструменты
+ Search tools… Поиск инструментов…
+ Select tools to display on your Home page for quick access. Выберите инструменты для отображения на Главной странице для быстрого доступа.
+ Unpin from Home Открепить от Главной
+ Unpin tool Открепить инструмент
+ Transform image Трансформировать изображение
+ Select Tool Инструмент «Выделение»
+ Hand Tool Инструмент «Рука»
+ Marquee Zoom Масштаб выделением
+ Paste Вставить
+ Edit this PDF Редактировать этот PDF
+ Export this PDF Экспортировать этот PDF
+ Find text or tools Найти текст или инструменты
Create & Edit Создание и редактирование
Review & Compare Рецензирование и сравнение
Forms & Signatures Формы и подписи
@@ -2475,6 +2499,13 @@ animation effects Off Выкл
field names can't contain a period: {name} имена полей не могут содержать точку: {name}
two cells would both be named {name} две ячейки получили бы одно имя {name}
a field named {name} already exists поле с именем {name} уже существует
+ Arrange image Упорядочить изображение
+ Bring forward Переместить вперед
+ Bring to front На передний план
+ Crop Обрезать
+ Remove crop Убрать обрезку
+ Send backward Переместить назад
+ Send to back На задний план
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Сегодня
@@ -2491,3 +2522,11 @@ animation effects Off Выкл
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Параметры этого ответа
Export value Значение экспорта
+ Show Page Edge Shadows Показывать тени по краям страниц
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Поиск…
+ Image pasted Изображение вставлено
+ No image on clipboard to paste В буфере обмена нет изображения для вставки
+ Pinned {tool} to Home {tool} закреплён на главной
+ Unpinned {tool} from Home {tool} откреплён от главной
diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv
index 83cb594158..c95f6d6bd6 100644
--- a/crates/ui-egui/src/i18n/sl.tsv
+++ b/crates/ui-egui/src/i18n/sl.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Meni
File Datoteka
Edit Urejanje
@@ -2520,6 +2524,26 @@ animation effects Off Izklopljeni
Make properties default Nastavi lastnosti kot privzete
Pages selected Izbrane strani
Page properties Lastnosti strani
+ Clear search Počisti iskanje
+ Pin Pripni
+ Pin a tool… Pripni orodje…
+ Pin to Home Pripni na domov
+ Pin tools to Home Pripni orodja na domov
+ Pin your favorite tools here for quick access. Tukaj pripnite svoja priljubljena orodja za hiter dostop.
+ Pinned Pripeto
+ Pinned Tools Pripeta orodja
+ Search tools… Išči orodja…
+ Select tools to display on your Home page for quick access. Izberite orodja za prikaz na domači strani za hiter dostop.
+ Unpin from Home Odpni z domače strani
+ Unpin tool Odpni orodje
+ Transform image Preoblikuj sliko
+ Select Tool Orodje za izbiro
+ Hand Tool Orodje z roko
+ Marquee Zoom Povečava z okvirjem
+ Paste Prilepi
+ Edit this PDF Uredi ta PDF
+ Export this PDF Izvozi ta PDF
+ Find text or tools Poišči besedilo ali orodja
Create & Edit Ustvarjanje in urejanje
Review & Compare Pregled in primerjava
Forms & Signatures Obrazci in podpisi
@@ -2567,6 +2591,13 @@ animation effects Off Izklopljeni
field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name}
two cells would both be named {name} dve celici bi imeli enako ime {name}
a field named {name} already exists polje z imenom {name} že obstaja
+ Arrange image Uredi sliko
+ Bring forward Premakni naprej
+ Bring to front Postavi v ospredje
+ Crop Obreži
+ Remove crop Odstrani obrezovanje
+ Send backward Premakni nazaj
+ Send to back Postavi v ozadje
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Danes
@@ -2583,3 +2614,11 @@ animation effects Off Izklopljeni
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Možnosti tega odgovora
Export value Izvozna vrednost
+ Show Page Edge Shadows Prikaži sence robov strani
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Išči…
+ Image pasted Slika prilepljena
+ No image on clipboard to paste V odložišču ni slike za lepljenje
+ Pinned {tool} to Home {tool} pripeto na domačo stran
+ Unpinned {tool} from Home {tool} odpeto z domače strani
diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv
index e5a8f5b037..eb9eed3a0e 100644
--- a/crates/ui-egui/src/i18n/sq.tsv
+++ b/crates/ui-egui/src/i18n/sq.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menyja
File Skedari
Edit Redakto
@@ -2385,7 +2389,7 @@ animation effects Off Joaktiv
Collapse all pages Collapse all pages
Counting words… {n} pages read Counting words… {n} pages read
Create Link… Create Link…
- Crop Image Crop Image
+ Crop Image Prit imazhin
Current page only Current page only
Cut Image Cut Image
Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet.
@@ -2463,7 +2467,7 @@ animation effects Off Joaktiv
Recognize Text… Recognize Text…
Redact Image Redact Image
Redaction mark appearance Redaction mark appearance
- Remove Crop Remove Crop
+ Remove Crop Hiq prerjen
Remove this leaf tag from reading order and mark its direct content as an artifact Remove this leaf tag from reading order and mark its direct content as an artifact
Resolution: Resolution:
Save anyway Save anyway
@@ -2541,6 +2545,32 @@ animation effects Off Joaktiv
field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name}
two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name}
a field named {name} already exists ekziston tashmë një fushë me emrin {name}
+ Clear search Pastro kërkimin
+ Edit this PDF Redakto këtë PDF
+ Export this PDF Eksporto këtë PDF
+ Find text or tools Gjej tekst ose mjete
+ Hand Tool Mjeti Dorë
+ Marquee Zoom Zmadhim me kornizë
+ Paste Ngjit
+ Pin Fikso
+ Pin a tool… Fikso një mjet…
+ Pin to Home Fikso në Ballinë
+ Pin tools to Home Fikso mjetet në Ballinë
+ Pin your favorite tools here for quick access. Fiksoni mjetet tuaja të preferuara këtu për qasje të shpejtë.
+ Pinned I fiksuar
+ Pinned Tools Mjete të fiksuara
+ Search tools… Kërko mjete…
+ Select Tool Mjeti Përzgjedhje
+ Select tools to display on your Home page for quick access. Zgjidhni mjetet për t'u shfaqur në faqen tuaj të Ballinës për qasje të shpejtë.
+ Unpin from Home Hiq fiksimin nga Ballina
+ Unpin tool Hiq fiksimin e mjetit
+ Arrange image Rregullo figurën
+ Bring forward Kalo përpara
+ Bring to front Sille përpara
+ Crop Prit
+ Remove crop Hiq prerjen
+ Send backward Kalo prapa
+ Send to back Dërgo mbrapa
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Sot
@@ -2564,3 +2594,10 @@ animation effects Off Joaktiv
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Opsionet për këtë përgjigje
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Kërko…
+ Image pasted Imazhi u ngjit
+ No image on clipboard to paste Nuk ka imazh në kujtesën e përkohshme për të ngjitur
+ Pinned {tool} to Home {tool} u fiksua te Kreu
+ Unpinned {tool} from Home {tool} u hoq nga fiksimi te Kreu
diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv
index 0bb56f4793..9b81ffb8b9 100644
--- a/crates/ui-egui/src/i18n/te.tsv
+++ b/crates/ui-egui/src/i18n/te.tsv
@@ -12,6 +12,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) are not translated;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu మెనూ
File ఫైల్
Edit సవరణ
@@ -2424,6 +2428,26 @@ animation effects Off ఆఫ్
Make properties default లక్షణాలను డిఫాల్ట్గా చేయి
Pages selected ఎంచుకున్న పేజీలు
Page properties పేజీ లక్షణాలు
+ Clear search శోధనను తీసివేయి
+ Pin పిన్ చేయి
+ Pin a tool… ఒక సాధనాన్ని పిన్ చేయి…
+ Pin to Home హోమ్కు పిన్ చేయి
+ Pin tools to Home సాధనాలను హోమ్కు పిన్ చేయి
+ Pin your favorite tools here for quick access. త్వరిత ప్రాప్యత కోసం మీకు ఇష్టమైన సాధనాలను ఇక్కడ పిన్ చేయండి.
+ Pinned పిన్ చేయబడింది
+ Pinned Tools పిన్ చేసిన సాధనాలు
+ Search tools… సాధనాలను శోధించండి…
+ Select tools to display on your Home page for quick access. త్వరిత ప్రాప్యత కోసం మీ హోమ్ పేజీలో ప్రదర్శించడానికి సాధనాలను ఎంచుకోండి.
+ Unpin from Home హోమ్ నుండి అన్పిన్ చేయి
+ Unpin tool సాధనాన్ని అన్పిన్ చేయి
+ Transform image చిత్రాన్ని రూపాంతరం చేయండి
+ Select Tool ఎంపిక సాధనం
+ Hand Tool హ్యాండ్ సాధనం
+ Marquee Zoom ఎంచుకున్న ప్రాంతానికి జూమ్
+ Paste అతికించు
+ Edit this PDF ఈ PDFను సవరించు
+ Export this PDF ఈ PDFను ఎగుమти చేయి
+ Find text or tools వచనం లేదా సాధనాలను కనుగొనండి
Create & Edit సృష్టించు & సవరించు
Review & Compare సమీక్షించు & పోల్చు
Forms & Signatures ఫారమ్లు & సంతకాలు
@@ -2471,6 +2495,13 @@ animation effects Off ఆఫ్
field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name}
two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది
a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది
+ Arrange image చిత్రాన్ని అమర్చు
+ Bring forward ముందుకు తీసుకురా
+ Bring to front అందరికంటే ముందుకు తీసుకురా
+ Crop కత్తిరించు
+ Remove crop కత్తిరింపును తీసివేయి
+ Send backward వెనుకకు పంపు
+ Send to back అందరికంటే వెనుకకు పంపు
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today ఈరోజు
@@ -2487,3 +2518,11 @@ animation effects Off ఆఫ్
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply ఈ ప్రత్యుత్తరం ఎంపికలు
Export value ఎగుమతి విలువ
+ Show Page Edge Shadows పేజీ అంచుల నీడలను చూపించు
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… శోధించండి…
+ Image pasted చిత్రం అతికించబడింది
+ No image on clipboard to paste అతికించడానికి క్లిప్బోర్డ్లో చిత్రం లేదు
+ Pinned {tool} to Home {tool} హోమ్కు పిన్ చేయబడింది
+ Unpinned {tool} from Home {tool} హోమ్ నుండి అన్పిన్ చేయబడింది
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
index 41a4578cf0..22110de938 100644
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -1217,3 +1217,12 @@ fn date_languages_are_the_interface_languages() {
let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect();
assert_eq!(dates, interface);
}
+
+#[test]
+fn image_paste_and_home_notifications_are_translated() {
+ for language in Lang::all().filter(|l| *l != Lang::EN) {
+ for source in ["Search…", "Image pasted", "No image on clipboard to paste", "Pinned {tool} to Home", "Unpinned {tool} from Home"] {
+ assert!(language.catalog().plain(source).is_some(), "{}: {source}", language.code());
+ }
+ }
+}
diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv
index 0c2b58a812..f7f8a65c5c 100644
--- a/crates/ui-egui/src/i18n/th.tsv
+++ b/crates/ui-egui/src/i18n/th.tsv
@@ -7,6 +7,10 @@
# names stay in Latin letters; the trailing ellipsis of dialog commands is kept. Thai has one
# plural form.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu เมนู
File ไฟล์
Edit แก้ไข
@@ -2711,6 +2715,13 @@ key Space Space
Collapse all tools ยุบเครื่องมือทั้งหมด
The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร
theme Appearance ลักษณะที่ปรากฏ
+ Arrange image จัดเรียงรูปภาพ
+ Bring forward นำไปข้างหน้า
+ Bring to front นำไปไว้ข้างหน้าสุด
+ Crop ครอบตัด
+ Remove crop เอาการครอบตัดออก
+ Send backward ส่งไปข้างหลัง
+ Send to back ส่งไปไว้ข้างหลังสุด
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today วันนี้
@@ -2736,3 +2747,32 @@ theme Appearance ลักษณะที่ปรากฏ
Unchecked reads Unchecked reads
{field} reads fields named as it runs {field} reads fields named as it runs
Some names are built as it runs Some names are built as it runs
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… ค้นหา…
+ Image pasted วางรูปภาพแล้ว
+ No image on clipboard to paste ไม่มีรูปภาพในคลิปบอร์ดให้วาง
+ Pinned {tool} to Home ปักหมุด {tool} ไว้ที่หน้าแรกแล้ว
+ Unpinned {tool} from Home เลิกปักหมุด {tool} จากหน้าแรกแล้ว
+
+# Home tools (AI-assisted; not separately native-reviewed)
+ Clear search ล้างการค้นหา
+ Pin ปักหมุด
+ Pin a tool… ปักหมุดเครื่องมือ…
+ Pin to Home ปักหมุดไว้ที่หน้าแรก
+ Pin tools to Home ปักหมุดเครื่องมือไว้ที่หน้าแรก
+ Pin your favorite tools here for quick access. ปักหมุดเครื่องมือโปรดที่นี่เพื่อเข้าถึงอย่างรวดเร็ว
+ Pinned ปักหมุดแล้ว
+ Pinned Tools เครื่องมือที่ปักหมุด
+ Search tools… ค้นหาเครื่องมือ…
+ Select tools to display on your Home page for quick access. เลือกเครื่องมือที่จะแสดงบนหน้าแรกเพื่อเข้าถึงอย่างรวดเร็ว
+ Unpin from Home เลิกปักหมุดจากหน้าแรก
+ Unpin tool เลิกปักหมุดเครื่องมือ
+ Transform image แปลงรูปภาพ
+ Select Tool เครื่องมือเลือก
+ Hand Tool เครื่องมือมือ
+ Marquee Zoom ซูมพื้นที่ที่เลือก
+ Edit this PDF แก้ไข PDF นี้
+ Export this PDF ส่งออก PDF นี้
+ Find text or tools ค้นหาข้อความหรือเครื่องมือ
+ Crop image ครอบตัดรูปภาพ
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
index b52688606b..6921a6c33e 100644
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -4,6 +4,10 @@
#
# Clean-room: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Menü
File Dosya
Edit Düzen
@@ -2511,6 +2515,26 @@ animation effects Off Kapalı
Make properties default Özellikleri varsayılan yap
Pages selected Seçilen sayfalar
Page properties Sayfa özellikleri
+ Clear search Aramayı temizle
+ Pin Sabitle
+ Pin a tool… Bir aracı sabitle…
+ Pin to Home Giriş'e sabitle
+ Pin tools to Home Araçları Giriş'e sabitle
+ Pin your favorite tools here for quick access. Hızlı erişim için favori araçlarınızı buraya sabitleyin.
+ Pinned Sabitlendi
+ Pinned Tools Sabitlenen Araçlar
+ Search tools… Araçları ara…
+ Select tools to display on your Home page for quick access. Hızlı erişim için Giriş sayfanızda görüntülenecek araçları seçin.
+ Unpin from Home Giriş'ten sabitlemeyi kaldır
+ Unpin tool Aracın sabitlemesini kaldır
+ Transform image Görüntüyü dönüştür
+ Select Tool Seçim Aracı
+ Hand Tool El Aracı
+ Marquee Zoom Seçim yakınlaştırma
+ Paste Yapıştır
+ Edit this PDF Bu PDF'i düzenle
+ Export this PDF Bu PDF'i dışa aktar
+ Find text or tools Metin veya araç bul
Create & Edit Oluştur ve Düzenle
Review & Compare İncele ve Karşılaştır
Forms & Signatures Formlar ve İmzalar
@@ -2558,6 +2582,13 @@ animation effects Off Kapalı
field names can't contain a period: {name} alan adları nokta içeremez: {name}
two cells would both be named {name} iki hücrenin de adı {name} olurdu
a field named {name} already exists {name} adlı bir alan zaten var
+ Arrange image Resmi düzenle
+ Bring forward Öne getir
+ Bring to front En öne getir
+ Crop Kırp
+ Remove crop Kırpmayı kaldır
+ Send backward Arkaya gönder
+ Send to back En arkaya gönder
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Bugün
@@ -2574,3 +2605,11 @@ animation effects Off Kapalı
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Bu yanıtın seçenekleri
Export value Dışa aktarma değeri
+ Show Page Edge Shadows Sayfa kenarı gölgelerini göster
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Ara…
+ Image pasted Görüntü yapıştırıldı
+ No image on clipboard to paste Panoda yapıştırılacak görüntü yok
+ Pinned {tool} to Home {tool} Ana Sayfaya sabitlendi
+ Unpinned {tool} from Home {tool} Ana Sayfadan kaldırıldı
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
index dc65faf7a2..a824769600 100644
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -18,6 +18,10 @@
# Integer plurals (CLDR uk): one (1, 21) | few (2–4, 22–24) | many (0, 5–19); 11–14 take many.
# Commands use infinitives. Fixed-count templates use count-neutral wording.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Меню
File Файл
Edit Редагування
@@ -2537,6 +2541,26 @@ animation effects Off Вимкнено
Make properties default Зробити властивості типовими
Pages selected Вибрані сторінки
Page properties Властивості сторінки
+ Clear search Очистити пошук
+ Pin Закріпити
+ Pin a tool… Закріпити інструмент…
+ Pin to Home Закріпити на Головній
+ Pin tools to Home Закріпити інструменти на Головній
+ Pin your favorite tools here for quick access. Закріпіть улюблені інструменти тут для швидкого доступу.
+ Pinned Закріплено
+ Pinned Tools Закріплені інструменти
+ Search tools… Шукати інструменти…
+ Select tools to display on your Home page for quick access. Виберіть інструменти для відображення на Головній для швидкого доступу.
+ Unpin from Home Відкріпити від Головної
+ Unpin tool Відкріпити інструмент
+ Transform image Трансформувати зображення
+ Select Tool Інструмент виділення
+ Hand Tool Інструмент «Рука»
+ Marquee Zoom Масштабувати виділену область
+ Paste Вставити
+ Edit this PDF Редактировать цей PDF
+ Export this PDF Експортувати цей PDF
+ Find text or tools Знайти текст або інструменти
Create & Edit Створення й редагування
Review & Compare Рецензування й порівняння
Forms & Signatures Форми й підписи
@@ -2584,6 +2608,13 @@ animation effects Off Вимкнено
field names can't contain a period: {name} імена полів не можуть містити крапку: {name}
two cells would both be named {name} дві клітинки мали б однакове ім'я {name}
a field named {name} already exists поле з іменем {name} уже існує
+ Arrange image Упорядкувати зображення
+ Bring forward Перемістити вперед
+ Bring to front На передній план
+ Crop Обрізати
+ Remove crop Прибрати обрізання
+ Send backward Перемістити назад
+ Send to back На задній план
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Сьогодні
@@ -2600,3 +2631,11 @@ animation effects Off Вимкнено
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Параметри цієї відповіді
Export value Значення експорту
+ Show Page Edge Shadows Показувати тіні на краях сторінок
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Пошук…
+ Image pasted Зображення вставлено
+ No image on clipboard to paste У буфері обміну немає зображення для вставлення
+ Pinned {tool} to Home {tool} закріплено на головній
+ Unpinned {tool} from Home {tool} відкріплено від головної
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
index e9a9a73f2b..e79a8aacc4 100644
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -7,6 +7,11 @@
# Plural rule: `plural_none` — every @plural row lists exactly one form.
# Context-sensitive wording and intentional unchanged strings are regression-tested in i18n/mod.rs.
#
+
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu Trình đơn
Edit Chỉnh sửa
Pages Trang
@@ -2702,6 +2707,12 @@ key Space Space
Redact Image Xóa nội dung nhạy cảm của hình ảnh
Create Link… Tạo liên kết…
Recognize Text… Nhận dạng văn bản…
+ Bring forward Chuyển lên trước
+ Bring to front Đưa lên trước cùng
+ Crop image Cắt hình ảnh
+ Remove crop Xóa cắt
+ Send backward Chuyển về sau
+ Send to back Đưa về sau cùng
Create & Edit Tạo và chỉnh sửa
Review & Compare Xem xét và so sánh
Forms & Signatures Biểu mẫu và chữ ký
@@ -2789,6 +2800,25 @@ key Space Space
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
+ Clear search Xóa tìm kiếm
+ Pin Ghim
+ Pin a tool… Ghim công cụ…
+ Pin to Home Ghim vào Trang chủ
+ Pin tools to Home Ghim công cụ vào Trang chủ
+ Pin your favorite tools here for quick access. Ghim các công cụ yêu thích của bạn vào đây để truy cập nhanh.
+ Pinned Đã ghim
+ Pinned Tools Công cụ đã ghim
+ Search tools… Tìm kiếm công cụ…
+ Select tools to display on your Home page for quick access. Chọn các công cụ hiển thị trên Trang chủ để truy cập nhanh.
+ Unpin from Home Bỏ ghim khỏi Trang chủ
+ Unpin tool Bỏ ghim công cụ
+ Transform image Chuyển đổi hình ảnh
+ Select Tool Công cụ chọn
+ Hand Tool Công cụ bàn tay
+ Marquee Zoom Thu phóng theo vùng chọn
+ Edit this PDF Chỉnh sửa PDF này
+ Export this PDF Xuất PDF này
+ Find text or tools Tìm văn bản hoặc công cụ
Attach a file… Đính kèm tệp…
Delete attachment Xóa tệp đính kèm
Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web
@@ -2826,6 +2856,8 @@ key Space Space
a table has 1 to {max} columns một bảng có từ 1 đến {max} cột
the first row number can be at most {max} số hàng đầu tiên tối đa là {max}
the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0
+ Arrange image Sắp xếp hình ảnh
+ Crop Xén
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today Hôm nay
@@ -2845,3 +2877,11 @@ key Space Space
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply Tùy chọn cho câu trả lời này
Export value Giá trị xuất
+ Show Page Edge Shadows Hiển thị bóng viền trang
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… Tìm kiếm…
+ Image pasted Đã dán hình ảnh
+ No image on clipboard to paste Không có hình ảnh trong bảng nhớ tạm để dán
+ Pinned {tool} to Home Đã ghim {tool} vào Trang chủ
+ Unpinned {tool} from Home Đã bỏ ghim {tool} khỏi Trang chủ
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
index 5f08576b8e..96e9c3bb75 100644
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
# About, contributor/model credits, and multiline interface guidance.
About 关于
Contributors 贡献者
@@ -2479,6 +2483,26 @@ animation effects Off 关
Make properties default 将属性设为默认
Pages selected 选定的页面
Page properties 页面属性
+ Clear search 清除搜索
+ Pin 固定
+ Pin a tool… 固定工具…
+ Pin to Home 固定到主页
+ Pin tools to Home 将工具固定到主页
+ Pin your favorite tools here for quick access. 将常用的工具固定在此处以便快速访问。
+ Pinned 已固定
+ Pinned Tools 已固定的工具
+ Search tools… 搜索工具…
+ Select tools to display on your Home page for quick access. 选择要在主页上显示的工具以便快速访问。
+ Unpin from Home 从主页取消固定
+ Unpin tool 取消固定工具
+ Transform image 变换图像
+ Select Tool 选择工具
+ Hand Tool 手形工具
+ Marquee Zoom 选框缩放
+ Paste 粘贴
+ Edit this PDF 编辑此 PDF
+ Export this PDF 导出此 PDF
+ Find text or tools 查找文本或工具
Create & Edit 创建和编辑
Review & Compare 审阅和比较
Forms & Signatures 表单和签名
@@ -2526,6 +2550,13 @@ animation effects Off 关
field names can't contain a period: {name} 字段名称不能包含句点:{name}
two cells would both be named {name} 两个单元格的名称都会是 {name}
a field named {name} already exists 已存在名为 {name} 的字段
+ Arrange image 排列图像
+ Bring forward 上移一层
+ Bring to front 置于顶层
+ Crop 裁剪
+ Remove crop 移除裁剪
+ Send backward 下移一层
+ Send to back 置于底层
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today 今天
@@ -2542,3 +2573,11 @@ animation effects Off 关
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply 此回复的选项
Export value 导出值
+ Show Page Edge Shadows 显示页面边缘阴影
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… 搜索…
+ Image pasted 已粘贴图像
+ No image on clipboard to paste 剪贴板中没有可粘贴的图像
+ Pinned {tool} to Home 已将 {tool} 固定到主页
+ Unpinned {tool} from Home 已从主页取消固定 {tool}
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
index 2570f12f1c..a1b933b830 100644
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -8,6 +8,10 @@
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
# the trailing "…" of dialog commands is kept.
+# Untranslated: English text until a translator fills these in.
+ Lowering this forgets the oldest files. At 0 there is no list. Lowering this forgets the oldest files. At 0 there is no list.
+ Recent files to keep Recent files to keep
+
Menu 選單
File 檔案
Edit 編輯
@@ -2465,6 +2469,26 @@ animation effects Off 關
Make properties default 將內容設為預設
Pages selected 選取的頁面
Page properties 頁面屬性
+ Clear search 清除搜尋
+ Pin 釘選
+ Pin a tool… 釘選工具…
+ Pin to Home 釘選到首頁
+ Pin tools to Home 將工具釘選到首頁
+ Pin your favorite tools here for quick access. 將常用的工具釘選在此處以便快速存取。
+ Pinned 已釘選
+ Pinned Tools 已釘選的工具
+ Search tools… 搜尋工具…
+ Select tools to display on your Home page for quick access. 選取要在首頁上顯示的工具以便快速存取。
+ Unpin from Home 從首頁取消釘選
+ Unpin tool 取消釘選工具
+ Transform image 變換影像
+ Select Tool 選取工具
+ Hand Tool 手形工具
+ Marquee Zoom 選取範圍縮放
+ Paste 貼上
+ Edit this PDF 編輯此 PDF
+ Export this PDF 匯出此 PDF
+ Find text or tools 尋找文字或工具
Create & Edit 建立與編輯
Review & Compare 審閱與比較
Forms & Signatures 表單與簽名
@@ -2512,6 +2536,13 @@ animation effects Off 關
field names can't contain a period: {name} 欄位名稱不能包含句點:{name}
two cells would both be named {name} 兩個儲存格的名稱都會是 {name}
a field named {name} already exists 已有名為 {name} 的欄位
+ Arrange image 排列影像
+ Bring forward 上移一層
+ Bring to front 移到最上層
+ Crop 裁切
+ Remove crop 移除裁切
+ Send backward 下移一層
+ Send to back 移到最下層
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
Today 今天
@@ -2528,3 +2559,11 @@ animation effects Off 關
# Comments panel: each reply's own "…" menu (AI-assisted, not separately native-reviewed)
Options for this reply 此回覆的選項
Export value 匯出值
+ Show Page Edge Shadows 顯示頁面邊緣陰影
+
+# Image paste and Home notifications (AI-assisted; not separately native-reviewed)
+ Search… 搜尋…
+ Image pasted 已貼上影像
+ No image on clipboard to paste 剪貼簿中沒有可貼上的影像
+ Pinned {tool} to Home 已將 {tool} 釘選至首頁
+ Unpinned {tool} from Home 已從首頁取消釘選 {tool}
diff --git a/crates/ui-egui/src/image_engine.rs b/crates/ui-egui/src/image_engine.rs
new file mode 100644
index 0000000000..518bea0223
--- /dev/null
+++ b/crates/ui-egui/src/image_engine.rs
@@ -0,0 +1,578 @@
+//! Image manipulation engine for non-destructive crop, anchor-based resize, and movement.
+//!
+//! Contributor-original clean-room design with zero-panic safety and lockstep pixel-scale invariance.
+
+use egui::{Color32, Pos2, Rect, Stroke, Vec2, pos2};
+
+use crate::edit_text_ui::ImageHandle;
+
+// ============================================================================
+// 1. MATHEMATICAL CORE: ANCHOR-BASED RESIZE & CROP
+// ============================================================================
+
+/// Minimum allowed dimensions in pixels to prevent panics, zero divisions, or inverted rects.
+pub const MIN_RECT_SIZE: f32 = 10.0;
+
+/// **Anchor-Based Resize System**
+///
+/// Recalculates the screen bounding box by locking the diagonally opposite point/edge as
+/// a fixed anchor.
+///
+/// Math breakdown:
+/// - Corner handles: The diagonally opposite corner is fixed in both X and Y.
+/// Width and height are calculated relative to that anchor, clamped to >= `MIN_RECT_SIZE`,
+/// and the new bounding box is constructed from `anchor` and the clamped dragged coordinate.
+/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is
+/// completely unaffected.
+pub fn apply_resize(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
+ if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
+ return image_rect;
+ }
+ let mut min = image_rect.min;
+ let mut max = image_rect.max;
+
+ match handle {
+ // Dragging TopLeft: Fixed Anchor = BottomRight (max.x, max.y)
+ ImageHandle::TopLeft => {
+ let anchor = max;
+ let target = min + delta;
+ let new_w = (anchor.x - target.x).max(MIN_RECT_SIZE);
+ let new_h = (anchor.y - target.y).max(MIN_RECT_SIZE);
+ min.x = anchor.x - new_w;
+ min.y = anchor.y - new_h;
+ }
+
+ // Dragging TopCenter: Fixed Anchor = Bottom edge (max.y)
+ ImageHandle::TopCenter => {
+ let anchor_y = max.y;
+ let target_y = min.y + delta.y;
+ let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
+ min.y = anchor_y - new_h;
+ }
+
+ // Dragging TopRight: Fixed Anchor = BottomLeft (min.x, max.y)
+ ImageHandle::TopRight => {
+ let anchor_x = min.x;
+ let anchor_y = max.y;
+ let target_x = max.x + delta.x;
+ let target_y = min.y + delta.y;
+ let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
+ let new_h = (anchor_y - target_y).max(MIN_RECT_SIZE);
+ max.x = anchor_x + new_w;
+ min.y = anchor_y - new_h;
+ }
+
+ // Dragging RightCenter: Fixed Anchor = Left edge (min.x)
+ ImageHandle::RightCenter => {
+ let anchor_x = min.x;
+ let target_x = max.x + delta.x;
+ let new_w = (target_x - anchor_x).max(MIN_RECT_SIZE);
+ max.x = anchor_x + new_w;
+ }
+
+ // Dragging BottomRight: Fixed Anchor = TopLeft (min.x, min.y)
+ ImageHandle::BottomRight => {
+ let anchor = min;
+ let target = max + delta;
+ let new_w = (target.x - anchor.x).max(MIN_RECT_SIZE);
+ let new_h = (target.y - anchor.y).max(MIN_RECT_SIZE);
+ max.x = anchor.x + new_w;
+ max.y = anchor.y + new_h;
+ }
+
+ // Dragging BottomCenter: Fixed Anchor = Top edge (min.y)
+ ImageHandle::BottomCenter => {
+ let anchor_y = min.y;
+ let target_y = max.y + delta.y;
+ let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
+ max.y = anchor_y + new_h;
+ }
+
+ // Dragging BottomLeft: Fixed Anchor = TopRight (max.x, min.y)
+ ImageHandle::BottomLeft => {
+ let anchor_x = max.x;
+ let anchor_y = min.y;
+ let target_x = min.x + delta.x;
+ let target_y = max.y + delta.y;
+ let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
+ let new_h = (target_y - anchor_y).max(MIN_RECT_SIZE);
+ min.x = anchor_x - new_w;
+ max.y = anchor_y + new_h;
+ }
+
+ // Dragging LeftCenter: Fixed Anchor = Right edge (max.x)
+ ImageHandle::LeftCenter => {
+ let anchor_x = max.x;
+ let target_x = min.x + delta.x;
+ let new_w = (anchor_x - target_x).max(MIN_RECT_SIZE);
+ min.x = anchor_x - new_w;
+ }
+ }
+
+ Rect::from_min_max(min, max)
+}
+
+/// **Anchor-Based Proportional Resize System**
+///
+/// Recalculates the screen bounding box by locking the diagonally opposite point as
+/// a fixed anchor while preserving the aspect ratio.
+///
+/// For corner handles, uses the axis with the larger relative change to scale both
+/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`.
+/// For mid-edge handles, delegates to `apply_resize`.
+pub fn apply_resize_proportional(image_rect: Rect, handle: ImageHandle, delta: Vec2) -> Rect {
+ if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
+ return image_rect;
+ }
+ let w0 = image_rect.width().max(1.0);
+ let h0 = image_rect.height().max(1.0);
+
+ match handle {
+ ImageHandle::TopLeft => {
+ let horizontal = -delta.x / w0;
+ let vertical = -delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.max.y - h), image_rect.max)
+ }
+ ImageHandle::TopRight => {
+ let horizontal = delta.x / w0;
+ let vertical = -delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.min.x, image_rect.max.y - h), Pos2::new(image_rect.min.x + w, image_rect.max.y))
+ }
+ ImageHandle::BottomLeft => {
+ let horizontal = -delta.x / w0;
+ let vertical = delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(Pos2::new(image_rect.max.x - w, image_rect.min.y), Pos2::new(image_rect.max.x, image_rect.min.y + h))
+ }
+ ImageHandle::BottomRight => {
+ let horizontal = delta.x / w0;
+ let vertical = delta.y / h0;
+ let d = if horizontal.abs() > vertical.abs() { horizontal } else { vertical };
+ let k = (1.0 + d).max(MIN_RECT_SIZE / w0).max(MIN_RECT_SIZE / h0);
+ let (w, h) = (w0 * k, h0 * k);
+ Rect::from_min_max(image_rect.min, Pos2::new(image_rect.min.x + w, image_rect.min.y + h))
+ }
+ mid => apply_resize(image_rect, mid, delta),
+ }
+}
+
+/// Trim a screen rectangle and its UVs together, keeping the opposite edge fixed.
+/// The pixel scale stays unchanged, and each edge stops at the texture boundary.
+pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: ImageHandle, delta: Vec2) -> (Rect, Rect) {
+ if !delta.is_finite() || !image_rect.is_finite() || !uv_rect.is_finite() || !image_rect.is_positive() || !uv_rect.is_positive() {
+ return (image_rect, uv_rect);
+ }
+ let scale = image_rect.size() / uv_rect.size();
+ if !scale.is_finite() || scale.x <= 0.0 || scale.y <= 0.0 {
+ return (image_rect, uv_rect);
+ }
+ let minimum = (egui::vec2(MIN_RECT_SIZE, MIN_RECT_SIZE) / scale).min(uv_rect.size() * 0.95);
+ let mut uv = uv_rect;
+ if matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) {
+ uv.min.x = (uv.min.x + delta.x / scale.x).clamp(0.0, (uv.max.x - minimum.x).max(0.0));
+ } else if matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) {
+ uv.max.x = (uv.max.x + delta.x / scale.x).clamp((uv.min.x + minimum.x).min(1.0), 1.0);
+ }
+ if matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) {
+ uv.min.y = (uv.min.y + delta.y / scale.y).clamp(0.0, (uv.max.y - minimum.y).max(0.0));
+ } else if matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) {
+ uv.max.y = (uv.max.y + delta.y / scale.y).clamp((uv.min.y + minimum.y).min(1.0), 1.0);
+ }
+ // Reconstruct from the fixed opposite edge, rather than adding a nearly full-width
+ // shift to the moved edge. Calculate in f64 so each screen coordinate rounds only
+ // once to f32; small crops far from the origin otherwise lose multiple ulps.
+ let span = |min: f32, max: f32| f64::from(max) - f64::from(min);
+ let width = span(image_rect.min.x, image_rect.max.x) * span(uv.min.x, uv.max.x) / span(uv_rect.min.x, uv_rect.max.x);
+ let height = span(image_rect.min.y, image_rect.max.y) * span(uv.min.y, uv.max.y) / span(uv_rect.min.y, uv_rect.max.y);
+ let mut screen = image_rect;
+ if uv.min.x != uv_rect.min.x {
+ screen.min.x = (f64::from(image_rect.max.x) - width) as f32;
+ } else if uv.max.x != uv_rect.max.x {
+ screen.max.x = (f64::from(image_rect.min.x) + width) as f32;
+ }
+ if uv.min.y != uv_rect.min.y {
+ screen.min.y = (f64::from(image_rect.max.y) - height) as f32;
+ } else if uv.max.y != uv_rect.max.y {
+ screen.max.y = (f64::from(image_rect.min.y) + height) as f32;
+ }
+ if !screen.is_finite() || !screen.is_positive() {
+ return (image_rect, uv_rect);
+ }
+ (screen, uv)
+}
+
+// ============================================================================
+// 2. VISUAL RENDERING HELPERS
+// ============================================================================
+
+/// Vibrant amber/orange color for Crop Mode.
+pub const CROP_ACCENT: Color32 = Color32::from_rgb(230, 126, 34);
+
+/// Renders the Rule-of-Thirds composition grid inside the given crop rectangle.
+pub fn draw_rule_of_thirds(painter: &egui::Painter, rect: Rect, stroke: Stroke) {
+ let w = rect.width();
+ let h = rect.height();
+ if w <= 0.0 || h <= 0.0 {
+ return;
+ }
+
+ let x1 = rect.min.x + w / 3.0;
+ let x2 = rect.min.x + (2.0 * w) / 3.0;
+ painter.line_segment([pos2(x1, rect.min.y), pos2(x1, rect.max.y)], stroke);
+ painter.line_segment([pos2(x2, rect.min.y), pos2(x2, rect.max.y)], stroke);
+
+ let y1 = rect.min.y + h / 3.0;
+ let y2 = rect.min.y + (2.0 * h) / 3.0;
+ painter.line_segment([pos2(rect.min.x, y1), pos2(rect.max.x, y1)], stroke);
+ painter.line_segment([pos2(rect.min.x, y2), pos2(rect.max.x, y2)], stroke);
+}
+
+/// Renders a circular handle with shadow, fill, and accent stroke using the image tool accent.
+pub fn draw_handle(painter: &egui::Painter, center: Pos2, is_active: bool, is_hovered: bool, crop_mode: bool, accent_color: Color32) {
+ let base_radius = 6.5;
+ let circle_radius = if is_active {
+ base_radius + 2.0
+ } else if is_hovered {
+ base_radius + 1.2
+ } else {
+ base_radius
+ };
+
+ let fill_color = if crop_mode {
+ if is_active || is_hovered { Color32::from_rgb(255, 230, 150) } else { Color32::from_rgb(255, 255, 255) }
+ } else if is_active || is_hovered {
+ Color32::from_rgb(210, 235, 255)
+ } else {
+ Color32::from_rgb(255, 255, 255)
+ };
+
+ // Draw handle shadow/glow
+ painter.circle_filled(center, circle_radius + 1.5, Color32::from_rgba_unmultiplied(0, 0, 0, 80));
+
+ // Draw handle circle
+ painter.circle_filled(center, circle_radius, fill_color);
+ painter.circle_stroke(center, circle_radius, Stroke::new(1.8, accent_color));
+}
+
+// ============================================================================
+// 3. UNIT TESTS: MATHEMATICAL VERIFICATION
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use egui::vec2;
+
+ // Verify scale in screen space: dividing an endpoint's rounding error by a tiny
+ // retained UV span would magnify it and make a fixed scale tolerance invalid.
+ fn crop_scale_preserved(before: Rect, uv_before: Rect, after: Rect, uv_after: Rect) -> bool {
+ let axis = |lo: f32, hi: f32, u0: f32, u1: f32, new_lo: f32, new_hi: f32, v0: f32, v1: f32| {
+ let span = f64::from(hi) - f64::from(lo);
+ let expected = span * (f64::from(v1) - f64::from(v0)) / (f64::from(u1) - f64::from(u0));
+ let actual = f64::from(new_hi) - f64::from(new_lo);
+ // One moved endpoint rounds once to f32. EPSILON * coordinate magnitude
+ // bounds one ulp (conservatively twice the nearest-rounding error).
+ // The f64 term also covers the two arithmetic operations above.
+ let magnitude = f64::from(new_lo).abs().max(f64::from(new_hi).abs()).max(expected.abs());
+ let bound = f64::from(f32::EPSILON) * magnitude + 4.0 * f64::EPSILON * span.abs();
+ (actual - expected).abs() <= bound
+ };
+ axis(before.min.x, before.max.x, uv_before.min.x, uv_before.max.x, after.min.x, after.max.x, uv_after.min.x, uv_after.max.x)
+ && axis(before.min.y, before.max.y, uv_before.min.y, uv_before.max.y, after.min.y, after.max.y, uv_after.min.y, uv_after.max.y)
+ }
+
+ #[test]
+ fn crop_scale_at_large_screen_offset() {
+ let screen = Rect::from_min_size(pos2(813.7157, 0.0), vec2(495.0725, 50.0));
+ let uv = Rect::from_min_size(pos2(0.114936605, 0.0), vec2(0.32074833, 0.3));
+ let (cropped, kept) = apply_crop(screen, uv, ImageHandle::BottomLeft, vec2(1255.676, 0.0));
+ let width = (f64::from(screen.max.x) - f64::from(screen.min.x)) * (f64::from(kept.max.x) - f64::from(kept.min.x))
+ / (f64::from(uv.max.x) - f64::from(uv.min.x));
+ assert_eq!(cropped.min.x, (f64::from(screen.max.x) - width) as f32, "moved edge rounds once from the fixed anchor");
+ assert_eq!(cropped.max.x, screen.max.x, "opposite horizontal edge stays fixed");
+ assert_eq!(cropped.min.y, screen.min.y, "opposite vertical edge stays fixed");
+ assert!(crop_scale_preserved(screen, uv, cropped, kept));
+ let stretched = Rect::from_min_max(cropped.min - vec2(0.1, 0.0), cropped.max);
+ assert!(!crop_scale_preserved(screen, uv, stretched, kept), "a visible stretch must exceed the rounding bound");
+ }
+
+ #[test]
+ fn test_resize_bottom_right_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, ImageHandle::BottomRight, vec2(50.0, 25.0));
+ assert_eq!(res.min, pos2(100.0, 100.0), "TopLeft anchor must remain fixed");
+ assert_eq!(res.max, pos2(350.0, 225.0), "BottomRight must expand by delta");
+ }
+
+ #[test]
+ fn test_resize_top_left_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, ImageHandle::TopLeft, vec2(20.0, 15.0));
+ assert_eq!(res.max, pos2(300.0, 200.0), "BottomRight anchor must remain fixed");
+ assert_eq!(res.min, pos2(120.0, 115.0), "TopLeft must move by delta");
+ }
+
+ #[test]
+ fn test_resize_clamping_prevents_inversion() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0));
+ let res = apply_resize(r, ImageHandle::TopLeft, vec2(500.0, 500.0));
+ assert_eq!(res.max, pos2(200.0, 200.0));
+ assert_eq!(res.min, pos2(200.0 - MIN_RECT_SIZE, 200.0 - MIN_RECT_SIZE));
+ assert!(res.width() >= MIN_RECT_SIZE);
+ assert!(res.height() >= MIN_RECT_SIZE);
+ }
+
+ #[test]
+ fn test_resize_top_right_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, ImageHandle::TopRight, vec2(10.0, -10.0));
+ assert_eq!(res.min.x, 100.0, "min.x (BL) anchor fixed");
+ assert_eq!(res.max.y, 200.0, "max.y (BL) anchor fixed");
+ assert_eq!(res.max.x, 310.0);
+ assert_eq!(res.min.y, 90.0);
+ }
+
+ #[test]
+ fn test_resize_bottom_left_anchor() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+ let res = apply_resize(r, ImageHandle::BottomLeft, vec2(-15.0, 20.0));
+ assert_eq!(res.max.x, 300.0, "max.x (TR) anchor fixed");
+ assert_eq!(res.min.y, 100.0, "min.y (TR) anchor fixed");
+ assert_eq!(res.min.x, 85.0);
+ assert_eq!(res.max.y, 220.0);
+ }
+
+ #[test]
+ fn test_resize_mid_edges() {
+ let r = Rect::from_min_max(pos2(100.0, 100.0), pos2(300.0, 200.0));
+
+ let tm = apply_resize(r, ImageHandle::TopCenter, vec2(50.0, -30.0));
+ assert_eq!(tm.min.x, 100.0, "X min unaffected");
+ assert_eq!(tm.max.x, 300.0, "X max unaffected");
+ assert_eq!(tm.max.y, 200.0, "Y max anchor locked");
+ assert_eq!(tm.min.y, 70.0);
+
+ let rm = apply_resize(r, ImageHandle::RightCenter, vec2(40.0, 50.0));
+ assert_eq!(rm.min.x, 100.0, "X min anchor locked");
+ assert_eq!(rm.max.x, 340.0);
+ assert_eq!(rm.min.y, 100.0, "Y unaffected");
+ assert_eq!(rm.max.y, 200.0, "Y unaffected");
+ }
+
+ #[test]
+ fn test_crop_lockstep_ratio_preservation() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let initial_px_per_uv_x = screen.width() / uv.width();
+ let initial_px_per_uv_y = screen.height() / uv.height();
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(-80.0, 0.0));
+
+ assert_eq!(new_screen.min.x, 100.0, "Left edge anchored");
+ assert_eq!(new_screen.max.x, 420.0, "Right edge moved in by 80px");
+ assert_eq!(new_screen.width(), 320.0);
+
+ assert_eq!(new_uv.min.x, 0.0);
+ assert!((new_uv.max.x - 0.8).abs() < 1e-4);
+
+ let new_px_per_uv_x = new_screen.width() / new_uv.width();
+ assert!((new_px_per_uv_x - initial_px_per_uv_x).abs() < 1e-4, "Pixel scale must remain invariant in crop mode");
+
+ let new_px_per_uv_y = new_screen.height() / new_uv.height();
+ assert!((new_px_per_uv_y - initial_px_per_uv_y).abs() < 1e-4, "Pixel scale Y must remain invariant");
+ }
+
+ #[test]
+ fn test_crop_left_edge_anchoring() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(50.0, 0.0));
+
+ assert_eq!(new_screen.max.x, 500.0, "Right edge must remain strictly anchored");
+ assert_eq!(new_screen.min.x, 150.0, "Left edge must move by 50px");
+ assert_eq!(new_screen.width(), 350.0);
+
+ assert!((new_uv.min.x - 0.125).abs() < 1e-4);
+ assert_eq!(new_uv.max.x, 1.0);
+ }
+
+ #[test]
+ fn test_crop_uncrop_clamped_at_zero_and_one() {
+ let screen = Rect::from_min_max(pos2(100.0, 100.0), pos2(500.0, 400.0));
+ let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
+
+ let (new_screen, new_uv) = apply_crop(screen, uv, ImageHandle::LeftCenter, vec2(-100.0, 0.0));
+ assert_eq!(new_uv.min.x, 0.0, "UV min cannot go below 0.0");
+ assert_eq!(new_screen.min.x, 100.0, "Screen min clamped at initial position");
+
+ let (new_screen2, new_uv2) = apply_crop(screen, uv, ImageHandle::RightCenter, vec2(100.0, 0.0));
+ assert_eq!(new_uv2.max.x, 1.0, "UV max cannot exceed 1.0");
+ assert_eq!(new_screen2.max.x, 500.0, "Screen max clamped at initial position");
+ }
+
+ #[test]
+ fn test_apply_resize_proportional_horizontal_only_scales_both_dimensions() {
+ let rect = Rect::from_min_max(pos2(100.0, 100.0), pos2(200.0, 200.0)); // 100 x 100
+ // TopRight corner moved horizontally by -25px (from x=200 to x=175)
+ let resized = apply_resize_proportional(rect, ImageHandle::TopRight, vec2(-25.0, 0.0));
+ assert_eq!(resized.min.x, 100.0, "Left anchor preserved");
+ assert_eq!(resized.max.y, 200.0, "Bottom anchor preserved");
+ assert_eq!(resized.width(), 75.0, "Width scaled to 75%");
+ assert_eq!(resized.height(), 75.0, "Height scaled to 75% proportionally");
+ assert_eq!(resized.max.x, 175.0);
+ assert_eq!(resized.min.y, 125.0);
+ }
+
+ #[test]
+ fn test_apply_resize_proportional_bottom_right_anchor_locked() {
+ let rect = Rect::from_min_max(pos2(50.0, 50.0), pos2(150.0, 250.0)); // 100 x 200
+ // BottomRight moved down/right by (20px, 40px)
+ let resized = apply_resize_proportional(rect, ImageHandle::BottomRight, vec2(20.0, 40.0));
+ assert_eq!(resized.min, pos2(50.0, 50.0), "TopLeft anchor strictly locked");
+ assert_eq!(resized.width(), 120.0);
+ assert_eq!(resized.height(), 240.0);
+ }
+
+ #[cfg(test)]
+ mod proptests {
+ use super::*;
+ use proptest::prelude::*;
+
+ fn arb_handle() -> impl Strategy {
+ prop_oneof![
+ Just(ImageHandle::TopLeft),
+ Just(ImageHandle::TopCenter),
+ Just(ImageHandle::TopRight),
+ Just(ImageHandle::RightCenter),
+ Just(ImageHandle::BottomRight),
+ Just(ImageHandle::BottomCenter),
+ Just(ImageHandle::BottomLeft),
+ Just(ImageHandle::LeftCenter),
+ ]
+ }
+
+ proptest! {
+ #[test]
+ fn prop_apply_resize_never_inverts_and_respects_min_size(
+ min_x in -5000.0f32..5000.0,
+ min_y in -5000.0f32..5000.0,
+ w in 10.0f32..2000.0,
+ h in 10.0f32..2000.0,
+ handle in arb_handle(),
+ dx in -10000.0f32..10000.0,
+ dy in -10000.0f32..10000.0,
+ ) {
+ let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
+ let res = apply_resize(rect, handle, vec2(dx, dy));
+ prop_assert!(res.is_finite());
+ prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
+ prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
+ prop_assert!(res.min.x <= res.max.x);
+ prop_assert!(res.min.y <= res.max.y);
+ }
+
+ #[test]
+ fn prop_apply_resize_proportional_preserves_aspect_ratio(
+ min_x in -2000.0f32..2000.0,
+ min_y in -2000.0f32..2000.0,
+ w in 20.0f32..1000.0,
+ h in 20.0f32..1000.0,
+ handle in arb_handle(),
+ dx in -5000.0f32..5000.0,
+ dy in -5000.0f32..5000.0,
+ ) {
+ let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
+ let res = apply_resize_proportional(rect, handle, vec2(dx, dy));
+ prop_assert!(res.is_finite());
+ prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
+ prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
+
+ let is_corner = matches!(handle, ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight);
+ if is_corner {
+ let orig_ratio = w / h;
+ let new_ratio = res.width() / res.height();
+ prop_assert!((orig_ratio - new_ratio).abs() <= 1e-3 * orig_ratio.max(1.0), "orig: {orig_ratio}, new: {new_ratio}");
+ }
+ }
+
+ #[test]
+ fn prop_apply_crop_uv_clamped(
+ min_x in 0.0f32..1000.0,
+ min_y in 0.0f32..1000.0,
+ w in 50.0f32..500.0,
+ h in 50.0f32..500.0,
+ uv_u0 in 0.0f32..0.4,
+ uv_v0 in 0.0f32..0.4,
+ uv_w in 0.3f32..0.5,
+ uv_h in 0.3f32..0.5,
+ handle in arb_handle(),
+ dx in -2000.0f32..2000.0,
+ dy in -2000.0f32..2000.0,
+ ) {
+ let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
+ let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h));
+ let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy));
+
+ prop_assert!(new_screen.is_finite());
+ prop_assert!(new_uv.is_finite());
+ prop_assert!(new_uv.min.x >= 0.0 - 1e-5);
+ prop_assert!(new_uv.min.y >= 0.0 - 1e-5);
+ prop_assert!(new_uv.max.x <= 1.0 + 1e-5);
+ prop_assert!(new_uv.max.y <= 1.0 + 1e-5);
+ prop_assert!(new_uv.width() > 0.0);
+ prop_assert!(new_uv.height() > 0.0);
+
+ if !matches!(handle, ImageHandle::TopLeft | ImageHandle::LeftCenter | ImageHandle::BottomLeft) {
+ prop_assert_eq!(new_screen.min.x, screen.min.x, "stationary left edge moved");
+ }
+ if !matches!(handle, ImageHandle::TopRight | ImageHandle::RightCenter | ImageHandle::BottomRight) {
+ prop_assert_eq!(new_screen.max.x, screen.max.x, "stationary right edge moved");
+ }
+ if !matches!(handle, ImageHandle::TopLeft | ImageHandle::TopCenter | ImageHandle::TopRight) {
+ prop_assert_eq!(new_screen.min.y, screen.min.y, "stationary top edge moved");
+ }
+ if !matches!(handle, ImageHandle::BottomLeft | ImageHandle::BottomCenter | ImageHandle::BottomRight) {
+ prop_assert_eq!(new_screen.max.y, screen.max.y, "stationary bottom edge moved");
+ }
+
+ prop_assert!(crop_scale_preserved(screen, uv, new_screen, new_uv),
+ "screen/UV scale changed beyond endpoint rounding: {screen:?} {uv:?} => {new_screen:?} {new_uv:?}");
+ }
+
+ #[test]
+ fn prop_quad_contains_never_panics(
+ q0_x in -1000.0f32..1000.0,
+ q0_y in -1000.0f32..1000.0,
+ q1_x in -1000.0f32..1000.0,
+ q1_y in -1000.0f32..1000.0,
+ q2_x in -1000.0f32..1000.0,
+ q2_y in -1000.0f32..1000.0,
+ q3_x in -1000.0f32..1000.0,
+ q3_y in -1000.0f32..1000.0,
+ px in -2000.0f32..2000.0,
+ py in -2000.0f32..2000.0,
+ ) {
+ let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)];
+ let p = pos2(px, py);
+ let _ = crate::edit_text_ui::quad_contains(quad, p);
+ }
+ }
+
+ #[test]
+ fn degenerate_quad_does_not_contain_points() {
+ let zero_quad = [pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0), pos2(0.0, 0.0)];
+ assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(10.0, 10.0)));
+ assert!(!crate::edit_text_ui::quad_contains(zero_quad, pos2(0.0, 0.0)));
+ }
+ }
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 222bb988e4..ab5d1334e3 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -36,6 +36,7 @@ macro_rules! tl_ctx {
}
pub mod canvas;
mod chrome;
+mod image_engine;
pub use combine_grid::ThumbState as CombineThumb;
pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey};
mod commands;
@@ -45,13 +46,14 @@ mod menus;
pub mod marks {
pub use crate::marks_ui::{MarksDraft, PageRange, Subset};
}
+pub use content_ui::TextDraft;
pub use create_ui::Clip;
pub use link_ui::LinkDraft;
pub use optimize_ui::{OptimizeDraft, OptimizeTab};
pub use sign_ui::{DigitalIdEntry, SignDraft, SignGraphic, SignStep};
mod bidi;
mod dialogs;
-pub use edit_text_ui::CropMode;
+pub use edit_text_ui::{CropMode, DragTarget, ImageDrag, ImageHandle, ImageSelection};
mod editing;
mod home;
mod panels;
@@ -77,6 +79,9 @@ mod state;
mod tools;
mod workflows;
+#[cfg(not(target_arch = "wasm32"))]
+pub(crate) mod os_clipboard;
+
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use app::same_file;
pub(crate) use app::{MAX_AUTHOR_CHARS, copy_refused, date_text_for_pdf, print_refused};
@@ -114,6 +119,7 @@ use panels::search_ui;
pub use panels::side_panels;
pub use persist::last_session;
pub use persist::last_view;
+pub use persist::paper;
use persist::recovery;
pub use persist::units;
#[cfg(not(target_arch = "wasm32"))]
@@ -140,7 +146,7 @@ use state::CopyPage;
use state::OpenRequest;
pub use state::{
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt,
- PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt,
+ PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt,
};
use state::{DocumentCopyJob, GridFile};
pub use tools::bulk_fields;
@@ -239,6 +245,7 @@ struct PendingOpen {
after_open: Option,
recovered: Option,
then: Option<&'static str>,
+ display_override: Option<(canvas::PageLayout, bool)>,
}
pub struct PdfCraftApp {
@@ -258,6 +265,8 @@ pub struct PdfCraftApp {
pub view_defaults: canvas::ViewDefaults,
/// The unit page sizes and margins are shown in (Preferences ▸ Documents and view).
pub units: units::Unit,
+ /// The paper Create ▸ Blank page makes (#5644): the system region's.
+ pub blank_paper: paper::Paper,
/// The Preferences ▸ Date format box while it's being edited, or while it holds an invalid
/// pattern (the session keeps the last valid one).
pub date_format_draft: Option,
@@ -291,6 +300,8 @@ pub struct PdfCraftApp {
/// The reading-colours preference (Preferences ▸ Reading colours): applied to every open
/// document, and documents open with it (Acrobat's Replace Document Colors).
pub reading_colors: pdfcraft_render::ReadingColors,
+ /// App-wide page decoration only; never part of a PDF edit or render request.
+ pub show_page_edge_shadows: bool,
/// The Linux portal is enabled only by the native host, and starts on the first System choice.
#[cfg(target_os = "linux")]
desktop_theme: Option,
@@ -338,6 +349,8 @@ pub struct PdfCraftApp {
/// tab coming to the front (Home, a document, Combine files) puts the view away.
all_tools_tab: (Option, bool),
pub recent: Vec,
+ /// How many recent files to keep, 0 for no list (Preferences ▸ Documents and view, #5572).
+ pub recent_limit: usize,
/// Preferences: reopen the files that were open when PdfCraft last closed (#442).
pub reopen_last_session: bool,
/// Restore last view settings when reopening documents (Preferences ▸ Documents and view;
@@ -368,10 +381,19 @@ pub struct PdfCraftApp {
pending_opens: Vec,
#[cfg(not(target_arch = "wasm32"))]
next_open_token: u64,
+ /// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs.
+ #[cfg(not(target_arch = "wasm32"))]
+ launch_layout: Option<(canvas::PageLayout, bool)>,
+ #[cfg(not(target_arch = "wasm32"))]
+ launch_display_target: Option,
#[cfg(all(test, not(target_arch = "wasm32")))]
open_prepare_gate: Option>,
/// Folders pinned to Home, and what they held when last listed.
pub pinned: folders_ui::PinnedFolders,
+ /// Tools pinned to Home, in user-defined order.
+ pub pinned_tools: Vec,
+ /// Search query for the Pin a tool selection dialog.
+ pub pin_tool_search: String,
pub toast: Option<(String, f64)>,
/// The form script message the toast shows now, if it shows one: drawn with its title and
/// icon (#4293).
@@ -537,8 +559,6 @@ pub struct PdfCraftApp {
pub system_clipboard: bool,
/// What ⌘V reads instead of the system clipboard (tests).
pub clipboard_override: Option,
- /// V is down as a plain key press (see [`Self::paste_key_released`]).
- v_pressed: bool,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec)>,
/// Duplicate Field: which field and onto which pages.
diff --git a/crates/ui-egui/src/os_clipboard.rs b/crates/ui-egui/src/os_clipboard.rs
new file mode 100644
index 0000000000..9f68bd957b
--- /dev/null
+++ b/crates/ui-egui/src/os_clipboard.rs
@@ -0,0 +1,28 @@
+//! The one way to the operating system's clipboard.
+//!
+//! The system clipboard is a single process-wide resource: on Windows, two threads that
+//! open it at once can corrupt the heap. Every access goes through [`with`], which holds a
+//! lock across the whole open, use and close, so parallel callers (test threads, workers)
+//! take turns instead of racing.
+
+use std::sync::{Mutex, PoisonError};
+
+static LOCK: Mutex<()> = Mutex::new(());
+
+/// Opens the system clipboard, runs `f` on it and closes it, with no other thread inside.
+pub(crate) fn with(f: impl FnOnce(&mut arboard::Clipboard) -> Result) -> Result {
+ let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
+ // Declared after the guard, so it's closed before the lock is released.
+ let mut clipboard = arboard::Clipboard::new()?;
+ f(&mut clipboard)
+}
+
+/// Nonempty text supersedes the app's cached image, even if another clipboard format also exists.
+pub(crate) fn has_text() -> bool {
+ with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty())
+}
+
+/// The system clipboard as image paste sees it; text takes precedence over a cached image.
+pub(crate) fn for_image_paste() -> Option {
+ if has_text() { Some(crate::create_ui::Clip::Text(String::new())) } else { crate::create_ui::read_clipboard() }
+}
diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs
index c549ed8e3e..d54daf5717 100644
--- a/crates/ui-egui/src/panels/comments_panel.rs
+++ b/crates/ui-egui/src/panels/comments_panel.rs
@@ -241,6 +241,10 @@ pub(crate) fn show(
}
let mut page = usize::MAX;
let mut group: Option = None;
+ // A card (or page header) out of view that was measured at this width only takes up its
+ // height: laying out hundreds of them every frame put the panel over the frame budget
+ // (#5488). Shorter lists are laid out whole, so every card stays in the accessibility tree.
+ let virtual_list = roots.len() > LAY_OUT_WHOLE;
for a in roots {
if sort != SortBy::Page {
let g = group_of(a);
@@ -252,48 +256,118 @@ pub(crate) fn show(
}
} else if a.page != page {
page = a.page;
- let n = page_counts.get(&page).copied().unwrap_or_default();
- let open = !view.comments.collapsed_pages.contains(&page);
- ui.add_space(6.0);
- let clicked = ui
- .horizontal(|ui| {
- let chevron = if open { "chevron-down" } else { "chevron-right" };
- let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked();
- let label = ui.add(
- egui::Label::new(
- egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", &info.pages[page].label)]))
- .font(theme::semibold(12.5))
- .color(t.text_muted),
- )
- .sense(Sense::click()),
- );
- ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
- ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small())
- });
- toggle || label.clicked()
- })
- .inner;
- if clicked {
- if open {
- view.comments.collapsed_pages.push(page);
- } else {
- view.comments.collapsed_pages.retain(|&c| c != page);
+ // Per document: two documents shown at the same width don't share heights.
+ let header = ui.scope_id().with(("comment-page-header", view.id, page));
+ if !(virtual_list && skip_if_hidden(ui, header)) {
+ let top = ui.cursor().min.y;
+ page_header(ui, t, info, page, page_counts.get(&page).copied().unwrap_or_default(), &mut view.comments.collapsed_pages);
+ if virtual_list {
+ remember_height(ui, header, top);
}
}
- ui.add_space(2.0);
}
if sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page) {
continue;
}
+ let key = (a.page, a.index);
let thread = replies_of(a);
+ let selected = view.comments.selected == Some(key);
+ // A height is kept for what the card showed when it was measured: its document, what the
+ // thread says, whether it was the selected card (which draws its menu and editor) and
+ // whether an editor was open on it. An
+ // edit, undo or redo that changes the card measures it afresh; other cards keep theirs.
+ // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on
+ // #5583): a kept height would show it at its size before the editor opened.
+ let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i)));
+ let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing));
+ // The selected card is always drawn, so it can scroll itself into view and keep its menu
+ // and editor.
+ if virtual_list && !selected && !editing && skip_if_hidden(ui, id) {
+ continue;
+ }
let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation);
+ let top = ui.cursor().min.y;
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) {
edit = Some(e);
}
+ if virtual_list {
+ remember_height(ui, id, top);
+ }
}
edit
}
+/// The "Page N" group header with its fold chevron and comment count `n`; a click folds or
+/// unfolds the page.
+fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: usize, collapsed: &mut Vec) {
+ let open = !collapsed.contains(&page);
+ ui.add_space(6.0);
+ let clicked = ui
+ .horizontal(|ui| {
+ let chevron = if open { "chevron-down" } else { "chevron-right" };
+ let toggle = icons::button(ui, chevron, 18.0, false, if open { tl!("Collapse") } else { tl!("Expand") }).clicked();
+ let label = ui.add(
+ egui::Label::new(
+ egui::RichText::new(crate::i18n::fmt(tl!("Page {label}"), &[("label", info.pages.get(page).map_or("?", |p| p.label.as_str()))]))
+ .font(theme::semibold(12.5))
+ .color(t.text_muted),
+ )
+ .sense(Sense::click()),
+ );
+ ui.with_layout(Layout::right_to_left(Align::Center), |ui| ui.label(egui::RichText::new(n.to_string()).color(t.text_faint).small()));
+ toggle || label.clicked()
+ })
+ .inner;
+ ui.add_space(2.0);
+ if clicked {
+ if open {
+ collapsed.push(page);
+ } else {
+ collapsed.retain(|&c| c != page);
+ }
+ }
+}
+
+/// What a card's height depends on in the document: the comment and its replies as shown (their
+/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out
+/// alike at one width.
+fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 {
+ use std::hash::{Hash, Hasher};
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ for x in std::iter::once(a).chain(thread.iter().copied()) {
+ (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h);
+ x.color.map(|c| c.map(f32::to_bits)).hash(&mut h);
+ }
+ h.finish()
+}
+
+/// Comment lists up to this long are laid out whole; longer ones lay out only what is in view.
+const LAY_OUT_WHOLE: usize = 100;
+
+/// For a part of the list (a card or a page header) that was measured at this width and would
+/// fall out of view: takes up its height without laying it out, and returns `true`.
+fn skip_if_hidden(ui: &mut egui::Ui, id: egui::Id) -> bool {
+ let width = ui.available_width();
+ let Some(height) = ui.data(|d| d.get_temp::<(f32, f32)>(id)).filter(|(w, _)| (w - width).abs() < 0.5).map(|(_, h)| h) else {
+ return false;
+ };
+ if ui.is_rect_visible(egui::Rect::from_min_size(ui.cursor().min, vec2(width, height))) {
+ return false;
+ }
+ ui.allocate_space(vec2(width, height));
+ true
+}
+
+/// Records the height part `id` took since the cursor was at `top`, for [`skip_if_hidden`]: less
+/// the spacing `allocate_space` adds back after it.
+fn remember_height(ui: &egui::Ui, id: egui::Id, top: f32) {
+ let height = ui.cursor().min.y - top - ui.spacing().item_spacing.y;
+ if height.is_finite() && height > 0.0 {
+ let width = ui.available_width();
+ ui.data_mut(|d| d.insert_temp(id, (width, height)));
+ }
+}
+
/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment
/// actions; returns an edit to apply.
struct CardContext<'a> {
diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs
index e95db85a72..2ba86ee5f5 100644
--- a/crates/ui-egui/src/panels/edit.rs
+++ b/crates/ui-egui/src/panels/edit.rs
@@ -266,11 +266,27 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
.into_iter()
.map(|f| (f.resource, f.face))
.collect();
- let shown = ed.font_shown(&sources);
+ let fmt = ed.selection_formatting(&sources);
+ ed.sync_selection_look(&fmt);
+ let shown = fmt.font_shown;
+ let mixed_size = fmt.size.is_none();
// A selection aligns the rows it touches; the caret alone, the whole paragraph.
let paragraphs = crate::format_text_ui::Paragraphs { text: &ed.text, selection: ed.selection.clone(), tidy: false };
- let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown }, Some(paragraphs));
+ let out = crate::format_text_ui::panel(
+ ui,
+ t,
+ &mut ed.look,
+ Some(&mut ed.extras),
+ Fonts { sources: Some(&sources), shown, mixed_size },
+ Some(paragraphs),
+ );
+ if out.size_committed {
+ ed.choose_size(ed.look.size);
+ }
let mut changed = out.changed;
+ if let Some(script) = out.script {
+ changed |= crate::edit_text_ui::toggle_script(&mut ed.text, &mut ed.selection, script);
+ }
match out.font {
Some(FontChoice::KeepSource) => {
ed.keep_source_font();
@@ -305,7 +321,13 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
}
let mut style = draft.style.clone();
let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true };
- added_text_format(ui, t, &mut style, Some(paragraphs));
+ let out = added_text_format(ui, t, &mut style, Some(paragraphs));
+ if let Some(script) = out.script {
+ let mut toggled = draft.clone();
+ if crate::edit_text_ui::toggle_script(&mut toggled.text, &mut toggled.selection, script) {
+ app.views[i].content.draft = Some(toggled);
+ }
+ }
if style != draft.style {
app.restyle_draft(i, style);
}
@@ -363,12 +385,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
/// Format text for added text: the standard families, size, colour, style and alignment
/// (of the lines `paragraphs` selects, while the text is being typed).
-fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) {
+fn added_text_format(
+ ui: &mut egui::Ui,
+ t: &Tokens,
+ style: &mut pdfcraft_engine::AddedText,
+ paragraphs: Option,
+) -> crate::format_text_ui::Outcome {
let shown = style.family.label().to_string();
- let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown }, paragraphs);
+ let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs);
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
style.family = family;
}
+ out
}
impl PdfCraftApp {
diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs
index eccef2aa79..dbb50f73ec 100644
--- a/crates/ui-egui/src/panels/format_text_ui.rs
+++ b/crates/ui-egui/src/panels/format_text_ui.rs
@@ -3,12 +3,13 @@
//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
//! fonts already on its page and the spacing controls) and added text (the standard families,
//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists,
-//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown
+//! paragraph spacing, or line spacing and scale for added text) is shown
//! disabled and says so.
//!
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
+use pdfcraft_engine::text_script::Script;
use pdfcraft_engine::{AddedText, FontFamily, TextAlign};
use crate::edit_text_ui::Extras;
@@ -38,6 +39,7 @@ pub(crate) struct Fonts<'a> {
pub sources: Option<&'a [(String, String)]>,
/// The name the list shows for the current choice.
pub shown: String,
+ pub mixed_size: bool,
}
/// Text being typed in, for the alignment buttons: with some of it selected, they show and set the
@@ -85,7 +87,10 @@ impl Paragraphs<'_> {
#[derive(Default)]
pub(crate) struct Outcome {
pub changed: bool,
+ pub size_committed: bool,
pub font: Option,
+ /// Superscript or Subscript was clicked, for the selected paragraphs' text.
+ pub script: Option"##;
+ let form = layout_xml(&form_of_draws(master, &line_draw("1in", "1in", ""))).unwrap();
+ let held = turned(&form.pages[0]);
+ assert_eq!(held.len(), 1, "{:?}", form.pages[0].items);
+ let text: Vec<&str> = held[0].items.iter().filter_map(|i| if let Item::Text(s) = i { Some(s.text.as_str()) } else { None }).collect();
+ assert_eq!(text.concat().trim(), "of 1", "{text:?}");
+}
+
+/// A link in turned text is clicked where the text is drawn, whichever way it turned: the box it
+/// has unturned, turned about the draw's anchor.
+#[test]
+fn a_link_turns_with_its_text() {
+ let rect_at = |rotate: &str| {
+ let draw = format!(
+ r#"a link in turned text
"#
+ );
+ let (doc, page) = written(&form_of_draws("", &draw));
+ let annots = page.get(b"Annots").map(|a| doc.resolve(a)).expect("the link");
+ let annot = doc.get(annots.as_array().unwrap()[0].as_ref().unwrap());
+ let rect: Vec = annot.as_dict().unwrap().get(b"Rect").unwrap().as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
+ rect
+ };
+ let upright = rect_at("");
+ let (ax, ay) = (18.0 + 72.0, 792.0 - 18.0 - 216.0);
+ let (left, bottom, right, top) = (upright[0] - ax, upright[1] - ay, upright[2] - ax, upright[3] - ay);
+ assert!(left >= -0.01 && top <= 0.01 && right - left > 3.0 * (top - bottom), "it stands right and down of its anchor: {upright:?}");
+ for (rotate, want) in [
+ ("90", [ax - top, ay + left, ax - bottom, ay + right]),
+ ("180", [ax - right, ay - top, ax - left, ay - bottom]),
+ ("270", [ax + bottom, ay - right, ax + top, ay - left]),
+ ] {
+ let got = rect_at(&format!(r#"rotate="{rotate}""#));
+ assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.01), "{rotate}: {got:?} for {want:?}");
+ }
+}
+
+/// Only a draw placed by position turns: a field, and a draw in a flow, stand as they did.
+#[test]
+fn only_positioned_draws_turn() {
+ let field = r#""#;
+ let form = layout_xml(&form_of_draws("", field)).unwrap();
+ assert!(turned(&form.pages[0]).is_empty());
+ assert_eq!(widgets(&form.pages[0]).len(), 1);
+ let flowed = form_of_draws("", &line_draw("1in", "1in", r#"rotate="90""#))
+ .replace(r#""#, r#""#);
+ let form = layout_xml(&flowed).unwrap();
+ assert!(turned(&form.pages[0]).is_empty(), "{:?}", form.pages[0].items);
+ assert!(form.pages[0].items.iter().any(|i| matches!(i, Item::Line { .. })), "it is still drawn");
+ // A draw that shows nothing has nothing to turn.
+ let form = layout_xml(&form_of_draws("", &line_draw("1in", "1in", r#"rotate="90" presence="invisible""#))).unwrap();
+ assert!(form.pages[0].items.is_empty(), "{:?}", form.pages[0].items);
+}
+
+/// A block carried over to a content area further across takes a turned draw with it, point and
+/// items both.
+#[test]
+fn a_turned_draw_moves_across_with_its_content_area() {
+ let xdp = form_of_draws("", &line_draw("1in", "6in", r#"rotate="90""#))
+ .replace(
+ r#""#,
+ r#""#,
+ )
+ .replace(r#"h="2.5in""#, r#"h="7in""#);
+ let form = layout_xml(&xdp).unwrap();
+ let moved = form.pages.iter().flat_map(turned).next().expect("turned");
+ let Item::Line { from, .. } = &moved.items[0] else { panic!("not a line") };
+ assert_eq!(*from, moved.about);
+ assert!((moved.about.0 - (4.0 + 1.0) * 72.0).abs() < 0.01, "{:?}", moved.about);
+}
+
+/// A positioned subform with no height grows to hold what it holds as it stands, so what comes next
+/// neither runs through a turned draw nor leaves the room its upright box would have had.
+#[test]
+fn a_container_grows_to_a_turned_draw_as_it_stands() {
+ let next = r#"next"#;
+ let baseline = |rotate: &str| {
+ let xdp = form_of_draws("", &line_draw("1in", "1in", rotate)).replace(r#" h="2.5in""#, "").replace("", next);
+ let form = layout_xml(&xdp).unwrap();
+ assert_eq!(form.pages.len(), 1);
+ form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.baseline) } else { None }).expect("the next block")
+ };
+ // Upright the box ends 0.4in below the anchor, and the next block starts there.
+ let upright = baseline("");
+ for (rotate, bottom) in [("90", 0.0), ("180", 0.0), ("270", 50.4)] {
+ let got = baseline(&format!(r#"rotate="{rotate}""#)) - (upright - 28.8);
+ assert!((got - bottom).abs() < 0.01, "{rotate}: the next block starts {got} below the anchor, not {bottom}");
+ }
+}
+
+/// A subform with no width is as wide as what it holds as it stands, whichever way that turned,
+/// so a neighbour in a row doesn't stand off from it or over it.
+#[test]
+fn a_container_is_as_wide_as_a_turned_draw_stands() {
+ let row = |rotate: &str| {
+ let xdp = form_of_draws(
+ "",
+ &format!(
+ r#"{}beside"#,
+ line_draw("1in", "1in", rotate)
+ ),
+ )
+ .replace(r#""#, r#""#);
+ let form = layout_xml(&xdp).unwrap();
+ form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { Some(s.x) } else { None }).expect("the neighbour")
+ };
+ let upright = row("");
+ // Upright it ends 0.7in right of the anchor; turned, 0.4in right, at the anchor, and at it again.
+ for (rotate, right) in [("90", 28.8), ("180", 0.0), ("270", 0.0)] {
+ let got = row(&format!(r#"rotate="{rotate}""#)) - (upright - 50.4);
+ assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}");
+ }
+ // From a bottom-right anchor the box starts behind it, and the same turns take it the other way.
+ for (rotate, right) in [("90", 0.0), ("180", 50.4), ("270", 28.8)] {
+ let got = row(&format!(r#"anchorType="bottomRight" rotate="{rotate}""#)) - (upright - 50.4);
+ assert!((got - right).abs() < 0.01, "{rotate}: the neighbour stands {got} right of the anchor, not {right}");
+ }
+}
diff --git a/docs/licensing-self-check.md b/docs/licensing-self-check.md
new file mode 100644
index 0000000000..305d756ff7
--- /dev/null
+++ b/docs/licensing-self-check.md
@@ -0,0 +1,33 @@
+# Source licensing self-check
+
+Run `cargo xtask license-check` to validate the checked-out source, offline. The command
+does not download assets, fetch models, inspect installers, or generate dependency notices.
+It reuses `cargo xtask assets` for attribution, asset policy and locked dependency versions,
+then checks the documents recorded in `LICENSES.toml`.
+
+Each document has a repository-relative source, a unique portable logical destination and
+the SHA-256 of its audited text. Destinations are validation metadata, not instructions to
+copy files into packages. Required project documents and every asset's `licence_file` must
+be mapped. Every dependency-bundled asset must have a document mapping matching its existing
+`ATTRIBUTION.toml` licence expression. Foxit has an explicit required document. Licence
+texts for egui's default fonts and CMaps are retained in `licenses/`, with their pinned
+upstream origins recorded in the manifest. Existing font and asset texts stay in place.
+
+Missing files, empty or invalid text, changed hashes, duplicate mappings, case-insensitive
+destination collisions, parent-file conflicts and unsafe paths fail the check. Source
+symlinks must resolve inside the checkout. OCR models need complete attribution and valid
+hashes and destination names; the model binaries and downloaded licence texts need not be
+present. The optional craft-fonts checkout remains governed by its own attribution and
+the existing asset validator; this command does not audit that external checkout.
+
+When changing attribution, edit `ATTRIBUTION.toml`, run `cargo xtask assets --write`. Generated attribution consistency remains checked only
+by the existing asset validator. When changing licence or notice text, re-audit
+its provenance before updating the corresponding hash. Do not replace a licence with
+guessed terms or update a hash merely to silence a failure. `LICENSES.toml` is validated
+with unknown fields rejected.
+
+The existing Linux source-validation step in `.github/workflows/ci.yml` and `cargo xtask ci`
+run `license-check` in place of `assets`; the asset validator runs once within that command.
+This Stage 1 check establishes source-document consistency, not completed-package
+compliance or legal sufficiency. Installer inspection, dependency-notice generation and
+font-delivery packaging changes remain separate work.
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0f1fb6f4d5..c5bc1ab7d0 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,5 +1,11 @@
# PdfCraft roadmap: milestones and what's next
+> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
+
+> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
+
+> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged)
+
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
@@ -194,7 +200,7 @@ total matches the parity estimate.
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
-| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. |
+| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
@@ -217,7 +223,7 @@ total matches the parity estimate.
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
-| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
+| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
@@ -290,6 +296,8 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
+| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. |
| 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. |
| 2026-10-31 | patch | M2, PR #4172: integrate main fa5f9f8c, preserve both raster test groups and restore explicit display_config imports; display-only CJK policy and estimates unchanged. |
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
diff --git a/licenses/CMap-LICENSE.txt b/licenses/CMap-LICENSE.txt
new file mode 100644
index 0000000000..2552cc990c
--- /dev/null
+++ b/licenses/CMap-LICENSE.txt
@@ -0,0 +1,28 @@
+Copyright 1990-2023 Adobe. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+Neither the name of Adobe nor the names of its contributors may be
+used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/licenses/egui-Hack-Regular.txt b/licenses/egui-Hack-Regular.txt
new file mode 100644
index 0000000000..b0ec74488a
--- /dev/null
+++ b/licenses/egui-Hack-Regular.txt
@@ -0,0 +1,31 @@
+The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
+
+The work in the DejaVu project was committed to the public domain.
+
+Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
+MIT License
+
+Copyright (c) 2018 Source Foundry Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+BITSTREAM VERA LICENSE
+
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
+
+The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
+
+This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
+
+The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+
+Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
diff --git a/licenses/egui-OFL.txt b/licenses/egui-OFL.txt
new file mode 100644
index 0000000000..d952d62c06
--- /dev/null
+++ b/licenses/egui-OFL.txt
@@ -0,0 +1,92 @@
+This Font Software is licensed under the SIL Open Font License,
+Version 1.1.
+
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font
+creation efforts of academic and linguistic communities, and to
+provide a free and open framework in which fonts may be shared and
+improved in partnership with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply to
+any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software
+components as distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to,
+deleting, or substituting -- in part or in whole -- any of the
+components of the Original Version, by changing formats or by porting
+the Font Software to a new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed,
+modify, redistribute, and sell modified and unmodified copies of the
+Font Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components, in
+Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the
+corresponding Copyright Holder. This restriction only applies to the
+primary font name as presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created using
+the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/licenses/egui-UFL.txt b/licenses/egui-UFL.txt
new file mode 100644
index 0000000000..ae78a8f94e
--- /dev/null
+++ b/licenses/egui-UFL.txt
@@ -0,0 +1,96 @@
+-------------------------------
+UBUNTU FONT LICENCE Version 1.0
+-------------------------------
+
+PREAMBLE
+This licence allows the licensed fonts to be used, studied, modified and
+redistributed freely. The fonts, including any derivative works, can be
+bundled, embedded, and redistributed provided the terms of this licence
+are met. The fonts and derivatives, however, cannot be released under
+any other licence. The requirement for fonts to remain under this
+licence does not require any document created using the fonts or their
+derivatives to be published under this licence, as long as the primary
+purpose of the document is not to be a vehicle for the distribution of
+the fonts.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this licence and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Original Version" refers to the collection of Font Software components
+as received under this licence.
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to
+a new environment.
+
+"Copyright Holder(s)" refers to all individuals and companies who have a
+copyright ownership of the Font Software.
+
+"Substantially Changed" refers to Modified Versions which can be easily
+identified as dissimilar to the Font Software by users of the Font
+Software comparing the Original Version with the Modified Version.
+
+To "Propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification and with or without charging
+a redistribution fee), making available to the public, and in some
+countries other activities as well.
+
+PERMISSION & CONDITIONS
+This licence does not grant any rights under trademark law and all such
+rights are reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of the Font Software, to propagate the Font Software, subject to
+the below conditions:
+
+1) Each copy of the Font Software must contain the above copyright
+notice and this licence. These can be included either as stand-alone
+text files, human-readable headers or in the appropriate machine-
+readable metadata fields within text or binary files as long as those
+fields can be easily viewed by the user.
+
+2) The font name complies with the following:
+(a) The Original Version must retain its name, unmodified.
+(b) Modified Versions which are Substantially Changed must be renamed to
+avoid use of the name of the Original Version or similar names entirely.
+(c) Modified Versions which are not Substantially Changed must be
+renamed to both (i) retain the name of the Original Version and (ii) add
+additional naming elements to distinguish the Modified Version from the
+Original Version. The name of such Modified Versions must be the name of
+the Original Version, with "derivative X" where X represents the name of
+the new work, appended to that name.
+
+3) The name(s) of the Copyright Holder(s) and any contributor to the
+Font Software shall not be used to promote, endorse or advertise any
+Modified Version, except (i) as required by this licence, (ii) to
+acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
+their explicit written permission.
+
+4) The Font Software, modified or unmodified, in part or in whole, must
+be distributed entirely under this licence, and must not be distributed
+under any other licence. The requirement for fonts to remain under this
+licence does not affect any document created using the Font Software,
+except any version of the Font Software extracted from a document
+created using the Font Software may only be distributed under this
+licence.
+
+TERMINATION
+This licence becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
+DEALINGS IN THE FONT SOFTWARE.
diff --git a/licenses/egui-emoji-icon-font-mit-license.txt b/licenses/egui-emoji-icon-font-mit-license.txt
new file mode 100644
index 0000000000..99016e120b
--- /dev/null
+++ b/licenses/egui-emoji-icon-font-mit-license.txt
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2014 John Slegers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh
index 26e09cd1c8..0534b3194e 100755
--- a/packaging/linux/flatpak-bundle.sh
+++ b/packaging/linux/flatpak-bundle.sh
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.pdfcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
+OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
+ --oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -62,6 +64,13 @@ OUT="$DIST/$BASENAME.flatpak"
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
+if [[ "$OCI" = 1 ]]; then
+ OUT_OCI="$DIST/$BASENAME-oci"
+ rm -rf "$OUT_OCI"
+ flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
+ echo "wrote OCI layout to $OUT_OCI"
+fi
+
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
diff --git a/packaging/linux/flatpak-oci.sh b/packaging/linux/flatpak-oci.sh
new file mode 100755
index 0000000000..0572ef1a3a
--- /dev/null
+++ b/packaging/linux/flatpak-oci.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Build PdfCraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
+# for consumption by Flatpak OCI clients (flatpak-oci-specs).
+#
+# Usage:
+# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
+#
+# Environment variables:
+# REGISTRY Target registry image (default: ghcr.io/storytold/pdfcraft)
+# TAG Target image tag (default: $VERSION-$ARCH, also tags stable and latest)
+# GITHUB_TOKEN Token for GHCR authentication when --push is used
+set -euo pipefail
+# shellcheck source=../env.sh
+. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
+HERE="$ROOT/packaging/linux"
+
+PUSH=0
+TARBALL=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --push) PUSH=1; shift ;;
+ -h | --help) sed -n '2,12p' "$0"; exit 0 ;;
+ -*) echo "unknown argument: $1" >&2; exit 2 ;;
+ *) TARBALL="$1"; shift ;;
+ esac
+done
+
+ARCH="$(uname -m)"
+case "$ARCH" in
+ x86_64) ;;
+ aarch64 | arm64) ARCH=aarch64 ;;
+ *) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
+esac
+
+BASENAME="pdfcraft-$VERSION-linux-$ARCH"
+OUT_OCI="$DIST/$BASENAME-oci"
+
+echo "==> Building Flatpak OCI layout for $BASENAME"
+"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
+
+[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
+
+REGISTRY="${REGISTRY:-ghcr.io/storytold/pdfcraft}"
+TAG="${TAG:-$VERSION-$ARCH}"
+
+echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
+
+if [[ "$PUSH" = 1 ]]; then
+ command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
+
+ AUTH_ARGS=()
+ if [[ -n "${GITHUB_TOKEN:-}" ]]; then
+ AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
+ fi
+
+ echo "==> Pushing to $REGISTRY:$TAG"
+ skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
+
+ # Also update architecture-specific stable tag
+ echo "==> Pushing to $REGISTRY:stable-$ARCH"
+ skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
+
+ echo "==> Push complete"
+fi
diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml
index a8d2ce3f32..401a0152ca 100644
--- a/parity/acrobat-features.toml
+++ b/parity/acrobat-features.toml
@@ -890,8 +890,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.layout.two_up"]
tools = []
-evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time"]
-notes = "next/previous page step a spread at a time"
+evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "apps/pdfcraft/src/startup.rs::startup_override_is_per_file_and_does_not_persist", "crates/ui-egui/src/platform/open_worker/tests.rs::flagged_startup_opens_wait_for_password_ownership_before_the_first_frame"]
+notes = "Next/previous page step a spread at a time. Optional desktop --two-paged launches named PDFs in noncontinuous two-page view without changing saved defaults or unrelated restored tabs. Explicit --layout wins after flagged opens settle, including password retries; repeated files reuse their tab. This is a launch override, not a declarative configuration module."
[[feature]]
id = "view.layout-two-up-scrolling"
@@ -1287,7 +1287,7 @@ status = "shipped"
commands = []
tools = []
evidence = ["crates/ui-egui/tests/it/view.rs::pages_sidebar_select_all_keeps_current_page_and_keyboard_scope", "crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"]
-notes = "Ctrl/Cmd+A selects every page while the Pages sidebar owns the keyboard, without navigation; Escape clears that selection. Viewer and text-field shortcuts keep their own scope. Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
+notes = "Ctrl/Cmd+A selects every page while the Pages sidebar owns the keyboard, without navigation; Escape clears that selection. Viewer and text-field shortcuts keep their own scope. Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Deleting pages makes the next Save a full rewrite (unsigned files), so the deleted pages leave the file; signed files stay incremental. Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel."
[[feature]]
id = "view.bookmarks-panel"
@@ -1799,11 +1799,17 @@ title = "Customise the quick-action toolbar and pinned tools"
area = "B"
tier = "P2"
milestone = "M14"
-status = "planned"
+status = "partial"
commands = []
tools = []
-evidence = []
-notes = ""
+evidence = [
+ "crates/ui-egui/tests/it/pinned_tools.rs::state_operations_and_validation",
+ "crates/ui-egui/tests/it/pinned_tools.rs::persistence_round_trip_and_sanitization",
+ "crates/ui-egui/tests/it/pinned_tools.rs::home_shows_pinned_tools_section_and_placeholder",
+ "crates/ui-egui/tests/it/pinned_tools.rs::pin_tool_dialog_workflow",
+ "crates/ui-egui/tests/it/pinned_tools.rs::sidebar_pin_toggle_unpins_and_pins",
+]
+notes = "Pin and unpin tools on the Home page via tool card hover button, All Tools row toggle, or 'Pin a tool' search dialog. Pinned tool order persists across sessions. Floating quick-action toolbar customisation remains planned."
[[feature]]
id = "view.restriction-notice"
@@ -1959,11 +1965,19 @@ title = "Superscript and subscript"
area = "C"
tier = "P2"
milestone = "M7"
-status = "planned"
-commands = []
-tools = []
-evidence = []
-notes = ""
+status = "partial"
+commands = ["edit.edit_text"]
+tools = ["text_script"]
+evidence = [
+ "crates/engine/src/text_script.rs::every_form_toggles_back_to_its_character",
+ "crates/engine/src/text_script.rs::text_with_characters_unicode_cannot_raise_toggles_back",
+ "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion",
+ "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection",
+ "crates/automation/tests/it/automation.rs::superscript_and_subscript_through_tools",
+ "crates/engine/src/text_script.rs::mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping",
+ "crates/automation/tests/it/automation.rs::mixed_script_tools_normalize_but_undo_restores_the_exact_mixture",
+]
+notes = "Toggled in the Format text panel for the selection, and headlessly with text_script on a paragraph or a byte range of it; each toggle of a paragraph is one undo step. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters. Characters Unicode has no form for (q, most capitals in subscript) stay unchanged and do not determine script state. Two toggles restore uniformly plain or uniformly scripted inputs. Mixed input normalizes (H₂2 → H₂₂ → H22), so a second toggle does not recover the original mixture; Undo restores the exact prior text. The mathematical minus sign U+2212 has its own script mapping, while ASCII hyphen-minus stays unchanged."
[[feature]]
id = "edit.paragraph-alignment"
@@ -2129,8 +2143,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.image"]
tools = ["content_update", "image_edit"]
-evidence = ["crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"]
-notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop shows the whole picture again. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Not yet: cropping grouped Form artwork by handles is allowed the same way (it is a clip), but a shared clip of the original file is not changed."
+evidence = ["crates/edit/src/tests.rs::removing_a_crop_preserves_author_clips_and_works_under_outer_clip", "crates/ui-egui/tests/it/editing.rs::cropping_a_picture_with_its_handles", "crates/engine/src/tests.rs::cropping_an_existing_image_clips_it_and_moves_with_it", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/it/automation.rs::adding_content_through_tools"]
+notes = "Existing page images: right-click ▸ Crop Image puts the picture in crop mode: its eight handles trim (the inside pans), the part that will go is dimmed, Enter or Done applies, Esc or Cancel leaves it; Remove Crop removes only a crop created by PdfCraft, preserving original document clips. The pixels stay where they are (a clipping path inside the picture's own scope, so a later move or resize carries it along; cropping again replaces the earlier crop, so it can grow back); image_edit crop. Added images: trim each side by percent in Edit image, clipped. Grouped Form artwork cannot be cropped. Shared clips and other clipping paths from the original file remain unchanged."
[[feature]]
id = "edit.image-rotate-flip"
@@ -2153,8 +2167,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["image_edit", "image_hit"]
-evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"]
-notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
+evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip", "crates/automation/tests/it/automation.rs::transforming_page_images_through_tools"]
+notes = "existing raster images and grouped Form artwork: select, drag to move, 8-point handles to resize (corners keep proportions, edges scale along axis, Shift inverts aspect lock); Delete removes one placement. Headless: image_edit action move (a new box) or transform (any finite, non-flat 6-number matrix in top-left-origin points, applied after the placement: the edit a resize of a turned image makes). Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md"
[[feature]]
id = "edit.image-extract"
@@ -2186,11 +2200,17 @@ title = "Arrange objects (bring forward, send backward)"
area = "C"
tier = "P1"
milestone = "M7"
-status = "planned"
+status = "partial"
commands = []
-tools = []
-evidence = []
-notes = ""
+tools = ["image_edit"]
+evidence = [
+ "crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop",
+ "crates/edit/src/tests.rs::image_arrange_refuses_crossed_scope_boundaries_without_changes",
+ "crates/edit/src/tests.rs::arrange_refuses_unrestorable_extended_print_state",
+ "crates/automation/tests/it/automation.rs::arranging_page_images_through_tools",
+ "crates/ui-egui/tests/it/editing.rs::duplicate_xobject_at_different_rects_arrange_and_delete_keeps_other_instance_through_menu",
+]
+notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and persistence. Refuses arranging across shared marked content, unbalanced graphics/marked-content boundaries, or when clipping path transforms cannot be cleanly preserved. Stacking for arbitrary vector paths and inline images remains planned."
[[feature]]
id = "edit.align-distribute-objects"
@@ -5320,8 +5340,8 @@ milestone = "M12"
status = "partial"
commands = ["file.open"]
tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"]
-evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"]
-notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)."
+evidence = ["crates/xfa/src/tests.rs::a_draw_turns_counter_clockwise_about_its_anchor", "crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/js/src/tests.rs::message_box_keeps_its_title_icon_and_buttons", "crates/js/src/tests.rs::app_alert_in_an_xfa_script_reads_app_alerts_arguments", "crates/engine/src/tests.rs::xfa_message_boxes_keep_their_title_icon_and_buttons", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"]
+notes = "A draw placed by position turns about its anchor (rotate); fields, subforms and draws in a flow don't. Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60). xfa.host.messageBox keeps its title, icon and buttons, and app.alert in an XFA script reads its own argument order; every message box still answers OK (1) (#4291)."
[[feature]]
id = "form.xfa-formcalc"
@@ -6968,8 +6988,8 @@ milestone = "M10"
status = "shipped"
commands = ["create.blank"]
tools = ["doc_create"]
-evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"]
-notes = ""
+evidence = ["crates/ui-egui/tests/it/create.rs::opening_images_and_text_converts_them_to_new_pdfs", "crates/ui-egui/tests/it/create.rs::a_blank_page_is_on_the_system_regions_paper", "crates/ui-egui/src/persist/paper.rs::region_decides_the_paper", "crates/create/src/tests.rs::blank_documents", "crates/automation/tests/it/automation.rs::creating_and_reducing_through_tools"]
+notes = "The page is the system region's paper (#5644): US Letter in the US, Canada, Mexico, the Philippines and the other CLDR Letter regions, A4 elsewhere; Windows' own Region paper size (iPaperSize) first, then the system locale (macOS) or LC_ALL/LC_PAPER/LANG; Letter when the region is unknown (web). The doc_create tool keeps Letter unless given width and height. Missing: a page size or count chooser like Acrobat's New Document dialog."
[[feature]]
id = "create.from-html"
@@ -9928,8 +9948,8 @@ milestone = "M14"
status = "partial"
commands = []
tools = []
-evidence = ["crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"]
-notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Missing: Acrobat's resolution, rendering and page-content options"
+evidence = ["crates/ui-egui/tests/page_shadows.rs::viewport_fades_follow_the_preference_and_keep_other_scroll_areas_styled", "crates/ui-egui/tests/page_shadows.rs::rendered_page_content_is_identical_with_and_without_decoration", "crates/ui-egui/tests/page_shadows.rs::page_shadow_preference_round_trips_and_ignores_invalid_settings", "crates/ui-egui/tests/page_shadows.rs::viewer_shadows_follow_the_preference_without_moving_pages", "crates/ui-egui/tests/page_shadows.rs::organize_shadows_follow_the_preference", "crates/ui-egui/tests/it/control.rs::page_shadows_are_an_observable_app_wide_preference", "crates/ui-egui/tests/it/view.rs::default_page_display_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::default_zoom_is_used_for_new_documents", "crates/ui-egui/tests/it/view.rs::preferences_set_the_default_zoom", "crates/ui-egui/tests/it/view.rs::always_use_my_default_zoom_and_page_layout_ignores_what_the_pdf_asks_for"]
+notes = "Preferences ▸ Documents and view: Default page display and Default zoom (Fit to width, Zoom to page level or a percentage) for every new tab; a PDF that asks for a layout or zoom gets it, unless Always use my default zoom and page layout is on. Preferences ▸ Appearance also has an app-wide, persistent Show Page Edge Shadows checkbox (on by default), exposed through ui.set and ui.state; it affects viewer and Organize decorations, including the viewport-edge gradients painted over pages, only. Missing: Acrobat's resolution, rendering and page-content options"
[[feature]]
id = "misc.prefs-documents"
@@ -9937,11 +9957,11 @@ title = "Preferences: documents (autosave interval, recent count)"
area = "N"
tier = "P1"
milestone = "M14"
-status = "planned"
+status = "partial"
commands = []
tools = []
-evidence = []
-notes = ""
+evidence = ["crates/ui-egui/tests/it/recent_files.rs::preferences_cuts_the_list_when_it_closes_and_not_before", "crates/ui-egui/tests/it/recent_files.rs::at_zero_home_has_no_recent_section", "crates/ui-egui/tests/it/recent_files.rs::a_settings_file_can_not_ask_for_a_limit_that_is_not_one"]
+notes = "Preferences ▸ Documents and view: Recent files to keep, from 0 to 50 with 12 by default (#5572). At 0 Home has no Recent section and File ▸ Open Recent is disabled; Restore the last view keeps its own record of paths under its own switch. Still missing: the autosave interval (recovery snapshots run every AUTOSAVE_SECS, 60 s) and the rest of Acrobat's Documents page."
[[feature]]
id = "misc.prefs-commenting"
diff --git a/vendor/README.md b/vendor/README.md
index 8b906996a2..812958b996 100644
--- a/vendor/README.md
+++ b/vendor/README.md
@@ -39,11 +39,11 @@ Other resource categories retain their existing parent-chain lookup.
| Crate | Version | Licence | Patches | Test |
|---|---|---|---|---|
-| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also 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; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `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 (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated` | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render) |
+| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also 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; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `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 (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (71) a lookup table in an indirect string reads: it was read as a stream first, which uses up a reference (ISO 32000-2 §8.6.6.3); (72) an uncoloured pattern's underlying space given as a name or a reference is used: it was skipped, so a CMYK cyan drew red (ISO 32000-2 §8.7.3.3); (73) `InterpreterSettings::jbig2_work`, set for each page in `interpret_page` and ended with it, and a JBIG2 image or an image with a JBIG2 mask past it left out with `ContentTruncated`; (74) colour spaces nest at most 32 levels, through every way one names another, so a chain of them ends instead of overflowing the stack; an ICC profile's `/Alternate` is read where it's met and isn't kept with the profile, or a page could build a chain past the limit on spaces already read | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (71) `raster::tests::an_indexed_lookup_table_is_read_in_every_legal_form` (pdfcraft-render); (72) `raster::tests::an_uncoloured_pattern_uses_its_underlying_space_in_every_form` (pdfcraft-render); (73) `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `a_picture_whose_jbig2_mask_is_past_the_pages_allowance_is_not_drawn` and `a_pages_jbig2_allowance_ends_with_the_page` (pdfcraft-render); (74) `raster::tests::{a_chain_of_colour_spaces_ends, colour_spaces_are_read_to_the_limit_of_their_nesting, a_chain_built_on_spaces_already_read_ends, a_profile_read_at_the_limit_keeps_its_alternate_for_a_read_nearer_the_top}` (pdfcraft-render) |
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) |
-| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (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); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_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`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters) |
+| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap and page bitmap, and 2^25 cells per halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one, and a 4-byte value per cell); (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); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176); (5) what a page's segments hold for later ones (intermediate regions, pattern and symbol dictionaries with their headers and retained contexts, and Huffman tables) is at most 64 MiB, two square bitmaps of 16,384 a side (`MAX_RETAINED_WORDS`), and a dictionary's new and exported symbols are counted against it as they are made: each was bounded on its own, but a few dozen bytes an intermediate region held 33 MB each, and 8 MiB symbols exported again and again killed the process, so a few kilobytes could exhaust memory; (6) a segment may refer to at most 2^20 symbols, checked before any are collected: references may repeat, and 65,535 of them to one dictionary, in a 78 KB stream, asked for 17 GB; (7) a pattern dictionary keeps at most 16 MiB of shifted copies, which only speed up drawing, and none that wouldn't fit what the page has left (`MAX_SHIFTED_WORDS`); (8) a custom Huffman table has at most 2^16 lines, and a stream with its globals at most 2^20 segments and 2^22 references, checked as they are read: both grew before anything counted them, and PDFs of a kilobyte or so reached 5 GB and more behind nested Flate; (9) a page past the bounds of (1) is refused when the stream is opened: a caller sizes its output from the header before the page bitmap is made, and an Indexed image in a 743-byte PDF asked for 137 GB, which aborts; (10) a stream may ask for at most 2^30 units of decoding work, four of the largest pages, about 6 s to 17 s (`MAX_WORK`): decoding a bitmap costs a pixel each and 4 a row, copying one a word each and 1 a row, and each integer an item reads costs 38, what one takes at worst, so one with no pixels pays too, before it is decoded: each bitmap was bounded, but nothing bounded how many there were, and a 795-byte PDF took 265 s; (11) `Image::decode_within` spends from what a run of images may still do, starting with its page's pixels, and `charge_reading` charges reading a stream and its globals before anything is parsed, so an operation over a document can bound what `MAX_WORK` bounds for one image, and images that fail or decode nothing add up too: a file of thousands of images made to reach that limit took as many times as long; (12) adding to a location, a reference offset, a symbol's corner or a Huffman range line near the end of an `i32`, and negating a decoded integer of 2^31, overflowed: a panic wherever overflow is checked. The refinement context, a text region's corners, the integer decoder and the Huffman table wrap, as a release build did, and `Bitmap::combine` saturates, which comes to the same, as a location that overflows is past the end of any page. What is drawn doesn't change | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`, `raster::tests::jbig2_halftone_grids_past_their_bound_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`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`); (5) `raster::tests::jbig2_regions_held_past_a_pages_bound_are_refused`, `jbig2_symbols_without_pixels_count_towards_a_pages_bound`, `jbig2_retained_contexts_count_towards_a_pages_bound`, `jbig2_huffman_tables_count_towards_a_pages_bound`, `jbig2_new_symbols_count_towards_a_pages_bound`, `jbig2_new_symbols_are_held_to_what_a_page_has_left`, `jbig2_pattern_dictionaries_count_towards_a_pages_bound`, and in the crate (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) `decode::symbol::tests::exported_symbols_are_counted_before_they_are_copied`, `tests::the_room_left_shrinks_as_segments_are_stored`; (6) `raster::tests::jbig2_segments_referring_to_too_many_symbols_are_refused`; (7) `raster::tests::jbig2_pattern_dictionaries_too_large_to_shift_still_draw`, `jbig2_shifted_patterns_never_refuse_an_image`; (8) `raster::tests::jbig2_huffman_tables_of_too_many_lines_are_refused`, `jbig2_streams_of_too_many_segments_are_refused`, `jbig2_streams_of_too_many_references_are_refused`, and in the crate `tests::the_segments_of_a_stream_and_its_globals_count_together`; (9) `raster::tests::jbig2_pages_past_the_bounds_are_refused_before_anything_is_sized_from_them`, `tests::a_page_past_the_bounds_is_refused_when_opened`; (10) `raster::tests::jbig2_decoding_work_is_counted_item_by_item`; (11) `jbig2::decode::tests::a_run_of_images_spends_its_allowance_down_to_the_step` (pdfcraft-filters); (12) `raster::tests::jbig2_placements_at_the_end_of_an_i32_draw_nothing` |
| 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); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (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`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` |
-| 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); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (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, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it | (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`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too` |
+| 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); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (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, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260); (29) a `/JBIG2Globals` stream that is itself JBIG2 isn't decoded: globals are raw segments, and a chain of 16,000 such streams overflowed the stack, which aborts the process; (31) `set_page_jbig2_work`: the JBIG2 images decoded on a thread spend one allowance, set per page, 2^30 steps (`MAX_PAGE_JBIG2_WORK`), so a page of many images made to reach their own limit stops at one, and what they cost to read counts, whether they decode or not; unbounded until a page sets it; (32) a group of array items read together (a tuple: an image's `/Decode`, `/W`, `/W2`, and a function's `/Domain`, `/Range`, `/Encode` and `/Decode`) can start with a reference: it was taken for the whole group and lost, which ended the array, so an image with `/Decode [7 0 R 0]` didn't draw and CID widths fell back to `/DW`; (33) an object that holds only a reference stands for what that names, stored on its own or in an object stream, up to 32 references: read as a number it was the reference's own object number, so a `/Decode` or `/W` entry naming such an object took a wrong value, and as anything else it was lost. Past 32, as any cycle goes, it reads as a missing object. An object stream isn't reached that way, as its checks are of the object named; reading its index counts against the 64 nested loads and reads its dictionary alone, as a chain of them, each with a member of the next for its `/N`, `/First` or `/Length`, ran out of stack; and a comment inside a reference counts as white space | (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`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content`; (29) `raster::tests::jbig2_globals_that_are_jbig2_themselves_are_not_decoded`, `raster::tests::jbig2_globals_are_read`, `raster::tests::the_jbig2_images_of_a_page_share_one_allowance`, `raster::tests::an_image_that_fails_spends_the_pages_allowance_too`; (32) `raster::tests::a_decode_array_with_references_in_it_is_read`, `raster::tests::a_functions_arrays_can_start_with_a_reference` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret); (33) `raster::tests::a_reference_to_a_reference_is_what_it_names`, `raster::tests::a_decode_array_with_references_in_it_is_read` (pdfcraft-render), `font::cid::width_tests::a_width_group_can_start_with_a_reference` (hayro-interpret), `xref::tests::an_object_stream_isnt_reached_through_a_reference`, `xref::tests::a_chain_of_object_streams_ends_at_the_limit_of_nested_loads`, `xref::tests::a_chain_of_object_streams_takes_no_more_work_for_two_ways_on`, `xref::tests::a_reference_held_in_either_storage_is_followed`, `xref::tests::a_page_a_reference_is_held_to_is_found_once` (hayro-syntax) |
| 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.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (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; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open |
| 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::vendor_provenance_egui_wgpu_surface_fit_identity`, 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::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure |
| vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate |
diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs
index 29a74a6de8..b93b69c73c 100644
--- a/vendor/hayro-interpret/src/color.rs
+++ b/vendor/hayro-interpret/src/color.rs
@@ -166,12 +166,24 @@ fn icc_stream_object(array: &Array<'_>, stream: &Stream<'_>) -> Option, cache: &Cache) -> Option {
- Self::new_inner(object, cache)
+ fn new(object: Object<'_>, cache: &Cache, depth: u32) -> Option {
+ if depth >= MAX_COLOR_SPACE_DEPTH {
+ warn!("colour spaces nest past the limit of {MAX_COLOR_SPACE_DEPTH} levels");
+ return None;
+ }
+
+ Self::new_inner(object, cache, depth)
}
- fn new_inner(object: Object<'_>, cache: &Cache) -> Option {
+ fn new_inner(object: Object<'_>, cache: &Cache, depth: u32) -> Option {
if let Object::Name(name) = object {
return Self::new_from_name(&name);
} else if let Object::Array(color_array) = object {
@@ -186,6 +198,9 @@ impl ColorSpaceType {
// PdfCraft patch (48): cached by what the profile is read from (see
// `IccProfileKey`), not by the stream dictionary's bytes alone.
+ // PdfCraft patch (74): what's kept is what the profile itself reads as. What
+ // it falls back to depends on how deep it was met, and kept with the profile
+ // a page could build a chain past the limit on spaces already read.
let read = || {
let data = icc_stream.raw_data();
let content = IccProfileKey::Content {
@@ -194,40 +209,37 @@ impl ColorSpaceType {
};
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::