#[cfg(feature = "xml")]
pub(crate) mod odf;
#[cfg(feature = "pdf")]
pub(crate) mod pdf;
mod polyline;
#[cfg(all(feature = "svg", feature = "xml"))]
pub(crate) mod svg;
use crate::types::diagram::{DiagramEdge, DiagramGraph, DiagramNode, DiagramShape};
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Rect {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
impl Rect {
fn width(&self) -> f32 {
self.x1 - self.x0
}
fn height(&self) -> f32 {
self.y1 - self.y0
}
fn area(&self) -> f32 {
self.width() * self.height()
}
fn centre_x(&self) -> f32 {
(self.x0 + self.x1) / 2.0
}
fn contains(&self, x: f32, y: f32) -> bool {
x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1
}
fn encloses(&self, other: &Rect) -> bool {
self.x0 <= other.x0 && self.y0 <= other.y0 && self.x1 >= other.x1 && self.y1 >= other.y1
}
fn overlap_area(&self, other: &Rect) -> f32 {
let width = (self.x1.min(other.x1) - self.x0.max(other.x0)).max(0.0);
let height = (self.y1.min(other.y1) - self.y0.max(other.y0)).max(0.0);
width * height
}
fn depth_of(&self, x: f32, y: f32) -> f32 {
if !self.contains(x, y) {
return 0.0;
}
(x - self.x0).min(self.x1 - x).min(y - self.y0).min(self.y1 - y)
}
fn distance_to(&self, x: f32, y: f32) -> f32 {
let dx = (self.x0 - x).max(0.0).max(x - self.x1);
let dy = (self.y0 - y).max(0.0).max(y - self.y1);
(dx * dx + dy * dy).sqrt()
}
}
#[derive(Debug, Clone)]
pub(crate) struct Outline {
pub bbox: Rect,
pub shape: DiagramShape,
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: Option<f32>,
pub dashed: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Connector {
pub start: (f32, f32),
pub end: (f32, f32),
pub midpoint: (f32, f32),
pub stroke: Option<String>,
pub dashed: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct Label {
pub x: f32,
pub y: f32,
pub text: String,
}
const MAX_OUTLINES: usize = 2_000;
const MAX_CONNECTORS: usize = 5_000;
const MAX_LABELS: usize = 20_000;
const BACKGROUND_AREA_RATIO: f32 = 0.9;
const MIN_NODE_SIDE_RATIO: f32 = 0.02;
const SNAP_RATIO: f32 = 0.02;
const RATIO_FLOOR: f32 = 4.0;
const MIN_NODE_SIDE_CEILING: f32 = 20.0;
const SNAP_CEILING: f32 = 40.0;
const CONCENTRIC_AREA_RATIO: f32 = 0.5;
const CONTAINER_MIN_MEMBERS: usize = 2;
const GRID_MIN_CELLS: usize = 3;
const GRID_CLUSTER_RATIO: f32 = 0.1;
const OVERLAP_RATIO: f32 = 0.1;
const UNLABELLED_DECORATION_RATIO: f32 = 0.25;
const ARROWHEAD_AREA_RATIO: f32 = 0.2;
const ARROWHEAD_MAX_SIDE_RATIO: f32 = 0.05;
const EDGE_LABEL_REACH: f32 = 4.0;
const CAPTION_REACH: f32 = 3.0;
const CAPTION_CEILING: f32 = 48.0;
const GRIDLINE_MIN_COUNT: usize = 3;
const GRIDLINE_AXIS_EPSILON: f32 = 0.75;
const GRIDLINE_SPACING_TOLERANCE_RATIO: f32 = 0.15;
const FRAME_MIN_FLUSH_EDGES: usize = GRIDLINE_MIN_COUNT;
fn select_candidate_outlines(
outlines: Vec<Outline>,
canvas_area: f32,
min_side: f32,
) -> Option<(Vec<Outline>, Vec<Outline>)> {
let (mut kept, decoration): (Vec<Outline>, Vec<Outline>) = outlines
.into_iter()
.take(MAX_OUTLINES)
.filter(|o| o.bbox.area() < canvas_area * BACKGROUND_AREA_RATIO)
.partition(|o| o.bbox.width() >= min_side && o.bbox.height() >= min_side);
kept.sort_by(|a, b| {
a.bbox
.y0
.total_cmp(&b.bbox.y0)
.then(a.bbox.x0.total_cmp(&b.bbox.x0))
.then(a.bbox.y1.total_cmp(&b.bbox.y1))
.then(a.bbox.x1.total_cmp(&b.bbox.x1))
});
kept.dedup_by(|a, b| a.bbox == b.bbox);
collapse_concentric(&mut kept);
if kept.is_empty() {
return None;
}
Some((kept, decoration))
}
struct Scale {
area: f32,
max: f32,
min_node_side: f32,
snap: f32,
edge_label_reach: f32,
}
fn compute_scale(canvas: (f32, f32)) -> Option<Scale> {
let (canvas_w, canvas_h) = canvas;
if !canvas_w.is_finite() || !canvas_h.is_finite() || canvas_w <= 0.0 || canvas_h <= 0.0 {
return None;
}
let max = canvas_w.max(canvas_h);
let snap = (max * SNAP_RATIO).clamp(RATIO_FLOOR, SNAP_CEILING);
Some(Scale {
area: canvas_w * canvas_h,
max,
min_node_side: (max * MIN_NODE_SIDE_RATIO).clamp(RATIO_FLOOR, MIN_NODE_SIDE_CEILING),
snap,
edge_label_reach: snap * EDGE_LABEL_REACH,
})
}
fn assign_label_owners(labels: Vec<Label>, kept: &[Outline]) -> (Vec<Label>, Vec<Option<usize>>) {
let mut labels: Vec<Label> = labels.into_iter().take(MAX_LABELS).collect();
labels.sort_by(|a, b| a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x)));
let owners: Vec<Option<usize>> = labels
.iter()
.map(|label| {
kept.iter()
.enumerate()
.filter(|(_, o)| o.bbox.contains(label.x, label.y))
.min_by(|(_, a), (_, b)| a.bbox.area().total_cmp(&b.bbox.area()))
.map(|(i, _)| i)
})
.collect();
(labels, owners)
}
fn filter_diagram_connectors(connectors: Vec<Connector>, kept: &[Outline], snap: f32) -> Vec<Connector> {
let connectors: Vec<Connector> = connectors.into_iter().take(MAX_CONNECTORS).collect();
let is_gridline = find_gridlines(&connectors, snap);
let is_frame = find_frame_lines(kept, &connectors, snap);
connectors
.into_iter()
.zip(is_gridline.into_iter().zip(is_frame))
.filter_map(|(connector, (gridline, frame_line))| (!gridline && !frame_line).then_some(connector))
.collect()
}
fn compute_arrowhead_reach(
arrowheads: &[bool],
kept: &[Outline],
decoration: &[Outline],
connectors: &[Connector],
snap: f32,
) -> Vec<Rect> {
arrowheads
.iter()
.zip(kept)
.filter(|(is_arrowhead, _)| **is_arrowhead)
.map(|(_, outline)| outline.bbox)
.chain(
decoration
.iter()
.filter(|o| {
connectors.iter().any(|c| {
o.bbox.distance_to(c.start.0, c.start.1) <= snap || o.bbox.distance_to(c.end.0, c.end.1) <= snap
})
})
.map(|o| o.bbox),
)
.collect()
}
fn labels_owned_per_shape<'a>(kept: &[Outline], labels: &'a [Label], owners: &[Option<usize>]) -> Vec<Vec<&'a Label>> {
let mut grouped: Vec<Vec<&Label>> = vec![Vec::new(); kept.len()];
for (label, owner) in labels.iter().zip(owners) {
if let Some(index) = owner {
grouped[*index].push(label);
}
}
grouped
}
fn compute_landings(kept: &[Outline], connectors: &[Connector], reach: &[Rect], snap: f32) -> Vec<Vec<(f32, f32)>> {
let endpoints: Vec<((f32, f32), f32)> = connectors
.iter()
.flat_map(|c| {
[
(c.start, snap + touching_arrowhead(reach, c.start, snap).unwrap_or(0.0)),
(c.end, snap + touching_arrowhead(reach, c.end, snap).unwrap_or(0.0)),
]
})
.collect();
kept.iter()
.map(|outline| {
endpoints
.iter()
.filter(|(point, tolerance)| outline.bbox.distance_to(point.0, point.1) <= *tolerance)
.map(|(point, _)| *point)
.collect()
})
.collect()
}
fn compute_overlapping(kept: &[Outline]) -> Vec<bool> {
kept.iter()
.enumerate()
.map(|(i, outline)| {
kept.iter().enumerate().any(|(j, other)| {
i != j
&& !other.bbox.encloses(&outline.bbox)
&& !outline.bbox.encloses(&other.bbox)
&& outline.bbox.overlap_area(&other.bbox)
> outline.bbox.area().min(other.bbox.area()) * OVERLAP_RATIO
})
})
.collect()
}
fn compute_decoration_cutoff(
kept: &[Outline],
arrowheads: &[bool],
grids: &[bool],
owned: &[Vec<&Label>],
) -> Option<f32> {
median_area(
kept.iter()
.enumerate()
.filter(|(i, _)| !arrowheads[*i] && !grids[*i] && !owned[*i].is_empty())
.map(|(_, o)| o.bbox.area()),
)
.map(|median| median * UNLABELLED_DECORATION_RATIO)
}
fn compute_label_backgrounds(
kept: &[Outline],
owned: &[Vec<&Label>],
decoration_cutoff: Option<f32>,
connectors: &[Connector],
edge_label_reach: f32,
) -> Vec<bool> {
kept.iter()
.enumerate()
.map(|(i, outline)| {
!owned[i].is_empty()
&& decoration_cutoff.is_some_and(|cutoff| outline.bbox.area() < cutoff)
&& owned[i].iter().all(|label| {
connectors
.iter()
.any(|c| (label.x - c.midpoint.0).hypot(label.y - c.midpoint.1) <= edge_label_reach)
})
})
.collect()
}
struct NodeSignals<'a> {
arrowheads: &'a [bool],
grids: &'a [bool],
is_container: &'a [bool],
owned: &'a [Vec<&'a Label>],
overlapping: &'a [bool],
decoration_cutoff: Option<f32>,
label_backgrounds: &'a [bool],
}
fn select_node_indices(kept: &[Outline], signals: &NodeSignals) -> Option<Vec<usize>> {
let node_indices: Vec<usize> = (0..kept.len())
.filter(|i| {
let anonymous_decoration = signals.owned[*i].is_empty()
&& (signals.overlapping[*i]
|| signals
.decoration_cutoff
.is_some_and(|cutoff| kept[*i].bbox.area() < cutoff));
!signals.arrowheads[*i]
&& !signals.grids[*i]
&& !signals.is_container[*i]
&& !anonymous_decoration
&& !signals.label_backgrounds[*i]
})
.collect();
(!node_indices.is_empty()).then_some(node_indices)
}
fn map_outlines_to_nodes(kept: &[Outline], node_indices: &[usize]) -> Vec<usize> {
let mut to_node = vec![usize::MAX; kept.len()];
for (new, old) in node_indices.iter().enumerate() {
to_node[*old] = new;
}
to_node
}
fn build_diagram_nodes(node_outlines: &[&Outline]) -> Vec<DiagramNode> {
node_outlines
.iter()
.enumerate()
.map(|(i, o)| DiagramNode {
id: format!("n{i}"),
label: String::new(),
shape: o.shape,
fill: o.fill.clone(),
stroke: o.stroke.clone(),
stroke_width: o.stroke_width,
dashed: o.dashed,
})
.collect()
}
fn distribute_free_labels<'a>(
labels: &'a [Label],
owners: &[Option<usize>],
to_node: &[usize],
nodes: &mut [DiagramNode],
) -> Vec<&'a Label> {
let mut free_labels: Vec<&Label> = Vec::new();
for (label, owner) in labels.iter().zip(owners) {
match owner.map(|old| to_node[old]).filter(|new| *new != usize::MAX) {
Some(i) => {
let node_label = &mut nodes[i].label;
if !node_label.is_empty() {
node_label.push('\n');
}
node_label.push_str(&label.text);
}
None => free_labels.push(label),
}
}
free_labels
}
fn build_diagram_edges(
connectors: Vec<Connector>,
node_outlines: &[&Outline],
reach: &[Rect],
free_labels: &[&Label],
snap: f32,
edge_label_reach: f32,
) -> Option<Vec<DiagramEdge>> {
let mut edges: Vec<DiagramEdge> = Vec::new();
for connector in connectors {
let head_at_start = touching_arrowhead(reach, connector.start, snap);
let head_at_end = touching_arrowhead(reach, connector.end, snap);
let (Some(from), Some(to)) = (
snap_to_outline(node_outlines, connector.start, snap + head_at_start.unwrap_or(0.0)),
snap_to_outline(node_outlines, connector.end, snap + head_at_end.unwrap_or(0.0)),
) else {
continue;
};
if from == to
&& node_outlines[from]
.bbox
.contains(connector.midpoint.0, connector.midpoint.1)
{
continue;
}
let (from, to, bidirectional) = match (head_at_start.is_some(), head_at_end.is_some()) {
(true, false) => (to, from, false),
(true, true) => (from, to, true),
_ => (from, to, false),
};
edges.push(DiagramEdge {
from,
to,
bidirectional,
label: nearest_free_label(free_labels, connector.midpoint, edge_label_reach),
stroke: connector.stroke,
dashed: connector.dashed,
});
}
if edges.is_empty() {
return None;
}
edges.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to)));
edges.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.label == b.label);
Some(edges)
}
pub(crate) fn assemble(
name: Option<String>,
canvas: (f32, f32),
outlines: Vec<Outline>,
connectors: Vec<Connector>,
labels: Vec<Label>,
) -> Option<DiagramGraph> {
let scale = compute_scale(canvas)?;
let (kept, decoration) = select_candidate_outlines(outlines, scale.area, scale.min_node_side)?;
let (labels, mut owners) = assign_label_owners(labels, &kept);
let connectors = filter_diagram_connectors(connectors, &kept, scale.snap);
let arrowheads = find_arrowheads(&kept, &connectors, &owners, scale.snap, scale.max);
let reach = compute_arrowhead_reach(&arrowheads, &kept, &decoration, &connectors, scale.snap);
let contained = labels_owned_per_shape(&kept, &labels, &owners);
let grids: Vec<bool> = kept
.iter()
.zip(&contained)
.map(|(outline, texts)| holds_a_grid(&outline.bbox, texts))
.collect();
let landings = compute_landings(&kept, &connectors, &reach, scale.snap);
adopt_captions(&kept, &labels, &arrowheads, &connectors, &mut owners, scale.snap);
let owned = labels_owned_per_shape(&kept, &labels, &owners);
let is_container = find_containers(&kept, &arrowheads, &owned, &landings, scale.snap);
let overlapping = compute_overlapping(&kept);
let decoration_cutoff = compute_decoration_cutoff(&kept, &arrowheads, &grids, &owned);
let label_backgrounds =
compute_label_backgrounds(&kept, &owned, decoration_cutoff, &connectors, scale.edge_label_reach);
let node_indices = select_node_indices(
&kept,
&NodeSignals {
arrowheads: &arrowheads,
grids: &grids,
is_container: &is_container,
owned: &owned,
overlapping: &overlapping,
decoration_cutoff,
label_backgrounds: &label_backgrounds,
},
)?;
let to_node = map_outlines_to_nodes(&kept, &node_indices);
let node_outlines: Vec<&Outline> = node_indices.iter().map(|i| &kept[*i]).collect();
let mut nodes = build_diagram_nodes(&node_outlines);
let free_labels = distribute_free_labels(&labels, &owners, &to_node, &mut nodes);
let edges = build_diagram_edges(
connectors,
&node_outlines,
&reach,
&free_labels,
scale.snap,
scale.edge_label_reach,
)?;
Some(DiagramGraph { name, nodes, edges })
}
mod matching;
use matching::*;
#[cfg(test)]
mod tests;