Oregami
Repositories/oxedyne/fe2o3

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
11use crate::diagram::{
12 Diagram,
13 DiagramStyle,
14 Endpoint,
15};
16use crate::diagram::layout::Route;
17use crate::diagram::shape::Shape;
18use crate::ir::{
19 Graphic,
20 Sp,
21};
22use crate::plot::{
23 nice_bar_axis,
24 AxisStyle,
25 BarChart,
26 Plot,
27 Series,
28};
29
30use super::parse::{
31 call_inner,
32 named_arg,
33 read_group,
34 split_top_args,
35};
36
37use oxedyne_fe2o3_core::prelude::*;
38use oxedyne_fe2o3_font::set::FontSet;
39use oxedyne_fe2o3_graphics::colour::Rgba;
40
41use std::collections::HashMap;
42use 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.
46const 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.
50const 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)]
55pub enum CodeFigure {
56 Flowchart { diagram: Diagram, style: DiagramStyle },
57 Bars(BarChart),
58 Lines(Plot),
59}
60
61impl 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(...)`.
75pub(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.
97struct 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).
108enum 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.
117fn 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.
230fn 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.
267enum 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.
275fn 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.
307fn 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.
328fn 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.
338fn 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.
356fn 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.
409fn 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.
444fn 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.
459fn 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.
526fn 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.
576fn 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`.
611fn 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.
620fn 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`.
656fn 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.
667fn 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).
685fn 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`.
709fn 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.
717fn 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.
738fn 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)`.
762fn 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`.
787fn 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.
797fn 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.
811fn 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.
822fn 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.
846fn 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`.
869fn 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`.
875fn 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`.
881fn 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`.
887fn 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.
896fn 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`.
906fn 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`.
913fn 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)]
924mod 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}