diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index c70d84e624..e8fa4adac6 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (256)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -263,6 +263,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 9638749929..f998b6fc3d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -3200,6 +3200,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+[[asset]]
+path = "assets/icons/a-large-small.svg"
+title = "Lucide icon \"a-large-small\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
+licence = "ISC AND MIT"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
+sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
+
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
diff --git a/ROADMAP.md b/ROADMAP.md
index cdab083d9d..f28bf19c75 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -24,7 +24,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
-> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
+> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
@@ -237,6 +237,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
- **2026-10-31 (M3, #5452):** Native Home Recent rows offer Reveal in Finder / File Explorer from their context menu. They reuse the tab action’s platform dispatch and missing-file notification without opening the PDF or changing Recent. Browser behavior and milestone estimates unchanged.
+
+- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
+
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
@@ -263,6 +266,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
- **2026-10-29 (M7, #2517):** Widening a paragraph rejoins a wrap with the number of spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split. The count survives an incremental save and a full rewrite, and a width-only `text_edit` uses it. Estimates unchanged.
+- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg
new file mode 100644
index 0000000000..7716db3cf2
--- /dev/null
+++ b/assets/icons/a-large-small.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs
index 9101012f66..be232d80eb 100644
--- a/crates/render/src/raster/tests.rs
+++ b/crates/render/src/raster/tests.rs
@@ -6436,8 +6436,8 @@ fn draw_minified_image(
/// making scans, screenshots and rasterized text look soft.
#[test]
fn non_interpolated_images_are_not_smoothed_when_minified() {
- // Top band K W R G, bottom band B Y M C. Nearest 8×4 → 4×2 sampling picks each block's
- // pixel, so every device pixel is one palette colour; Catmull-Rom blends across blocks.
+ // Top band K W R G, bottom band B Y M C. Shrinking 8×4 → 4×2 averages each 2×2 block,
+ // so every device pixel is one palette colour; Catmull-Rom blends across blocks.
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
let rgb: Vec = block_source(palette).concat();
let exact: Vec<[u8; 4]> = palette.iter().map(|c| [c[0], c[1], c[2], 255]).collect();
@@ -6455,38 +6455,76 @@ fn non_interpolated_images_are_not_smoothed_when_minified() {
assert!(pixels(&enabled).iter().any(|px| px[..3].iter().any(|&c| (32..=223).contains(&c))), "/Interpolate true made no mid-tone");
}
-/// A shrunk, interpolated image whose edges fall between device pixels is snapped to whole
-/// pixels (shrunk once to that size, drawn 1:1). Before, the shrunk raster was sampled a second
-/// time at the fractional offset, so every edge of a scan was a blended, soft pixel.
+/// A shrunk image whose edges fall between device pixels is snapped to whole pixels (shrunk
+/// once to that size, drawn 1:1), interpolated or not. Before, the shrunk raster was sampled a
+/// second time at the fractional offset, so every edge of a scan was a blended, soft pixel.
#[test]
-fn interpolated_minified_image_edges_snap_to_whole_pixels() {
- let black = vec![0u8; 40 * 40];
- let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
- let image = hex(&black);
- let pdf = format!(
- "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+fn minified_image_edges_snap_to_whole_pixels() {
+ for interpolate in ["/Interpolate true ", ""] {
+ let black = vec![0u8; 40 * 40];
+ let content = "q 10 0 0 10 0.4 0.4 cm /Im0 Do Q";
+ let image = hex(&black);
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 11 11] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
- 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF\n",
- content.len(),
- image.len() + 1
- );
- let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
- let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
- assert!(page.error.is_none(), "{:?}", page.error);
- assert_eq!((page.width, page.height), (11, 11));
- let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
- // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
- for y in 1..11 {
- for x in 0..10 {
- assert_eq!(gray(x, y), 0, "pixel ({x}, {y}) inside the snapped image");
+ content.len(),
+ image.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!((page.width, page.height), (11, 11));
+ let gray = |x: usize, y: usize| page.rgba[(y * 11 + x) * 4];
+ // Device box 0.4..10.4 × 0.6..10.6 snaps to columns 0..10 and rows 1..11.
+ for y in 1..11 {
+ for x in 0..10 {
+ assert_eq!(gray(x, y), 0, "{interpolate:?}: pixel ({x}, {y}) inside the snapped image");
+ }
+ assert_eq!(gray(10, y), 255, "{interpolate:?}: column 10 is outside the snapped image (row {y})");
+ }
+ for x in 0..11 {
+ assert_eq!(gray(x, 0), 255, "{interpolate:?}: row 0 is outside the snapped image (column {x})");
}
- assert_eq!(gray(10, y), 255, "column 10 is outside the snapped image (row {y})");
}
- for x in 0..11 {
- assert_eq!(gray(x, 0), 255, "row 0 is outside the snapped image (column {x})");
+}
+
+/// A tile draws exactly the pixels of the whole page under it, also where a shrunk image's
+/// edge falls on a half pixel. The edges were snapped by rounding halves away from zero, so
+/// the tile at x = 5, which moves the left edge from 2.5 to -2.5, snapped it one pixel left of
+/// where the whole page did and shifted every column of the image.
+#[test]
+fn a_tile_snaps_half_pixel_image_edges_as_the_whole_page_does() {
+ for interpolate in ["/Interpolate true ", ""] {
+ let ramp: Vec = (0..40 * 40).map(|i| (i % 40 * 6) as u8).collect();
+ let content = "q 10 0 0 10 2.5 2.5 cm /Im0 Do Q";
+ let image = hex(&ramp);
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 40 /Height 40 /ColorSpace /DeviceGray /BitsPerComponent 8 {interpolate}/Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ image.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let req = RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() };
+ let full = renderer.render(req);
+ let tile = Tile { x: 5, y: 5, w: 10, h: 10 };
+ let part = renderer.render(RenderRequest { tile: Some(tile), ..req });
+ assert!(full.error.is_none() && part.error.is_none(), "{:?} {:?}", full.error, part.error);
+ assert_eq!((full.width, part.width, part.height), (20, 10, 10));
+ for y in 0..10usize {
+ for x in 0..10usize {
+ let whole = full.rgba[((y + 5) * 20 + x + 5) * 4];
+ assert_eq!(part.rgba[(y * 10 + x) * 4], whole, "{interpolate:?}: pixel ({x}, {y}) of the tile");
+ }
+ }
}
}
@@ -6570,28 +6608,161 @@ fn non_interpolated_gray_images_are_not_smoothed_when_minified() {
}
#[test]
-fn non_integer_minification_samples_the_same_columns() {
- // Ten columns drawn six pixels wide: centre sampling reads columns 0, 2, 4, 5, 7 and 9.
+fn non_integer_minification_averages_the_columns_each_pixel_covers() {
+ // Ten columns drawn six pixels wide: each pixel covers 10/6 columns. Pixel 0 is 60% of
+ // column 0 and 40% of column 1 (0.6 × 10 + 0.4 × 20 = 14), pixel 1 is 20% of column 1,
+ // all of column 2's 60% and 20% of column 3 (30), and so on. Nearest-neighbour read whole
+ // columns 0, 2, 4, 5, 7 and 9 instead, dropping the four others.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, None, (6, 1));
let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
- assert_eq!(grays, [10, 30, 50, 60, 80, 100]);
+ assert_eq!(grays, [14, 30, 46, 64, 80, 96]);
}
#[test]
-fn non_integer_minification_samples_colour_and_mask_from_the_same_columns() {
- // Ten gray columns with a soft mask, drawn six pixels wide: columns 0, 2, 4, 5, 7 and 9 are
- // sampled, so the mask must pick the same columns as the colours.
+fn non_integer_minification_averages_colour_and_mask_together() {
+ // Ten gray columns with a soft mask, drawn six pixels wide. Colours are averaged
+ // premultiplied, over the same columns as the mask, and composited over the white page.
+ // Pixel 0: 60% of column 0 (gray 10, opaque) and 40% of column 1 (transparent) is
+ // premultiplied gray 6 at alpha 153, so 6 + 255 × (1 − 153/255) = 108. Pixels 1 and 4
+ // cover only transparent columns and show the page.
let ramp = [10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100];
let mask = [255u8, 0, 0, 0, 255, 255, 0, 0, 0, 255];
let page = draw_minified_image("DeviceGray", (10, 1), None, &ramp, Some(&mask), (6, 1));
- // Opaque columns keep their gray; transparent columns show the white page.
assert_eq!(
page.rgba.as_chunks::<4>().0.to_vec(),
- vec![[10, 10, 10, 255], [255, 255, 255, 255], [50, 50, 50, 255], [60, 60, 60, 255], [255, 255, 255, 255], [100, 100, 100, 255]]
+ vec![[108, 108, 108, 255], [255, 255, 255, 255], [132, 132, 132, 255], [138, 138, 138, 255], [255, 255, 255, 255], [162, 162, 162, 255]]
);
}
+/// A scan shown at screen size: a stroke one sample wide in an image drawn at a third of its
+/// resolution. Nearest-neighbour read columns 1, 4 and 7 and lost the stroke in column 3, which
+/// is how the text of 300 dpi scans broke up at 100% zoom. Area averaging keeps it as the
+/// third of pixel 1 it covers.
+#[test]
+fn non_interpolated_minification_keeps_thin_strokes() {
+ let mut stroke = [255u8; 9];
+ stroke[3] = 0;
+ let page = draw_minified_image("DeviceGray", (9, 1), None, &stroke, None, (3, 1));
+ let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
+ assert_eq!(grays, [255, 170, 255]);
+}
+
+/// A zoomed scan: an image of page-scan size drawn three times its resolution without
+/// `/Interpolate`. Nearest-neighbour turned each sample into a 3 × 3 block, so the edges of
+/// scanned text were staircases; it is smoothed (bilinearly) instead, as browsers draw it.
+#[test]
+fn large_non_interpolated_images_are_smoothed_when_enlarged() {
+ let side = 512usize;
+ let half: Vec = (0..side * side).map(|i| if i % side < side / 2 { 0 } else { 255 }).collect();
+ let page = draw_minified_image("DeviceGray", (512, 512), None, &half, None, (1536, 1536));
+ let row = 768 * 1536;
+ let edge: Vec = (760..776).map(|x| page.rgba[(row + x) * 4]).collect();
+ assert!(edge.iter().any(|v| (32..=223).contains(v)), "a hard edge, not a smoothed one: {edge:?}");
+ assert_eq!((page.rgba[(row + 700) * 4], page.rgba[(row + 836) * 4]), (0, 255), "away from the edge");
+}
+
+/// Small images (icons, QR codes, pixel art) keep their hard pixels when enlarged.
+#[test]
+fn small_non_interpolated_images_keep_hard_pixels_when_enlarged() {
+ let checker: Vec = (0..64).map(|i| if (i / 8 + i % 8) % 2 == 0 { 0 } else { 255 }).collect();
+ let page = draw_minified_image("DeviceGray", (8, 8), None, &checker, None, (80, 80));
+ assert!(page.rgba.as_chunks::<4>().0.iter().all(|px| px[0] == 0 || px[0] == 255), "a small image was smoothed");
+}
+
+/// A scan shrunk across and stretched down (a fax shown at screen size, zoomed in): the shrunk
+/// side is still averaged, and the stretched side is smoothed as a scan enlarged both ways
+/// is. Shrinking one side kept the nearest sampler for the other, so its edges were staircases.
+#[test]
+fn a_scan_shrunk_one_way_is_smoothed_the_way_it_is_stretched() {
+ // Top half black; bottom half columns alternating black and white, which averages to gray.
+ let side = 512usize;
+ let source: Vec = (0..side * side).map(|i| if i / side < side / 2 || i % 2 == 0 { 0 } else { 255 }).collect();
+ let page = draw_minified_image("DeviceGray", (512, 512), None, &source, None, (256, 1536));
+ let gray = |x: usize, y: usize| page.rgba[(y * 256 + x) * 4];
+ let edge: Vec = (760..776).map(|y| gray(128, y)).collect();
+ assert!(edge.iter().any(|v| (16..=112).contains(v)), "a hard edge down the stretched side: {edge:?}");
+ assert_eq!(gray(128, 700), 0, "above the edge");
+ assert!((0..256).all(|x| gray(x, 836).abs_diff(128) <= 1), "the shrunk side is averaged at 1:1");
+}
+
+/// An image shrunk across and stretched down (a 204 × 98 dpi fax shown at screen size): the
+/// shrunk side is still averaged. The area shrink was asked to enlarge the other side too,
+/// refused, and the image fell back to its source resolution and the nearest sampler, which
+/// read single columns.
+#[test]
+fn an_image_shrunk_one_way_and_stretched_the_other_is_still_averaged() {
+ let ramp: Vec = [10u8, 20, 30, 40, 50, 60, 70, 80].repeat(2);
+ let page = draw_minified_image("DeviceGray", (8, 2), None, &ramp, None, (4, 4));
+ let grays: Vec = page.rgba.as_chunks::<4>().0.iter().map(|px| px[0]).collect();
+ assert_eq!(grays, [15, 35, 55, 75].repeat(4), "each pixel is the mean of the two columns it covers");
+}
+
+/// Averaging straight colours would tint an opaque sample with the colour of a transparent
+/// neighbour: white beside transparent black, shrunk to one pixel, came out gray over the white
+/// page. Premultiplied, the transparent sample adds nothing but its transparency.
+#[test]
+fn non_interpolated_minification_does_not_bleed_transparent_colour() {
+ let page = draw_minified_image("DeviceGray", (2, 1), None, &[255, 0], Some(&[255, 0]), (1, 1));
+ assert_eq!(page.rgba.as_chunks::<4>().0.to_vec(), vec![[255, 255, 255, 255]]);
+}
+
+/// The same with a soft mask smaller than its image: red, red, blue, blue under a mask that is
+/// opaque, then transparent, shrunk to one pixel, is half-covered red over the white page.
+/// Colour and mask were averaged apart and multiplied, so the transparent blue came through
+/// as purple (191, 127, 191).
+#[test]
+fn a_smaller_soft_mask_does_not_bleed_transparent_colour_when_minified() {
+ let content = "q 1 0 0 1 0 0 cm /Im0 Do Q";
+ let (image, mask) = ("FF0000FF00000000FF0000FF", "FF00");
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 1 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ image.len() + 1,
+ mask.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ let [r, g, b, _] = page.rgba.as_chunks::<4>().0.first().copied().unwrap_or_default();
+ assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got ({r}, {g}, {b})");
+}
+
+/// A smaller soft mask that asks for `/Interpolate` stays smooth when it is sampled onto the grid
+/// of an area-shrunk image. Here a 64 × 64 black image is drawn at 32 × 32 under a 2 × 2 mask,
+/// opaque on the left and clear on the right: the mask is enlarged while the image shrinks, and
+/// sampled nearest-neighbour onto the image's grid the edge became a hard black and white step.
+#[test]
+fn an_interpolated_smaller_soft_mask_stays_smooth_when_its_image_is_minified() {
+ let content = "q 32 0 0 32 0 0 cm /Im0 Do Q";
+ let (image, mask) = ("00".repeat(64 * 64), "FF00FF00");
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 32 32] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 8 /SMask 6 0 R /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ 6 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true /Filter /ASCIIHexDecode /Length {} >> stream\n{mask}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ image.len() + 1,
+ mask.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!((page.width, page.height), (32, 32));
+ let row: Vec = page.rgba.as_chunks::<4>().0.iter().skip(16 * 32).take(32).map(|[r, ..]| *r).collect();
+ let ramp = row.iter().filter(|v| (1..255).contains(*v)).count();
+ assert!(ramp >= 8 && row.is_sorted() && row.first() == Some(&0) && row.last() == Some(&255), "a smooth black to white edge, got {row:?}");
+}
+
#[test]
fn non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified() {
let palette: [[u8; 3]; 8] = [[0, 0, 0], [255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255]];
@@ -6643,6 +6814,59 @@ fn shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes() {
assert_eq!(grays[3], 255, "{grays:?}");
}
+/// Issue #4800: scanners save the text of a page as a 1-bit `/ImageMask` stencil, painted in the
+/// fill colour, and a stencil shrunk by nearest-neighbour lost the strokes thinner than the shrink
+/// factor. Here a one-column red stroke of an 8-column stencil is drawn 4 pixels wide: nearest
+/// reads columns 1, 3, 5 and 7 and misses it, while the area average makes it a half-covered red.
+#[test]
+fn a_shrunk_stencil_mask_keeps_thin_strokes() {
+ // Sample 0 paints (the default `/Decode [0 1]`), so 0xDF is a stroke in column 2.
+ let content = "q 1 0 0 rg 4 0 0 1 0 0 cm /Im0 Do Q";
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 4 1] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 1 /ImageMask true /Filter /ASCIIHexDecode /Length 3 >> stream\nDF>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len()
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ assert_eq!((page.width, page.height), (4, 1));
+ let px = page.rgba.as_chunks::<4>().0.to_vec();
+ let [r, g, b, _] = px.get(1).copied().unwrap_or_default();
+ assert!(r >= 254 && g.abs_diff(128) <= 1 && b == g, "half-covered red over white, got {px:?}");
+ assert_eq!(px.get(3), Some(&[255, 255, 255, 255]), "{px:?}");
+}
+
+/// A scan drawn at its own resolution keeps its hard pixels when its scale passes 1 only by
+/// rounding: `x_scale` is an `f32`, and a 192 dpi scan on a 2× display at 100% can come out a
+/// few ulps above 1. Smoothed bilinearly at a half-pixel offset, one-pixel stripes became gray.
+/// Here `600.0001` (an `f32` of 600.00012) draws 600 samples 2·10⁻⁷ wider than 1:1.
+#[test]
+fn a_scan_drawn_a_rounding_error_above_its_resolution_stays_sharp() {
+ let content = "q 600.0001 0 0 600 0.5 0 cm /Im0 Do Q";
+ let image: String = (0..600 * 600).map(|i| if i % 2 == 0 { "00" } else { "FF" }).collect();
+ let pdf = format!(
+ "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
+ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
+ 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 601 600] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /XObject /Subtype /Image /Width 600 /Height 600 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length {} >> stream\n{image}>\nendstream endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF\n",
+ content.len(),
+ image.len() + 1
+ );
+ let mut renderer = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
+ let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() });
+ assert!(page.error.is_none(), "{:?}", page.error);
+ let row: Vec = page.rgba.as_chunks::<4>().0.iter().skip(300 * page.width as usize).take(page.width as usize).map(|[r, ..]| *r).collect();
+ let gray = row.iter().skip(10).take(580).filter(|v| (16..240).contains(*v)).count();
+ assert_eq!(gray, 0, "one-pixel stripes were blurred: {:?}", row.get(10..30));
+}
+
/// Type 3 glyph images used to be sampled bicubically (`ImageQuality::High`) whatever their
/// `/Interpolate`, which blends the nearest pre-resize back in. The glyph is 4.6 × 2.3 pixels
/// and its image pre-resizes to 5 × 3, so the final draw samples fractionally and bicubic
@@ -7716,7 +7940,7 @@ fn stitched_render_partitions_match_a_whole_render() {
/// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a
/// curve, text, and a page shown rotated.
///
-/// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing
+/// Images are skipped the same way (vendored patch (11)): an image on a tile, one crossing
/// tile lines, and one fully off the page.
#[test]
fn skipping_paths_off_the_canvas_changes_no_pixel() {
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 43a1e5ce1d..5ac073960f 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -108,6 +108,7 @@ impl PdfCraftApp {
ui_scale_applied: None,
flatten_fill_sign_on_save: false,
fill_text_size: fill_sign::TEXT_SIZE,
+ fill_tool: fill_sign::FillTool::Signature,
fill_text_color: [0.0; 3],
dialog: None,
updates: updates::Updates::default(),
@@ -128,6 +129,7 @@ impl PdfCraftApp {
last_session: Default::default(),
quit_session: None,
reload_changed_files: true,
+ update_check_at_start: true,
show_secured_notice: true,
always_default_view: false,
#[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs
index 0309042bf1..5d51adfdb4 100644
--- a/crates/ui-egui/src/app/control.rs
+++ b/crates/ui-egui/src/app/control.rs
@@ -47,6 +47,9 @@ impl PdfCraftApp {
("show_page_edge_shadows", _) => {
self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?;
}
+ ("update_check_at_start", _) => {
+ self.update_check_at_start = value.parse::().map_err(|_| "update_check_at_start 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")?;
}
@@ -333,9 +336,12 @@ impl PdfCraftApp {
field if field.starts_with("field-") => QuickTool::Field(
prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?,
),
- fill if fill.starts_with("fill-") => QuickTool::Fill(
- fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?,
- ),
+ fill if fill.starts_with("fill-") => {
+ let tool =
+ fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?;
+ self.fill_tool = tool;
+ QuickTool::Fill(tool)
+ }
other => {
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
self.comment_prefs.group_tool[t.group()] = t;
diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs
index 08a20afb45..0df7d6f30b 100644
--- a/crates/ui-egui/src/app/prefs.rs
+++ b/crates/ui-egui/src/app/prefs.rs
@@ -147,6 +147,9 @@ impl PdfCraftApp {
// 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();
+ // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and
+ // settings written then would turn this off unasked.
+ v["update_check_at_start"] = self.update_check_at_start.into();
v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default();
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
@@ -219,6 +222,9 @@ impl PdfCraftApp {
if let Some(on) = v["reload_changed_files"].as_bool() {
self.reload_changed_files = on;
}
+ if let Some(on) = v["update_check_at_start"].as_bool() {
+ self.update_check_at_start = on;
+ }
if let Some(on) = v["show_secured_notice"].as_bool() {
self.show_secured_notice = on;
}
diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs
index dd3fe8c498..fbd4200217 100644
--- a/crates/ui-egui/src/canvas/bars.rs
+++ b/crates/ui-egui/src/canvas/bars.rs
@@ -315,7 +315,7 @@ mod tests {
#[test]
fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() {
- // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it.
+ // A 70 pt bar centres by that, and 72 pt no longer holds it.
assert_eq!(quick_bar_offset(120.0, 70.0), 15.0);
assert_eq!(quick_bar_offset(72.0, 70.0), 14.0);
}
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 083ada4253..3549418ae3 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -697,6 +697,7 @@ impl PdfCraftApp {
}
fill if crate::fill_sign::FillTool::from_command(fill).is_some() => {
let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) };
+ self.fill_tool = tool;
self.quick_tool = crate::QuickTool::Fill(tool);
let initials = tool == crate::fill_sign::FillTool::Initials;
if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) {
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 84c36f41aa..0e7e2c0ed0 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -132,7 +132,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::Preferences => 610.0,
- Dialog::About => 780.0,
+ Dialog::About => 820.0,
Dialog::PinTool => 540.0,
_ => 520.0,
});
@@ -1352,6 +1352,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
app.signature = d.saved();
crate::fill_sign::FillTool::Signature
};
+ app.fill_tool = tool;
app.quick_tool = crate::QuickTool::Fill(tool);
app.toast = None;
}
diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs
index ad45ec77b6..d69d545b3f 100644
--- a/crates/ui-egui/src/dialogs/credits.rs
+++ b/crates/ui-egui/src/dialogs/credits.rs
@@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]);
ui.label(RichText::new(counts).small().color(t.text_muted));
ui.separator();
- egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
- if list.is_empty() {
- ui.label(crate::i18n::t("No contributor data was built into this copy."));
- } else if v.table {
- table(ui, &list, &mut v);
- } else {
+ if list.is_empty() {
+ ui.label(crate::i18n::t("No contributor data was built into this copy."));
+ } else if v.table {
+ // The table scrolls vertically itself, under a fixed header; it only scrolls sideways
+ // once its columns are dragged wider than the dialog.
+ ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
+ ui.set_max_height(LIST_HEIGHT);
+ egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
+ });
+ } else {
+ egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -280,43 +285,154 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
- }
- });
+ });
+ }
ui.data_mut(|d| d.insert_temp(id, v));
}
-fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
- egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
- for k in SortKey::ALL {
- let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
- if ui
- .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong())
- .on_hover_text(crate::i18n::t(k.label().0))
- .clicked()
- {
- if v.key == k {
- v.ascending = !v.ascending;
- } else {
- v.key = k;
- v.ascending = k.default_ascending();
- }
+/// How the contributor table's spare width is shared out: all of it to Name.
+const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+
+/// How the model table's spare width is shared out: to Company and Model, the text columns.
+const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
+
+/// The narrowest a column can be dragged.
+const MIN_COLUMN: f32 = 28.0;
+
+/// The width a column needs for its header and its widest cell.
+fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 {
+ let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
+ let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
+ widest + 2.0 * ui.spacing().button_padding.x
+}
+
+/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
+/// so the columns fill `room` exactly. When there is too little room, only the growing columns
+/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
+fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec {
+ let spare = room - natural.iter().sum::();
+ let weight = grow.iter().sum::();
+ let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
+ let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
+ natural
+ .iter()
+ .zip(grow)
+ .map(|(w, g)| {
+ if *g > 0.0 && spare >= 0.0 {
+ w + spare * g / weight
+ } else if *g > 0.0 && flexible > 0.0 {
+ w - (-spare).min(flexible) * slack(*w) / flexible
+ } else {
+ *w
}
- }
- ui.end_row();
- for c in list {
- ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
- ui.label(group(c.prs));
- ui.label(group(c.commits));
- ui.label(group(c.lines_added));
- ui.label(group(c.lines_deleted));
- ui.label(signed(c.lines_delta()));
- ui.label(group(c.binary_added));
- ui.label(group(c.binary_deleted));
- ui.label(day(c.first_commit));
- ui.label(day(c.last_commit));
- ui.end_row();
- }
- });
+ })
+ .collect()
+}
+
+/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
+/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
+/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
+/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
+/// dropping any dragged widths.
+fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
+ // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
+ let s = ui.spacing();
+ let gaps = s.item_spacing.x * natural.len() as f32;
+ let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
+ let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
+ // The table keeps its own column widths, so a change in what they were fitted to has to clear
+ // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
+ // width, so this compares the natural widths and the clip rect, which scrolling never moves.
+ let fit = egui::Id::unique(("credits_fit", id));
+ let measured = (natural.to_vec(), ui.clip_rect().width());
+ let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured);
+ ui.data_mut(|d| d.insert_temp(fit, measured));
+ let mut t =
+ egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
+ if refit {
+ t.reset();
+ }
+ for w in widths {
+ t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
+ }
+ t
+}
+
+/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
+fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
+ let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+ ui.with_layout(layout, add).inner
+}
+
+fn row_height(ui: &egui::Ui) -> f32 {
+ ui.text_style_height(&egui::TextStyle::Body) + 6.0
+}
+
+/// One contributor's table cells after the name, in `SortKey::ALL` order.
+fn cells(c: &Contributor) -> [String; 9] {
+ [
+ group(c.prs),
+ group(c.commits),
+ group(c.lines_added),
+ group(c.lines_deleted),
+ signed(c.lines_delta()),
+ group(c.binary_added),
+ group(c.binary_deleted),
+ day(c.first_commit).to_string(),
+ day(c.last_commit).to_string(),
+ ]
+}
+
+/// A contributor-table header. The sorted column is highlighted; its direction shows on the
+/// toolbar's sort-order button, so no arrow widens the column.
+fn header_text(k: SortKey) -> RichText {
+ RichText::new(crate::i18n::t(k.label().1)).strong()
+}
+
+fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
+ let h = row_height(ui);
+ let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
+ let widths: Vec = SortKey::ALL
+ .iter()
+ .enumerate()
+ .map(|(i, &k)| match i.checked_sub(1) {
+ None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
+ Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
+ })
+ .collect();
+ fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
+ .header(h + 4.0, |mut header| {
+ for k in SortKey::ALL {
+ header.col(|ui| {
+ let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k)))
+ .on_hover_text(crate::i18n::t(k.label().0));
+
+ if r.clicked() {
+ if v.key == k {
+ v.ascending = !v.ascending;
+ } else {
+ v.key = k;
+ v.ascending = k.default_ascending();
+ }
+ }
+ });
+ }
+ })
+ .body(|mut body| {
+ for (c, cells) in list.iter().zip(rows) {
+ body.row(h, |mut row| {
+ row.col(|ui| {
+ aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
+ .on_hover_text(c.summary());
+ });
+ for text in cells {
+ row.col(|ui| {
+ aligned(ui, true, |ui| ui.label(text));
+ });
+ }
+ });
+ }
+ });
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -326,21 +442,50 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
- egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
- egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
- for h in MODEL_COLUMNS {
- ui.label(RichText::new(crate::i18n::t(h)).strong());
- }
- ui.end_row();
- for m in MODELS {
- ui.label(m.company);
- ui.label(m.model);
- ui.label(m.version);
- ui.label(group(m.commits));
- ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
- ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
- ui.end_row();
- }
+ let h = row_height(ui);
+ let titles = MODEL_COLUMNS;
+ // Company, Model and Version read as text; the counts line up on the right.
+ let right = [false, false, false, true, true, true];
+ let rows: Vec<[String; 6]> = MODELS
+ .iter()
+ .map(|m| {
+ [
+ m.company.to_string(),
+ m.model.to_string(),
+ m.version.to_string(),
+ group(m.commits),
+ format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
+ format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
+ ]
+ })
+ .collect();
+ let widths: Vec = titles
+ .iter()
+ .enumerate()
+ .map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
+ .collect();
+ ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
+ ui.set_max_height(LIST_HEIGHT);
+ egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
+ fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
+ .header(h + 4.0, |mut header| {
+ for (title, r) in titles.into_iter().zip(right) {
+ header.col(|ui| {
+ aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong()));
+ });
+ }
+ })
+ .body(|mut body| {
+ for cells in rows {
+ body.row(h, |mut row| {
+ for (text, r) in cells.into_iter().zip(right) {
+ row.col(|ui| {
+ aligned(ui, r, |ui| ui.label(text));
+ });
+ }
+ });
+ }
+ });
});
});
}
@@ -421,4 +566,131 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
+
+ #[test]
+ fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
+ let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
+ assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
+ }
+
+ #[test]
+ fn too_little_room_shrinks_only_growing_columns() {
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
+ }
+
+ #[test]
+ fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
+ let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
+ assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
+ assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
+ assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
+ }
+
+ const WINDOW: f32 = 640.5;
+ const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
+ const GROW: [f32; 3] = [1.0, 0.0, 0.0];
+
+ /// One frame of a table long enough to scroll; the rects of its header cells.
+ fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec {
+ scrolled_frame(ctx, width, natural, 0.0, events)
+ }
+
+ /// [`table_frame`], scrolled `offset` points sideways.
+ fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec {
+ let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
+ let raw = egui::RawInput { screen_rect, events, ..Default::default() };
+ let mut cells = Vec::new();
+ let out = ctx.run_ui(raw, |ui| {
+ cells.clear();
+ egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
+ fitted_table(ui, "test", natural, &GROW)
+ .header(20.0, |mut header| {
+ for _ in natural {
+ header.col(|ui| cells.push(ui.max_rect()));
+ }
+ })
+ .body(|mut body| {
+ for _ in 0..40 {
+ body.row(20.0, |mut row| {
+ for _ in natural {
+ row.col(|ui| {
+ ui.label("x");
+ });
+ }
+ });
+ }
+ });
+ });
+ });
+ out.drop_without_applying_deltas();
+ cells
+ }
+
+ /// A new context with the table open and settled, and the rects of its header cells.
+ fn opened_table() -> (egui::Context, Vec) {
+ let ctx = egui::Context::default();
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ (ctx, cells)
+ }
+
+ /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
+ fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
+ use egui::{Event, Modifiers, PointerButton};
+ let start = egui::pos2(cell.right() + 4.0, cell.center().y);
+ let halfway = start + egui::vec2(by / 2.0, 0.0);
+ let end = start + egui::vec2(by, 0.0);
+ let moved = Event::PointerMoved;
+ let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
+ for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
+ table_frame(ctx, WINDOW, &NATURAL, vec![event]);
+ }
+ }
+
+ #[test]
+ fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
+ let (_, opened) = opened_table();
+ for (dragged, cell) in opened.iter().enumerate() {
+ let (ctx, _) = opened_table();
+ drag_edge(&ctx, *cell, -30.0);
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
+ let want = if column == dragged { was.width() - 30.0 } else { was.width() };
+ assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn a_changed_fit_replaces_the_column_widths() {
+ let (ctx, opened) = opened_table();
+ drag_edge(&ctx, opened[0], -30.0);
+ let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
+ assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
+ let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
+ let widths: Vec = wider.iter().map(|c| c.width().round()).collect();
+ assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
+ assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
+ let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
+ assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
+ }
+
+ #[test]
+ fn scrolling_sideways_keeps_dragged_widths() {
+ let (ctx, opened) = opened_table();
+ drag_edge(&ctx, opened[0], 100.0);
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
+ for offset in [13.3, 27.77, 41.1, 0.6] {
+ scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
+ let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
+ assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
+ }
+ }
}
diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs
index 9e3182e416..ea985f3207 100644
--- a/crates/ui-egui/src/dialogs/preferences.rs
+++ b/crates/ui-egui/src/dialogs/preferences.rs
@@ -659,6 +659,8 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut
ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t);
#[cfg(not(target_arch = "wasm32"))]
ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t);
+ #[cfg(not(target_arch = "wasm32"))]
+ ctx.checkbox(ui, &mut app.update_check_at_start, tl!("Check for updates when PdfCraft starts"), t);
ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t);
#[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs
index 1fd5dbd75c..269f0a12c2 100644
--- a/crates/ui-egui/src/hotbar.rs
+++ b/crates/ui-egui/src/hotbar.rs
@@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
QuickTool::Fill(f) => Some(f),
_ => None,
};
- let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
- let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign"));
+ let shown = current_fill.unwrap_or(app.fill_tool);
+ let tip = match shown {
+ crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => {
+ let initials = shown == crate::fill_sign::FillTool::Initials;
+ let saved = if initials { app.initials.is_some() } else { app.signature.is_some() };
+ crate::i18n::fmt(
+ tl!(if saved { "Place saved {what}" } else { "Add {what}" }),
+ &[("what", tl!(if initials { "initials" } else { "signature" }))],
+ )
+ }
+ _ => tl!(shown.label()).to_string(),
+ };
+ let tip = format!("{}: {tip}", tl!("Fill & Sign"));
+ let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip);
let r = resp.rect;
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE));
@@ -287,6 +299,30 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
});
}
+fn controls_divider(ui: &mut egui::Ui, t: &Tokens, vertical: bool) {
+ let (r, _) = if vertical {
+ ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
+ } else {
+ ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
+ };
+ if vertical {
+ ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
+ } else {
+ ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
+ }
+}
+
+fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> {
+ let (index, id) = app.active_ids()?;
+ let (page, annotation) = app.views.get(index)?.comments.selected?;
+ let doc = app.session.get(id).filter(|doc| doc.allows_annotation())?;
+ doc.info
+ .annotations
+ .iter()
+ .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
+ .then_some((page, annotation))
+}
+
/// Extra controls rendered below/beside the buttons when a comment or fill tool is active.
fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) {
let current_fill = match app.quick_tool {
@@ -294,47 +330,44 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve
_ => None,
};
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
- if vertical {
- ui.label(tl!("Font size:"));
- }
- let size = ui.add(
- egui::DragValue::new(&mut app.fill_text_size)
- .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
- .speed(0.25)
- .suffix(" pt"),
- );
- if ((size.changed() && !size.dragged()) || size.drag_stopped())
- && let Some((index, id)) = app.active_ids()
- && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
- && app.session.get(id).is_some_and(|doc| {
- doc.allows_annotation()
- && doc
- .info
- .annotations
- .iter()
- .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
- })
- {
- app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
- }
- ui.menu_button(tl!("Colour"), |ui| {
- if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
- app.fill_text_color = color;
- ui.close();
+ controls_divider(ui, t, vertical);
+ if let Some(color) = comments::quick_bar_colour(ui, app.fill_text_color) {
+ app.fill_text_color = color;
+ if let Some((page, index)) = selected_fill_text(app) {
+ app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None });
}
- });
+ }
+ let resp = icons::button(ui, "a-large-small", 32.0, false, tl!("Font size"));
+ let resized = egui::Popup::menu(&resp)
+ .align(egui::RectAlign::RIGHT_START)
+ .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+ .show(|ui| {
+ ui.set_min_width(180.0);
+ ui.label(egui::RichText::new(tl!("Font size")).font(theme::semibold(12.0)));
+ let size = ui.add(
+ egui::Slider::new(&mut app.fill_text_size, crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
+ .logarithmic(true)
+ .suffix(" pt"),
+ );
+ // #1274: the size also applies to the Fill & Sign text that is selected,
+ // once per change (a typed value, or the end of a drag), so a drag adds
+ // one undo step and not one per frame.
+ (size.changed() && !size.dragged()) || size.drag_stopped()
+ })
+ .is_some_and(|popup| popup.inner);
+ let size = app.fill_text_size;
+ if resized
+ && let Some((page, index)) = selected_fill_text(app)
+ && app
+ .active_ids()
+ .and_then(|(_, id)| app.session.get(id)?.comment_props(page, index)?.font_size)
+ .is_none_or(|stored| format!("{stored:.3}") != format!("{size:.3}"))
+ {
+ app.apply_edit(Edit::FontSizeAnnotation { page, index, size });
+ }
}
if let QuickTool::Comment(tool) = app.quick_tool {
- let (r, _) = if vertical {
- ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
- } else {
- ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
- };
- if vertical {
- ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
- } else {
- ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
- }
+ controls_divider(ui, t, vertical);
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
&& let Some((index, id)) = app.active_ids()
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c0acc17a30..4ab5b4343f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -332,6 +332,7 @@ pub struct PdfCraftApp {
pub flatten_fill_sign_on_save: bool,
/// Point size for new Fill & Sign text and dates, kept across launches.
pub fill_text_size: f64,
+ pub fill_tool: fill_sign::FillTool,
/// Colour for new Fill & Sign text and dates, kept across launches.
pub fill_text_color: [f64; 3],
pub dialog: Option