Skip to main content

rapiddoc_native/
lines.rs

1//! 从自有 Path 事实构建轴线候选;曲线不当作直边,填充细条另由后续规则处理。
2
3use rapiddoc_core::document::{DrawingLine, Page, PathSegment, VectorPath};
4use rapiddoc_core::geometry::{intersect, page_transform, Affine};
5use rapiddoc_core::{Error, Rect};
6
7/// 从页面路径提取有色描边轴线,合并后按视觉位置稳定排序。
8pub fn extract(page: &Page) -> Result<Vec<DrawingLine>, Error> {
9    let visual = page_transform(page.page_bounds, page.rotation)?;
10    let frame = visual.rect(page.page_bounds)?;
11    let mut result = Vec::new();
12    for path in &page.paths {
13        if !path.stroke_visible {
14            continue;
15        }
16        let matrix = Affine::new(path.matrix)?;
17        let clip = match path.clip_bounds {
18            Some(value) => intersect(frame, visual.rect(value)?)?,
19            None => frame,
20        };
21        let mut current = None;
22        let mut first = None;
23        for segment in &path.segments {
24            if segment.kind == "move" {
25                first = Some(segment);
26            } else if segment.kind == "line" {
27                if let Some(start) = current {
28                    add_segment(start, segment, path, &[visual, matrix], clip, &mut result)?;
29                }
30            }
31            current = Some(segment);
32            if segment.close {
33                if let Some(start) = first {
34                    if start.point != segment.point {
35                        add_segment(segment, start, path, &[visual, matrix], clip, &mut result)?;
36                    }
37                    current = Some(start);
38                }
39            }
40        }
41    }
42    merge(result, frame)
43}
44
45/// 对同方向线按轴坐标聚类,再合并 2 point 内相接区间;最终使用视觉位置稳定排序。
46fn merge(lines: Vec<DrawingLine>, frame: Rect) -> Result<Vec<DrawingLine>, Error> {
47    let mut result = Vec::new();
48    for orientation in ["horizontal", "vertical"] {
49        let mut values: Vec<_> = lines
50            .iter()
51            .filter(|line| line.orientation == orientation)
52            .cloned()
53            .collect();
54        values.sort_by(|a, b| {
55            coordinate(a)
56                .total_cmp(&coordinate(b))
57                .then_with(|| interval(a).0.total_cmp(&interval(b).0))
58        });
59        let mut clusters: Vec<Vec<DrawingLine>> = Vec::new();
60        for line in values {
61            let fits = clusters
62                .last()
63                .and_then(|cluster| cluster.first())
64                .is_some_and(|first| coordinate(&line) - coordinate(first) <= 2.);
65            if fits {
66                if let Some(cluster) = clusters.last_mut() {
67                    cluster.push(line);
68                }
69            } else {
70                clusters.push(vec![line]);
71            }
72        }
73        for mut cluster in clusters {
74            cluster.sort_by(|a, b| interval(a).0.total_cmp(&interval(b).0));
75            let mut group = Vec::new();
76            let mut end = f64::NEG_INFINITY;
77            for line in cluster {
78                let (start, last) = interval(&line);
79                if !group.is_empty() && start > end + 2. {
80                    if let Some(line) = combine(&group, frame)? {
81                        result.push(line);
82                    }
83                    group.clear();
84                    end = f64::NEG_INFINITY;
85                }
86                group.push(line);
87                end = end.max(last);
88            }
89            if !group.is_empty() {
90                if let Some(line) = combine(&group, frame)? {
91                    result.push(line);
92                }
93            }
94        }
95    }
96    result.sort_by(|a, b| {
97        let a_box: [f64; 4] = a.bounds.into();
98        let b_box: [f64; 4] = b.bounds.into();
99        a_box[1]
100            .total_cmp(&b_box[1])
101            .then_with(|| a_box[0].total_cmp(&b_box[0]))
102            .then_with(|| a.orientation.cmp(&b.orientation))
103    });
104    Ok(result)
105}
106
107/// 取得垂直于线段方向的轴坐标。
108fn coordinate(line: &DrawingLine) -> f64 {
109    if line.orientation == "horizontal" {
110        line.start[1]
111    } else {
112        line.start[0]
113    }
114}
115
116/// 取得沿线段方向的有序区间。
117fn interval(line: &DrawingLine) -> (f64, f64) {
118    if line.orientation == "horizontal" {
119        (line.start[0], line.end[0])
120    } else {
121        (line.start[1], line.end[1])
122    }
123}
124
125/// 使用长度加权中心和覆盖宽度合并一组共线段,保留原求和顺序。
126fn combine(lines: &[DrawingLine], frame: Rect) -> Result<Option<DrawingLine>, Error> {
127    let Some(first) = lines.first() else {
128        return Ok(None);
129    };
130    let mut total = 0.;
131    let mut weighted = 0.;
132    let mut low = f64::INFINITY;
133    let mut high = f64::NEG_INFINITY;
134    let mut start = f64::INFINITY;
135    let mut end = f64::NEG_INFINITY;
136    let mut width = 0f64;
137    for line in lines {
138        let (a, b) = interval(line);
139        let length = (b - a).max(0.01);
140        let value = coordinate(line);
141        total += length;
142        weighted += value * length;
143        low = low.min(value);
144        high = high.max(value);
145        start = start.min(a);
146        end = end.max(b);
147        width = width.max(line.width);
148    }
149    let center = weighted / total;
150    let width = width + high - low;
151    let (a, b) = if first.orientation == "horizontal" {
152        ([start, center], [end, center])
153    } else {
154        ([center, start], [center, end])
155    };
156    axis_line(a, b, width, frame)
157}
158
159/// 按局部法向量换算非等比变换后的描边宽度,不使用路径整体框猜线宽。
160fn add_segment(
161    start: &PathSegment,
162    end: &PathSegment,
163    path: &VectorPath,
164    transforms: &[Affine; 2],
165    clip: Rect,
166    result: &mut Vec<DrawingLine>,
167) -> Result<(), Error> {
168    let [visual, matrix] = *transforms;
169    let local_start = start.local_point;
170    let local_end = end.local_point;
171    let dx = local_end[0] - local_start[0];
172    let dy = local_end[1] - local_start[1];
173    let length = dx.hypot(dy);
174    if length == 0. {
175        return Ok(());
176    }
177    let [a, b, c, d, _, _] = matrix.values();
178    let nx = -dy / length;
179    let ny = dx / length;
180    let width = path.stroke_width_local * (a * nx + c * ny).hypot(b * nx + d * ny);
181    if let Some(line) = axis_line(
182        visual.point(start.point)?,
183        visual.point(end.point)?,
184        width,
185        clip,
186    )? {
187        result.push(line);
188    }
189    Ok(())
190}
191
192/// 按既有 1 point/2% 轴线容差吸附并裁剪,斜线和不足 1 point 的线段不组成规则证据。
193fn axis_line(
194    start: [f64; 2],
195    end: [f64; 2],
196    width: f64,
197    clip: Rect,
198) -> Result<Option<DrawingLine>, Error> {
199    let dx = (end[0] - start[0]).abs();
200    let dy = (end[1] - start[1]).abs();
201    let (horizontal, coordinate, first, last, width) = if dx >= dy && dy <= 1f64.max(dx * 0.02) {
202        (
203            true,
204            (start[1] + end[1]) / 2.,
205            start[0].min(end[0]),
206            start[0].max(end[0]),
207            width.max(dy),
208        )
209    } else if dy > dx && dx <= 1f64.max(dy * 0.02) {
210        (
211            false,
212            (start[0] + end[0]) / 2.,
213            start[1].min(end[1]),
214            start[1].max(end[1]),
215            width.max(dx),
216        )
217    } else {
218        return Ok(None);
219    };
220    if last - first < 1. {
221        return Ok(None);
222    }
223    let raw = if horizontal {
224        [
225            first,
226            coordinate - width / 2.,
227            last,
228            coordinate + width / 2.,
229        ]
230    } else {
231        [
232            coordinate - width / 2.,
233            first,
234            coordinate + width / 2.,
235            last,
236        ]
237    };
238    let clipped = intersect(Rect::try_from(raw)?, clip)?;
239    let [left, top, right, bottom] = clipped.into();
240    if right <= left || bottom <= top {
241        return Ok(None);
242    }
243    let (start, end) = if horizontal {
244        let y = coordinate.clamp(top, bottom);
245        ([left, y], [right, y])
246    } else {
247        let x = coordinate.clamp(left, right);
248        ([x, top], [x, bottom])
249    };
250    Ok(Some(DrawingLine {
251        start,
252        end,
253        bounds: clipped,
254        width,
255        orientation: if horizontal { "horizontal" } else { "vertical" }.into(),
256    }))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn diagonal_curve_candidates_and_short_lines_are_not_rules() {
265        let clip = Rect::try_from([0., 0., 100., 100.]).expect("clip");
266        assert!(axis_line([0., 0.], [20., 20.], 1., clip)
267            .expect("diagonal")
268            .is_none());
269        assert!(axis_line([2., 2.], [2.5, 2.], 1., clip)
270            .expect("short")
271            .is_none());
272    }
273
274    #[test]
275    fn clipping_preserves_axis_and_original_width() {
276        let clip = Rect::try_from([10., 10., 20., 20.]).expect("clip");
277        let line = axis_line([0., 15.], [30., 15.], 2., clip)
278            .expect("valid")
279            .expect("line");
280        assert_eq!(line.start, [10., 15.]);
281        assert_eq!(line.end, [20., 15.]);
282        assert_eq!(line.width, 2.);
283        let empty = Rect::try_from([20., 20., 20., 20.]).expect("empty clip");
284        assert!(axis_line([0., 15.], [30., 15.], 2., empty)
285            .expect("clipped")
286            .is_none());
287    }
288
289    #[test]
290    fn fragmented_rules_merge_but_transitive_coordinate_drift_does_not() {
291        let frame = Rect::try_from([0., 0., 100., 100.]).expect("frame");
292        let a = axis_line([10., 10.], [20., 10.], 1., frame)
293            .expect("valid")
294            .expect("line");
295        let b = axis_line([21., 10.], [30., 10.], 1., frame)
296            .expect("valid")
297            .expect("line");
298        let merged = merge(vec![a, b], frame).expect("merged");
299        assert_eq!(merged.len(), 1);
300        assert_eq!(merged[0].end, [30., 10.]);
301        let lines = [10., 11.5, 13.]
302            .into_iter()
303            .map(|y| {
304                axis_line([10., y], [20., y], 1., frame)
305                    .expect("valid")
306                    .expect("line")
307            })
308            .collect();
309        assert_eq!(merge(lines, frame).expect("clusters").len(), 2);
310    }
311}