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)
This commit is contained in:
jonathanwxh-cell authored and GitHub committed 2026-11-09 08:31:41 +08:00
1 parent c02341f6c0
commit 90def111c9
1 file changed
+110 -11
+110 -11
View File
@@ -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<u8>> = 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:?}");
}
}
}
}