oxedyne/fe2o3/fe2o3_austenite/src/diagram/mod.rs
19.4 KiB, 76 runs
created by r1870400018:36165, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The diagram sub-language: labelled nodes and routed edges, emitted as drawn vector paths. |
| 2 | //! |
| 3 | //! A diagram stands to Austenite as CeTZ stands to Typst, under one rule carried from the |
| 4 | //! architecture: elements are placed by anchor and relative offset in a single dependency-ordered |
| 5 | //! pass, with no constraint solver. A [`Diagram`] is built up as a list of nodes and edges and then |
| 6 | //! [`build`](Diagram::build) into a [`Graphic`] -- the same `fe2o3_graphics` outline paths the body |
| 7 | //! text emits, so a figure is first-class content in the SVG and the PDF, not an embedded picture. |
| 8 | //! |
| 9 | //! A node's label travels the prose's own shaping path: `fe2o3_font` shapes it, each glyph becomes an |
| 10 | //! outline, and the outlines are baked into the figure exactly as the SVG writer's `draw_text` bakes a |
| 11 | //! line of text -- flipped from the font's y-up frame onto the figure's y-down baseline. The box is |
| 12 | //! auto-sized to the label. An edge is a real polyline between two ports, routed once the nodes are |
| 13 | //! placed, ended with a filled-triangle arrowhead; an orthogonal edge keeps every segment axis |
| 14 | //! aligned. Because placement is one ordered pass, a node depends only on nodes placed before it, and |
| 15 | //! a bad reference names the node and says what is wrong. |
| 16 | |
| 17 | pub mod layout; |
| 18 | pub mod shape; |
| 19 | |
| 20 | use crate::diagram::layout::{ |
| 21 | Placement, |
| 22 | Route, |
| 23 | }; |
| 24 | use crate::diagram::shape::{ |
| 25 | Port, |
| 26 | Rect, |
| 27 | Shape, |
| 28 | }; |
| 29 | use crate::font::ShapedText; |
| 30 | use crate::ir::{ |
| 31 | Dims, |
| 32 | DrawOp, |
| 33 | Graphic, |
| 34 | Sp, |
| 35 | }; |
| 36 | |
| 37 | use oxedyne_fe2o3_core::prelude::*; |
| 38 | use oxedyne_fe2o3_font::{ |
| 39 | face::Role, |
| 40 | set::FontSet, |
| 41 | shape::Dir, |
| 42 | }; |
| 43 | use oxedyne_fe2o3_graphics::{ |
| 44 | colour::Rgba, |
| 45 | path::Bounds, |
| 46 | transform::Transform, |
| 47 | }; |
| 48 | |
| 49 | use std::collections::BTreeMap; |
| 50 | use std::sync::Arc; |
| 51 | |
| 52 | /// One end of an edge: the node it attaches to, and optionally the named port. With no port the |
| 53 | /// builder chooses the side facing the other end once both are placed. |
| 54 | #[derive(Clone, Debug)] |
| 55 | pub struct Endpoint { |
| 56 | pub node: String, |
| 57 | pub port: Option<Port>, |
| 58 | } |
| 59 | |
| 60 | impl Endpoint { |
| 61 | /// An end attached to a node, its port chosen automatically at build. |
| 62 | pub fn node<S: Into<String>>(node: S) -> Self { |
| 63 | Self { node: node.into(), port: None } |
| 64 | } |
| 65 | |
| 66 | /// An end attached to a named port of a node. |
| 67 | pub fn port<S: Into<String>>(node: S, port: Port) -> Self { |
| 68 | Self { node: node.into(), port: Some(port) } |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // How a node is placed, keyed by node id, before ids are resolved to indices at build. |
| 73 | #[derive(Clone, Debug)] |
| 74 | enum PlaceSpec { |
| 75 | At { x: Sp, y: Sp }, |
| 76 | Below { of: String, gap: Sp }, |
| 77 | Right { of: String, gap: Sp }, |
| 78 | } |
| 79 | |
| 80 | #[derive(Clone, Debug)] |
| 81 | struct NodeSpec { |
| 82 | id: String, |
| 83 | label: String, // a `\n` in the label parts it into stacked, centred lines |
| 84 | place: PlaceSpec, |
| 85 | shape: Shape, |
| 86 | fill: Option<Rgba>, // this node's own wash, overriding the style default when set |
| 87 | size: Option<(Sp, Sp)>, // an explicit box, grown to hold the label but never shrunk below it |
| 88 | } |
| 89 | |
| 90 | #[derive(Clone, Debug)] |
| 91 | struct EdgeSpec { |
| 92 | from: Endpoint, |
| 93 | to: Endpoint, |
| 94 | label: Option<String>, |
| 95 | route: Route, |
| 96 | near_src: bool, // seat the label by the source end rather than at the longest segment's midpoint |
| 97 | } |
| 98 | |
| 99 | /// The lengths and colours a diagram is drawn to. Every length is scaled points, so the styling never |
| 100 | /// leaves the integer domain; the widths and the arrowhead sizes are points, as the stroke and the |
| 101 | /// graphics boundary take them. |
| 102 | #[derive(Clone, Copy, Debug)] |
| 103 | pub struct DiagramStyle { |
| 104 | pub label_size: Sp, // the node label's body size |
| 105 | pub edge_label_size: Sp, // the smaller size an edge label is set at |
| 106 | pub pad_x: Sp, // horizontal gap between a label and its box side |
| 107 | pub pad_y: Sp, // vertical gap between a label and its box side |
| 108 | pub stub: Sp, // the perpendicular lead out of a port on an orthogonal edge |
| 109 | pub node_stroke: f32, // node outline width, points |
| 110 | pub edge_stroke: f32, // edge line width, points |
| 111 | pub arrow_len: f32, // arrowhead length along the edge, points |
| 112 | pub arrow_half: f32, // arrowhead half-width across the edge, points |
| 113 | pub node_fill: Option<Rgba>, // the interior wash, or None to leave the box unfilled |
| 114 | pub margin: f32, // clear border left around the whole figure, points |
| 115 | } |
| 116 | |
| 117 | impl Default for DiagramStyle { |
| 118 | fn default() -> Self { |
| 119 | Self { |
| 120 | label_size: Sp::from_pt(11.0), |
| 121 | edge_label_size: Sp::from_pt(9.5), |
| 122 | pad_x: Sp::from_pt(9.0), |
| 123 | pad_y: Sp::from_pt(6.0), |
| 124 | stub: Sp::from_pt(10.0), |
| 125 | node_stroke: 1.0, |
| 126 | edge_stroke: 1.0, |
| 127 | arrow_len: 7.0, |
| 128 | arrow_half: 3.0, |
| 129 | node_fill: Some(Rgba::new(246, 246, 248, 255)), |
| 130 | margin: 3.0, |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// A flowchart under construction: nodes placed by anchor and offset, and edges joining their ports. |
| 136 | /// Built once, then turned into a [`Graphic`]. |
| 137 | #[derive(Clone, Debug, Default)] |
| 138 | pub struct Diagram { |
| 139 | nodes: Vec<NodeSpec>, |
| 140 | edges: Vec<EdgeSpec>, |
| 141 | } |
| 142 | |
| 143 | impl Diagram { |
| 144 | pub fn new() -> Self { |
| 145 | Self::default() |
| 146 | } |
| 147 | |
| 148 | /// Places a node with its centre at an absolute point in the diagram's own frame. |
| 149 | pub fn node_at<I, L>(&mut self, id: I, label: L, x: Sp, y: Sp, shape: Shape) -> &mut Self |
| 150 | where |
| 151 | I: Into<String>, |
| 152 | L: Into<String>, |
| 153 | { |
| 154 | self.nodes.push(NodeSpec { |
| 155 | id: id.into(), |
| 156 | label: label.into(), |
| 157 | place: PlaceSpec::At { x, y }, |
| 158 | shape, |
| 159 | fill: None, |
| 160 | size: None, |
| 161 | }); |
| 162 | self |
| 163 | } |
| 164 | |
| 165 | /// Places a node centred beneath an already-added node, its box top a `gap` below the anchor's box |
| 166 | /// bottom. |
| 167 | pub fn node_below<I, L>(&mut self, id: I, label: L, of: &str, gap: Sp, shape: Shape) -> &mut Self |
| 168 | where |
| 169 | I: Into<String>, |
| 170 | L: Into<String>, |
| 171 | { |
| 172 | self.nodes.push(NodeSpec { |
| 173 | id: id.into(), |
| 174 | label: label.into(), |
| 175 | place: PlaceSpec::Below { of: of.to_string(), gap }, |
| 176 | shape, |
| 177 | fill: None, |
| 178 | size: None, |
| 179 | }); |
| 180 | self |
| 181 | } |
| 182 | |
| 183 | /// Places a node centred to the right of an already-added node, its box left a `gap` past the |
| 184 | /// anchor's box right. |
| 185 | pub fn node_right<I, L>(&mut self, id: I, label: L, of: &str, gap: Sp, shape: Shape) -> &mut Self |
| 186 | where |
| 187 | I: Into<String>, |
| 188 | L: Into<String>, |
| 189 | { |
| 190 | self.nodes.push(NodeSpec { |
| 191 | id: id.into(), |
| 192 | label: label.into(), |
| 193 | place: PlaceSpec::Right { of: of.to_string(), gap }, |
| 194 | shape, |
| 195 | fill: None, |
| 196 | size: None, |
| 197 | }); |
| 198 | self |
| 199 | } |
| 200 | |
| 201 | /// Sets the fill wash of the most recently added node, overriding the style default. A node with no |
| 202 | /// fill set takes the diagram style's fill. |
| 203 | pub fn fill(&mut self, colour: Rgba) -> &mut Self { |
| 204 | if let Some(n) = self.nodes.last_mut() { |
| 205 | n.fill = Some(colour); |
| 206 | } |
| 207 | self |
| 208 | } |
| 209 | |
| 210 | /// Sets an explicit box for the most recently added node. The box is grown to hold the label if the |
| 211 | /// label is larger, but never shrunk below the given extent -- the way a flowchart fixes a decision's |
| 212 | /// diamond to a uniform size. |
| 213 | pub fn size(&mut self, w: Sp, h: Sp) -> &mut Self { |
| 214 | if let Some(n) = self.nodes.last_mut() { |
| 215 | n.size = Some((w, h)); |
| 216 | } |
| 217 | self |
| 218 | } |
| 219 | |
| 220 | /// Joins two ports with an edge, optionally labelled, routed straight or orthogonally. |
| 221 | pub fn edge(&mut self, from: Endpoint, to: Endpoint, label: Option<&str>, route: Route) -> &mut Self { |
| 222 | self.edges.push(EdgeSpec { |
| 223 | from, |
| 224 | to, |
| 225 | label: label.map(|s| s.to_string()), |
| 226 | route, |
| 227 | near_src: false, |
| 228 | }); |
| 229 | self |
| 230 | } |
| 231 | |
| 232 | /// Joins two ports with an edge whose label is seated by the source end rather than at the midpoint of |
| 233 | /// the longest segment -- the placement a flowchart's decision branch labels ("Y", "N") take. |
| 234 | pub fn edge_near(&mut self, from: Endpoint, to: Endpoint, label: Option<&str>, route: Route) -> &mut Self { |
| 235 | self.edges.push(EdgeSpec { |
| 236 | from, |
| 237 | to, |
| 238 | label: label.map(|s| s.to_string()), |
| 239 | route, |
| 240 | near_src: true, |
| 241 | }); |
| 242 | self |
| 243 | } |
| 244 | |
| 245 | /// Composes the diagram into a graphic: shape every label, size and place every node, draw the |
| 246 | /// boxes and the labels, route and arrow every edge, then normalise the whole to the figure origin. |
| 247 | /// The returned [`Graphic`]'s dimensions are its bounding box, width and height the full extent and |
| 248 | /// depth zero, so the block layer places it as one box. |
| 249 | pub fn build(&self, fonts: Arc<FontSet>, style: &DiagramStyle) -> Outcome<Graphic> { |
| 250 | // Every node id, so an edge or a relative placement can resolve to an index in one pass. |
| 251 | let mut index: BTreeMap<String, usize> = BTreeMap::new(); |
| 252 | for (i, n) in self.nodes.iter().enumerate() { |
| 253 | if index.insert(n.id.clone(), i).is_some() { |
| 254 | return Err(err!( |
| 255 | "Two nodes share the id \"{}\"; each node needs a distinct id.", n.id; |
| 256 | Invalid, Input)); |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // Shape each label and size its box. A relative placement is resolved to the anchor's index |
| 261 | // here, which enforces the dependency order: the anchor must already be known. A label carrying a |
| 262 | // `\n` is shaped a line at a time and stacked, so the box holds the widest line and the full stack. |
| 263 | let mut labels: Vec<Vec<ShapedText>> = Vec::with_capacity(self.nodes.len()); |
| 264 | let mut specs: Vec<(Placement, Sp, Sp)> = Vec::with_capacity(self.nodes.len()); |
| 265 | for (i, n) in self.nodes.iter().enumerate() { |
| 266 | let mut lines: Vec<ShapedText> = Vec::new(); |
| 267 | let mut max_w = Sp::ZERO; |
| 268 | let mut ext = Sp::ZERO; |
| 269 | for (li, line) in n.label.split('\n').enumerate() { |
| 270 | let shaped = res!(ShapedText::new(fonts.clone(), Role::Body, Dir::Ltr, style.label_size, line)); |
| 271 | let ld = shaped.dims(); |
| 272 | if ld.width > max_w { |
| 273 | max_w = ld.width; |
| 274 | } |
| 275 | if li > 0 { |
| 276 | ext = ext + line_gap(style.label_size); |
| 277 | } |
| 278 | ext = ext + ld.height + ld.depth; |
| 279 | lines.push(shaped); |
| 280 | } |
| 281 | let (mut w, mut h) = n.shape.size_for_label(max_w, ext, style.pad_x, style.pad_y); |
| 282 | // An explicit box grows the node but never shrinks it below what the label needs. |
| 283 | if let Some((ew, eh)) = n.size { |
| 284 | if ew > w { w = ew; } |
| 285 | if eh > h { h = eh; } |
| 286 | } |
| 287 | labels.push(lines); |
| 288 | |
| 289 | let placement = match &n.place { |
| 290 | PlaceSpec::At { x, y } => Placement::At { x: *x, y: *y }, |
| 291 | PlaceSpec::Below { of, gap } => { |
| 292 | Placement::Below { of: res!(anchor(&index, &n.id, of, i, "below")), gap: *gap } |
| 293 | }, |
| 294 | PlaceSpec::Right { of, gap } => { |
| 295 | Placement::Right { of: res!(anchor(&index, &n.id, of, i, "right of")), gap: *gap } |
| 296 | }, |
| 297 | }; |
| 298 | specs.push((placement, w, h)); |
| 299 | } |
| 300 | let rects = res!(layout::place(&specs)); |
| 301 | |
| 302 | let mut ops: Vec<DrawOp> = Vec::new(); |
| 303 | |
| 304 | // The boxes and their labels. Fill first, so the black outline and the black label sit over the |
| 305 | // wash rather than under it. |
| 306 | for (i, n) in self.nodes.iter().enumerate() { |
| 307 | let r = &rects[i]; |
| 308 | let outline = res!(n.shape.outline(r)); |
| 309 | // A node's own fill wins; otherwise the diagram style's default fill, or none. |
| 310 | let fill = n.fill.or(style.node_fill); |
| 311 | if let Some(fill) = fill { |
| 312 | ops.push(DrawOp::Fill { path: outline.clone(), colour: fill }); |
| 313 | } |
| 314 | ops.push(DrawOp::Stroke { path: outline, colour: Rgba::BLACK, width: style.node_stroke }); |
| 315 | res!(bake_label_centred(&mut ops, &labels[i], style.label_size, r)); |
| 316 | } |
| 317 | |
| 318 | // The edges, each routed once its ports are placed. |
| 319 | for e in &self.edges { |
| 320 | res!(self.draw_edge(&mut ops, &index, &rects, fonts.clone(), style, e)); |
| 321 | } |
| 322 | |
| 323 | // Normalise: translate the whole so its bounding box sits at (margin, margin), and report that |
| 324 | // padded box as the figure's dimensions. |
| 325 | let bb = res!(ink_bounds(&ops)); |
| 326 | let t = Transform::translate(-bb.x0 + style.margin, -bb.y0 + style.margin); |
| 327 | let mut placed: Vec<DrawOp> = Vec::with_capacity(ops.len()); |
| 328 | for op in ops { |
| 329 | placed.push(res!(shift(op, &t))); |
| 330 | } |
| 331 | let w = bb.width() + 2.0 * style.margin; |
| 332 | let h = bb.height() + 2.0 * style.margin; |
| 333 | Ok(Graphic::new(placed, Dims::new( |
| 334 | Sp::from_pt(w as f64), |
| 335 | Sp::from_pt(h as f64), |
| 336 | Sp::ZERO, |
| 337 | ))) |
| 338 | } |
| 339 | |
| 340 | /// Draws one edge: resolve its two ends to coordinates and facings, route the polyline, stroke it |
| 341 | /// short of the arrowhead, fill the arrowhead, and bake any label clear of the longest segment. |
| 342 | fn draw_edge( |
| 343 | &self, |
| 344 | ops: &mut Vec<DrawOp>, |
| 345 | index: &BTreeMap<String, usize>, |
| 346 | rects: &[Rect], |
| 347 | fonts: Arc<FontSet>, |
| 348 | style: &DiagramStyle, |
| 349 | e: &EdgeSpec, |
| 350 | ) |
| 351 | -> Outcome<()> |
| 352 | { |
| 353 | let ai = res!(edge_node(index, &e.from.node)); |
| 354 | let bi = res!(edge_node(index, &e.to.node)); |
| 355 | let ra = &rects[ai]; |
| 356 | let rb = &rects[bi]; |
| 357 | |
| 358 | // A reference point for each end -- its explicit port, or its centre -- so an end with no port |
| 359 | // can pick the side facing the other end. |
| 360 | let a_ref = ref_point(ra, &e.from); |
| 361 | let b_ref = ref_point(rb, &e.to); |
| 362 | // A feedback loop always leaves and re-enters on the east side, whatever the endpoints declared. |
| 363 | let (a_port, b_port) = if matches!(e.route, Route::Feedback { .. }) { |
| 364 | (Port::East, Port::East) |
| 365 | } else { |
| 366 | let a_port = match e.from.port { |
| 367 | Some(p) => p, |
| 368 | None => layout::nearest_port(ra, b_ref), |
| 369 | }; |
| 370 | let b_port = match e.to.port { |
| 371 | Some(p) => p, |
| 372 | None => layout::nearest_port(rb, a_ref), |
| 373 | }; |
| 374 | (a_port, b_port) |
| 375 | }; |
| 376 | |
| 377 | let a_xy = self.nodes[ai].shape.port(ra, a_port); |
| 378 | let b_xy = self.nodes[bi].shape.port(rb, b_port); |
| 379 | let pts = layout::route_points( |
| 380 | a_xy, layout::facing(a_port), b_xy, layout::facing(b_port), e.route, style.stub); |
| 381 | |
| 382 | let n = pts.len(); |
| 383 | if n < 2 { |
| 384 | return Err(err!( |
| 385 | "An edge from \"{}\" to \"{}\" routed to fewer than two points.", |
| 386 | e.from.node, e.to.node; Bug)); |
| 387 | } |
| 388 | let tip = pts[n - 1]; |
| 389 | let prev = pts[n - 2]; |
| 390 | |
| 391 | // Stroke stops at the arrowhead's base, so the line does not run under the tip. |
| 392 | let mut stroke_pts = pts.clone(); |
| 393 | stroke_pts[n - 1] = layout::retract(tip, prev, style.arrow_len); |
| 394 | ops.push(DrawOp::Stroke { |
| 395 | path: res!(layout::stroke_path(&stroke_pts)), |
| 396 | colour: Rgba::BLACK, |
| 397 | width: style.edge_stroke, |
| 398 | }); |
| 399 | ops.push(DrawOp::Fill { |
| 400 | path: res!(layout::arrowhead(tip, prev, style.arrow_len, style.arrow_half)), |
| 401 | colour: Rgba::BLACK, |
| 402 | }); |
| 403 | |
| 404 | if let Some(text) = &e.label { |
| 405 | res!(self.bake_edge_label(ops, fonts, style, &pts, text, e.near_src)); |
| 406 | } |
| 407 | Ok(()) |
| 408 | } |
| 409 | |
| 410 | /// Bakes an edge label. By default it sits at the midpoint of the edge's longest segment, nudged clear |
| 411 | /// of the line along its perpendicular; when `near_src` is set it sits a little way along the first |
| 412 | /// segment from the source, the placement a flowchart's branch label ("Y"/"N") takes. |
| 413 | fn bake_edge_label( |
| 414 | &self, |
| 415 | ops: &mut Vec<DrawOp>, |
| 416 | fonts: Arc<FontSet>, |
| 417 | style: &DiagramStyle, |
| 418 | pts: &[(Sp, Sp)], |
| 419 | text: &str, |
| 420 | near_src: bool, |
| 421 | ) |
| 422 | -> Outcome<()> |
| 423 | { |
| 424 | let shaped = res!(ShapedText::new(fonts, Role::Italic, Dir::Ltr, style.edge_label_size, text)); |
| 425 | let ld = shaped.dims(); |
| 426 | let anchor = if near_src { |
| 427 | layout::label_anchor_near_source(pts, style.stub) |
| 428 | } else { |
| 429 | layout::label_anchor(pts) |
| 430 | }; |
| 431 | let (mid, perp) = match anchor { |
| 432 | Some(a) => a, |
| 433 | None => return Ok(()), // a zero-length edge carries no label |
| 434 | }; |
| 435 | |
| 436 | // The clear gap is half the label height plus a little, pushed along the perpendicular so the |
| 437 | // label sits beside a vertical edge and above or below a horizontal one. |
| 438 | let ext = ld.height + ld.depth; |
| 439 | let gap = Sp(ext.raw() * 3 / 4); |
| 440 | let off_x = Sp::from_pt((perp.0 * (gap.to_pt() as f32)) as f64); |
| 441 | let off_y = Sp::from_pt((perp.1 * (gap.to_pt() as f32)) as f64); |
| 442 | let cx = mid.0 + off_x; |
| 443 | let cy = mid.1 + off_y; |
| 444 | |
| 445 | let base_x = cx - Sp(ld.width.raw() / 2); |
| 446 | let base_y = cy + Sp((ld.height.raw() - ld.depth.raw()) / 2); |
| 447 | bake_label(ops, &shaped, base_x, base_y) |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | /// Resolves a relative placement's anchor to its index, enforcing the single-pass rule: the anchor |
| 452 | /// must exist and must be declared before the node that leans on it. |
| 453 | fn anchor( |
| 454 | index: &BTreeMap<String, usize>, |
| 455 | id: &str, |
| 456 | of: &str, |
| 457 | i: usize, |
| 458 | rel: &str, |
| 459 | ) |
| 460 | -> Outcome<usize> |
| 461 | { |
| 462 | let j = res!(index.get(of).ok_or_else(|| err!( |
| 463 | "Node \"{}\" is placed {} \"{}\", but no such node exists.", id, rel, of; |
| 464 | Invalid, Input, Missing))); |
| 465 | if *j >= i { |
| 466 | return Err(err!( |
| 467 | "Node \"{}\" is placed {} \"{}\", which is not declared before it; placement is a single \ |
| 468 | forward pass, so a node may lean only on nodes already placed.", id, rel, of; |
| 469 | Invalid, Input)); |
| 470 | } |
| 471 | Ok(*j) |
| 472 | } |
| 473 | |
| 474 | /// Resolves an edge endpoint's node to its index. |
| 475 | fn edge_node(index: &BTreeMap<String, usize>, id: &str) -> Outcome<usize> { |
| 476 | Ok(*res!(index.get(id).ok_or_else(|| err!( |
| 477 | "An edge names node \"{}\", but no such node exists.", id; Invalid, Input, Missing)))) |
| 478 | } |
| 479 | |
| 480 | /// The reference point of an edge end: its explicit port, or the box centre when the port is left open. |
| 481 | fn ref_point(r: &Rect, end: &Endpoint) -> (Sp, Sp) { |
| 482 | match end.port { |
| 483 | Some(p) => shape::port_of(r, p), |
| 484 | None => shape::port_of(r, Port::Centre), |
| 485 | } |
| 486 | } |
| 487 | |
| 488 | /// The gap left between two stacked label lines, a fifth of the label size, so a two-line label reads as |
| 489 | /// one centred block rather than a crammed pair. |
| 490 | fn line_gap(label_size: Sp) -> Sp { |
| 491 | Sp(label_size.raw() / 5) |
| 492 | } |
| 493 | |
| 494 | /// Bakes a stack of shaped lines centred within a node's box: each line centred horizontally on the box |
| 495 | /// centre, the whole stack centred vertically on it. A single line is the common case and lands exactly |
| 496 | /// as before; extra lines are seated above and below by their own extents plus the line gap. |
| 497 | fn bake_label_centred( |
| 498 | ops: &mut Vec<DrawOp>, |
| 499 | lines: &[ShapedText], |
| 500 | label_size: Sp, |
| 501 | r: &Rect, |
| 502 | ) |
| 503 | -> Outcome<()> |
| 504 | { |
| 505 | // The stack's full height: the sum of each line's extent, parted by the line gap. |
| 506 | let mut total = Sp::ZERO; |
| 507 | for (i, line) in lines.iter().enumerate() { |
| 508 | if i > 0 { |
| 509 | total = total + line_gap(label_size); |
| 510 | } |
| 511 | let ld = line.dims(); |
| 512 | total = total + ld.height + ld.depth; |
| 513 | } |
| 514 | // The top of the stack, so its middle seats on the box centre. |
| 515 | let mut y = r.centre_y() - Sp(total.raw() / 2); |
| 516 | for line in lines { |
| 517 | let ld = line.dims(); |
| 518 | let base_x = r.centre_x() - Sp(ld.width.raw() / 2); |
| 519 | let base_y = y + ld.height; // baseline sits a height below the line's top |
| 520 | res!(bake_label(ops, line, base_x, base_y)); |
| 521 | y = y + ld.height + ld.depth + line_gap(label_size); |
| 522 | } |
| 523 | Ok(()) |
| 524 | } |
| 525 | |
| 526 | /// Bakes a shaped run as filled glyph outlines at a baseline, exactly as the SVG writer draws a line |
| 527 | /// of text: the outline is font-frame and y up, so it is flipped in y and moved onto the baseline at |
| 528 | /// the glyph's own offset. `base_x` is the run's left, `base_y` its baseline, both in the figure frame. |
| 529 | fn bake_label(ops: &mut Vec<DrawOp>, shaped: &ShapedText, base_x: Sp, base_y: Sp) -> Outcome<()> { |
| 530 | let bx = base_x.to_pt() as f32; |
| 531 | let by = base_y.to_pt() as f32; |
| 532 | for glyph in &shaped.run().glyphs { |
| 533 | let path = res!(shaped.outline(glyph)); |
| 534 | // A glyph with no ink -- a space -- carries an advance but nothing to fill. |
| 535 | if path.is_empty() { |
| 536 | continue; |
| 537 | } |
| 538 | let t = Transform::scale(1.0, -1.0) |
| 539 | .then(&Transform::translate(bx + glyph.x, by - glyph.y)); |
| 540 | let placed = res!(path.transform(&t)); |
| 541 | ops.push(DrawOp::Fill { path: placed, colour: Rgba::BLACK }); |
| 542 | } |
| 543 | Ok(()) |
| 544 | } |
| 545 | |
| 546 | /// The bounding box of every op's ink, in the provisional frame, from which the figure is normalised. |
| 547 | fn ink_bounds(ops: &[DrawOp]) -> Outcome<Bounds> { |
| 548 | let mut bb: Option<Bounds> = None; |
| 549 | for op in ops { |
| 550 | let path = match op { |
| 551 | DrawOp::Fill { path, .. } => path, |
| 552 | DrawOp::Stroke { path, .. } => path, |
| 553 | DrawOp::Image { .. } => continue, // a raster has no vector ink to bound; a diagram emits none |
| 554 | }; |
| 555 | if let Some(b) = path.bounds(&Transform::IDENTITY) { |
| 556 | bb = Some(match bb { |
| 557 | None => b, |
| 558 | Some(cur) => cur.union(b), |
| 559 | }); |
| 560 | } |
| 561 | } |
| 562 | bb.ok_or_else(|| err!("The diagram produced no ink to bound."; Bug, Missing)) |
| 563 | } |
| 564 | |
| 565 | /// One op with its path carried into a new frame. |
| 566 | fn shift(op: DrawOp, t: &Transform) -> Outcome<DrawOp> { |
| 567 | Ok(match op { |
| 568 | DrawOp::Fill { path, colour } => DrawOp::Fill { |
| 569 | path: res!(path.transform(t)), |
| 570 | colour, |
| 571 | }, |
| 572 | DrawOp::Stroke { path, colour, width } => DrawOp::Stroke { |
| 573 | path: res!(path.transform(t)), |
| 574 | colour, |
| 575 | width, |
| 576 | }, |
| 577 | // A diagram carries only vector ink, so a raster never reaches this frame shift; it passes through |
| 578 | // untouched, the arm present only for exhaustiveness over the shared draw-op vocabulary. |
| 579 | img @ DrawOp::Image { .. } => img, |
| 580 | }) |
| 581 | } |