M2: keep the raw crop minimum in physical points (#1982)

Apply the minimum after page-unit scaling without inflating small raw crops. Retain the existing unit policy and nearly-zero fallback, with regression coverage for origins, rotations and reciprocal rounding.

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
sanyamsingh001
2026-10-15 02:36:12 -04:00
committed by GitHub
co-authored by sanyamsingh001 Codex Brandon Thomas
parent 5fffd4cacd
commit d3571c4019
5 changed files with 202 additions and 2 deletions
+1
View File
@@ -162,6 +162,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged.
- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged.
- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged.
- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
+188
View File
@@ -5178,6 +5178,194 @@ trailer << /Root 1 0 R >>
}
}
// Contributor-original classic-xref vector fixtures; no embedded or external assets.
fn raw_crop_unit_pdf(unit: f64, rotation: i32, crop: [f64; 4], marker: Option<[f64; 4]>) -> Vec<u8> {
let bounds = crop.map(|n| n.to_string()).join(" ");
let body = marker.map(|rect| format!("0 0 0 rg {} re f\n", rect.map(|n| n.to_string()).join(" "))).unwrap_or_default();
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Rotate {rotation} /Resources << >> /Contents 4 0 R >>"
),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
#[test]
fn raw_crop_user_unit_inspection_keeps_observed_dimensions() {
let bytes = raw_crop_unit_pdf(4096.0, 0, [0.0, 0.0, 0.1, 0.1], None);
let info = crate::inspect(Arc::new(bytes), None).expect("original valid raw-0.1 PDF");
assert_eq!(info.pages.len(), 1);
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
let page = &info.pages[0];
assert!(
(page.width - 409.6).abs() < 0.0001 && (page.height - 409.6).abs() < 0.0001,
"raw 0.1 at UserUnit4096 is 409.6 points, got {} by {}",
page.width,
page.height
);
}
#[test]
fn raw_crop_unit_floor_corners_match_nonzero_origin_and_rotations() {
use hayro::hayro_interpret::TransformExt;
for (unit, width, height, dx, dy) in
[(1.0, 1.0, 1.0, 0.125, 0.25), (4096.0, 512.0, 1024.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0, 9375.0, 18750.0)]
{
for rotation in [0, 90, 180, 270] {
let bytes = raw_crop_unit_pdf(unit, rotation, [10.0, 20.0, 10.125, 20.25], None);
let info = crate::inspect(Arc::new(bytes.clone()), None).expect("original valid offset crop");
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
let dimensions = if rotation % 180 == 0 { (width as f32, height as f32) } else { (height as f32, width as f32) };
assert_eq!((info.pages[0].width, info.pages[0].height), dimensions, "unit={unit}, rotation={rotation}");
let pdf = Pdf::new(Arc::new(bytes)).expect("same PDF parser");
let page = pdf.pages().first().expect("one page");
assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32));
let transform = page.initial_transform(true).to_kurbo();
let (origin, opposite) = match rotation {
0 => ([0.0, height], [dx, height - dy]),
90 => ([0.0, 0.0], [dy, dx]),
180 => ([width, 0.0], [width - dx, dy]),
_ => ([height, width], [height - dy, width - dx]),
};
for (point, expected) in [(kurbo::Point::new(10.0, 20.0), origin), (kurbo::Point::new(10.125, 20.25), opposite)] {
let actual = transform * point;
assert!(
(actual.x - expected[0]).abs() < 1e-9 && (actual.y - expected[1]).abs() < 1e-9,
"unit={unit}, rotation={rotation}, point={point:?}: {actual:?} != {expected:?}"
);
}
}
}
}
#[test]
fn raw_crop_unit_floor_reciprocal_does_not_trigger_letter_fallback() {
for unit in [1.0, 41.0, 47.0] {
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, 0.01, 0.01], None);
let pdf = Pdf::new(Arc::new(bytes.clone())).expect("ordinary small crop");
let page = pdf.pages().first().expect("one page");
let (width, height) = page.base_dimensions();
// In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994.
assert!(
width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0,
"unit={unit}: floor must not round below one physical point"
);
let info = crate::inspect(Arc::new(bytes), None).expect("inspect the same small crop");
let page = &info.pages[0];
assert!(
(1.0..=1.000001).contains(&page.width) && (1.0..=1.000001).contains(&page.height),
"unit={unit}: one-point floor must not become a Letter placeholder: {} by {}",
page.width,
page.height
);
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
}
#[test]
fn raw_crop_unit_floor_preserves_nearly_zero_fallback() {
for unit in [1.0, 4096.0, 75000.0] {
for side in [0.0, 0.0001] {
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None);
let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback");
let page = pdf.pages().first().expect("one page");
// Existing A4 raw fallback remains raw A4, then scales by the page unit.
let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32);
assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}");
assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32));
}
}
}
fn raw_crop_ink(page: &RenderedPage) -> ([u32; 4], usize) {
let (mut left, mut top, mut right, mut bottom, mut count) = (page.width, page.height, 0, 0, 0);
for (i, pixel) in page.rgba.as_chunks::<4>().0.iter().enumerate() {
if pixel[0] < 128 && pixel[1] < 128 && pixel[2] < 128 {
let (x, y) = (i as u32 % page.width, i as u32 / page.width);
left = left.min(x);
top = top.min(y);
right = right.max(x + 1);
bottom = bottom.max(y + 1);
count += 1;
}
}
assert!(count > 0, "original vector marker must draw");
([left, top, right, bottom], count)
}
#[test]
fn raw_crop_user_unit_vector_rasters_match_physical_controls() {
for (unit, scale, width, height, physical_width, physical_height, rects) in [
(
4096.0,
0.5,
256,
512,
512.0,
1024.0,
[[32.0, 320.0, 96.0, 448.0], [64.0, 32.0, 192.0, 96.0], [160.0, 64.0, 224.0, 192.0], [320.0, 160.0, 448.0, 224.0]],
),
(
75000.0,
0.04,
375,
750,
9375.0,
18750.0,
[
[46.875, 468.75, 140.625, 656.25],
[93.75, 46.875, 281.25, 140.625],
[234.375, 93.75, 328.125, 281.25],
[468.75, 234.375, 656.25, 328.125],
],
),
] {
for (index, rotation) in [0, 90, 180, 270].into_iter().enumerate() {
let raw_crop = [10.0, 20.0, 10.125, 20.25];
let raw_marker = [10.015625, 20.03125, 0.03125, 0.0625];
let control_crop = raw_crop.map(|n| n * unit);
let control_marker = raw_marker.map(|n| n * unit);
let physical = if rotation % 180 == 0 { (physical_width, physical_height) } else { (physical_height, physical_width) };
let pixels = if rotation % 180 == 0 { (width, height) } else { (height, width) };
let render = |bytes: Vec<u8>| {
let info = crate::inspect(Arc::new(bytes.clone()), None).expect("bounded original vector PDF");
assert_eq!((info.pages[0].width, info.pages[0].height), physical, "check dimensions before any raster allocation");
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), pixels);
page
};
let high = render(raw_crop_unit_pdf(unit, rotation, raw_crop, Some(raw_marker)));
let control = render(raw_crop_unit_pdf(1.0, rotation, control_crop, Some(control_marker)));
let (bbox, count) = raw_crop_ink(&high);
for (actual, expected) in bbox.into_iter().zip(rects[index]) {
assert!((f64::from(actual) - expected).abs() <= 1.0, "unit={unit}, rotation={rotation}: {bbox:?} != {:?}", rects[index]);
}
let (control_bbox, control_count) = raw_crop_ink(&control);
assert_eq!(bbox, control_bbox, "unit={unit}, rotation={rotation}: marker placement");
assert_eq!(count, control_count, "unit={unit}, rotation={rotation}: visible marker ink");
assert!(high.rgba == control.rgba, "unit={unit}, rotation={rotation}: physically equivalent crops render identically");
}
}
}
/// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on
/// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with
/// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in
+1
View File
@@ -93,6 +93,7 @@ total matches the parity estimate.
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 98% | 1–2 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. |
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
+2
View File
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -558,9 +558,17 @@ impl<'a> Page<'a> {
{
(A4.width() as f32, A4.height() as f32)
} else {
// PdfCraft patch: retain a one-point floor after UserUnit, not one raw unit.
// The stored unit is finite and within 1..=75000. Round upward only if the
// reciprocal's f32 product would fall below one and trigger a placeholder.
let mut raw_floor = 1.0 / self.user_unit;
if raw_floor * self.user_unit < 1.0 {
raw_floor = raw_floor.next_up();
}
let raw_floor = f64::from(raw_floor);
(
crop_box.width().max(1.0) as f32,
crop_box.height().max(1.0) as f32,
crop_box.width().max(raw_floor) as f32,
crop_box.height().max(raw_floor) as f32,
)
}
}