From 90def111c90ef93cc77f52b1444534aba4ac533e Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Mon, 9 Nov 2026 08:31:41 +0800 Subject: [PATCH] fix(engine): map URL text line rects with view_rect_to_user and filter non-finite coordinates (#6536) * fix(engine): map URL text line rects with view_rect_to_user and filter non-finite coordinates * test(engine): assert concrete rotated user rects and overflow guards (#6469) --- crates/engine/src/session/view.rs | 121 +++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 5ef135d6c8..711779903a 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -120,17 +120,7 @@ impl Session { let rects: Vec<[f64; 4]> = text .line_rects(glyphs) .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) + .filter_map(|v| line_user_rect(&doc.info.pages[page], v)) .filter(|r| { !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) }) @@ -313,3 +303,112 @@ impl Session { &self.docs } } + +/// Convert a view-space text line rectangle into a normalized, finite PDF user-space rectangle. +fn line_user_rect(page: &pdfcraft_render::PageInfo, v: [f32; 4]) -> Option<[f64; 4]> { + if !v.iter().all(|x| x.is_finite()) { + return None; + } + let r = page.view_rect_to_user(v).map(f64::from); + r.iter().all(|x| x.is_finite()).then_some(r) +} + +#[cfg(test)] +mod tests { + use super::line_user_rect; + use crate::{Edit, Session}; + use pdfcraft_render::PageInfo; + use std::sync::Arc; + + /// Build a sample `PageInfo` with a non-origin crop box `[10.0, 20.0, 210.0, 320.0]` (`200 × 300`). + fn sample_page(rotation: u16) -> PageInfo { + let (width, height) = if rotation % 180 == 90 { (300.0, 200.0) } else { (200.0, 300.0) }; + PageInfo { width, height, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation } + } + + #[test] + fn line_user_rect_rejects_non_finite_coordinates() { + let page = sample_page(0); + assert_eq!(line_user_rect(&page, [f32::NAN; 4]), None); + assert_eq!(line_user_rect(&page, [f32::NAN, 10.0, 50.0, 20.0]), None); + assert_eq!(line_user_rect(&page, [10.0, 20.0, f32::INFINITY, 30.0]), None); + assert_eq!(line_user_rect(&page, [10.0, f32::NEG_INFINITY, 50.0, 30.0]), None); + + for bad_crop_box in [[f32::NAN, 0.0, 200.0, 300.0], [0.0, f32::INFINITY, 200.0, 300.0], [0.0, 0.0, f32::NEG_INFINITY, 300.0]] { + let mut bad_crop = sample_page(0); + bad_crop.crop = bad_crop_box; + assert_eq!(line_user_rect(&bad_crop, [10.0, 20.0, 50.0, 40.0]), None); + } + + // Finite inputs whose crop span overflows f32 during view-to-user mapping must also be rejected. + let overflow_page = PageInfo { width: 1.0, height: 1.0, label: String::new(), crop: [-f32::MAX, 0.0, f32::MAX, 300.0], rotation: 0 }; + assert_eq!(line_user_rect(&overflow_page, [1.0, 10.0, 2.0, 20.0]), None); + } + + #[test] + fn line_user_rect_matches_view_rect_to_user_across_rotations() { + let v = [12.0, 24.0, 96.0, 48.0]; + let cases = [ + (0, [22.0, 272.0, 106.0, 296.0]), + (90, [34.0, 32.0, 58.0, 116.0]), + (180, [114.0, 44.0, 198.0, 68.0]), + (270, [162.0, 224.0, 186.0, 308.0]), + ]; + for (rotation, expected) in cases { + let page = sample_page(rotation); + let got = line_user_rect(&page, v).expect("finite view rect maps to user space"); + for (g, e) in got.into_iter().zip(expected) { + assert!((g - e).abs() < 1e-4, "rotation {rotation}: got {got:?}, expected {expected:?}"); + } + } + } + + #[test] + fn find_urls_returns_finite_user_rects_on_rotated_page() { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(), + ]; + let body = "BT /F1 12 Tf 20 150 Td (Visit https://example.com today) Tj ET"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes()); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + let mut session = Session::new(); + let id = session.open("urls.pdf", None, Arc::new(pdf), None).expect("opens"); + let expected_rect = [46.004, 147.6, 153.38, 159.6]; + let base_urls = session.find_urls(id); + assert_eq!(base_urls.len(), 1); + let base_rect = base_urls[0].1[0]; + for (got, exp) in base_rect.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "0 deg: got {base_rect:?}, expected {expected_rect:?}"); + } + + for step in [90, 180, 270] { + session.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotates"); + let urls = session.find_urls(id); + assert_eq!(urls.len(), 1); + assert_eq!(urls[0].0, 0); + assert_eq!(urls[0].2, "https://example.com"); + assert_eq!(urls[0].1.len(), 1); + let r = urls[0].1[0]; + for (got, exp) in r.into_iter().zip(expected_rect) { + assert!((got - exp).abs() < 1e-2, "rotation {step} deg: got {r:?}, expected {expected_rect:?}"); + } + } + } +}