mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-13 15:25:10 +00:00
Merge upstream/main into feat/recent-reveal-5452
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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\""
|
||||
|
||||
+5
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-a-large-small"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -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<u8> = 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<u8> = (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<u8> = 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<u8> = 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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = (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<u8> = [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<u8> = 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<u8> = 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<u8> = 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() {
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -47,6 +47,9 @@ impl PdfCraftApp {
|
||||
("show_page_edge_shadows", _) => {
|
||||
self.show_page_edge_shadows = value.parse::<bool>().map_err(|_| "show_page_edge_shadows must be true or false")?;
|
||||
}
|
||||
("update_check_at_start", _) => {
|
||||
self.update_check_at_start = value.parse::<bool>().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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Item = String>) -> 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<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
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>, 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<R>(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<f32> = 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<f32> = 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::<f32>() - 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::<f32>() - 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<egui::Event>) -> Vec<egui::Rect> {
|
||||
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<egui::Event>) -> Vec<egui::Rect> {
|
||||
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<egui::Rect>) {
|
||||
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<f32> = 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Dialog>,
|
||||
@@ -371,6 +372,9 @@ pub struct PdfCraftApp {
|
||||
quit_session: Option<last_session::LastSession>,
|
||||
/// Preferences: reload a document when its file changes on disk (#431).
|
||||
pub reload_changed_files: bool,
|
||||
/// Preferences: ask for a newer release when the desktop app starts (#5737). Off, the app
|
||||
/// asks only from Help ▸ Check for updates.
|
||||
pub update_check_at_start: bool,
|
||||
/// Preferences: tell the user, above the pages, that a document is secured (its security
|
||||
/// settings restrict changes). Off only hides that notice (#4035); kept in the settings.
|
||||
pub show_secured_notice: bool,
|
||||
|
||||
@@ -21,7 +21,8 @@ pub(crate) struct Updates {
|
||||
updater: Updater,
|
||||
/// The Updates dialog is showing.
|
||||
pub(crate) open: bool,
|
||||
/// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network.
|
||||
/// Ask for a newer release at start, unless Preferences says not to; the desktop app turns
|
||||
/// it on, so tests never reach the network.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
startup_check: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -73,10 +74,12 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(ctx) = self.ctx.clone() else { return };
|
||||
// Settled on the first frame: a preference turned on later waits for the next start.
|
||||
let at_start = std::mem::take(&mut self.updates.startup_check) && self.update_check_at_start;
|
||||
let updates = &mut self.updates;
|
||||
if std::mem::take(&mut updates.check_requested) {
|
||||
updates.updater.check_now(&ctx);
|
||||
} else if updates.startup_check {
|
||||
} else if at_start {
|
||||
updates.updater.check_at_start(&ctx);
|
||||
}
|
||||
updates.updater.tick(&ctx);
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
pub(crate) use paint::paint_page;
|
||||
#[cfg(test)]
|
||||
use paint::{changing_points, drawn_shape, edited_in_view};
|
||||
|
||||
@@ -286,25 +286,32 @@ pub fn fill_picker(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Option<Rgb
|
||||
picked
|
||||
}
|
||||
|
||||
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
|
||||
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
|
||||
let style = prefs.style(tool);
|
||||
let mut selected_color = None;
|
||||
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
|
||||
prefs.pinned = !prefs.pinned;
|
||||
}
|
||||
pub(crate) fn quick_bar_colour(ui: &mut egui::Ui, current: Rgb) -> Option<Rgb> {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(current));
|
||||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour")));
|
||||
let resp = resp.on_hover_text(tl!("Colour"));
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(c) = swatch_grid(ui, Some(style.color)) {
|
||||
prefs.set_color(tool, c);
|
||||
selected_color = Some(c);
|
||||
if let Some(c) = swatch_grid(ui, Some(current)) {
|
||||
picked = Some(c);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
|
||||
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
|
||||
let style = prefs.style(tool);
|
||||
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
|
||||
prefs.pinned = !prefs.pinned;
|
||||
}
|
||||
let selected_color = quick_bar_colour(ui, style.color);
|
||||
if let Some(c) = selected_color {
|
||||
prefs.set_color(tool, c);
|
||||
}
|
||||
if tool.has_fill() {
|
||||
let resp = fill_button(ui, style.fill);
|
||||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
|
||||
@@ -374,20 +374,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials });
|
||||
let wash = ui.painter().add(egui::Shape::Noop);
|
||||
let row = ui.horizontal(|ui| {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click());
|
||||
let use_label = label("Use {what}");
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone()));
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone()));
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(4),
|
||||
Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }),
|
||||
Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}"));
|
||||
if response.on_hover_text(label("Place saved {what}")).clicked() {
|
||||
if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() {
|
||||
command = Some(use_id);
|
||||
}
|
||||
if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() {
|
||||
@@ -397,6 +399,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
||||
command = Some(remove_id);
|
||||
}
|
||||
});
|
||||
if on {
|
||||
ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
command
|
||||
|
||||
@@ -187,6 +187,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("wrench", include_bytes!("../../../../assets/icons/wrench.svg")),
|
||||
("move-horizontal", include_bytes!("../../../../assets/icons/move-horizontal.svg")),
|
||||
("pilcrow", include_bytes!("../../../../assets/icons/pilcrow.svg")),
|
||||
("a-large-small", include_bytes!("../../../../assets/icons/a-large-small.svg")),
|
||||
("subscript", include_bytes!("../../../../assets/icons/subscript.svg")),
|
||||
("superscript", include_bytes!("../../../../assets/icons/superscript.svg")),
|
||||
("x", include_bytes!("../../../../assets/icons/x.svg")),
|
||||
|
||||
@@ -99,7 +99,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
|
||||
assert_eq!(ICONS.len(), 190);
|
||||
assert_eq!(ICONS.len(), 191);
|
||||
assert!(exists("minimize-2"));
|
||||
for (name, original) in ICONS {
|
||||
let expected =
|
||||
|
||||
@@ -3286,7 +3286,7 @@ fn hotbar_docked_left_does_not_override_quick_tools() {
|
||||
});
|
||||
h.run_steps(5);
|
||||
// Controls in the docked bar share one layout, so none may overlap (#5890).
|
||||
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"];
|
||||
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"];
|
||||
let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect();
|
||||
for (i, a) in rects.iter().enumerate() {
|
||||
for (j, b) in rects.iter().enumerate().skip(i + 1) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use egui::{Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig};
|
||||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
|
||||
|
||||
@@ -164,7 +164,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.text");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Font size:");
|
||||
h.get_by_label("Font size");
|
||||
h.state_mut().fill_text_size = 24.0;
|
||||
click(&mut h, 50.0, 250.0);
|
||||
assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0));
|
||||
@@ -201,6 +201,8 @@ fn fill_text_size_resizes_the_selected_text() {
|
||||
};
|
||||
let before = height(&h);
|
||||
let set_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click();
|
||||
h.run_steps(2);
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.find(|n| n.value().is_some_and(|v| v.ends_with(" pt")))
|
||||
.expect("the Font size control")
|
||||
@@ -211,6 +213,8 @@ fn fill_text_size_resizes_the_selected_text() {
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click();
|
||||
h.run_steps(2);
|
||||
};
|
||||
// Nothing selected: only the size for new text changes.
|
||||
h.state_mut().views[0].comments.selected = None;
|
||||
@@ -423,9 +427,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
|
||||
let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations);
|
||||
|
||||
// The quick toolbar exposes replacement without changing the normal placement action.
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Change signature").click();
|
||||
h.run_steps(2);
|
||||
@@ -445,9 +449,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
|
||||
click(&mut h, 40.0, 200.0);
|
||||
assert_eq!(items(&h).len(), 2);
|
||||
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Change initials").click();
|
||||
h.run_steps(2);
|
||||
@@ -1813,6 +1817,269 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() {
|
||||
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box");
|
||||
}
|
||||
|
||||
fn place_selected_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
|
||||
h.state_mut().execute("sign.fill.text");
|
||||
click(h, 50.0, 250.0);
|
||||
h.event(egui::Event::Text(text.into()));
|
||||
h.run_steps(1);
|
||||
commit_typed_text(h);
|
||||
let selected = {
|
||||
let s = h.state();
|
||||
s.session
|
||||
.get(s.views[0].id)
|
||||
.unwrap()
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.find(|a| a.contents.as_deref() == Some(text))
|
||||
.map(|a| (a.page, a.index))
|
||||
.unwrap()
|
||||
};
|
||||
h.state_mut().views[0].comments.selected = Some(selected);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn placed_height(h: &Harness<'static, PdfCraftApp>, text: &str) -> f32 {
|
||||
let s = h.state();
|
||||
let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap();
|
||||
a.rect[3] - a.rect[1]
|
||||
}
|
||||
|
||||
fn placed_color(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<[f32; 3]> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_text_size_flyout_opens_from_an_icon_and_leaves_a_stored_size_alone() {
|
||||
let mut h = harness();
|
||||
h.state_mut().fill_text_size = 9.1;
|
||||
place_selected_text(&mut h, "Keep me");
|
||||
let height = placed_height(&h, "Keep me");
|
||||
assert_eq!(h.query_all_by_role(egui::accesskit::Role::SpinButton).filter(|n| n.value().is_some_and(|v| v.ends_with(" pt"))).count(), 0);
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(4);
|
||||
assert!(
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton).any(|n| n.value().is_some_and(|v| v.ends_with(" pt"))),
|
||||
"the flyout holds the size"
|
||||
);
|
||||
assert_eq!(h.state().fill_text_size, 9.1, "opening the flyout does not round the size");
|
||||
assert_eq!(placed_height(&h, "Keep me"), height, "opening the flyout does not resize the selected text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_fill_text_size_slider_is_one_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Drag me");
|
||||
let height = placed_height(&h, "Drag me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let y = (slider.min.y + slider.max.y) / 2.0;
|
||||
let from = pos2(slider.min.x + 10.0, y);
|
||||
let to = pos2(slider.max.x - 10.0, y);
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(placed_height(&h, "Drag me"), height, "nothing is applied before the release");
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
assert!(placed_height(&h, "Drag me") > height, "the release resizes the selected text");
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Drag me"), height, "one undo takes the whole drag back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_the_fill_text_size_slider_without_moving_it_adds_no_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Hold me");
|
||||
let height = placed_height(&h, "Hold me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let at = pos2(slider.min.x + (slider.max.x - slider.min.x) * 0.75, (slider.min.y + slider.max.y) / 2.0);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
};
|
||||
press(&mut h);
|
||||
let resized = placed_height(&h, "Hold me");
|
||||
assert!(resized > height, "the first press moves the size: {height} -> {resized}");
|
||||
press(&mut h);
|
||||
assert_eq!(placed_height(&h, "Hold me"), resized);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Hold me"), height, "one undo takes back the only change; the second press added no step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_fill_text_size_that_rounds_to_the_stored_one_adds_no_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Round me");
|
||||
let height = placed_height(&h, "Round me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let type_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| {
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.find(|n| n.value().is_some_and(|v| v.ends_with(" pt")))
|
||||
.expect("the Font size control")
|
||||
.click();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.event(egui::Event::Text(value.into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
};
|
||||
type_size(&mut h, "36.1234");
|
||||
let resized = placed_height(&h, "Round me");
|
||||
assert!(resized > height, "the first size applies: {height} -> {resized}");
|
||||
type_size(&mut h, "36.1231");
|
||||
assert_eq!(h.state().fill_text_size, 36.1231);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Round me"), height, "one undo takes back the only change; the rounded retype added no step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_colour_dot_recolours_the_selected_text() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Paint me");
|
||||
let before = placed_color(&h, "Paint me");
|
||||
h.get_by_label("Colour").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Blue").click();
|
||||
h.run_steps(3);
|
||||
let color = h.state().fill_text_color.map(|v| v as f32);
|
||||
assert!(color[2] > color[0], "blue was selected: {color:?}");
|
||||
assert_ne!(before, Some(color));
|
||||
assert_eq!(placed_color(&h, "Paint me"), Some(color));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_color(&h, "Paint me"), before, "one undo takes the colour back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_bar_fill_button_shows_the_tool_it_picks() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Fill & Sign: Add signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Signature));
|
||||
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature));
|
||||
assert_eq!(h.state().dialog, None);
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
h.state_mut().execute("sign.fill.check");
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Checkmark").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_fill_tool_shows_without_replacing_the_last_selection() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.date");
|
||||
h.run_steps(2);
|
||||
h.state_mut().apply_full_screen(true);
|
||||
h.run_steps(2);
|
||||
h.state_mut().execute("sign.fill.check");
|
||||
h.run_steps(2);
|
||||
h.state_mut().apply_full_screen(false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date));
|
||||
h.get_by_label("Fill & Sign: Date");
|
||||
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Checkmark").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_tool_selected_while_the_quick_bar_is_hidden_is_remembered() {
|
||||
let mut h = harness();
|
||||
let mode = h.state().mode;
|
||||
h.state_mut().set_option("mode", "read").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().set_option("quick", "fill-date").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.state_mut().mode = mode;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date");
|
||||
|
||||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||||
h.state_mut().set_option("mode", "read").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().execute("sign.fill.initials.change");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Apply").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials));
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.state_mut().mode = mode;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Place saved initials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_bar_fill_button_keeps_date_after_deselecting_and_across_documents() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Fill & Sign: Add signature").click_secondary();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date));
|
||||
h.get_by_label("Select (V)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
|
||||
h.state_mut().open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
h.state_mut().active = Some(1);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "a click picks the shown tool, not the default");
|
||||
assert_eq!(h.state().dialog, None, "the default tool's dialog stays closed");
|
||||
h.get_by_label("Select (V)").click();
|
||||
h.run_steps(2);
|
||||
|
||||
h.state_mut().close_tab(1);
|
||||
h.state_mut().close_tab(0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_bytes("third.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "the tool outlives closing every document");
|
||||
}
|
||||
|
||||
fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box.
|
||||
h.hover_at(p);
|
||||
|
||||
@@ -100,6 +100,93 @@ fn nothing_is_asked_until_the_user_checks() {
|
||||
h.get_by_label_contains("PdfCraft 99.0.0 is available");
|
||||
}
|
||||
|
||||
/// The desktop app's start, with `settings` restored, and the count of what the source was asked.
|
||||
fn started_with(settings: &'static str) -> (Harness<'static, PdfCraftApp>, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = calls.clone();
|
||||
let h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_startup_update_check(true);
|
||||
app.restore(settings);
|
||||
let counted = counted.clone();
|
||||
app.set_update_source(Arc::new(move || {
|
||||
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(Some(release("v99.0.0")))
|
||||
}));
|
||||
app
|
||||
});
|
||||
(h, calls)
|
||||
}
|
||||
|
||||
/// Run frames for `time`, or until the source has been asked. The check at start shows nothing
|
||||
/// while it runs, so `settle` can't wait for it.
|
||||
fn run_until_asked(h: &mut Harness<'static, PdfCraftApp>, calls: &std::sync::atomic::AtomicUsize, time: std::time::Duration) {
|
||||
let deadline = std::time::Instant::now() + time;
|
||||
while calls.load(std::sync::atomic::Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_keeps_the_check_at_start_from_reaching_the_network() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
assert!(PdfCraftApp::new().update_check_at_start, "on by default");
|
||||
|
||||
// The same start with the preference left on does ask, so a count of none below means
|
||||
// the preference held the check back and not that it hadn't run yet.
|
||||
let (mut h, calls) = started_with("{}");
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_secs(30));
|
||||
assert_eq!(calls.load(SeqCst), 1, "on, the app asks at start");
|
||||
|
||||
let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#);
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500));
|
||||
assert_eq!(calls.load(SeqCst), 0, "nothing is asked at start");
|
||||
|
||||
let mut restarted = PdfCraftApp::new();
|
||||
restarted.restore(&h.state().persist());
|
||||
assert!(!restarted.update_check_at_start, "the choice is kept");
|
||||
|
||||
// Help ▸ Check for updates still asks.
|
||||
h.state_mut().execute("help.check_updates");
|
||||
settle(&mut h);
|
||||
assert_eq!(calls.load(SeqCst), 1);
|
||||
h.get_by_label_contains("PdfCraft 99.0.0 is available");
|
||||
}
|
||||
|
||||
/// The setting is about starting: turning it on asks nothing until the next start.
|
||||
#[test]
|
||||
fn turning_the_check_at_start_on_waits_for_the_next_start() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#);
|
||||
h.run_steps(3);
|
||||
h.state_mut().set_option("update_check_at_start", "true").unwrap();
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500));
|
||||
assert_eq!(calls.load(SeqCst), 0, "nothing is asked this session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_turn_the_check_at_start_off() {
|
||||
let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#);
|
||||
h.state_mut().execute("app.preferences");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Check for updates when PdfCraft starts").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().update_check_at_start);
|
||||
// The control channel's option, as an agent sets it.
|
||||
h.state_mut().set_option("update_check_at_start", "true").unwrap();
|
||||
assert!(h.state().update_check_at_start);
|
||||
assert!(h.state_mut().set_option("update_check_at_start", "maybe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_from_the_old_opt_in_leave_the_check_at_start_on() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.restore(r#"{"check_updates_at_start": false}"#);
|
||||
assert!(app.update_check_at_start, "a key of the removed option decides nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
|
||||
|
||||
+3
-1
@@ -23,7 +23,7 @@
|
||||
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: document installed-font helpers and regression contracts; behavior and estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: print-preview font policy matches output, with isolated caches and GBK switching regressions; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** patch (M2, PR #4172: reconcile current main and retain Japanese serif/weight selection alongside display-only CJK fallback; estimates unchanged)
|
||||
> **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 (M5: one reply in the Comments panel is edited, copied or deleted from its own "…" menu; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-26 · **Last updated:** 2026-10-26 · **Change:** minor (M11: lossless JBIG2 stores each distinct mark once, as a symbol; estimates unchanged)
|
||||
@@ -311,6 +311,7 @@ Estimates unchanged.
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer |
|
||||
| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 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. |
|
||||
@@ -325,6 +326,7 @@ Estimates unchanged.
|
||||
| 2026-10-23 | minor | M11: black-and-white images (1-bit, and image masks) are stored losslessly as CCITT Group 4 when that is smaller: PDF Optimizer ▸ Images ▸ Monochrome Images (on by default, so Reduce File Size too), `doc_optimize` `mono`; `pdfcraft_filters::encode_g4`. JBIG2 and monochrome downsampling not yet. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M6: calculation checks: a formula reading a field the form doesn't have, and a script building the names it reads, are flagged by `form_calculation_order` and on the Calculation order page (a red mark, a card naming the broken formulas, a note for built names); `form_calculation_order` gives each formula and `form_fields` a custom Calculate script. Estimates unchanged. |
|
||||
| 2026-10-29 | minor | M7 #2517: widening a paragraph rejoins a wrap with the spaces that wrap consumed, including a typed run and none for a Chinese or Japanese word split, after an incremental save and a full rewrite. Estimates unchanged. |
|
||||
| 2026-10-29 | minor | M2: non-interpolated images drawn below their resolution are area-averaged to whole device pixels instead of nearest-neighbour (scanned text kept one sample in N per axis); page-scan-size images are smoothed when enlarged. Estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. |
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
|
||||
@@ -734,8 +734,8 @@ milestone = "M2"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["page_render"]
|
||||
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it"]
|
||||
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value"
|
||||
evidence = ["crates/render/src/raster/tests.rs::renders_a_blue_rectangle", "crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/render/src/raster/tests.rs::user_unit_scales_the_page_and_everything_on_it", "crates/render/src/raster/tests.rs::non_interpolated_minification_keeps_thin_strokes", "crates/render/src/raster/tests.rs::a_shrunk_stencil_mask_keeps_thin_strokes", "crates/render/src/raster/tests.rs::minified_image_edges_snap_to_whole_pixels", "crates/render/src/raster/tests.rs::large_non_interpolated_images_are_smoothed_when_enlarged", "crates/render/src/raster/tests.rs::small_non_interpolated_images_keep_hard_pixels_when_enlarged"]
|
||||
notes = "hayro bootstrap renderer; own DisplayList devices in M2.6. Pages with /UserUnit are shown at their size in points, as Acrobat reads the value. An image drawn below its resolution is shrunk once to whole device pixels: by area averaging unless it asks for /Interpolate (then Catmull-Rom), so the text of a 300 dpi scan at 100% keeps its strokes, a 1-bit /ImageMask text layer included (it was nearest-neighbour, which dropped them). Enlarged by more than 1%, an image of page-scan size (512 samples or more on its shorter side) is smoothed bilinearly whatever its /Interpolate, so a zoomed scan has no staircases; smaller images keep their hard pixels. Not measured against Acrobat"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-shadings-patterns"
|
||||
@@ -1778,8 +1778,8 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = ["help.check_updates"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"]
|
||||
notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved"
|
||||
evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::turning_the_check_at_start_on_waits_for_the_next_start", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"]
|
||||
notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu; turning it on takes effect at the next start"
|
||||
|
||||
[[feature]]
|
||||
id = "view.single-key-accelerators"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+605
-25
@@ -193,6 +193,67 @@ fn image_buffer(len: usize) -> Option<Vec<u8>> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: `mask` sampled onto the grid of a `width` × `height` image (no side smaller
|
||||
/// than the mask's). Each image sample takes the mask sample under its centre or, for a mask that
|
||||
/// asks for `/Interpolate`, the bilinear blend of the four around it, so the mask's edges stay as
|
||||
/// smooth as when it was enlarged on its own. `None` for an empty side, an image side smaller than
|
||||
/// the mask's, or a buffer over the limits.
|
||||
fn mask_on_image_grid(mask: &LumaData, width: u32, height: u32) -> Option<Vec<u8>> {
|
||||
if width == 0 || height == 0 || mask.width == 0 || mask.height == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = image_buffer(source_byte_len(width, height, 1, MAX_IMAGE_PIXELS)?)?;
|
||||
let columns = grid_axis(mask.width, width, mask.interpolate)?;
|
||||
let rows = grid_axis(mask.height, height, mask.interpolate)?;
|
||||
let mask_width = mask.width as usize;
|
||||
let row = |y: usize| {
|
||||
let start = y.checked_mul(mask_width)?;
|
||||
mask.data.get(start..start.checked_add(mask_width)?)
|
||||
};
|
||||
let one = u64::from(GRID_ONE);
|
||||
for &(y0, y1, fy) in &rows {
|
||||
let (top, bottom, fy) = (row(y0)?, row(y1)?, u64::from(fy));
|
||||
for &(x0, x1, fx) in &columns {
|
||||
let fx = u64::from(fx);
|
||||
let blend = |r: &[u8]| Some(u64::from(*r.get(x0)?) * (one - fx) + u64::from(*r.get(x1)?) * fx);
|
||||
// At most 255 × GRID_ONE², so the sum fits in a u64 and the result in a u8.
|
||||
let v = (blend(top)? * (one - fy) + blend(bottom)? * fy + one * one / 2) / (one * one);
|
||||
out.push(v.min(255) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: fixed-point unit of [`grid_axis`]'s blend weights.
|
||||
const GRID_ONE: u32 = 1 << 16;
|
||||
|
||||
/// PdfCraft patch: for each of `dst` samples (`dst >= src`) taken from `src` along one axis, the
|
||||
/// two source samples it lies between and the weight of the second, in [`GRID_ONE`] units.
|
||||
/// Centres line up: target `t` lies at `(t + ½)·src/dst − ½` source samples, held to the ends.
|
||||
/// Without `smooth` both are the nearest sample, so the weight does not matter.
|
||||
fn grid_axis(src: u32, dst: u32, smooth: bool) -> Option<Vec<(usize, usize, u32)>> {
|
||||
if src == 0 || dst < src {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(dst as usize).ok()?;
|
||||
let last = u64::from(src - 1) << 16;
|
||||
for t in 0..dst {
|
||||
if !smooth {
|
||||
let s = nearest_source_index(src, dst, t);
|
||||
out.push((s, s, 0));
|
||||
continue;
|
||||
}
|
||||
// In u128, as `(2t + 1)·src·2^16` can pass 2^64 for a long side; the quotient is at
|
||||
// most `src·2^16`, so it fits back in a u64.
|
||||
let centre = ((2 * u128::from(t) + 1) * u128::from(src) << 16) / (2 * u128::from(dst));
|
||||
let at = u64::try_from(centre).ok()?.saturating_sub(u64::from(GRID_ONE / 2)).min(last);
|
||||
let s = (at >> 16) as usize;
|
||||
out.push((s, (s + 1).min(src as usize - 1), (at & 0xFFFF) as u32));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch (#624): the source index that PicScale's nearest-neighbour plan copies for
|
||||
/// destination `index` of `dst` samples taken from `src`. It is the same fixed-point centre
|
||||
/// sampling as `ResampleNearestPlan`, so these are the samples a nearest resize would copy.
|
||||
@@ -236,6 +297,255 @@ fn nearest_premultiplied_rgba(
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the shorter side, in samples, from which an image that did not ask for
|
||||
/// `/Interpolate` is still smoothed when enlarged. A page scanned at 72 dpi is 612 samples wide;
|
||||
/// icons, QR codes and pixel art are usually far smaller and keep their hard pixels.
|
||||
const SMOOTHED_IMAGE_SIDE: u32 = 512;
|
||||
|
||||
/// PdfCraft patch: the scale above which such an image counts as enlarged. A scan drawn at its own
|
||||
/// resolution can come out a few ulps above 1 (the scale is an `f32`: a 192 dpi scan on a 2×
|
||||
/// display at 100%), and sampled bilinearly at a half-pixel offset it blurred by half a pixel.
|
||||
/// Below 1% nearest-neighbour repeats at most one sample in a hundred.
|
||||
const SMOOTHED_IMAGE_SCALE: f32 = 1.01;
|
||||
|
||||
/// PdfCraft patch: fixed-point unit of [`area_shrink`]'s weights.
|
||||
const AREA_ONE: u64 = 1 << 16;
|
||||
|
||||
/// PdfCraft patch: the source samples target sample `t` covers when `src` samples shrink to `dst`
|
||||
/// (`dst <= src`), with their weights in [`AREA_ONE`] units. Target `t` spans `[t·src, (t+1)·src)`
|
||||
/// and source `s` spans `[s·dst, (s+1)·dst)`, both in units of 1/(src·dst) of the axis, so every
|
||||
/// overlap is exact. The weights are differences of one rounded running sum, so they add up to
|
||||
/// exactly [`AREA_ONE`] and a flat area keeps its exact value.
|
||||
fn area_taps(src: u32, dst: u32, t: u32) -> impl Iterator<Item = (usize, u64)> {
|
||||
let (src, dst, t) = (u64::from(src), u64::from(dst), u64::from(t));
|
||||
// `t < dst <= src <= 2^28` (MAX_IMAGE_PIXELS), so every product below fits in a u64.
|
||||
let (lo, hi) = (t * src, (t + 1) * src);
|
||||
let fixed = move |x: u64| (x * AREA_ONE + src / 2) / src;
|
||||
(lo / dst..hi.div_ceil(dst)).map(move |s| {
|
||||
let from = (s * dst).max(lo) - lo;
|
||||
let to = ((s + 1) * dst).min(hi) - lo;
|
||||
(s as usize, fixed(to) - fixed(from))
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: [`area_taps`] of every target sample of one axis, worked out once: for each
|
||||
/// target, the first source sample it covers and the weights of the samples from there on (the
|
||||
/// samples a target covers are consecutive). There are at most `src + dst` taps in all.
|
||||
struct AreaAxis {
|
||||
first: Vec<usize>,
|
||||
/// Where each target's weights start in `weights`, and where the last one's end.
|
||||
offsets: Vec<usize>,
|
||||
weights: Vec<u32>,
|
||||
}
|
||||
|
||||
impl AreaAxis {
|
||||
fn new(src: u32, dst: u32) -> Option<Self> {
|
||||
let n = dst as usize;
|
||||
let (mut first, mut offsets, mut weights) = (Vec::new(), Vec::new(), Vec::new());
|
||||
first.try_reserve_exact(n).ok()?;
|
||||
offsets.try_reserve_exact(n.checked_add(1)?).ok()?;
|
||||
weights.try_reserve_exact((src as usize).checked_add(n.checked_mul(2)?)?).ok()?;
|
||||
offsets.push(0);
|
||||
for t in 0..dst {
|
||||
let mut start = None;
|
||||
for (s, w) in area_taps(src, dst, t) {
|
||||
start.get_or_insert(s);
|
||||
// A weight is at most AREA_ONE, so it fits in a u32.
|
||||
weights.push(w as u32);
|
||||
}
|
||||
first.push(start?);
|
||||
offsets.push(weights.len());
|
||||
}
|
||||
Some(Self { first, offsets, weights })
|
||||
}
|
||||
|
||||
/// Target `t`'s first source sample and its weights.
|
||||
fn get(&self, t: usize) -> Option<(usize, &[u32])> {
|
||||
let (lo, hi) = (*self.offsets.get(t)?, *self.offsets.get(t.checked_add(1)?)?);
|
||||
Some((*self.first.get(t)?, self.weights.get(lo..hi)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: shrinks `N`-channel samples from `source` to `target` (no side larger than its
|
||||
/// source side) by area averaging: each target sample is the mean of the source it covers,
|
||||
/// weighted by how much of it it covers. Nothing is invented between samples, which is what
|
||||
/// `/Interpolate` governs (ISO 32000-2 §8.9.5.3, for images drawn above their resolution), and
|
||||
/// nothing is dropped: nearest-neighbour keeps one sample in N per axis, so a scan shown at screen
|
||||
/// size lost the strokes of its text. Premultiplied input stays premultiplied. `None` if nothing
|
||||
/// shrinks, the sizes don't fit or a buffer cannot be allocated.
|
||||
fn area_shrink<const N: usize>(data: &[u8], source: (u32, u32), target: (u32, u32)) -> Option<Vec<u8>> {
|
||||
if data.len() != source_byte_len(source.0, source.1, N, MAX_IMAGE_PIXELS)? {
|
||||
return None;
|
||||
}
|
||||
let row_len = (source.0 as usize).checked_mul(N)?;
|
||||
area_shrink_rows::<N>(source, target, |s, row| {
|
||||
let start = s.checked_mul(row_len)?;
|
||||
row.copy_from_slice(data.get(start..start.checked_add(row_len)?)?);
|
||||
Some(())
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: [`area_shrink`] of a colour image under its same-size soft mask (a stencil's
|
||||
/// ink under the stencil, or an image under a mask sampled onto its grid), premultiplied a source
|
||||
/// row at a time as it is averaged. The result equals premultiplying the expanded RGBA and
|
||||
/// shrinking that, without building it: a 10718 × 13701 stencil shown at page size peaked at
|
||||
/// 1.16 GB with the full-resolution RGBA, which is 587 MB.
|
||||
fn area_shrink_premultiplied(
|
||||
color: &ImageData,
|
||||
alpha: &[u8],
|
||||
source: (u32, u32),
|
||||
target: (u32, u32),
|
||||
) -> Option<Vec<u8>> {
|
||||
let (samples, channels) = match color {
|
||||
ImageData::Luma(luma) => (luma.data.as_slice(), 1),
|
||||
ImageData::Rgb(rgb) => (rgb.data.as_slice(), 3),
|
||||
};
|
||||
if samples.len() != source_byte_len(source.0, source.1, channels, MAX_IMAGE_PIXELS)?
|
||||
|| alpha.len() != source_byte_len(source.0, source.1, 1, MAX_IMAGE_PIXELS)?
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width = source.0 as usize;
|
||||
area_shrink_rows::<4>(source, target, |s, row| {
|
||||
let start = s.checked_mul(width)?;
|
||||
let end = start.checked_add(width)?;
|
||||
let colors = samples.get(start.checked_mul(channels)?..end.checked_mul(channels)?)?;
|
||||
let alphas = alpha.get(start..end)?;
|
||||
for ((out, px), a) in row.chunks_exact_mut(4).zip(colors.chunks_exact(channels)).zip(alphas) {
|
||||
let [r, g, b] = match px {
|
||||
[v] => [*v; 3],
|
||||
[r, g, b] => [*r, *g, *b],
|
||||
_ => return None,
|
||||
};
|
||||
out.copy_from_slice(&AlphaColor::from_rgba8(r, g, b, *a).premultiply().to_rgba8().to_u8_array());
|
||||
}
|
||||
Some(())
|
||||
})
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the area shrink behind [`area_shrink`], reading source row `s` through
|
||||
/// `read_row(s, row)` into a buffer of one row. Each target row averages the source rows it
|
||||
/// covers, and that row is shrunk across at once with taps worked out once per image, so work
|
||||
/// memory is one source row of samples, one of `u32` accumulators, one shrunk row and the
|
||||
/// horizontal taps (at most `sw + 2 × tw`): never the image. A side over
|
||||
/// `MAX_RESAMPLING_SOURCE_SIDE` is refused, so none of it exceeds a few times one source row.
|
||||
fn area_shrink_rows<const N: usize>(
|
||||
source: (u32, u32),
|
||||
target: (u32, u32),
|
||||
mut read_row: impl FnMut(usize, &mut [u8]) -> Option<()>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let ((sw, sh), (tw, th)) = (source, target);
|
||||
// A side over MAX_RESAMPLING_SOURCE_SIDE would make the accumulator row and the tap table
|
||||
// several times larger than the samples (a 2^27 × 2 image of zeros is a tiny file).
|
||||
if tw == 0
|
||||
|| th == 0
|
||||
|| tw > sw
|
||||
|| th > sh
|
||||
|| (tw, th) == (sw, sh)
|
||||
|| sw > MAX_RESAMPLING_SOURCE_SIDE
|
||||
|| sh > MAX_RESAMPLING_SOURCE_SIDE
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let row_len = (sw as usize).checked_mul(N)?;
|
||||
let zeroed = |len: usize| {
|
||||
let mut v = image_buffer(len)?;
|
||||
v.resize(len, 0);
|
||||
Some(v)
|
||||
};
|
||||
let (mut source_row, mut shrunk_row) = (zeroed(row_len)?, image_buffer(row_len)?);
|
||||
let mut acc: Vec<u32> = Vec::new();
|
||||
acc.try_reserve_exact(row_len).ok()?;
|
||||
acc.resize(row_len, 0);
|
||||
// The taps depend only on the widths, so they are worked out once, not again for each row.
|
||||
let taps = if tw == sw { None } else { Some(AreaAxis::new(sw, tw)?) };
|
||||
let out_len = match taps {
|
||||
None => source_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
|
||||
Some(_) => image_byte_len(tw, th, N, MAX_IMAGE_PIXELS)?,
|
||||
};
|
||||
let mut out = image_buffer(out_len)?;
|
||||
// Weights sum to AREA_ONE (2^16) and samples are at most 255, so every sum fits in a u32.
|
||||
let round = |acc: u32| ((acc + (AREA_ONE as u32) / 2) >> 16).min(255) as u8;
|
||||
// The last source row read: neighbouring target rows share the row on their boundary.
|
||||
let mut loaded = None;
|
||||
for t in 0..th {
|
||||
// Vertical: the whole source rows target row `t` covers, averaged.
|
||||
let row: &[u8] = if th == sh {
|
||||
read_row(t as usize, &mut source_row)?;
|
||||
&source_row
|
||||
} else {
|
||||
acc.fill(0);
|
||||
for (s, w) in area_taps(sh, th, t) {
|
||||
if loaded != Some(s) {
|
||||
read_row(s, &mut source_row)?;
|
||||
loaded = Some(s);
|
||||
}
|
||||
// A weight is at most AREA_ONE, so it fits in a u32.
|
||||
let w = w as u32;
|
||||
for (a, v) in acc.iter_mut().zip(&source_row) {
|
||||
*a += w * u32::from(*v);
|
||||
}
|
||||
}
|
||||
shrunk_row.clear();
|
||||
shrunk_row.extend(acc.iter().map(|a| round(*a)));
|
||||
&shrunk_row
|
||||
};
|
||||
// Horizontal: that row shrunk to `tw` samples.
|
||||
let Some(taps) = &taps else {
|
||||
out.extend_from_slice(row);
|
||||
continue;
|
||||
};
|
||||
for x in 0..tw as usize {
|
||||
let (first, weights) = taps.get(x)?;
|
||||
let samples = row.get(first * N..(first + weights.len()) * N)?;
|
||||
let mut acc = [0_u32; N];
|
||||
for (w, px) in weights.iter().zip(samples.chunks_exact(N)) {
|
||||
for (a, v) in acc.iter_mut().zip(px) {
|
||||
*a += w * u32::from(*v);
|
||||
}
|
||||
}
|
||||
out.extend(acc.map(round));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: [`area_shrink`] for an image's pixel format. If it cannot run, the image keeps
|
||||
/// its source resolution, as when [`Renderer::resize_image_data`] fails.
|
||||
fn area_resize(
|
||||
data: Vec<u8>,
|
||||
source: (u32, u32),
|
||||
target: (u32, u32),
|
||||
format: ImagePixelFormat,
|
||||
) -> (Vec<u8>, u32, u32) {
|
||||
let shrunk = match format {
|
||||
ImagePixelFormat::Luma => area_shrink::<1>(&data, source, target),
|
||||
ImagePixelFormat::Rgb => area_shrink::<3>(&data, source, target),
|
||||
ImagePixelFormat::Rgba => area_shrink::<4>(&data, source, target),
|
||||
};
|
||||
match shrunk {
|
||||
Some(out) => (out, target.0, target.1),
|
||||
None => {
|
||||
log::warn!(
|
||||
"Image area shrink failed or exceeded its buffer limit; falling back to source data"
|
||||
);
|
||||
(data, source.0, source.1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: premultiplies straight RGBA samples in place.
|
||||
fn premultiply_rgba(rgba: &mut [u8]) {
|
||||
let (chunks, _) = rgba.as_chunks_mut::<4>();
|
||||
for chunk in chunks {
|
||||
let [r, g, b, a] = *chunk;
|
||||
*chunk = AlphaColor::from_rgba8(r, g, b, a)
|
||||
.premultiply()
|
||||
.to_rgba8()
|
||||
.to_u8_array();
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: most pieces one dashed stroke may be cut into. Stroke expansion keeps every
|
||||
/// dash, so a pattern that is tiny next to its path asks for billions: a fuzzed `/D [[11] 0]` on
|
||||
/// a line from x = 92234775807 allocated over 5 GB, and `[0.000001] 0 d` on a 20 pt line over
|
||||
@@ -423,9 +733,12 @@ fn snap_minified_image(
|
||||
planned: (u32, u32),
|
||||
) -> Option<(u32, u32, Affine)> {
|
||||
let [a, b, c, d, e, f] = transform.as_coeffs();
|
||||
// Halves round up, not away from zero: a tile's origin moves edges by whole pixels, and only
|
||||
// then do they snap where the whole page snaps them (-2.5 must go to -2 as 2.5 goes to 3).
|
||||
let snap = |v: f64| (v + 0.5).floor();
|
||||
// The device span of a source edge from `origin` across `side` samples of `scale`, rounded.
|
||||
let edge = |origin: f64, scale: f64, side: u32| {
|
||||
let (p, q) = (origin.round(), (origin + scale * f64::from(side)).round());
|
||||
let (p, q) = (snap(origin), snap(origin + scale * f64::from(side)));
|
||||
let (lo, hi) = (p.min(q), p.max(q));
|
||||
(hi - lo >= 1.0 && lo.abs() < 1e6 && hi.abs() < 1e6).then_some((lo, hi))
|
||||
};
|
||||
@@ -935,17 +1248,51 @@ impl Renderer {
|
||||
return;
|
||||
}
|
||||
let interpolate = image_data.interpolate();
|
||||
if let Some(a) = &alpha_data {
|
||||
let requested_resize = x_scale < 1.0 || y_scale < 1.0;
|
||||
// PdfCraft patch: an image that did not ask for interpolation and is drawn below its
|
||||
// resolution is shrunk by area averaging (`area_shrink`), not nearest-neighbour, which
|
||||
// broke up the text of scans shown at screen size. Type 3 glyph images keep the nearest
|
||||
// shrink of #624: they are drawn with a frame of their own and never snapped.
|
||||
let area = !interpolate
|
||||
&& requested_resize
|
||||
&& !self.in_type3_glyph
|
||||
// Wider sources keep #624's bounded nearest-neighbour shrink (`area_shrink` refuses
|
||||
// them): skipping them, as a refused shrink at source size would, loses the image.
|
||||
&& source_width.max(source_height) <= MAX_RESAMPLING_SOURCE_SIDE;
|
||||
let mut alpha_data = alpha_data;
|
||||
if let Some(a) = alpha_data.take() {
|
||||
if source_byte_len(a.width, a.height, 1, MAX_IMAGE_PIXELS) != Some(a.data.len()) {
|
||||
log::warn!("Image has invalid or over-limit alpha dimensions/data and was skipped");
|
||||
return;
|
||||
}
|
||||
if a.width != source_width || a.height != source_height || a.interpolate != interpolate
|
||||
{
|
||||
if let Some(alpha) = alpha_data {
|
||||
self.draw_image_with_alpha_mask(image_data, alpha);
|
||||
// PdfCraft patch: a soft mask no larger than its area-shrunk image is sampled onto
|
||||
// the image's grid, so colour and mask are averaged together, premultiplied. Drawn
|
||||
// as a mask layer over the image instead, the averaged colour (transparent samples
|
||||
// included) was multiplied by the averaged mask, and the colour of transparent
|
||||
// samples showed at the mask's edges. A larger mask carries detail the image does
|
||||
// not (#1649) and keeps the layer.
|
||||
let on_grid = (area && a.width <= source_width && a.height <= source_height)
|
||||
.then(|| mask_on_image_grid(&a, source_width, source_height))
|
||||
.flatten();
|
||||
match on_grid {
|
||||
Some(data) => {
|
||||
alpha_data = Some(LumaData {
|
||||
data,
|
||||
width: source_width,
|
||||
height: source_height,
|
||||
interpolate,
|
||||
..a
|
||||
});
|
||||
}
|
||||
None => {
|
||||
self.draw_image_with_alpha_mask(image_data, a);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
alpha_data = Some(a);
|
||||
}
|
||||
}
|
||||
let mut quality = if interpolate {
|
||||
@@ -955,7 +1302,6 @@ impl Renderer {
|
||||
};
|
||||
let has_alpha = alpha_data.is_some();
|
||||
let mut may_have_transparency = has_alpha || self.force_images_may_have_transparency();
|
||||
let requested_resize = x_scale < 1.0 || y_scale < 1.0;
|
||||
let (new_width, new_height) = match image_resampling_size(
|
||||
source_width,
|
||||
source_height,
|
||||
@@ -985,12 +1331,28 @@ impl Renderer {
|
||||
if requested_resize && self.in_type3_glyph && interpolate {
|
||||
quality = ImageQuality::High;
|
||||
}
|
||||
// PdfCraft patch: an interpolated image that is shrunk, with its axes along the device's
|
||||
// (upright, mirrored or a quarter turn), lands on whole device pixels. It is shrunk once to
|
||||
// exactly its snapped device rectangle and drawn 1:1 from the snapped corner. Otherwise the
|
||||
// shrunk raster (rounded up to a whole size) is sampled a second time at a fractional scale
|
||||
// and offset, which blurs scans and photos.
|
||||
let snapped = if interpolate
|
||||
// PdfCraft patch: an image the size of a page scan is smoothed when drawn above its
|
||||
// resolution, even without `/Interpolate`: bilinearly, as images that ask for it are.
|
||||
// Scans rarely ask, and drawn nearest-neighbour each sample of a zoomed scan became a
|
||||
// block, so the edges of its text were staircases. Bicubic looked the same on a zoomed
|
||||
// 300 dpi scan and drew its tiles five times slower. Smaller images (icons, QR codes,
|
||||
// pixel art) keep their hard pixels. A scan shrunk on one side and stretched on the other
|
||||
// is smoothed too: the area shrink leaves the shrunk side at 1:1, where bilinear adds
|
||||
// nothing, and the stretched side would otherwise be drawn nearest-neighbour.
|
||||
if !interpolate
|
||||
&& !self.in_type3_glyph
|
||||
&& (x_scale > SMOOTHED_IMAGE_SCALE || y_scale > SMOOTHED_IMAGE_SCALE)
|
||||
&& source_width.min(source_height) >= SMOOTHED_IMAGE_SIDE
|
||||
{
|
||||
quality = ImageQuality::Medium;
|
||||
}
|
||||
// PdfCraft patch: an image that is shrunk (filtered or area-averaged), with its axes along
|
||||
// the device's (upright, mirrored or a quarter turn), lands on whole device pixels. It is
|
||||
// shrunk once to exactly its snapped device rectangle and drawn 1:1 from the snapped
|
||||
// corner. Otherwise the shrunk raster (rounded up to a whole size) is sampled a second time
|
||||
// at a fractional scale and offset, which blurs scans and photos (or, by the nearest
|
||||
// sampler of a non-interpolated image, drops samples again).
|
||||
let snapped = if (interpolate || area)
|
||||
&& requested_resize
|
||||
&& !self.in_type3_glyph
|
||||
&& (new_width, new_height) != (source_width, source_height)
|
||||
@@ -1000,13 +1362,31 @@ impl Renderer {
|
||||
None
|
||||
};
|
||||
let (new_width, new_height) = snapped.map_or((new_width, new_height), |s| (s.0, s.1));
|
||||
// PdfCraft patch: an area shrink never enlarges. A side drawn at or above its resolution
|
||||
// (an image shrunk across and stretched down, as a 204 × 98 dpi fax is, or a snapped side
|
||||
// rounded past its source) keeps its samples and is enlarged by the sampler; the snap
|
||||
// then no longer matches, so the full transform places the image.
|
||||
let (new_width, new_height) = if area {
|
||||
(new_width.min(source_width), new_height.min(source_height))
|
||||
} else {
|
||||
(new_width, new_height)
|
||||
};
|
||||
let needs_resize = (new_width, new_height) != (source_width, source_height);
|
||||
|
||||
// PdfCraft patch (#624): a minified image with a soft mask that is not interpolated is
|
||||
// nearest-sampled straight into premultiplied target RGBA. That skips the full-resolution
|
||||
// RGBA copy below and the premultiply pass after it.
|
||||
// PdfCraft patch (#624): a minified image with a soft mask that is neither interpolated nor
|
||||
// area-averaged (a Type 3 glyph image, or a source wider than MAX_RESAMPLING_SOURCE_SIDE)
|
||||
// is nearest-sampled straight into premultiplied target RGBA. That skips the
|
||||
// full-resolution RGBA copy below and the premultiply pass after it. PdfCraft patch: an
|
||||
// area-averaged one (a stencil's ink, or an image under its mask) skips them too, and is
|
||||
// premultiplied a source row at a time as it is averaged (`area_shrink_premultiplied`).
|
||||
let direct = match &alpha_data {
|
||||
Some(alpha) if needs_resize && !interpolate => nearest_premultiplied_rgba(
|
||||
Some(alpha) if needs_resize && !interpolate && !area => nearest_premultiplied_rgba(
|
||||
&image_data,
|
||||
&alpha.data,
|
||||
(source_width, source_height),
|
||||
(new_width, new_height),
|
||||
),
|
||||
Some(alpha) if needs_resize && area => area_shrink_premultiplied(
|
||||
&image_data,
|
||||
&alpha.data,
|
||||
(source_width, source_height),
|
||||
@@ -1014,7 +1394,7 @@ impl Renderer {
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let premultiplied = direct.is_some();
|
||||
let mut premultiplied = direct.is_some();
|
||||
// Preserve the single-channel/RGB fast paths. With alpha, expand directly to RGBA;
|
||||
// every source and alpha length was validated before zipping or allocating.
|
||||
let (data, format) = match (direct, image_data, alpha_data) {
|
||||
@@ -1057,6 +1437,15 @@ impl Renderer {
|
||||
};
|
||||
let (data, mut img_width, mut img_height) = if premultiplied {
|
||||
(data, new_width, new_height)
|
||||
} else if needs_resize && area {
|
||||
let mut data = data;
|
||||
if matches!(format, ImagePixelFormat::Rgba) {
|
||||
// Averaging straight colours would bleed the colour of transparent samples into
|
||||
// their neighbours; premultiplied colours count each by its coverage.
|
||||
premultiply_rgba(&mut data);
|
||||
premultiplied = true;
|
||||
}
|
||||
area_resize(data, (source_width, source_height), (new_width, new_height), format)
|
||||
} else if needs_resize {
|
||||
self.resize_image_data(
|
||||
data,
|
||||
@@ -1103,14 +1492,7 @@ impl Renderer {
|
||||
rgba
|
||||
};
|
||||
if has_alpha && !premultiplied {
|
||||
let (chunks, _) = rgba_data.as_chunks_mut::<4>();
|
||||
for chunk in chunks {
|
||||
let [r, g, b, a] = *chunk;
|
||||
*chunk = AlphaColor::from_rgba8(r, g, b, a)
|
||||
.premultiply()
|
||||
.to_rgba8()
|
||||
.to_u8_array();
|
||||
}
|
||||
premultiply_rgba(&mut rgba_data);
|
||||
}
|
||||
// Type 3 glyphs get a transparent two-pixel frame for interpolation. Check the padded
|
||||
// RGBA allocation and u16 dimensions too, before narrowing or growing either side.
|
||||
@@ -2635,3 +3017,201 @@ mod snap_tests {
|
||||
assert!(snap_minified_image(&turned_noisy, source, (20_000, 200)).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the area-averaging shrink of non-interpolated images.
|
||||
#[cfg(test)]
|
||||
mod area_shrink_tests {
|
||||
use super::{
|
||||
AREA_ONE, AreaAxis, ImageData, LumaData, MAX_IMAGE_PIXELS, RgbData, area_shrink,
|
||||
area_shrink_premultiplied, area_taps, mask_on_image_grid, premultiply_rgba,
|
||||
};
|
||||
|
||||
/// Deterministic samples that vary from one to the next.
|
||||
fn noise(len: usize, seed: u32) -> Vec<u8> {
|
||||
let mut x = seed.wrapping_mul(2_654_435_761) | 1;
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
(x >> 24) as u8
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The shrink as two whole passes, every row averaged down and then every row across, each
|
||||
/// rounded to a byte: what `area_shrink` did before it worked a row at a time.
|
||||
fn two_pass_shrink<const N: usize>(data: &[u8], (sw, sh): (u32, u32), (tw, th): (u32, u32)) -> Vec<u8> {
|
||||
let round = |acc: u64| ((acc + AREA_ONE / 2) >> 16).min(255) as u8;
|
||||
let row_len = sw as usize * N;
|
||||
let tall: Vec<u8> = (0..th)
|
||||
.flat_map(|t| {
|
||||
(0..row_len).map(move |i| {
|
||||
round(area_taps(sh, th, t).map(|(s, w)| w * u64::from(data[s * row_len + i])).sum())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
tall.chunks_exact(row_len)
|
||||
.flat_map(|row| {
|
||||
(0..tw).flat_map(move |t| {
|
||||
(0..N).map(move |c| round(area_taps(sw, tw, t).map(|(s, w)| w * u64::from(row[s * N + c])).sum()))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Working a row at a time gives the same bytes as two whole passes, in both directions, one
|
||||
/// direction alone, and with source rows shared by neighbouring targets.
|
||||
#[test]
|
||||
fn a_row_at_a_time_shrink_equals_two_whole_passes() {
|
||||
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1)), ((31, 17), (12, 7))];
|
||||
for (i, (source, target)) in sizes.into_iter().enumerate() {
|
||||
let gray = noise((source.0 * source.1) as usize, i as u32);
|
||||
assert_eq!(area_shrink::<1>(&gray, source, target), Some(two_pass_shrink::<1>(&gray, source, target)), "{source:?} → {target:?}");
|
||||
let rgba = noise((source.0 * source.1 * 4) as usize, i as u32 + 99);
|
||||
assert_eq!(area_shrink::<4>(&rgba, source, target), Some(two_pass_shrink::<4>(&rgba, source, target)), "{source:?} → {target:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplying a row at a time as it is averaged gives the same bytes as premultiplying the
|
||||
/// whole expanded RGBA first and shrinking that, for gray and RGB colour.
|
||||
#[test]
|
||||
fn premultiplying_row_by_row_equals_premultiplying_the_whole_image() {
|
||||
let sizes = [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((31, 17), (12, 7))];
|
||||
for (i, (source, target)) in sizes.into_iter().enumerate() {
|
||||
let (w, h) = source;
|
||||
let pixels = (w * h) as usize;
|
||||
let alpha = noise(pixels, i as u32 + 7);
|
||||
let gray = noise(pixels, i as u32 + 11);
|
||||
let rgb = noise(pixels * 3, i as u32 + 13);
|
||||
let colors = [
|
||||
ImageData::Luma(LumaData { data: gray.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
|
||||
ImageData::Rgb(RgbData { data: rgb.clone(), width: w, height: h, interpolate: false, scale_factors: (1.0, 1.0) }),
|
||||
];
|
||||
for color in &colors {
|
||||
let mut whole: Vec<u8> = match color {
|
||||
ImageData::Luma(_) => gray.iter().zip(&alpha).flat_map(|(g, a)| [*g, *g, *g, *a]).collect(),
|
||||
ImageData::Rgb(_) => rgb.chunks_exact(3).zip(&alpha).flat_map(|(c, a)| [c[0], c[1], c[2], *a]).collect(),
|
||||
};
|
||||
premultiply_rgba(&mut whole);
|
||||
assert_eq!(
|
||||
area_shrink_premultiplied(color, &alpha, source, target),
|
||||
area_shrink::<4>(&whole, source, target),
|
||||
"{source:?} → {target:?}"
|
||||
);
|
||||
}
|
||||
let short = alpha.get(1..).unwrap_or_default();
|
||||
assert_eq!(area_shrink_premultiplied(&colors[0], short, source, target), None, "alpha shorter than the image");
|
||||
}
|
||||
}
|
||||
|
||||
fn mask(data: &[u8], width: u32, height: u32) -> LumaData {
|
||||
LumaData { data: data.to_vec(), width, height, interpolate: false, scale_factors: (1.0, 1.0) }
|
||||
}
|
||||
|
||||
/// Each image sample takes the mask sample under its centre, at whole and fractional ratios.
|
||||
#[test]
|
||||
fn a_mask_is_sampled_onto_the_image_grid() {
|
||||
assert_eq!(mask_on_image_grid(&mask(&[10, 20], 2, 1), 4, 2), Some(vec![10, 10, 20, 20, 10, 10, 20, 20]));
|
||||
// Centres of 7 samples over 3: 0.5/7·3 = 0.21 → 0, 1.5/7·3 = 0.64 → 0, 2.5/7·3 = 1.07 → 1, …
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3], 3, 1), 7, 1), Some(vec![1, 1, 2, 2, 2, 3, 3]));
|
||||
}
|
||||
|
||||
/// A mask that asks for `/Interpolate` is blended between the samples around each centre,
|
||||
/// and held at its ends: the centres of 4 samples over 2 lie at -¼, ¼, ¾ and 1¼.
|
||||
#[test]
|
||||
fn an_interpolated_mask_is_blended_onto_the_image_grid() {
|
||||
let smooth = |data: &[u8], width, height| LumaData { interpolate: true, ..mask(data, width, height) };
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 2, 1), 4, 2), Some(vec![0, 64, 191, 255, 0, 64, 191, 255]));
|
||||
// Centres of 7 samples over 3 lie 3/7 apart, from -2/7 to 2 2/7: a ramp, flat at the ends.
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 70, 140], 3, 1), 7, 1), Some(vec![0, 10, 40, 70, 100, 130, 140]));
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[0, 255], 1, 2), 1, 4), Some(vec![0, 64, 191, 255]));
|
||||
assert_eq!(mask_on_image_grid(&smooth(&[7, 9], 2, 1), 2, 1), Some(vec![7, 9]), "same size is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_masks_are_not_sampled() {
|
||||
assert_eq!(mask_on_image_grid(&mask(&[], 0, 1), 4, 1), None, "an empty mask");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2], 2, 1), 0, 1), None, "an empty image");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1], 2, 1), 4, 1), None, "mask data shorter than its size");
|
||||
assert_eq!(mask_on_image_grid(&mask(&[1, 2, 3, 4], 1, 4), 1, 2), None, "a mask larger than the image");
|
||||
}
|
||||
|
||||
/// Every target sample's weights add up to exactly one, and together the targets take every
|
||||
/// source sample exactly once (each `dst / src` of it, in fixed point within rounding).
|
||||
#[test]
|
||||
fn taps_are_exact_partitions_of_unity() {
|
||||
for src in 1..=40_u32 {
|
||||
for dst in 1..=src {
|
||||
let mut taken = vec![0_u64; src as usize];
|
||||
for t in 0..dst {
|
||||
let taps: Vec<_> = area_taps(src, dst, t).collect();
|
||||
assert_eq!(taps.iter().map(|(_, w)| w).sum::<u64>(), AREA_ONE, "{src} → {dst}, target {t}");
|
||||
assert!(taps.iter().all(|(_, w)| *w > 0), "{src} → {dst}, target {t}: an empty tap");
|
||||
for (s, w) in taps {
|
||||
taken[s] += w;
|
||||
}
|
||||
}
|
||||
// Source s is dst/src of one target in all; scaled by src/dst, that is one.
|
||||
for (s, w) in taken.iter().enumerate() {
|
||||
let share = (*w * u64::from(src)) as f64 / (AREA_ONE * u64::from(dst)) as f64;
|
||||
assert!((share - 1.0).abs() < 1e-3, "{src} → {dst}: source {s} weighs {share}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The table the horizontal pass reads holds exactly the taps of every target, in order.
|
||||
#[test]
|
||||
fn the_tap_table_holds_every_targets_taps() {
|
||||
for src in 1..=40_u32 {
|
||||
for dst in 1..=src {
|
||||
let axis = AreaAxis::new(src, dst).expect("table");
|
||||
for t in 0..dst {
|
||||
let (first, weights) = axis.get(t as usize).expect("target");
|
||||
let expected: Vec<(usize, u64)> = area_taps(src, dst, t).collect();
|
||||
let got: Vec<(usize, u64)> = weights.iter().enumerate().map(|(k, w)| (first + k, u64::from(*w))).collect();
|
||||
assert_eq!(got, expected, "{src} → {dst}, target {t}");
|
||||
}
|
||||
assert_eq!(axis.get(dst as usize), None, "{src} → {dst}: no target past the last");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest side an image may have still computes in u64 without overflowing.
|
||||
#[test]
|
||||
fn taps_at_the_pixel_limit_do_not_overflow() {
|
||||
let side = MAX_IMAGE_PIXELS as u32;
|
||||
for (dst, t) in [(1, 0), (3, 2), (side - 1, side - 2), (side, side - 1)] {
|
||||
assert_eq!(area_taps(side, dst, t).map(|(_, w)| w).sum::<u64>(), AREA_ONE, "{side} → {dst}, target {t}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_images_keep_their_exact_value() {
|
||||
for (source, target) in [((7, 5), (3, 2)), ((10, 3), (6, 3)), ((4, 9), (4, 4)), ((13, 11), (1, 1))] {
|
||||
let data = vec![77_u8; (source.0 * source.1 * 3) as usize];
|
||||
let out = area_shrink::<3>(&data, source, target).expect("shrinks");
|
||||
assert_eq!(out.len(), (target.0 * target.1 * 3) as usize);
|
||||
assert!(out.iter().all(|v| *v == 77), "{source:?} → {target:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A side over the resampling limit is refused before any work buffer is allocated.
|
||||
#[test]
|
||||
fn sides_over_the_resampling_limit_are_refused() {
|
||||
let wide = (super::MAX_RESAMPLING_SOURCE_SIDE + 1) as usize;
|
||||
assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (wide as u32, 1), (10, 1)), None, "too wide");
|
||||
assert_eq!(area_shrink::<1>(&vec![0_u8; wide], (1, wide as u32), (1, 10)), None, "too tall");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_shrinks_are_refused() {
|
||||
let data = vec![0_u8; 4 * 4];
|
||||
assert_eq!(area_shrink::<1>(&data, (4, 4), (4, 4)), None, "nothing shrinks");
|
||||
assert_eq!(area_shrink::<1>(&data, (4, 4), (5, 2)), None, "a side grows");
|
||||
assert_eq!(area_shrink::<1>(&data, (4, 4), (0, 2)), None, "an empty side");
|
||||
assert_eq!(area_shrink::<1>(&data[1..], (4, 4), (2, 2)), None, "data shorter than the source");
|
||||
assert_eq!(area_shrink::<3>(&data, (4, 4), (2, 2)), None, "data of another channel count");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user