1use rapiddoc_core::document::{DrawingLine, Page, PathSegment, VectorPath};
4use rapiddoc_core::geometry::{intersect, page_transform, Affine};
5use rapiddoc_core::{Error, Rect};
6
7pub 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
45fn 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
107fn coordinate(line: &DrawingLine) -> f64 {
109 if line.orientation == "horizontal" {
110 line.start[1]
111 } else {
112 line.start[0]
113 }
114}
115
116fn 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
125fn 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
159fn 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
192fn 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}