Skip to main content

docvortex_core/
classification_visuals.rs

1//! 图上字符候选与差分像素核验;不保存 PDFium 地址或 Python 对象。
2use std::collections::HashMap;
3
4pub type Box4 = [f64; 4];
5pub type Record = (u32, usize, Box4);
6pub type Visibility = (usize, bool, Option<Box4>);
7
8/// 精确使用 CPython 的空白集合,包含 Rust Unicode whitespace 未包含的控制分隔符。
9pub fn python_space(code: u32) -> bool {
10    matches!(code, 9..=13 | 28..=32 | 0x85 | 0xa0 | 0x1680 | 0x2000..=0x200a | 0x2028 | 0x2029 | 0x202f | 0x205f | 0x3000)
11}
12
13/// 保持 Python min 对相等值和有符号零的首项选择。
14fn minimum(a: f64, b: f64) -> f64 {
15    if b < a {
16        b
17    } else {
18        a
19    }
20}
21
22/// 保持 Python max 对相等值和有符号零的首项选择。
23fn maximum(a: f64, b: f64) -> f64 {
24    if b > a {
25        b
26    } else {
27        a
28    }
29}
30
31/// 计算非空矩形面积,不能把裁剪后的反向边界重新归一化。
32fn area(b: Box4) -> f64 {
33    maximum(0.0, b[2] - b[0]) * maximum(0.0, b[3] - b[1])
34}
35
36/// 计算与参考实现相同的边界交集。
37fn intersect(a: Box4, b: Box4) -> Box4 {
38    [
39        maximum(a[0], b[0]),
40        maximum(a[1], b[1]),
41        minimum(a[2], b[2]),
42        minimum(a[3], b[3]),
43    ]
44}
45
46/// 按参考切片顺序计算矩形并集,浮点加法顺序保持不变。
47fn union_area(boxes: &[Box4]) -> f64 {
48    let valid: Vec<_> = boxes.iter().copied().filter(|b| area(*b) > 0.0).collect();
49    let mut edges: Vec<_> = valid.iter().flat_map(|b| [b[0], b[2]]).collect();
50    edges.sort_by(|a, b| a.partial_cmp(b).unwrap());
51    edges.dedup();
52    let mut total = 0.0;
53    for edge in edges.windows(2) {
54        let (left, right) = (edge[0], edge[1]);
55        let mut intervals: Vec<_> = valid
56            .iter()
57            .filter(|b| b[0] < right && b[2] > left)
58            .map(|b| (b[1], b[3]))
59            .collect();
60        intervals.sort_by(|a, b| a.partial_cmp(b).unwrap());
61        let (mut top, mut height) = (f64::NEG_INFINITY, 0.0);
62        for (bottom, end) in intervals {
63            height += maximum(0.0, end - maximum(bottom, top));
64            top = maximum(top, end);
65        }
66        total += (right - left) * height;
67    }
68    total
69}
70
71/// 将四个角按 Python 的 x/y 嵌套顺序变换,保留页面旋转与 CropBox 偏移。
72fn visual_box(b: Box4, frame: Box4, rotation: i32) -> Box4 {
73    let mut points = Vec::with_capacity(4);
74    for x in [b[0], b[2]] {
75        for y in [b[1], b[3]] {
76            points.push(match rotation {
77                90 => (y - frame[1], x - frame[0]),
78                180 => (frame[2] - x, y - frame[1]),
79                270 => (frame[3] - y, frame[2] - x),
80                _ => (x - frame[0], frame[3] - y),
81            });
82        }
83    }
84    [
85        points.iter().map(|p| p.0).reduce(minimum).unwrap(),
86        points.iter().map(|p| p.1).reduce(minimum).unwrap(),
87        points.iter().map(|p| p.0).reduce(maximum).unwrap(),
88        points.iter().map(|p| p.1).reduce(maximum).unwrap(),
89    ]
90}
91
92/// 独立候选框可在原页面和文本页关闭后继续消费,不携带原生对象地址。
93pub struct Candidates {
94    pub boxes: Vec<Box4>,
95    pub paints: bool,
96}
97
98/// 批量应用字符过滤、视觉裁剪与图像并集覆盖;未知可见性沿用参考的保守可见默认值。
99pub fn candidates(
100    records: &[Record],
101    frame: Box4,
102    rotation: i32,
103    images: &[Box4],
104    visibility: &[Visibility],
105    overlap: f64,
106) -> Option<Candidates> {
107    if frame
108        .iter()
109        .chain(images.iter().flatten())
110        .any(|v| !v.is_finite())
111        || records.iter().flat_map(|r| r.2).any(|v| !v.is_finite())
112        || visibility
113            .iter()
114            .filter_map(|v| v.2)
115            .flatten()
116            .any(|v| !v.is_finite())
117        || !overlap.is_finite()
118        || ![0, 90, 180, 270].contains(&rotation)
119    {
120        return None;
121    }
122    let (mut width, mut height) = (frame[2] - frame[0], frame[3] - frame[1]);
123    if rotation == 90 || rotation == 270 {
124        std::mem::swap(&mut width, &mut height);
125    }
126    let lookup: HashMap<_, _> = visibility.iter().map(|v| (v.0, (v.1, v.2))).collect();
127    let mut result = Candidates {
128        boxes: Vec::new(),
129        paints: false,
130    };
131    for &(code, address, bounds) in records {
132        if code == 0 || code > 0x10ffff || python_space(code) || address == 0 {
133            continue;
134        }
135        let (visible, clip) = lookup.get(&address).copied().unwrap_or((true, None));
136        let mut b = visual_box(bounds, frame, rotation);
137        if let Some(clip) = clip {
138            b = intersect(b, clip);
139        }
140        b = intersect(b, [0.0, 0.0, width, height]);
141        let a = area(b);
142        if a > 0.0 {
143            let overlaps: Vec<_> = images.iter().map(|image| intersect(b, *image)).collect();
144            if union_area(&overlaps) >= a * overlap {
145                result.boxes.push(b);
146                result.paints |= visible;
147            }
148        }
149    }
150    Some(result)
151}
152
153/// 直接扫描每个像素区域,等价于 Pillow crop/getbbox,避免每字符创建图像对象。
154pub fn painted_count(
155    data: &[u8],
156    width: usize,
157    height: usize,
158    boxes: &[Box4],
159    page_width: f64,
160    page_height: f64,
161) -> usize {
162    let (sx, sy) = (width as f64 / page_width, height as f64 / page_height);
163    boxes
164        .iter()
165        .filter(|b| {
166            let x0 = maximum(0.0, (b[0] * sx).floor()) as usize;
167            let y0 = maximum(0.0, (b[1] * sy).floor()) as usize;
168            let x1 = minimum(width as f64, (b[2] * sx).ceil()) as usize;
169            let y1 = minimum(height as f64, (b[3] * sy).ceil()) as usize;
170            x0 < x1
171                && y0 < y1
172                && x1 <= width
173                && y1 <= height
174                && (y0..y1).any(|y| data[y * width + x0..y * width + x1].iter().any(|v| *v != 0))
175        })
176        .count()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    /// 控制空白要跳过,代理码点与合法非空白字符保持参考计数规则。
184    #[test]
185    fn whitespace_surrogates_and_overlap() {
186        let records = vec![
187            (28, 1, [1.0, 1.0, 2.0, 2.0]),
188            (0xd800, 1, [1.0, 1.0, 2.0, 2.0]),
189            (65, 2, [2.0, 2.0, 3.0, 3.0]),
190        ];
191        let result = candidates(
192            &records,
193            [0.0, 0.0, 10.0, 10.0],
194            0,
195            &[[0.0, 0.0, 10.0, 10.0]],
196            &[(1, false, None)],
197            0.8,
198        )
199        .unwrap();
200        assert_eq!(result.boxes.len(), 2);
201        assert!(result.paints);
202    }
203
204    /// floor/ceil、空区域、边界像素与重复框都按原 Pillow 查询次数计数。
205    #[test]
206    fn fractional_pixels_are_counted_exactly() {
207        let result = painted_count(
208            &[0, 1, 0, 0],
209            2,
210            2,
211            &[
212                [0.51, 0.0, 1.01, 0.5],
213                [0.0, 1.0, 0.5, 2.0],
214                [0.51, 0.0, 1.01, 0.5],
215            ],
216            2.0,
217            2.0,
218        );
219        assert_eq!(result, 2);
220    }
221}