oxedyne/fe2o3/fe2o3_austenite/src/lang/codefig.rs
32.6 KiB, 1 run
created by r1870400018:37747, 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 | //! Evaluating the figures the two books draw by code -- Typst CeTZ / Fletcher diagrams written inline in |
| 2 | //! the document rather than exported to an image. |
| 3 | //! |
| 4 | //! Three constructs are read, the subset those figures actually use: a Fletcher `diagram` of grid-placed |
| 5 | //! `node`s and chained/feedback `edge`s (the stochastic-model flowchart), a cetz-plot `chart.barchart` |
| 6 | //! (the productivity-gap bars), and a cetz-plot `plot.plot` of one or more line series (the |
| 7 | //! productivity-versus-wages plot). Each is parsed from the `#figure` body's source into a builder from |
| 8 | //! [`crate::diagram`] or [`crate::plot`], which draws it as real vector ink. Anything outside this subset |
| 9 | //! is left to the caller's placeholder, so an unhandled figure keeps its space and its caption. |
| 10 | |
| 11 | use crate::diagram::{ |
| 12 | Diagram, |
| 13 | DiagramStyle, |
| 14 | Endpoint, |
| 15 | }; |
| 16 | use crate::diagram::layout::Route; |
| 17 | use crate::diagram::shape::Shape; |
| 18 | use crate::ir::{ |
| 19 | Graphic, |
| 20 | Sp, |
| 21 | }; |
| 22 | use crate::plot::{ |
| 23 | nice_bar_axis, |
| 24 | AxisStyle, |
| 25 | BarChart, |
| 26 | Plot, |
| 27 | Series, |
| 28 | }; |
| 29 | |
| 30 | use super::parse::{ |
| 31 | call_inner, |
| 32 | named_arg, |
| 33 | read_group, |
| 34 | split_top_args, |
| 35 | }; |
| 36 | |
| 37 | use oxedyne_fe2o3_core::prelude::*; |
| 38 | use oxedyne_fe2o3_font::set::FontSet; |
| 39 | use oxedyne_fe2o3_graphics::colour::Rgba; |
| 40 | |
| 41 | use std::collections::HashMap; |
| 42 | use std::sync::Arc; |
| 43 | |
| 44 | /// Points per centimetre, the unit a cetz canvas measures in by default, so a `size: (8, 4)` becomes a |
| 45 | /// figure eight by four centimetres. |
| 46 | const CM: f64 = 72.0 / 2.54; |
| 47 | |
| 48 | /// The em the flowchart is set at, in points. Fletcher's `spacing` and node sizes are given in em; the |
| 49 | /// diagram sets its labels at this size, so one em stands for one label size throughout. |
| 50 | const EM: f64 = 11.0; |
| 51 | |
| 52 | /// A figure the document draws by code. Each arm carries a ready builder; [`build`](CodeFigure::build) |
| 53 | /// turns it into the same [`Graphic`] a raster or an SVG figure produces, so it places identically. |
| 54 | #[derive(Clone, Debug)] |
| 55 | pub enum CodeFigure { |
| 56 | Flowchart { diagram: Diagram, style: DiagramStyle }, |
| 57 | Bars(BarChart), |
| 58 | Lines(Plot), |
| 59 | } |
| 60 | |
| 61 | impl CodeFigure { |
| 62 | pub fn build(&self, fonts: Arc<FontSet>) -> Outcome<Graphic> { |
| 63 | match self { |
| 64 | CodeFigure::Flowchart { diagram, style } => diagram.build(fonts, style), |
| 65 | CodeFigure::Bars(chart) => chart.build(fonts), |
| 66 | CodeFigure::Lines(plot) => plot.build(fonts), |
| 67 | } |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Parses a `#figure` body's source into a [`CodeFigure`], or `None` when the body is not one of the code |
| 72 | /// figures this reader draws (an image, a table, or a construct outside the subset). The three kinds are |
| 73 | /// told apart by the call each uses: a Fletcher `diagram(...)`, a cetz-plot `barchart(...)`, or a |
| 74 | /// cetz-plot `plot.plot(...)`. |
| 75 | pub(crate) fn parse_code_figure(text: &str) -> Option<CodeFigure> { |
| 76 | if text.contains("diagram(") { |
| 77 | if let Some(cf) = parse_flowchart(text) { |
| 78 | return Some(cf); |
| 79 | } |
| 80 | } |
| 81 | if text.contains("barchart") { |
| 82 | if let Some(cf) = parse_barchart(text) { |
| 83 | return Some(cf); |
| 84 | } |
| 85 | } |
| 86 | if text.contains("plot.add") || text.contains("plot.plot") { |
| 87 | if let Some(cf) = parse_lineplot(text) { |
| 88 | return Some(cf); |
| 89 | } |
| 90 | } |
| 91 | None |
| 92 | } |
| 93 | |
| 94 | // ---- Fletcher flowchart ---------------------------------------------------------------------------- |
| 95 | |
| 96 | /// One node gathered from the diagram source before it is placed. |
| 97 | struct NodeDef { |
| 98 | row: i64, |
| 99 | label: String, |
| 100 | shape: Shape, |
| 101 | fill: Option<Rgba>, |
| 102 | w_em: Option<f64>, |
| 103 | h_em: Option<f64>, |
| 104 | } |
| 105 | |
| 106 | /// A resolved edge: an index pair, its optional branch label, and, for a feedback loop, how far right it |
| 107 | /// detours (in grid columns). |
| 108 | enum EdgeDef { |
| 109 | Chain { from: usize, to: usize, label: Option<String> }, |
| 110 | Feedback { from: usize, to: usize, label: Option<String>, cols: i64 }, |
| 111 | } |
| 112 | |
| 113 | /// Parses a Fletcher `diagram(...)` of grid-placed nodes and chained/feedback edges into a flowchart. The |
| 114 | /// nodes lie in one column, so they are placed top to bottom with the gap between two nodes set by the |
| 115 | /// difference of their grid rows; a plain `edge` chains one node to the next, and an `edge` given a |
| 116 | /// direction path (`"r,r,u,u,l,l"`) is a feedback loop back up to the node the path's `u` steps reach. |
| 117 | fn parse_flowchart(text: &str) -> Option<CodeFigure> { |
| 118 | let inner = call_inner(text, "diagram")?; |
| 119 | let args = split_top_args(&inner); |
| 120 | |
| 121 | let mut spacing_em = 1.0f64; |
| 122 | let mut node_stroke = 1.0f32; |
| 123 | let mut nodes: Vec<NodeDef> = Vec::new(); |
| 124 | let mut edges: Vec<EdgeDef> = Vec::new(); |
| 125 | // Chain edges wait for the next node to be declared, so their target resolves in one forward pass. |
| 126 | let mut pending: Vec<(usize, Option<String>)> = Vec::new(); |
| 127 | let mut last_node: Option<usize> = None; |
| 128 | |
| 129 | for arg in &args { |
| 130 | let a = arg.trim(); |
| 131 | if a.is_empty() { |
| 132 | continue; |
| 133 | } |
| 134 | // A diagram-level named argument (spacing, node-stroke, debug), told apart from a node/edge call by |
| 135 | // having a top-level colon. |
| 136 | if let Some((key, val)) = named_arg(a) { |
| 137 | match key.as_str() { |
| 138 | "spacing" => if let Some(v) = em_value(&val) { spacing_em = v; }, |
| 139 | "node-stroke" => if let Some(v) = pt_value(&val) { node_stroke = v as f32; }, |
| 140 | _ => {}, |
| 141 | } |
| 142 | continue; |
| 143 | } |
| 144 | if a.starts_with("node(") { |
| 145 | if let Some(nd) = parse_node(a) { |
| 146 | let idx = nodes.len(); |
| 147 | nodes.push(nd); |
| 148 | // Resolve every chain edge waiting on the next node. |
| 149 | for (from, label) in pending.drain(..) { |
| 150 | edges.push(EdgeDef::Chain { from, to: idx, label }); |
| 151 | } |
| 152 | last_node = Some(idx); |
| 153 | } |
| 154 | continue; |
| 155 | } |
| 156 | if a.starts_with("edge(") { |
| 157 | let from = match last_node { |
| 158 | Some(i) => i, |
| 159 | None => continue, // an edge before any node has nothing to leave from |
| 160 | }; |
| 161 | let (route, label) = parse_edge(a); |
| 162 | match route { |
| 163 | ParsedRoute::Chain => pending.push((from, label)), |
| 164 | ParsedRoute::Feedback { u, cols } => { |
| 165 | let target_row = nodes[from].row - u; |
| 166 | if let Some(to) = nodes.iter().position(|n| n.row == target_row) { |
| 167 | edges.push(EdgeDef::Feedback { from, to, label, cols }); |
| 168 | } |
| 169 | }, |
| 170 | } |
| 171 | continue; |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | if nodes.is_empty() { |
| 176 | return None; |
| 177 | } |
| 178 | |
| 179 | let spacing_pt = spacing_em * EM; |
| 180 | |
| 181 | let mut d = Diagram::new(); |
| 182 | for (i, nd) in nodes.iter().enumerate() { |
| 183 | let id = fmt!("n{}", i); |
| 184 | if i == 0 { |
| 185 | d.node_at(id.clone(), nd.label.clone(), Sp::ZERO, Sp::ZERO, nd.shape); |
| 186 | } else { |
| 187 | let delta = (nd.row - nodes[i - 1].row).max(1); |
| 188 | let gap = Sp::from_pt(spacing_pt * delta as f64); |
| 189 | let prev = fmt!("n{}", i - 1); |
| 190 | d.node_below(id.clone(), nd.label.clone(), &prev, gap, nd.shape); |
| 191 | } |
| 192 | if let Some(f) = nd.fill { |
| 193 | d.fill(f); |
| 194 | } |
| 195 | match (nd.w_em, nd.h_em) { |
| 196 | (Some(w), Some(h)) => { d.size(Sp::from_pt(w * EM), Sp::from_pt(h * EM)); }, |
| 197 | (Some(w), None) => { d.size(Sp::from_pt(w * EM), Sp::ZERO); }, |
| 198 | (None, Some(h)) => { d.size(Sp::ZERO, Sp::from_pt(h * EM)); }, |
| 199 | (None, None) => {}, |
| 200 | } |
| 201 | } |
| 202 | for e in &edges { |
| 203 | match e { |
| 204 | EdgeDef::Chain { from, to, label } => { |
| 205 | d.edge_near( |
| 206 | Endpoint::node(fmt!("n{}", from)), |
| 207 | Endpoint::node(fmt!("n{}", to)), |
| 208 | label.as_deref(), |
| 209 | Route::Straight); |
| 210 | }, |
| 211 | EdgeDef::Feedback { from, to, label, cols } => { |
| 212 | let out = Sp::from_pt(spacing_pt * (*cols).max(1) as f64); |
| 213 | d.edge_near( |
| 214 | Endpoint::node(fmt!("n{}", from)), |
| 215 | Endpoint::node(fmt!("n{}", to)), |
| 216 | label.as_deref(), |
| 217 | Route::Feedback { out }); |
| 218 | }, |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | let mut style = DiagramStyle::default(); |
| 223 | style.node_fill = None; // an unfilled node is white; every filled node carries its own wash |
| 224 | style.node_stroke = node_stroke; |
| 225 | style.label_size = Sp::from_pt(EM); |
| 226 | Some(CodeFigure::Flowchart { diagram: d, style }) |
| 227 | } |
| 228 | |
| 229 | /// Parses one `node((c, r), label, fill: .., shape: .., width: .., height: ..)` call. |
| 230 | fn parse_node(arg: &str) -> Option<NodeDef> { |
| 231 | let inner = call_inner(arg, "node")?; |
| 232 | let parts = split_top_args(&inner); |
| 233 | let mut coord: Option<String> = None; |
| 234 | let mut label: Option<String> = None; |
| 235 | let mut shape = Shape::Box; |
| 236 | let mut fill: Option<Rgba> = None; |
| 237 | let mut w_em: Option<f64> = None; |
| 238 | let mut h_em: Option<f64> = None; |
| 239 | for p in &parts { |
| 240 | let pt = p.trim(); |
| 241 | if pt.is_empty() { |
| 242 | continue; |
| 243 | } |
| 244 | if let Some((key, val)) = named_arg(pt) { |
| 245 | match key.as_str() { |
| 246 | "fill" => fill = resolve_colour(&val), |
| 247 | "shape" => shape = resolve_shape(&val), |
| 248 | "width" => w_em = em_value(&val), |
| 249 | "height" => h_em = em_value(&val), |
| 250 | _ => {}, |
| 251 | } |
| 252 | continue; |
| 253 | } |
| 254 | if coord.is_none() { |
| 255 | coord = Some(pt.to_string()); |
| 256 | } else if label.is_none() { |
| 257 | label = Some(clean_label(pt)); |
| 258 | } |
| 259 | } |
| 260 | let coord = coord?; |
| 261 | let row = grid_row(&coord)?; |
| 262 | Some(NodeDef { row, label: label.unwrap_or_default(), shape, fill, w_em, h_em }) |
| 263 | } |
| 264 | |
| 265 | /// A parsed edge's routing: a plain chain to the next node, or a feedback loop with its `u` (rows up) and |
| 266 | /// column detour count read from the direction path. |
| 267 | enum ParsedRoute { |
| 268 | Chain, |
| 269 | Feedback { u: i64, cols: i64 }, |
| 270 | } |
| 271 | |
| 272 | /// Parses one `edge(...)` call into its route and optional branch label. A first positional that is a |
| 273 | /// direction path (`"r,r,u,u,l,l"`) marks a feedback loop; otherwise it is a marks string (`"-|>"`) and |
| 274 | /// the edge chains to the next node. A bracketed positional (`[N]`) is the branch label. |
| 275 | fn parse_edge(arg: &str) -> (ParsedRoute, Option<String>) { |
| 276 | let inner = match call_inner(arg, "edge") { |
| 277 | Some(i) => i, |
| 278 | None => return (ParsedRoute::Chain, None), |
| 279 | }; |
| 280 | let parts = split_top_args(&inner); |
| 281 | let mut positionals: Vec<String> = Vec::new(); |
| 282 | for p in &parts { |
| 283 | let pt = p.trim(); |
| 284 | if pt.is_empty() || named_arg(pt).is_some() { |
| 285 | continue; // label-pos and the rest do not change the topology |
| 286 | } |
| 287 | positionals.push(pt.to_string()); |
| 288 | } |
| 289 | let mut label: Option<String> = None; |
| 290 | for p in &positionals { |
| 291 | if p.starts_with('[') { |
| 292 | label = Some(clean_label(p)); |
| 293 | } |
| 294 | } |
| 295 | if let Some(first) = positionals.first() { |
| 296 | if let Some(path) = direction_path(first) { |
| 297 | let u = path.iter().filter(|&&c| c == 'u').count() as i64; |
| 298 | let cols = path.iter().filter(|&&c| c == 'r').count() as i64; |
| 299 | return (ParsedRoute::Feedback { u, cols }, label); |
| 300 | } |
| 301 | } |
| 302 | (ParsedRoute::Chain, label) |
| 303 | } |
| 304 | |
| 305 | /// The direction letters of a route path (`"r,r,u,u,l,l"`), or `None` when the string is a marks spec |
| 306 | /// (`"-|>"`) rather than a path. A path is a comma list of the single letters r/l/u/d. |
| 307 | fn direction_path(s: &str) -> Option<Vec<char>> { |
| 308 | let inner = unquote(s); |
| 309 | let mut out = Vec::new(); |
| 310 | for part in inner.split(',') { |
| 311 | let t = part.trim(); |
| 312 | if t.len() != 1 { |
| 313 | return None; |
| 314 | } |
| 315 | match t.chars().next() { |
| 316 | Some(c @ ('r' | 'l' | 'u' | 'd')) => out.push(c), |
| 317 | _ => return None, |
| 318 | } |
| 319 | } |
| 320 | if out.is_empty() { |
| 321 | None |
| 322 | } else { |
| 323 | Some(out) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | /// The grid row of a `(c, r)` coordinate: the second component, rounded to an integer. |
| 328 | fn grid_row(coord: &str) -> Option<i64> { |
| 329 | let chars: Vec<char> = coord.chars().collect(); |
| 330 | let (inner, _) = read_group(&chars, chars.iter().position(|&c| c == '(')?)?; |
| 331 | let comps = split_top_args(&inner); |
| 332 | let r = comps.get(1)?.trim(); |
| 333 | r.parse::<f64>().ok().map(|v| v.round() as i64) |
| 334 | } |
| 335 | |
| 336 | /// Maps a Fletcher shape argument to a [`Shape`]: `shapes.hexagon`/`hexagon` to a hexagon, `diamond` to a |
| 337 | /// diamond, anything else (the default rectangle) to a box. |
| 338 | fn resolve_shape(val: &str) -> Shape { |
| 339 | let v = val.trim(); |
| 340 | if v.ends_with("hexagon") { |
| 341 | Shape::Hexagon |
| 342 | } else if v.ends_with("diamond") { |
| 343 | Shape::Diamond |
| 344 | } else if v.ends_with("pill") || v.ends_with("stadium") { |
| 345 | Shape::Stadium |
| 346 | } else { |
| 347 | Shape::Box |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // ---- cetz-plot bar chart --------------------------------------------------------------------------- |
| 352 | |
| 353 | /// Parses a cetz-plot `chart.barchart(...)` inside a `cetz.canvas` block into a [`BarChart`]. The bar |
| 354 | /// data is a `let`-bound array of `([label], value)` tuples referenced by name in the call; the value |
| 355 | /// axis is sized to the data with a nice tick step. |
| 356 | fn parse_barchart(text: &str) -> Option<CodeFigure> { |
| 357 | let block = canvas_block(text)?; |
| 358 | let lets = let_bindings(&block); |
| 359 | let inner = call_inner(&block, "barchart")?; |
| 360 | let parts = split_top_args(&inner); |
| 361 | |
| 362 | let mut data_expr: Option<String> = None; |
| 363 | let mut bar_frac = 0.8f64; |
| 364 | let mut x_label: Option<String> = None; |
| 365 | let mut size: Option<(f64, f64)> = None; |
| 366 | for p in &parts { |
| 367 | let pt = p.trim(); |
| 368 | if pt.is_empty() { |
| 369 | continue; |
| 370 | } |
| 371 | if let Some((key, val)) = named_arg(pt) { |
| 372 | match key.as_str() { |
| 373 | "bar-width" => if let Some(v) = plain_f64(&val) { bar_frac = v; }, |
| 374 | "x-label" => x_label = content_opt(&val), |
| 375 | "size" => size = pair_f64(&val), |
| 376 | _ => {}, |
| 377 | } |
| 378 | continue; |
| 379 | } |
| 380 | data_expr = Some(pt.to_string()); // the last positional is the data array |
| 381 | } |
| 382 | |
| 383 | let data_src = match data_expr { |
| 384 | Some(name) => lets.get(name.trim()).cloned().unwrap_or(name), |
| 385 | None => return None, |
| 386 | }; |
| 387 | let bars = parse_bar_data(&data_src); |
| 388 | if bars.is_empty() { |
| 389 | return None; |
| 390 | } |
| 391 | |
| 392 | let (w, h) = size.unwrap_or((8.0, 4.0)); |
| 393 | let data_max = bars.iter().fold(0.0f64, |m, (_, v)| m.max(*v)); |
| 394 | let (x_max, x_ticks) = nice_bar_axis(data_max); |
| 395 | |
| 396 | Some(CodeFigure::Bars(BarChart { |
| 397 | width: (w * CM) as f32, |
| 398 | height: (h * CM) as f32, |
| 399 | bars, |
| 400 | x_max, |
| 401 | x_ticks, |
| 402 | x_label, |
| 403 | bar_frac, |
| 404 | fills: bar_palette(), |
| 405 | })) |
| 406 | } |
| 407 | |
| 408 | /// Parses a `(([US], 60), ([UK], 25), ...)` array into label/value pairs, in order. |
| 409 | fn parse_bar_data(src: &str) -> Vec<(String, f64)> { |
| 410 | let chars: Vec<char> = src.trim().chars().collect(); |
| 411 | let open = match chars.iter().position(|&c| c == '(') { |
| 412 | Some(i) => i, |
| 413 | None => return Vec::new(), |
| 414 | }; |
| 415 | let inner = match read_group(&chars, open) { |
| 416 | Some((s, _)) => s, |
| 417 | None => return Vec::new(), |
| 418 | }; |
| 419 | let mut out = Vec::new(); |
| 420 | for entry in split_top_args(&inner) { |
| 421 | let ec: Vec<char> = entry.trim().chars().collect(); |
| 422 | let eo = match ec.iter().position(|&c| c == '(') { |
| 423 | Some(i) => i, |
| 424 | None => continue, |
| 425 | }; |
| 426 | let einner = match read_group(&ec, eo) { |
| 427 | Some((s, _)) => s, |
| 428 | None => continue, |
| 429 | }; |
| 430 | let fields = split_top_args(&einner); |
| 431 | if fields.len() < 2 { |
| 432 | continue; |
| 433 | } |
| 434 | let label = clean_label(fields[0].trim()); |
| 435 | if let Some(v) = plain_f64(fields[1].trim()) { |
| 436 | out.push((label, v)); |
| 437 | } |
| 438 | } |
| 439 | out |
| 440 | } |
| 441 | |
| 442 | /// A red-family palette cycled across the bars, echoing cetz-plot's default warm sequence closely enough |
| 443 | /// for the pattern to read; the exact hues are not load-bearing. |
| 444 | fn bar_palette() -> Vec<Rgba> { |
| 445 | vec![ |
| 446 | Rgba::opaque(0xf4, 0xb8, 0xb8), |
| 447 | Rgba::opaque(0xe8, 0x7d, 0x7d), |
| 448 | Rgba::opaque(0xd6, 0x4a, 0x4a), |
| 449 | Rgba::opaque(0xb8, 0x2a, 0x2a), |
| 450 | Rgba::opaque(0x8f, 0x1d, 0x1d), |
| 451 | ] |
| 452 | } |
| 453 | |
| 454 | // ---- cetz-plot line plot --------------------------------------------------------------------------- |
| 455 | |
| 456 | /// Parses a cetz-plot `plot.plot(...)` inside a `cetz.canvas` block into a [`Plot`] of line series. Each |
| 457 | /// `plot.add` in the plot body names a `let`-bound array of `(x, y)` samples, a label and a style whose |
| 458 | /// dash marks the series dashed; the axis ranges, tick steps and legend come from the call's arguments. |
| 459 | fn parse_lineplot(text: &str) -> Option<CodeFigure> { |
| 460 | let block = canvas_block(text)?; |
| 461 | let lets = let_bindings(&block); |
| 462 | let inner = call_inner(&block, "plot")?; |
| 463 | let parts = split_top_args(&inner); |
| 464 | |
| 465 | let mut x_min = 0.0; let mut x_max = 1.0; |
| 466 | let mut y_min = 0.0; let mut y_max = 1.0; |
| 467 | let mut x_step: Option<f64> = None; |
| 468 | let mut y_step: Option<f64> = None; |
| 469 | let mut size: Option<(f64, f64)> = None; |
| 470 | let mut legend = false; |
| 471 | let mut left = false; |
| 472 | let mut body: Option<String> = None; |
| 473 | for p in &parts { |
| 474 | let pt = p.trim(); |
| 475 | if pt.is_empty() { |
| 476 | continue; |
| 477 | } |
| 478 | if let Some((key, val)) = named_arg(pt) { |
| 479 | match key.as_str() { |
| 480 | "x-min" => if let Some(v) = plain_f64(&val) { x_min = v; }, |
| 481 | "x-max" => if let Some(v) = plain_f64(&val) { x_max = v; }, |
| 482 | "y-min" => if let Some(v) = plain_f64(&val) { y_min = v; }, |
| 483 | "y-max" => if let Some(v) = plain_f64(&val) { y_max = v; }, |
| 484 | "x-tick-step" => x_step = plain_f64(&val), |
| 485 | "y-tick-step" => y_step = plain_f64(&val), |
| 486 | "size" => size = pair_f64(&val), |
| 487 | "legend" => legend = val.trim() != "none", |
| 488 | "axis-style" => left = unquote(&val) == "left", |
| 489 | _ => {}, |
| 490 | } |
| 491 | continue; |
| 492 | } |
| 493 | if pt.starts_with('{') { |
| 494 | body = Some(pt.to_string()); // the plot body block, holding the plot.add calls |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | let body = body?; |
| 499 | let series = parse_plot_adds(&body, &lets); |
| 500 | if series.is_empty() { |
| 501 | return None; |
| 502 | } |
| 503 | |
| 504 | let (w, h) = size.unwrap_or((8.0, 5.0)); |
| 505 | let x_ticks = ticks(x_min, x_max, x_step); |
| 506 | let y_ticks = ticks(y_min, y_max, y_step); |
| 507 | |
| 508 | // The overall figure is the plot area plus the margins the plot builder reserves for labels. |
| 509 | Some(CodeFigure::Lines(Plot { |
| 510 | width: (w * CM) as f32 + 48.0, |
| 511 | height: (h * CM) as f32 + 34.0, |
| 512 | x_range: (x_min, x_max), |
| 513 | y_range: (y_min, y_max), |
| 514 | x_ticks, |
| 515 | y_ticks, |
| 516 | series, |
| 517 | axis: if left { AxisStyle::Left } else { AxisStyle::Framed }, |
| 518 | x_label: None, |
| 519 | y_label: None, |
| 520 | legend, |
| 521 | })) |
| 522 | } |
| 523 | |
| 524 | /// Parses the `plot.add(...)` calls in a plot body into line series, resolving each data reference against |
| 525 | /// the `let` bindings. |
| 526 | fn parse_plot_adds(body: &str, lets: &HashMap<String, String>) -> Vec<Series> { |
| 527 | let mut out = Vec::new(); |
| 528 | let chars: Vec<char> = body.chars().collect(); |
| 529 | let mut from = 0usize; |
| 530 | // Walk every `plot.add(` in order; call_inner from a moving offset would re-find the first, so scan by |
| 531 | // hand for the literal and read each group. |
| 532 | while let Some(pos) = find_from(&chars, "plot.add(", from) { |
| 533 | let open = pos + "plot.add".chars().count(); |
| 534 | let (inner, after) = match read_group(&chars, open) { |
| 535 | Some(t) => t, |
| 536 | None => break, |
| 537 | }; |
| 538 | from = after; |
| 539 | let parts = split_top_args(&inner); |
| 540 | let mut label: Option<String> = None; |
| 541 | let mut dashed = false; |
| 542 | let mut width = 1.4f32; |
| 543 | let mut data_expr: Option<String> = None; |
| 544 | for p in &parts { |
| 545 | let pt = p.trim(); |
| 546 | if pt.is_empty() { |
| 547 | continue; |
| 548 | } |
| 549 | if let Some((key, val)) = named_arg(pt) { |
| 550 | match key.as_str() { |
| 551 | "label" => label = content_opt(&val), |
| 552 | "style" => { |
| 553 | if val.contains("dash") { dashed = true; } |
| 554 | if let Some(t) = thickness_pt(&val) { width = t as f32; } |
| 555 | }, |
| 556 | _ => {}, |
| 557 | } |
| 558 | continue; |
| 559 | } |
| 560 | data_expr = Some(pt.to_string()); |
| 561 | } |
| 562 | let data_src = match data_expr { |
| 563 | Some(name) => lets.get(name.trim()).cloned().unwrap_or(name), |
| 564 | None => continue, |
| 565 | }; |
| 566 | let points = parse_xy(&data_src); |
| 567 | if points.is_empty() { |
| 568 | continue; |
| 569 | } |
| 570 | out.push(Series { points, colour: Rgba::opaque(20, 20, 20), width, dashed, label }); |
| 571 | } |
| 572 | out |
| 573 | } |
| 574 | |
| 575 | /// Parses an `((x, y), (x, y), ...)` array into sample points, in order. |
| 576 | fn parse_xy(src: &str) -> Vec<(f64, f64)> { |
| 577 | let chars: Vec<char> = src.trim().chars().collect(); |
| 578 | let open = match chars.iter().position(|&c| c == '(') { |
| 579 | Some(i) => i, |
| 580 | None => return Vec::new(), |
| 581 | }; |
| 582 | let inner = match read_group(&chars, open) { |
| 583 | Some((s, _)) => s, |
| 584 | None => return Vec::new(), |
| 585 | }; |
| 586 | let mut out = Vec::new(); |
| 587 | for entry in split_top_args(&inner) { |
| 588 | let ec: Vec<char> = entry.trim().chars().collect(); |
| 589 | let eo = match ec.iter().position(|&c| c == '(') { |
| 590 | Some(i) => i, |
| 591 | None => continue, |
| 592 | }; |
| 593 | let einner = match read_group(&ec, eo) { |
| 594 | Some((s, _)) => s, |
| 595 | None => continue, |
| 596 | }; |
| 597 | let fields = split_top_args(&einner); |
| 598 | if fields.len() < 2 { |
| 599 | continue; |
| 600 | } |
| 601 | if let (Some(x), Some(y)) = (plain_f64(fields[0].trim()), plain_f64(fields[1].trim())) { |
| 602 | out.push((x, y)); |
| 603 | } |
| 604 | } |
| 605 | out |
| 606 | } |
| 607 | |
| 608 | // ---- shared helpers -------------------------------------------------------------------------------- |
| 609 | |
| 610 | /// The inner code of a `cetz.canvas({ ... })` block, its outer braces stripped, or `None`. |
| 611 | fn canvas_block(text: &str) -> Option<String> { |
| 612 | let group = call_inner(text, "canvas")?; // "{ ... }", the paren group after canvas( |
| 613 | let trimmed = group.trim(); |
| 614 | let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?; |
| 615 | Some(inner.to_string()) |
| 616 | } |
| 617 | |
| 618 | /// The `let name = <group>` bindings in a code block, mapped name to the raw group source (parens kept), |
| 619 | /// so an array binding can be resolved where it is referenced by name. |
| 620 | fn let_bindings(block: &str) -> HashMap<String, String> { |
| 621 | let mut out = HashMap::new(); |
| 622 | let chars: Vec<char> = block.chars().collect(); |
| 623 | let mut i = 0usize; |
| 624 | while let Some(pos) = find_from(&chars, "let ", i) { |
| 625 | let mut j = pos + 4; |
| 626 | let start = j; |
| 627 | while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_' || chars[j] == '-') { |
| 628 | j += 1; |
| 629 | } |
| 630 | let name: String = chars[start..j].iter().collect(); |
| 631 | // Skip whitespace, then require '='. |
| 632 | while j < chars.len() && chars[j].is_whitespace() { |
| 633 | j += 1; |
| 634 | } |
| 635 | if chars.get(j) != Some(&'=') { |
| 636 | i = pos + 4; |
| 637 | continue; |
| 638 | } |
| 639 | j += 1; |
| 640 | while j < chars.len() && chars[j].is_whitespace() { |
| 641 | j += 1; |
| 642 | } |
| 643 | if chars.get(j) == Some(&'(') { |
| 644 | if let Some((inner, after)) = read_group(&chars, j) { |
| 645 | out.insert(name, fmt!("({})", inner)); |
| 646 | i = after; |
| 647 | continue; |
| 648 | } |
| 649 | } |
| 650 | i = pos + 4; |
| 651 | } |
| 652 | out |
| 653 | } |
| 654 | |
| 655 | /// The index of the first occurrence of the literal `pat` in `chars` at or after `from`, or `None`. |
| 656 | fn find_from(chars: &[char], pat: &str, from: usize) -> Option<usize> { |
| 657 | let p: Vec<char> = pat.chars().collect(); |
| 658 | if p.is_empty() || chars.len() < p.len() { |
| 659 | return None; |
| 660 | } |
| 661 | (from..=chars.len().saturating_sub(p.len())).find(|&s| chars[s..s + p.len()] == p[..]) |
| 662 | } |
| 663 | |
| 664 | /// The tick values within `[min, max]` at multiples of `step`, the way cetz-plot marks an axis: the first |
| 665 | /// tick is the lowest multiple of the step not below `min`, so a range of 90 to 170 stepped by 20 marks |
| 666 | /// 100, 120, 140, 160 rather than 90, 110, .... With no step, just the endpoints. |
| 667 | fn ticks(min: f64, max: f64, step: Option<f64>) -> Vec<f64> { |
| 668 | match step { |
| 669 | Some(s) if s > 0.0 => { |
| 670 | let mut out = Vec::new(); |
| 671 | let mut v = (min / s).ceil() * s; |
| 672 | while v <= max + s * 0.001 { |
| 673 | out.push((v * 1e6).round() / 1e6); |
| 674 | v += s; |
| 675 | } |
| 676 | out |
| 677 | }, |
| 678 | _ => vec![min, max], |
| 679 | } |
| 680 | } |
| 681 | |
| 682 | /// Cleans a node or cell label to plain, single- or multi-line text: `align(...)[...]` is unwrapped, the |
| 683 | /// outer brackets are dropped, a `\` line break becomes a newline, and a `$...$` maths span is reduced to |
| 684 | /// readable Unicode (`gamma` to the Greek letter, `bold`/`underline` to their argument). |
| 685 | fn clean_label(raw: &str) -> String { |
| 686 | let mut s = raw.trim().to_string(); |
| 687 | // Unwrap an `align(...)[...]` wrapper to its bracketed body. |
| 688 | if s.starts_with("align(") { |
| 689 | if let Some(b) = bracket_body(&s) { |
| 690 | s = b; |
| 691 | } |
| 692 | } |
| 693 | // Drop the outer content brackets. |
| 694 | let t = s.trim(); |
| 695 | if t.starts_with('[') && t.ends_with(']') { |
| 696 | s = t[1..t.len() - 1].to_string(); |
| 697 | } |
| 698 | // Line breaks: a backslash followed by whitespace or end of string. |
| 699 | let s = break_lines(&s); |
| 700 | // Reduce maths spans, then collapse runs of spaces on each line. |
| 701 | let lines: Vec<String> = s.split('\n').map(|line| { |
| 702 | let m = reduce_math(line); |
| 703 | m.split_whitespace().collect::<Vec<_>>().join(" ") |
| 704 | }).collect(); |
| 705 | lines.join("\n") |
| 706 | } |
| 707 | |
| 708 | /// The `[...]` body at the end of an `align(...)[...]` call, or `None`. |
| 709 | fn bracket_body(s: &str) -> Option<String> { |
| 710 | let chars: Vec<char> = s.chars().collect(); |
| 711 | let open = chars.iter().position(|&c| c == '[')?; |
| 712 | read_group(&chars, open).map(|(inner, _)| inner) |
| 713 | } |
| 714 | |
| 715 | /// Replaces Typst content line breaks (`\`) with newlines. A backslash that begins a maths escape inside |
| 716 | /// a `$...$` span is left alone, but node labels here use `\` only as a break, so a plain replace serves. |
| 717 | fn break_lines(s: &str) -> String { |
| 718 | let mut out = String::new(); |
| 719 | let chars: Vec<char> = s.chars().collect(); |
| 720 | let mut i = 0; |
| 721 | while i < chars.len() { |
| 722 | if chars[i] == '\\' { |
| 723 | out.push('\n'); |
| 724 | i += 1; |
| 725 | // Swallow the space that usually follows a break. |
| 726 | while i < chars.len() && chars[i] == ' ' { |
| 727 | i += 1; |
| 728 | } |
| 729 | continue; |
| 730 | } |
| 731 | out.push(chars[i]); |
| 732 | i += 1; |
| 733 | } |
| 734 | out |
| 735 | } |
| 736 | |
| 737 | /// Reduces the `$...$` maths spans in a line to readable Unicode, leaving the surrounding text as is. |
| 738 | fn reduce_math(line: &str) -> String { |
| 739 | let mut out = String::new(); |
| 740 | let chars: Vec<char> = line.chars().collect(); |
| 741 | let mut i = 0; |
| 742 | while i < chars.len() { |
| 743 | if chars[i] == '$' { |
| 744 | let mut j = i + 1; |
| 745 | let mut expr = String::new(); |
| 746 | while j < chars.len() && chars[j] != '$' { |
| 747 | expr.push(chars[j]); |
| 748 | j += 1; |
| 749 | } |
| 750 | out.push_str(&reduce_expr(&expr)); |
| 751 | i = if j < chars.len() { j + 1 } else { j }; |
| 752 | continue; |
| 753 | } |
| 754 | out.push(chars[i]); |
| 755 | i += 1; |
| 756 | } |
| 757 | out |
| 758 | } |
| 759 | |
| 760 | /// Reduces one maths expression to Unicode: `bold(X)`/`underline(X)`/`upright(X)` to their argument, and a |
| 761 | /// Greek name to its letter. Enough for the flowchart's `bold(D)` and `underline(gamma)`. |
| 762 | fn reduce_expr(expr: &str) -> String { |
| 763 | let mut s = expr.trim().to_string(); |
| 764 | for f in ["bold", "underline", "upright", "italic", "arrow"] { |
| 765 | while let Some(inner) = strip_call(&s, f) { |
| 766 | s = inner; |
| 767 | } |
| 768 | } |
| 769 | let s = s.trim(); |
| 770 | match s { |
| 771 | "gamma" => "\u{03b3}".to_string(), |
| 772 | "Gamma" => "\u{0393}".to_string(), |
| 773 | "alpha" => "\u{03b1}".to_string(), |
| 774 | "beta" => "\u{03b2}".to_string(), |
| 775 | "delta" => "\u{03b4}".to_string(), |
| 776 | "Delta" => "\u{0394}".to_string(), |
| 777 | "phi" => "\u{03c6}".to_string(), |
| 778 | "Phi" => "\u{03a6}".to_string(), |
| 779 | "psi" => "\u{03c8}".to_string(), |
| 780 | "eta" => "\u{03b7}".to_string(), |
| 781 | "zeta" => "\u{03b6}".to_string(), |
| 782 | other => other.to_string(), |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | /// If `s` is exactly `name(<inner>)`, its inner; else `None`. |
| 787 | fn strip_call(s: &str, name: &str) -> Option<String> { |
| 788 | let t = s.trim(); |
| 789 | let pref = fmt!("{}(", name); |
| 790 | if !t.starts_with(&pref) || !t.ends_with(')') { |
| 791 | return None; |
| 792 | } |
| 793 | Some(t[pref.len()..t.len() - 1].to_string()) |
| 794 | } |
| 795 | |
| 796 | /// The text of a `[...]` content argument, or `None` for `none`/empty. |
| 797 | fn content_opt(val: &str) -> Option<String> { |
| 798 | let v = val.trim(); |
| 799 | if v == "none" || v.is_empty() { |
| 800 | return None; |
| 801 | } |
| 802 | let cleaned = clean_label(v); |
| 803 | if cleaned.is_empty() { |
| 804 | None |
| 805 | } else { |
| 806 | Some(cleaned) |
| 807 | } |
| 808 | } |
| 809 | |
| 810 | /// Strips a pair of surrounding `"..."` quotes, returning the trimmed content or the trimmed input. |
| 811 | fn unquote(s: &str) -> String { |
| 812 | let t = s.trim(); |
| 813 | if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') { |
| 814 | t[1..t.len() - 1].to_string() |
| 815 | } else { |
| 816 | t.to_string() |
| 817 | } |
| 818 | } |
| 819 | |
| 820 | /// A colour expression resolved to an [`Rgba`]: a base colour (`colours.light`, `blue`, ...) optionally |
| 821 | /// lightened or darkened, matching the small palette these figures draw with. |
| 822 | fn resolve_colour(expr: &str) -> Option<Rgba> { |
| 823 | let e = expr.trim(); |
| 824 | // Split off a single `.lighten(N%)` / `.darken(N%)` modifier. |
| 825 | let (base, modifier) = if let Some(pos) = e.find(".lighten(") { |
| 826 | (&e[..pos], Some(("lighten", &e[pos + ".lighten(".len()..]))) |
| 827 | } else if let Some(pos) = e.find(".darken(") { |
| 828 | (&e[..pos], Some(("darken", &e[pos + ".darken(".len()..]))) |
| 829 | } else { |
| 830 | (e, None) |
| 831 | }; |
| 832 | let mut colour = base_colour(base)?; |
| 833 | if let Some((kind, rest)) = modifier { |
| 834 | let pct = rest.trim_end_matches(')').trim().trim_end_matches('%').trim().parse::<f64>().ok()? / 100.0; |
| 835 | colour = match kind { |
| 836 | "lighten" => lighten(colour, pct), |
| 837 | "darken" => darken(colour, pct), |
| 838 | _ => colour, |
| 839 | }; |
| 840 | } |
| 841 | Some(colour) |
| 842 | } |
| 843 | |
| 844 | /// The named base colours these books use: the custom `colours.*` dictionary and the Typst built-ins the |
| 845 | /// figures reach for directly. |
| 846 | fn base_colour(name: &str) -> Option<Rgba> { |
| 847 | match name.trim() { |
| 848 | "colours.light" => Some(Rgba::opaque(0xE9, 0xEC, 0xEF)), |
| 849 | "colours.green" => Some(Rgba::opaque(0x00, 0x7c, 0x77)), |
| 850 | "colours.purple" => Some(Rgba::opaque(0x4c, 0x1a, 0x57)), |
| 851 | "colours.pink" => Some(Rgba::opaque(0xff, 0x3c, 0xc7)), |
| 852 | "colours.yellow" => Some(Rgba::opaque(0xf0, 0xf6, 0x00)), |
| 853 | "colours.aqua" => Some(Rgba::opaque(0x00, 0xe5, 0xe8)), |
| 854 | "blue" => Some(Rgba::opaque(0x00, 0x74, 0xd9)), |
| 855 | "orange" => Some(Rgba::opaque(0xff, 0x85, 0x1b)), |
| 856 | "green" => Some(Rgba::opaque(0x2e, 0xcc, 0x40)), |
| 857 | "red" => Some(Rgba::opaque(0xff, 0x41, 0x36)), |
| 858 | "aqua" => Some(Rgba::opaque(0x7f, 0xdb, 0xff)), |
| 859 | "yellow" => Some(Rgba::opaque(0xff, 0xdc, 0x00)), |
| 860 | "purple" => Some(Rgba::opaque(0xb1, 0x0d, 0xc9)), |
| 861 | "gray" | "grey" => Some(Rgba::opaque(0xaa, 0xaa, 0xaa)), |
| 862 | "black" => Some(Rgba::BLACK), |
| 863 | "white" => Some(Rgba::WHITE), |
| 864 | _ => None, |
| 865 | } |
| 866 | } |
| 867 | |
| 868 | /// Blends a colour toward white by `p` (0 to 1), Typst's `lighten`. |
| 869 | fn lighten(c: Rgba, p: f64) -> Rgba { |
| 870 | let f = |v: u8| -> u8 { (v as f64 + (255.0 - v as f64) * p).round().clamp(0.0, 255.0) as u8 }; |
| 871 | Rgba::new(f(c.r), f(c.g), f(c.b), c.a) |
| 872 | } |
| 873 | |
| 874 | /// Blends a colour toward black by `p` (0 to 1), Typst's `darken`. |
| 875 | fn darken(c: Rgba, p: f64) -> Rgba { |
| 876 | let f = |v: u8| -> u8 { (v as f64 * (1.0 - p)).round().clamp(0.0, 255.0) as u8 }; |
| 877 | Rgba::new(f(c.r), f(c.g), f(c.b), c.a) |
| 878 | } |
| 879 | |
| 880 | /// An `Nem` length in em, or `None`. |
| 881 | fn em_value(val: &str) -> Option<f64> { |
| 882 | let v = val.trim(); |
| 883 | v.strip_suffix("em").and_then(|n| n.trim().parse::<f64>().ok()) |
| 884 | } |
| 885 | |
| 886 | /// An `Npt` length in points, or a bare number as points, or `None`. |
| 887 | fn pt_value(val: &str) -> Option<f64> { |
| 888 | let v = val.trim(); |
| 889 | if let Some(n) = v.strip_suffix("pt") { |
| 890 | return n.trim().parse::<f64>().ok(); |
| 891 | } |
| 892 | v.parse::<f64>().ok() |
| 893 | } |
| 894 | |
| 895 | /// A `thickness: Npt` inside a style value, in points. |
| 896 | fn thickness_pt(val: &str) -> Option<f64> { |
| 897 | let idx = val.find("thickness")?; |
| 898 | let rest = &val[idx + "thickness".len()..]; |
| 899 | let colon = rest.find(':')?; |
| 900 | let after = &rest[colon + 1..]; |
| 901 | let num: String = after.trim_start().chars().take_while(|c| c.is_ascii_digit() || *c == '.').collect(); |
| 902 | num.parse::<f64>().ok() |
| 903 | } |
| 904 | |
| 905 | /// A plain number, ignoring a trailing unit, or `None`. |
| 906 | fn plain_f64(val: &str) -> Option<f64> { |
| 907 | let v = val.trim(); |
| 908 | let num: String = v.chars().take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); |
| 909 | num.parse::<f64>().ok() |
| 910 | } |
| 911 | |
| 912 | /// A `(a, b)` pair of numbers, or `None`. |
| 913 | fn pair_f64(val: &str) -> Option<(f64, f64)> { |
| 914 | let chars: Vec<char> = val.trim().chars().collect(); |
| 915 | let open = chars.iter().position(|&c| c == '(')?; |
| 916 | let (inner, _) = read_group(&chars, open)?; |
| 917 | let parts = split_top_args(&inner); |
| 918 | let a = plain_f64(parts.first()?.trim())?; |
| 919 | let b = plain_f64(parts.get(1)?.trim())?; |
| 920 | Some((a, b)) |
| 921 | } |
| 922 | |
| 923 | #[cfg(test)] |
| 924 | mod tests { |
| 925 | use super::*; |
| 926 | |
| 927 | #[test] |
| 928 | fn clean_label_reduces_maths_and_breaks() { |
| 929 | assert_eq!(clean_label(r"[Initial LLM $bold(D)$ \ and scaled $underline(gamma)$]"), |
| 930 | "Initial LLM D\nand scaled \u{03b3}"); |
| 931 | assert_eq!(clean_label(r"align(center)[Finished \ SA loop?]"), "Finished\nSA loop?"); |
| 932 | assert_eq!(clean_label("[Add next system]"), "Add next system"); |
| 933 | } |
| 934 | |
| 935 | #[test] |
| 936 | fn direction_path_tells_route_from_marks() { |
| 937 | assert_eq!(direction_path("\"r,r,u,u,l,l\""), Some(vec!['r', 'r', 'u', 'u', 'l', 'l'])); |
| 938 | assert_eq!(direction_path("\"-|>\""), None); |
| 939 | } |
| 940 | |
| 941 | #[test] |
| 942 | fn colours_resolve_and_lighten() -> Outcome<()> { |
| 943 | assert_eq!(resolve_colour("colours.light"), Some(Rgba::opaque(0xE9, 0xEC, 0xEF))); |
| 944 | // green #007c77 lightened 50% blends halfway to white. |
| 945 | let g = res!(resolve_colour("colours.green.lighten(50%)").ok_or_else(|| err!("no colour"; Bug))); |
| 946 | assert_eq!(g, Rgba::opaque(0x80, 0xbe, 0xbb)); |
| 947 | Ok(()) |
| 948 | } |
| 949 | |
| 950 | #[test] |
| 951 | fn bar_axis_and_ticks_align_to_step() { |
| 952 | let (max, bticks) = nice_bar_axis(60.0); |
| 953 | assert_eq!(max, 60.0); |
| 954 | assert_eq!(bticks, vec![0.0, 6.0, 12.0, 18.0, 24.0, 30.0, 36.0, 42.0, 48.0, 54.0, 60.0]); |
| 955 | // A range not starting on a step multiple marks the multiples inside it, not the endpoints. |
| 956 | assert_eq!(ticks(90.0, 170.0, Some(20.0)), vec![100.0, 120.0, 140.0, 160.0]); |
| 957 | assert_eq!(ticks(1979.0, 2020.0, Some(10.0)), vec![1980.0, 1990.0, 2000.0, 2010.0, 2020.0]); |
| 958 | } |
| 959 | |
| 960 | #[test] |
| 961 | fn parses_barchart_data_in_order() { |
| 962 | let src = "(([US], 60), ([UK], 25), ([NZ], 20))"; |
| 963 | let bars = parse_bar_data(src); |
| 964 | assert_eq!(bars, vec![ |
| 965 | ("US".to_string(), 60.0), |
| 966 | ("UK".to_string(), 25.0), |
| 967 | ("NZ".to_string(), 20.0), |
| 968 | ]); |
| 969 | } |
| 970 | |
| 971 | #[test] |
| 972 | fn parses_xy_samples() { |
| 973 | let pts = parse_xy("((1979, 100), (1983, 105))"); |
| 974 | assert_eq!(pts, vec![(1979.0, 100.0), (1983.0, 105.0)]); |
| 975 | } |
| 976 | |
| 977 | #[test] |
| 978 | fn top_level_dispatch_picks_the_right_figure() { |
| 979 | let bar = r#"align(center, cetz.canvas({ |
| 980 | import cetz.draw: * |
| 981 | import "@preview/cetz-plot:0.1.1": chart |
| 982 | let data = (([US], 60), ([UK], 25)) |
| 983 | chart.barchart(mode: "basic", size: (8, 4), label-key: 0, value-key: 1, |
| 984 | bar-width: 0.6, x-label: [%], y-label: none, data) |
| 985 | }))"#; |
| 986 | match parse_code_figure(bar) { |
| 987 | Some(CodeFigure::Bars(b)) => { |
| 988 | assert_eq!(b.bars.len(), 2); |
| 989 | assert_eq!(b.bars[0], ("US".to_string(), 60.0)); |
| 990 | assert_eq!(b.x_max, 60.0); |
| 991 | }, |
| 992 | other => panic!("expected a bar chart, got {:?}", other.is_some()), |
| 993 | } |
| 994 | |
| 995 | let line = r#"align(center, cetz.canvas({ |
| 996 | import "@preview/cetz-plot:0.1.1": plot |
| 997 | let a = ((1979, 100), (2019, 160)) |
| 998 | let b = ((1979, 100), (2019, 104)) |
| 999 | plot.plot(axis-style: "left", x-min: 1979, x-max: 2020, y-min: 90, y-max: 170, |
| 1000 | size: (8, 5), x-tick-step: 10, y-tick-step: 20, legend: (1.0, 5.0), |
| 1001 | { |
| 1002 | plot.add(style: (stroke: (paint: black, thickness: 1.5pt)), label: [Productivity], a) |
| 1003 | plot.add(style: (stroke: (paint: black, thickness: 1.5pt, dash: "dashed")), label: [Median hourly wages], b) |
| 1004 | }) |
| 1005 | }))"#; |
| 1006 | match parse_code_figure(line) { |
| 1007 | Some(CodeFigure::Lines(p)) => { |
| 1008 | assert_eq!(p.series.len(), 2); |
| 1009 | assert!(!p.series[0].dashed); |
| 1010 | assert!(p.series[1].dashed); |
| 1011 | assert_eq!(p.axis, AxisStyle::Left); |
| 1012 | assert!(p.legend); |
| 1013 | assert_eq!(p.x_ticks, vec![1980.0, 1990.0, 2000.0, 2010.0, 2020.0]); |
| 1014 | }, |
| 1015 | other => panic!("expected a line plot, got {:?}", other.is_some()), |
| 1016 | } |
| 1017 | |
| 1018 | let flow = r#"align(center, [#diagram( |
| 1019 | spacing: 1.5em, node-stroke: 1pt, |
| 1020 | node((0,2), [Start], fill: colours.light, shape: shapes.hexagon), |
| 1021 | edge("-|>"), |
| 1022 | node((0,4), align(center)[Decide?], fill: blue.lighten(50%), shape: diamond, width: 6em, height: 3.25em), |
| 1023 | edge("r,r,u,u,l,l", "-|>", [N]), |
| 1024 | edge("-|>", [Y]), |
| 1025 | node((0,6), [End]), |
| 1026 | )])"#; |
| 1027 | assert!(matches!(parse_code_figure(flow), Some(CodeFigure::Flowchart { .. }))); |
| 1028 | } |
| 1029 | } |