use xberg_native_pdf::elements::{PathContent, PathOperation};
use crate::pdf::native::NativeDocument;
use crate::types::diagram::DiagramGraph;
use super::polyline::{Polyline, classify, halfway_along};
use super::{Connector, Label, Outline};
const MAX_PAGES: usize = 200;
const MIN_OUTLINES: usize = 2;
pub(crate) fn recover(doc: &mut NativeDocument) -> Vec<DiagramGraph> {
let Ok(page_count) = doc.doc.page_count() else {
return Vec::new();
};
let mut graphs = Vec::new();
for page_index in 0..page_count.min(MAX_PAGES) {
if let Some(graph) = recover_page(doc, page_index) {
graphs.push(graph);
}
}
graphs
}
fn recover_page(doc: &mut NativeDocument, page_index: usize) -> Option<DiagramGraph> {
let (x0, y0, x1, y1) = doc.doc.get_page_media_box(page_index).ok()?;
let canvas = ((x1 - x0).abs(), (y1 - y0).abs());
let origin = (x0.min(x1), y0.min(y1));
let top = y0.max(y1);
let paths = crate::pdf::native::guard_native_panic(
|| doc.doc.extract_paths(page_index).map_err(|error| error.to_string()),
|message| message,
)
.ok()?;
let mut outlines = Vec::new();
let mut connectors = Vec::new();
for path in &paths {
collect_path(path, origin, top, &mut outlines, &mut connectors);
}
if outlines.len() < MIN_OUTLINES || connectors.is_empty() {
return None;
}
let page_text = crate::pdf::native::guard_native_panic(
|| {
doc.doc
.extract_page_text_with_options(page_index, xberg_native_pdf::ReadingOrder::ColumnAware)
.map_err(|error| error.to_string())
},
|message| message,
)
.ok()?;
let labels = page_text
.spans
.iter()
.filter(|span| !span.text.trim().is_empty())
.map(|span| Label {
x: span.bbox.x + span.bbox.width / 2.0 - origin.0,
y: top - (span.bbox.y + span.bbox.height / 2.0),
text: span.text.trim().to_string(),
})
.collect();
super::assemble(None, canvas, outlines, connectors, labels)
}
fn collect_path(
path: &PathContent,
origin: (f32, f32),
top: f32,
outlines: &mut Vec<Outline>,
connectors: &mut Vec<Connector>,
) {
let stroke = path.stroke_color.as_ref().map(hex);
let fill = path.fill_color.as_ref().map(hex);
let dashed = path.dash_pattern.as_ref().is_some_and(|(dashes, _)| !dashes.is_empty());
let filled = path.fill_color.is_some() || path.stroke_color.is_none();
for (line, explicitly_closed) in split_subpaths(&path.operations, origin, top) {
if line.len() < 2 {
continue;
}
if explicitly_closed || filled || line.ends_where_it_started() {
let Some(bbox) = line.bounds() else {
continue;
};
outlines.push(Outline {
shape: classify(line.points(), &bbox),
bbox,
fill: fill.clone(),
stroke: stroke.clone(),
stroke_width: (stroke.is_some() && path.stroke_width > 0.0).then_some(path.stroke_width),
dashed,
});
} else {
let points = line.into_points();
connectors.push(Connector {
start: points[0],
end: points[points.len() - 1],
midpoint: halfway_along(&points),
stroke: stroke.clone(),
dashed,
});
}
}
}
fn split_subpaths(operations: &[PathOperation], origin: (f32, f32), top: f32) -> Vec<(Polyline, bool)> {
let to_canvas = |x: f32, y: f32| (x - origin.0, top - y);
let mut subpaths: Vec<(Polyline, bool)> = Vec::new();
let mut line = Polyline::default();
let mut cursor = (0.0f32, 0.0f32);
let mut subpath_start = (0.0f32, 0.0f32);
fn flush(line: &mut Polyline, subpaths: &mut Vec<(Polyline, bool)>, closed: bool) {
let finished = std::mem::take(line);
if finished.len() >= 2 {
subpaths.push((finished, closed));
}
}
for operation in operations {
match *operation {
PathOperation::MoveTo(x, y) => {
flush(&mut line, &mut subpaths, false);
cursor = to_canvas(x, y);
subpath_start = cursor;
line.push(cursor);
}
PathOperation::LineTo(x, y) => {
cursor = to_canvas(x, y);
line.push(cursor);
}
PathOperation::CurveTo(c1x, c1y, c2x, c2y, x, y) => {
let end = to_canvas(x, y);
line.push_cubic(cursor, to_canvas(c1x, c1y), to_canvas(c2x, c2y), end);
cursor = end;
}
PathOperation::Rectangle(x, y, width, height) => {
flush(&mut line, &mut subpaths, false);
let corners = [
to_canvas(x, y),
to_canvas(x + width, y),
to_canvas(x + width, y + height),
to_canvas(x, y + height),
];
let mut rect = Polyline::default();
for corner in corners {
rect.push(corner);
}
rect.push(corners[0]);
subpaths.push((rect, true));
cursor = corners[0];
subpath_start = cursor;
}
PathOperation::ClosePath => {
cursor = subpath_start;
line.push(cursor);
flush(&mut line, &mut subpaths, true);
line.push(cursor);
}
}
}
flush(&mut line, &mut subpaths, false);
subpaths
}
fn hex(color: &xberg_native_pdf::layout::Color) -> String {
let channel = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(
"#{:02x}{:02x}{:02x}",
channel(color.r),
channel(color.g),
channel(color.b)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::diagram::DiagramShape;
fn colour(r: f32, g: f32, b: f32) -> xberg_native_pdf::layout::Color {
xberg_native_pdf::layout::Color { r, g, b }
}
fn path(operations: Vec<PathOperation>, fill: Option<xberg_native_pdf::layout::Color>) -> PathContent {
let mut content = PathContent::new(xberg_native_pdf::geometry::Rect::new(0.0, 0.0, 0.0, 0.0));
content.operations = operations;
content.fill_color = fill;
content.stroke_color = Some(colour(0.0, 0.0, 0.0));
content.stroke_width = 1.0;
content
}
fn sorted(path: &PathContent) -> (Vec<Outline>, Vec<Connector>) {
let (mut outlines, mut connectors) = (Vec::new(), Vec::new());
collect_path(path, (0.0, 0.0), 100.0, &mut outlines, &mut connectors);
(outlines, connectors)
}
#[test]
fn the_y_axis_is_flipped_into_reading_order() {
let low = path(vec![PathOperation::Rectangle(10.0, 5.0, 30.0, 20.0)], None);
let (outlines, _) = sorted(&low);
let bbox = outlines[0].bbox;
assert_eq!((bbox.x0, bbox.x1), (10.0, 40.0));
assert_eq!((bbox.y0, bbox.y1), (75.0, 95.0));
}
#[test]
fn a_filled_path_is_closed_without_a_close_operator() {
let arrowhead = path(
vec![
PathOperation::MoveTo(0.0, 0.0),
PathOperation::LineTo(10.0, 0.0),
PathOperation::LineTo(5.0, 8.0),
],
Some(colour(0.2, 0.4, 0.6)),
);
let (outlines, connectors) = sorted(&arrowhead);
assert_eq!(outlines.len(), 1);
assert!(connectors.is_empty());
assert_eq!(outlines[0].fill.as_deref(), Some("#336699"));
}
#[test]
fn an_unfilled_open_stroke_is_a_connector() {
let edge = path(
vec![PathOperation::MoveTo(0.0, 90.0), PathOperation::LineTo(0.0, 50.0)],
None,
);
let (outlines, connectors) = sorted(&edge);
assert!(outlines.is_empty());
assert_eq!(connectors.len(), 1);
assert_eq!(connectors[0].start, (0.0, 10.0));
assert_eq!(connectors[0].end, (0.0, 50.0));
assert_eq!(connectors[0].midpoint, (0.0, 30.0));
}
#[test]
fn a_rectangle_operator_measures_as_a_box() {
let node = path(vec![PathOperation::Rectangle(0.0, 0.0, 40.0, 20.0)], None);
let (outlines, _) = sorted(&node);
assert_eq!(outlines[0].shape, DiagramShape::Box);
}
#[test]
fn a_negative_rectangle_bounds_the_same_area() {
let flipped = path(vec![PathOperation::Rectangle(40.0, 20.0, -40.0, -20.0)], None);
let (outlines, _) = sorted(&flipped);
let bbox = outlines[0].bbox;
assert_eq!((bbox.x0, bbox.x1), (0.0, 40.0));
assert_eq!((bbox.y0, bbox.y1), (80.0, 100.0));
}
#[test]
fn a_default_coloured_fill_is_still_a_closed_shape() {
let mut arrowhead = path(
vec![
PathOperation::MoveTo(0.0, 0.0),
PathOperation::LineTo(10.0, 0.0),
PathOperation::LineTo(5.0, 8.0),
],
None,
);
arrowhead.stroke_color = None;
let (outlines, connectors) = sorted(&arrowhead);
assert_eq!(outlines.len(), 1);
assert!(outlines[0].fill.is_none());
assert!(connectors.is_empty());
}
#[test]
fn channels_round_to_hex() {
assert_eq!(hex(&colour(1.0, 1.0, 1.0)), "#ffffff");
assert_eq!(hex(&colour(0.0, 0.0, 0.0)), "#000000");
assert_eq!(hex(&colour(-0.5, 2.0, 0.5)), "#00ff80");
}
}