mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 12:20:12 -06:00
* DOCX: VML horizontal rules take their line A web page's <hr> pasted into Word is a VML rule: `v:rect` with `o:hr="t"`, often `width:0`. WordCraft took the width as written and drew a 1 pt dot where Word draws a line across the page. Measured on Word with a synthetic document: a rule with no width and no `o:hrpct` spans the line inside the paragraph's indents; `o:hrpct` is a share of it in tenths of a percent (500 = half); `o:hrpct="0"` keeps the shape's own width; `o:hralign` places a shorter one (left, centre, right); `fillcolor` and `stroked` apply as to any VML shape. WordCraft now matches each case within 0.1 pt. - wordcraft-doc: ShapeExtra::hr (HorizontalRule: share, alignment, Word's standard look). - wordcraft-docx: `o:hr`, `o:hrpct`, `o:hralign`, `o:hrstd` read; a rule is written back as the same VML rule (DrawingML has none), and Word opens the re-saved file with the same lines. - wordcraft-layout: a rule takes its whole line and is drawn at its share; measured for a table column it asks for no width, so it follows its column. (Word draws a rule in a cell at the page's text width, centred on the cell and past its edges; it stays in the cell here.) docs/file-format-parity.md is updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Horizontal rules: only an inline, shown rule is one Review: the rule arm of the writer ignored the shape's placement, so a rule imported with position:absolute or visibility:hidden came back inline and visible. Only a rule in the text and shown is read as a rule now (a pasted <hr> is one); a positioned or hidden one stays a plain shape, which keeps both when saved. The writer checks the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Horizontal rules: a see-through fill is kept; column measuring asks for the right width Review: `write_hr` wrote only the fill's colour, so a rule with `v:fill opacity` came back opaque after a save; the opacity now goes back as `v:fill`. Review: measured for a table column, every rule asked for no width, so one of its own width (`o:hrpct="0"`) could end up clipped by a narrow column; it now asks for its width. AutoFit to contents measures on a line of its own width that the rule took for a real line, so a rule sharing its line made the column as wide as it could go; any measuring width now counts. `Placed::Shape` boxes its extras: with the rule, `Placed` went past clippy's large_enum_variant limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Horizontal rules: placed in what the line has left; rotation and flips kept on save Review: a whole-line rule ignores a first-line indent or a line narrowed by a float. Measured in Word, it does too: the width stays the share of the paragraph's width and the rule runs past the margin, or under a float on its right. What did differ was the placement: Word centres and right-aligns a rule in what the line has left, with a first-line indent moving the end of that room along, and a right-aligned rule beside a float was drawn off the page here. The display now places it that way (`ParaLayout::rule_room`). Review: a save dropped a rule's rotation and flips; they now go back in its style, as Word writes them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: VML horizontal-rule revision row at the top of the history --------- Co-authored-by: Daniele Mori <Dinasty@hotmail.it> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
744 lines
33 KiB
Rust
744 lines
33 KiB
Rust
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
|
||
//! warp, a rounded rectangle's corner, and a connector's ends.
|
||
//!
|
||
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
|
||
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
|
||
//! glued to a connection site of another shape (`a:stCxn` / `a:endCxn`, ECMA-376 §20.1.2.2.36 and
|
||
//! §20.1.2.2.13: the shape's drawing id and a site index). When a glued shape moves, [`route`]
|
||
//! gives the connector's new frame from its ends.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::graphic::PathSeg;
|
||
use crate::para::{InlineObject, ShapeKind};
|
||
use crate::wordart::TextWarp;
|
||
|
||
/// DrawingML outline ends (ST_LineCap); missing and unknown values use flat ends.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum LineCap {
|
||
#[default]
|
||
Flat,
|
||
Square,
|
||
Round,
|
||
}
|
||
|
||
impl LineCap {
|
||
/// Read an ST_LineCap value; absent or unrecognized values use the flat default.
|
||
pub fn from_ooxml(value: &str) -> Self {
|
||
match value {
|
||
"sq" => Self::Square,
|
||
"rnd" => Self::Round,
|
||
_ => Self::Flat,
|
||
}
|
||
}
|
||
|
||
/// Return the canonical DrawingML spelling used when saving the outline.
|
||
pub fn ooxml(self) -> &'static str {
|
||
match self {
|
||
Self::Flat => "flat",
|
||
Self::Square => "sq",
|
||
Self::Round => "rnd",
|
||
}
|
||
}
|
||
|
||
/// Whether the cap can be omitted from older-compatible serialized shape properties.
|
||
pub fn is_flat(&self) -> bool {
|
||
*self == Self::Flat
|
||
}
|
||
}
|
||
|
||
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
|
||
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum ArrowSize {
|
||
Small,
|
||
#[default]
|
||
Medium,
|
||
Large,
|
||
}
|
||
|
||
impl ArrowSize {
|
||
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
|
||
pub fn from_ooxml(value: &str) -> Self {
|
||
match value {
|
||
"sm" => Self::Small,
|
||
"lg" => Self::Large,
|
||
_ => Self::Medium,
|
||
}
|
||
}
|
||
|
||
/// The DrawingML spelling.
|
||
pub fn ooxml(self) -> &'static str {
|
||
match self {
|
||
Self::Small => "sm",
|
||
Self::Medium => "med",
|
||
Self::Large => "lg",
|
||
}
|
||
}
|
||
|
||
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
|
||
pub fn from_vml(value: &str) -> Self {
|
||
match value {
|
||
"narrow" | "short" => Self::Small,
|
||
"wide" | "long" => Self::Large,
|
||
_ => Self::Medium,
|
||
}
|
||
}
|
||
|
||
/// How many [`arrow_unit`]s the arrowhead measures.
|
||
pub fn factor(self) -> f32 {
|
||
match self {
|
||
Self::Small => 2.0,
|
||
Self::Medium => 3.0,
|
||
Self::Large => 5.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
|
||
/// is drawn as a triangle.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum ArrowKind {
|
||
#[default]
|
||
Triangle,
|
||
Stealth,
|
||
Diamond,
|
||
Oval,
|
||
Arrow,
|
||
}
|
||
|
||
impl ArrowKind {
|
||
/// Read a DrawingML end type other than `none`; unknown values are triangles.
|
||
pub fn from_ooxml(value: &str) -> Self {
|
||
match value {
|
||
"stealth" => Self::Stealth,
|
||
"diamond" => Self::Diamond,
|
||
"oval" => Self::Oval,
|
||
"arrow" => Self::Arrow,
|
||
_ => Self::Triangle,
|
||
}
|
||
}
|
||
|
||
/// The DrawingML spelling.
|
||
pub fn ooxml(self) -> &'static str {
|
||
match self {
|
||
Self::Triangle => "triangle",
|
||
Self::Stealth => "stealth",
|
||
Self::Diamond => "diamond",
|
||
Self::Oval => "oval",
|
||
Self::Arrow => "arrow",
|
||
}
|
||
}
|
||
|
||
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
|
||
pub fn from_vml(value: &str) -> Self {
|
||
match value {
|
||
"classic" => Self::Stealth,
|
||
"diamond" => Self::Diamond,
|
||
"oval" => Self::Oval,
|
||
"open" => Self::Arrow,
|
||
_ => Self::Triangle,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct ArrowStyle {
|
||
pub kind: ArrowKind,
|
||
pub width: ArrowSize,
|
||
pub length: ArrowSize,
|
||
}
|
||
|
||
impl ArrowStyle {
|
||
pub fn is_default(&self) -> bool {
|
||
*self == Self::default()
|
||
}
|
||
|
||
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
|
||
pub fn dims(self, line_width: f32) -> (f32, f32) {
|
||
let unit = arrow_unit(line_width);
|
||
(self.length.factor() * unit, self.width.factor() * unit)
|
||
}
|
||
}
|
||
|
||
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
|
||
/// a thinner line's arrowheads as for a 2-point line).
|
||
pub fn arrow_unit(line_width: f32) -> f32 {
|
||
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
|
||
w.max(2.0)
|
||
}
|
||
|
||
/// The rest of a shape's properties. Empty (the default) for most shapes.
|
||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct ShapeExtra {
|
||
/// The drawing id connectors refer to (`wp:docPr/@id`, `wps:cNvPr/@id`); 0 = none. Kept only
|
||
/// for shapes a connector is glued to.
|
||
#[serde(skip_serializing_if = "is_zero")]
|
||
pub id: u32,
|
||
/// The text's warp (Text Effects › Transform).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub warp: Option<TextWarp>,
|
||
/// What a connector's start and end are glued to.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub start: Option<ConnEnd>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub end: Option<ConnEnd>,
|
||
/// Arrowheads at a line's or connector's start (`a:headEnd`) and end (`a:tailEnd`).
|
||
#[serde(skip_serializing_if = "is_false")]
|
||
pub arrow_start: bool,
|
||
#[serde(skip_serializing_if = "is_false")]
|
||
pub arrow_end: bool,
|
||
/// The kind and size of the start and end arrowheads, when there are any.
|
||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||
pub start_style: ArrowStyle,
|
||
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
|
||
pub end_style: ArrowStyle,
|
||
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
|
||
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub corner: Option<u32>,
|
||
/// The outline dash pattern; absent in older saved documents means solid.
|
||
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
|
||
pub dash: crate::line_dash::LineDash,
|
||
/// The outline ends; older saved documents and absent DrawingML caps are flat.
|
||
#[serde(skip_serializing_if = "LineCap::is_flat")]
|
||
pub cap: LineCap,
|
||
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
|
||
/// main colour (the shape's `stroke`).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub outline_fill: Option<crate::fill::Fill>,
|
||
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
|
||
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub hr: Option<HorizontalRule>,
|
||
}
|
||
|
||
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
|
||
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
|
||
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct HorizontalRule {
|
||
pub pct: f32,
|
||
pub align: crate::props::Align,
|
||
/// Word's standard look (`o:hrstd`), kept for saving.
|
||
pub standard: bool,
|
||
}
|
||
|
||
impl HorizontalRule {
|
||
/// The rule's width on a line `line` wide, for a shape `own` wide.
|
||
pub fn width(&self, line: f32, own: f32) -> f32 {
|
||
let line = line.max(0.0);
|
||
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
|
||
}
|
||
|
||
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
|
||
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
|
||
match self.align {
|
||
crate::props::Align::Center => x + (line - w) / 2.0,
|
||
crate::props::Align::Right => x + line - w,
|
||
_ => x,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_zero(v: &u32) -> bool {
|
||
*v == 0
|
||
}
|
||
fn is_false(v: &bool) -> bool {
|
||
!*v
|
||
}
|
||
|
||
impl ShapeExtra {
|
||
pub fn is_empty(&self) -> bool {
|
||
*self == ShapeExtra::default()
|
||
}
|
||
}
|
||
|
||
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
|
||
pub const MAX_CORNER: u32 = 50_000;
|
||
|
||
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
|
||
pub const DEFAULT_CORNER: f32 = 0.16;
|
||
|
||
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
|
||
/// [`ShapeExtra::corner`]).
|
||
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
|
||
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
|
||
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
|
||
}
|
||
|
||
/// One glued end of a connector: the drawing id of the shape and its connection site index.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ConnEnd {
|
||
pub id: u32,
|
||
pub site: u32,
|
||
}
|
||
|
||
/// Largest connection site index read from a file.
|
||
pub const MAX_SITE: u32 = 64;
|
||
|
||
impl ShapeKind {
|
||
/// A connector (straight, elbow or curved): a line that can be glued to shapes.
|
||
pub fn is_connector(self) -> bool {
|
||
matches!(self, ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector)
|
||
}
|
||
/// A line or connector: drawn as an open path, never filled.
|
||
pub fn is_open(self) -> bool {
|
||
self == ShapeKind::Line || self.is_connector()
|
||
}
|
||
}
|
||
|
||
/// The connection sites of a `kind` shape `w` × `h`, in its own unrotated frame, by index. The
|
||
/// order follows the preset shapes' connection lists (for a rectangle: top, left, bottom, right).
|
||
pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
|
||
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
|
||
let (cx, cy) = (w / 2.0, h / 2.0);
|
||
match kind {
|
||
ShapeKind::Ellipse => {
|
||
// The four sides, and the points of the ellipse at 45° between them.
|
||
let (dx, dy) = (cx * std::f32::consts::FRAC_1_SQRT_2, cy * std::f32::consts::FRAC_1_SQRT_2);
|
||
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
|
||
}
|
||
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
|
||
// The corners, the legs' midpoints and the hypotenuse's.
|
||
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
|
||
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
|
||
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
|
||
}
|
||
}
|
||
|
||
/// Site `site` of a `kind` shape with frame `rect` (x, y, w, h) on the page, turned and flipped by
|
||
/// `spin` about its centre. `None` for a site the shape doesn't have.
|
||
pub fn site_point(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, site: u32) -> Option<(f32, f32)> {
|
||
let [x, y, w, h] = rect;
|
||
let (sx, sy) = *sites(kind, w, h).get(site as usize)?;
|
||
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
|
||
let (px, py) = spin.apply(cx, cy, x + sx, y + sy);
|
||
(px.is_finite() && py.is_finite()).then_some((px, py))
|
||
}
|
||
|
||
/// The site of a `kind` shape (frame `rect`, `spin`) nearest to page point `at`: its index, point
|
||
/// and distance.
|
||
pub fn nearest_site(kind: ShapeKind, rect: [f32; 4], spin: wordcraft_geom::Spin, at: (f32, f32)) -> Option<(u32, (f32, f32), f32)> {
|
||
let n = sites(kind, rect[2], rect[3]).len() as u32;
|
||
(0..n)
|
||
.filter_map(|i| site_point(kind, rect, spin, i).map(|p| (i, p, ((p.0 - at.0).powi(2) + (p.1 - at.1).powi(2)).sqrt())))
|
||
.min_by(|a, b| a.2.total_cmp(&b.2))
|
||
}
|
||
|
||
/// A connector's frame: top-left corner, size and flips.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct Frame {
|
||
pub x: f32,
|
||
pub y: f32,
|
||
pub w: f32,
|
||
pub h: f32,
|
||
pub flip_h: bool,
|
||
pub flip_v: bool,
|
||
}
|
||
|
||
/// The frame of a connector from `start` to `end`.
|
||
pub fn route(start: (f32, f32), end: (f32, f32)) -> Frame {
|
||
let f = wordcraft_geom::finite;
|
||
let (x0, y0, x1, y1) = (f(start.0), f(start.1), f(end.0), f(end.1));
|
||
Frame { x: x0.min(x1), y: y0.min(y1), w: (x1 - x0).abs(), h: (y1 - y0).abs(), flip_h: x1 < x0, flip_v: y1 < y0 }
|
||
}
|
||
|
||
/// A connector's start and end from its frame (the inverse of [`route`]).
|
||
pub fn ends(x: f32, y: f32, w: f32, h: f32, flip_h: bool, flip_v: bool) -> ((f32, f32), (f32, f32)) {
|
||
let (xa, xb) = if flip_h { (x + w, x) } else { (x, x + w) };
|
||
let (ya, yb) = if flip_v { (y + h, y) } else { (y, y + h) };
|
||
((xa, ya), (xb, yb))
|
||
}
|
||
|
||
/// The path of a connector `kind` in a `w` × `h` frame at the origin, from its top-left to its
|
||
/// bottom-right corner (flips are the frame's turn): straight, an elbow through the middle (out
|
||
/// horizontally, across, in horizontally), or an S-curve. `None` for other kinds.
|
||
pub fn connector_segs(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<PathSeg>> {
|
||
let (w, h) = (wordcraft_geom::finite(w), wordcraft_geom::finite(h));
|
||
let mx = w / 2.0;
|
||
Some(match kind {
|
||
ShapeKind::StraightConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(w, h)],
|
||
ShapeKind::ElbowConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(mx, 0.0), PathSeg::Line(mx, h), PathSeg::Line(w, h)],
|
||
ShapeKind::CurvedConnector => vec![PathSeg::Move(0.0, 0.0), PathSeg::Cubic(mx, 0.0, mx, h, w, h)],
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// The direction (unit vector) a line or connector `kind` arrives at its end (`at_end`) or
|
||
/// leaves its start, in its unflipped `w` × `h` frame: where an arrowhead points.
|
||
pub fn end_direction(kind: ShapeKind, w: f32, h: f32, at_end: bool) -> (f32, f32) {
|
||
let (dx, dy) = match kind {
|
||
ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => {
|
||
if w.abs() > 0.01 || h.abs() < 0.01 {
|
||
(1.0, 0.0)
|
||
} else {
|
||
(0.0, 1.0)
|
||
}
|
||
}
|
||
ShapeKind::Line => (w, -h),
|
||
_ => (w, h),
|
||
};
|
||
let len = (dx * dx + dy * dy).sqrt();
|
||
let (ux, uy) = if len > 1e-6 && len.is_finite() { (dx / len, dy / len) } else { (1.0, 0.0) };
|
||
if at_end { (ux, uy) } else { (-ux, -uy) }
|
||
}
|
||
|
||
/// Re-route a group's glued connectors. Resolved ends use the shapes' connection sites in child
|
||
/// coordinates; free or unresolved ends keep their displayed position after child scaling and spin.
|
||
/// Returns whether any moved. Non-groups and connectors needing a non-invertible or display-capped
|
||
/// fallback are unchanged.
|
||
pub fn reroute_group(group: &mut InlineObject) -> bool {
|
||
let InlineObject::Group { w, h, ch_w, ch_h, children, .. } = group else { return false };
|
||
let fin = wordcraft_geom::finite;
|
||
// Match group_rects: invalid child extents use unit scale; invalid display extents become zero.
|
||
let sx = if fin(*ch_w) > 0.0 { fin(*w) / *ch_w } else { 1.0 };
|
||
let sy = if fin(*ch_h) > 0.0 { fin(*h) / *ch_h } else { 1.0 };
|
||
let shapes: Vec<(u32, ShapeKind, [f32; 4], wordcraft_geom::Spin)> = children
|
||
.iter()
|
||
.filter_map(|c| match &c.obj {
|
||
InlineObject::Shape { kind, w, h, float, extra, .. } if extra.id != 0 => Some((extra.id, *kind, [c.x, c.y, *w, *h], float.spin())),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
if shapes.is_empty() {
|
||
return false;
|
||
}
|
||
let find = |e: Option<ConnEnd>| {
|
||
let e = e?;
|
||
let (_, kind, rect, spin) = shapes.iter().find(|s| s.0 == e.id)?;
|
||
site_point(*kind, *rect, *spin, e.site)
|
||
};
|
||
let mut moved = false;
|
||
for c in children.iter_mut() {
|
||
let InlineObject::Shape { kind, w, h, float, extra, .. } = &mut c.obj else { continue };
|
||
if !kind.is_connector() || (extra.start.is_none() && extra.end.is_none()) {
|
||
continue;
|
||
}
|
||
let (s, e, fallback) = match (find(extra.start), find(extra.end)) {
|
||
(Some(s), Some(e)) => (s, e, false),
|
||
(s, e) => {
|
||
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
|
||
continue;
|
||
}
|
||
// Rendering scales the rectangle before spinning it. Undo only the group scale
|
||
// so route can rebuild an unrotated child frame without moving fallback ends.
|
||
let (x, y) = (fin(c.x) * sx, fin(c.y) * sy);
|
||
let (cw, ch) = ((fin(*w) * sx).clamp(0.0, 4000.0), (fin(*h) * sy).clamp(0.0, 4000.0));
|
||
let (cx, cy) = (x + cw / 2.0, y + ch / 2.0);
|
||
let spin = float.spin();
|
||
let s0 = spin.apply(cx, cy, x, y);
|
||
let e0 = spin.apply(cx, cy, x + cw, y + ch);
|
||
(s.unwrap_or((s0.0 / sx, s0.1 / sy)), e.unwrap_or((e0.0 / sx, e0.1 / sy)), true)
|
||
}
|
||
};
|
||
if ![s.0, s.1, e.0, e.1].into_iter().all(f32::is_finite) {
|
||
continue;
|
||
}
|
||
let f = route(s, e);
|
||
if ![f.x, f.y, f.w, f.h].into_iter().all(f32::is_finite) {
|
||
continue;
|
||
}
|
||
// group_rects caps displayed child sizes. A larger fallback span cannot be represented
|
||
// by an unrotated frame, so keep the original instead of moving its free/unresolved end.
|
||
if fallback && (f.w * sx > 4000.0 || f.h * sy > 4000.0) {
|
||
continue;
|
||
}
|
||
let same = (f.x - c.x).abs() < 0.01 && (f.y - c.y).abs() < 0.01 && (f.w - *w).abs() < 0.01 && (f.h - *h).abs() < 0.01;
|
||
if same && f.flip_h == float.flip_h && f.flip_v == float.flip_v && float.rot == 0.0 {
|
||
continue;
|
||
}
|
||
c.x = f.x;
|
||
c.y = f.y;
|
||
*w = f.w;
|
||
*h = f.h;
|
||
float.rot = 0.0;
|
||
float.flip_h = f.flip_h;
|
||
float.flip_v = f.flip_v;
|
||
moved = true;
|
||
}
|
||
moved
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::para::{Float, GroupChild};
|
||
|
||
/// Older JSON retains flat ends without adding a property; non-flat values round-trip.
|
||
#[test]
|
||
fn older_shape_json_defaults_to_flat_caps() {
|
||
let extra: ShapeExtra = serde_json::from_str("{}").unwrap();
|
||
assert_eq!(extra.cap, LineCap::Flat);
|
||
assert_eq!(serde_json::to_value(&extra).unwrap(), serde_json::json!({}));
|
||
for cap in [LineCap::Square, LineCap::Round] {
|
||
let extra = ShapeExtra { cap, ..Default::default() };
|
||
assert_eq!(serde_json::from_value::<ShapeExtra>(serde_json::to_value(&extra).unwrap()).unwrap(), extra);
|
||
}
|
||
}
|
||
|
||
fn shape(kind: ShapeKind, w: f32, h: f32, extra: ShapeExtra) -> InlineObject {
|
||
InlineObject::Shape {
|
||
kind,
|
||
w,
|
||
h,
|
||
fill: None,
|
||
stroke: None,
|
||
stroke_width: 1.0,
|
||
float: Float::default(),
|
||
story: None,
|
||
freeform: None,
|
||
effects: Default::default(),
|
||
extra,
|
||
}
|
||
}
|
||
|
||
fn group(children: Vec<GroupChild>) -> InlineObject {
|
||
InlineObject::Group { w: 200.0, h: 300.0, ch_w: 200.0, ch_h: 300.0, float: Float::default(), children, canvas: None }
|
||
}
|
||
|
||
#[test]
|
||
fn route_and_ends_are_inverse() {
|
||
for (s, e) in [((0.0, 0.0), (10.0, 5.0)), ((10.0, 5.0), (0.0, 0.0)), ((3.0, 9.0), (7.0, 1.0))] {
|
||
let f = route(s, e);
|
||
assert_eq!(ends(f.x, f.y, f.w, f.h, f.flip_h, f.flip_v), (s, e));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn corner_radius_follows_the_shorter_side() {
|
||
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
|
||
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
|
||
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
|
||
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
|
||
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
|
||
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
|
||
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
|
||
}
|
||
|
||
/// One rerouting case: the free end settles and the attached end keeps its site.
|
||
fn assert_transformed_reroute(kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, free: Option<ConnEnd>, free_start: bool) {
|
||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||
let mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
|
||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||
float.rot = rot;
|
||
float.flip_h = flip_h;
|
||
float.flip_v = flip_v;
|
||
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
|
||
// clockwise in y-down group space. Do not use the routing transform
|
||
// to compute the expectation.
|
||
let sign = if free_start { -1.0 } else { 1.0 };
|
||
let dx = sign * if flip_h { -20.0 } else { 20.0 };
|
||
let dy = sign * if flip_v { -10.0 } else { 10.0 };
|
||
let (sin, cos) = rot.to_radians().sin_cos();
|
||
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
|
||
let kids = vec![
|
||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||
GroupChild { x: 10.0, y: 30.0, obj },
|
||
];
|
||
let mut group = group(kids);
|
||
assert!(reroute_group(&mut group));
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[1];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||
let (free, attached) = if free_start { (start, end) } else { (end, start) };
|
||
assert!(
|
||
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
|
||
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
|
||
);
|
||
assert_eq!(attached, (140.0, 210.0));
|
||
assert_eq!(float.rot, 0.0);
|
||
assert!(!reroute_group(&mut group), "rerouting must settle");
|
||
}
|
||
|
||
#[test]
|
||
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
|
||
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
|
||
for rot in [0.0_f32, 30.0, 90.0, 180.0, 270.0, -90.0, 450.0] {
|
||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||
for free_start in [false, true] {
|
||
assert_transformed_reroute(kind, rot, flip_h, flip_v, free, free_start);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rerouting_preserves_both_unresolved_ends() {
|
||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||
float.rot = 90.0;
|
||
let kids = vec![
|
||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||
GroupChild { x: 10.0, y: 30.0, obj },
|
||
];
|
||
let mut group = group(kids);
|
||
assert!(reroute_group(&mut group));
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[1];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||
assert!((start.0 - 40.0).abs() < 0.001 && (start.1 - 20.0).abs() < 0.001);
|
||
assert!((end.0 - 20.0).abs() < 0.001 && (end.1 - 60.0).abs() < 0.001);
|
||
assert_eq!(float.rot, 0.0);
|
||
assert!(!reroute_group(&mut group));
|
||
}
|
||
|
||
#[test]
|
||
fn rerouting_nonuniform_group_preserves_both_unresolved_ends() {
|
||
let extra = ShapeExtra { start: Some(ConnEnd { id: 404, site: 0 }), end: Some(ConnEnd { id: 7, site: 99 }), ..Default::default() };
|
||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 20.0, extra);
|
||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||
float.rot = 90.0;
|
||
let mut group = group(vec![
|
||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||
GroupChild { x: 10.0, y: 30.0, obj },
|
||
]);
|
||
let InlineObject::Group { w, h, .. } = &mut group else { panic!() };
|
||
(*w, *h) = (400.0, 150.0);
|
||
assert!(reroute_group(&mut group));
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[1];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||
// Displayed rect (20, 15, 80, 10), spun 90 degrees: (65, -20) to (55, 60).
|
||
assert!((start.0 - 32.5).abs() < 0.001 && (start.1 + 40.0).abs() < 0.001);
|
||
assert!((end.0 - 27.5).abs() < 0.001 && (end.1 - 120.0).abs() < 0.001);
|
||
assert!(!reroute_group(&mut group));
|
||
}
|
||
|
||
#[test]
|
||
fn rerouting_preserves_fallback_when_group_display_cap_prevents_route() {
|
||
for free_start in [false, true] {
|
||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||
let (start, end) = if free_start { (None, attached) } else { (attached, None) };
|
||
let mut obj = shape(ShapeKind::StraightConnector, 40.0, 0.0, ShapeExtra { start, end, ..Default::default() });
|
||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||
float.flip_h = free_start;
|
||
let mut group = group(vec![
|
||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||
GroupChild { x: 3000.0, y: 10.0, obj },
|
||
]);
|
||
let InlineObject::Group { w, .. } = &mut group else { panic!() };
|
||
*w = 400.0;
|
||
let visual_free = |group: &InlineObject| {
|
||
let rects = group.group_rects(0.0, 0.0, 400.0, 300.0);
|
||
let ([x, y, w, h], obj) = rects[1];
|
||
let spin = obj.frame().unwrap().2.spin();
|
||
let (px, py) = if free_start { (x, y) } else { (x + w, y + h) };
|
||
spin.apply(x + w / 2.0, y + h / 2.0, px, py)
|
||
};
|
||
let before = visual_free(&group);
|
||
assert_eq!(before, (6080.0, 10.0));
|
||
let moved = reroute_group(&mut group);
|
||
assert_eq!(visual_free(&group), before, "the rebuilt display frame cannot span both endpoints");
|
||
assert!(!moved, "an unrepresentable fallback route must leave the connector unchanged");
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[1];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
assert_eq!((c.x, c.y, *w, *h, float.rot, float.flip_h), (3000.0, 10.0, 40.0, 0.0, 0.0, free_start));
|
||
assert!(!reroute_group(&mut group), "repeated rerouting must not shrink the connector");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn rerouting_group_handles_degenerate_dimensions_without_nonfinite_writes() {
|
||
for dims in [
|
||
[0.0, 300.0, 200.0, 300.0],
|
||
[200.0, 0.0, 200.0, 300.0],
|
||
[-200.0, 300.0, 200.0, 300.0],
|
||
[f32::NAN, 300.0, 200.0, 300.0],
|
||
[200.0, f32::INFINITY, 200.0, 300.0],
|
||
[f32::MAX, 300.0, f32::MIN_POSITIVE, 300.0],
|
||
[f32::MIN_POSITIVE, 300.0, f32::MAX, 300.0],
|
||
[20000.0, f32::MIN_POSITIVE, 200.0, 300.0],
|
||
[200.0, 300.0, 0.0, 0.0],
|
||
[200.0, 300.0, f32::NAN, f32::INFINITY],
|
||
[200.0, 300.0, -200.0, -300.0],
|
||
] {
|
||
let mut obj =
|
||
shape(ShapeKind::StraightConnector, 40.0, 20.0, ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), ..Default::default() });
|
||
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
|
||
float.rot = 90.0;
|
||
let mut group = group(vec![
|
||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
|
||
GroupChild { x: 10.0, y: 30.0, obj },
|
||
]);
|
||
let InlineObject::Group { w, h, ch_w, ch_h, .. } = &mut group else { panic!() };
|
||
(*w, *h, *ch_w, *ch_h) = (dims[0], dims[1], dims[2], dims[3]);
|
||
let moved = reroute_group(&mut group);
|
||
// group_rects treats invalid child extents as unit scale; display scales that
|
||
// cannot be inverted (or whose inverse overflows) must not change the connector.
|
||
assert_eq!(moved, dims[2] <= 0.0 || !dims[2].is_finite(), "{dims:?}");
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[1];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
assert!([c.x, c.y, *w, *h, float.rot].into_iter().all(f32::is_finite), "{dims:?}");
|
||
assert!(!reroute_group(&mut group), "rerouting must settle: {dims:?}");
|
||
}
|
||
assert!(!reroute_group(&mut shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra::default())));
|
||
}
|
||
|
||
#[test]
|
||
fn group_connectors_follow_their_shapes() {
|
||
let a = ShapeExtra { id: 7, ..Default::default() };
|
||
let b = ShapeExtra { id: 9, ..Default::default() };
|
||
let link = ShapeExtra { start: Some(ConnEnd { id: 7, site: 3 }), end: Some(ConnEnd { id: 9, site: 2 }), ..Default::default() };
|
||
let mut kids = vec![
|
||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 20.0, 10.0, a) },
|
||
GroupChild { x: 100.0, y: 50.0, obj: shape(ShapeKind::Ellipse, 20.0, 10.0, b) },
|
||
GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::ElbowConnector, 1.0, 1.0, link) },
|
||
];
|
||
// Fully resolved endpoints must still come from their sites, regardless of the
|
||
// connector's old rotation and flips.
|
||
let InlineObject::Shape { float, .. } = &mut kids[2].obj else { panic!() };
|
||
float.rot = 90.0;
|
||
float.flip_h = true;
|
||
float.flip_v = true;
|
||
let mut group = group(kids);
|
||
assert!(reroute_group(&mut group));
|
||
// From the rectangle's right side (20, 5) to the ellipse's left side (100, 55).
|
||
let InlineObject::Group { children, .. } = &group else { panic!() };
|
||
let c = &children[2];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
assert_eq!((c.x, c.y, *w, *h, float.flip_h, float.flip_v), (20.0, 5.0, 80.0, 50.0, false, false));
|
||
assert!(!reroute_group(&mut group), "settled");
|
||
// A fully resolved connector does not need an invertible group scale.
|
||
for (width, height) in [(400.0, 150.0), (0.0, 0.0), (f32::NAN, f32::INFINITY)] {
|
||
let mut scaled = group.clone();
|
||
let InlineObject::Group { w, h, children, .. } = &mut scaled else { panic!() };
|
||
(*w, *h) = (width, height);
|
||
let InlineObject::Shape { float, .. } = &mut children[2].obj else { panic!() };
|
||
float.rot = 90.0;
|
||
assert!(reroute_group(&mut scaled));
|
||
let InlineObject::Group { children, .. } = &scaled else { panic!() };
|
||
let c = &children[2];
|
||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
|
||
assert_eq!(ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v), ((20.0, 5.0), (100.0, 55.0)));
|
||
assert!(!reroute_group(&mut scaled));
|
||
}
|
||
// A connection to a missing id or site keeps that end where it was.
|
||
let InlineObject::Group { children, .. } = &mut group else { panic!() };
|
||
if let InlineObject::Shape { extra, .. } = &mut children[2].obj {
|
||
extra.end = Some(ConnEnd { id: 404, site: 0 });
|
||
extra.start = Some(ConnEnd { id: 7, site: 99 });
|
||
}
|
||
assert!(!reroute_group(&mut group));
|
||
}
|
||
}
|