Skip to main content

rpic_core/
eval.rs

1//! Evaluator: walks the [`crate::ast`] and produces a placed-primitive
2//! [`Drawing`] using pic's positioning semantics.
3//!
4//! Model (Kernighan §3): a pen with a *current position* and *current
5//! direction* walks the plane dropping primitives. Closed objects (box/circle/
6//! ellipse/block) attach at the current point and advance by their extent;
7//! open objects (line/arrow/move/spline/arc) trace from the current point in
8//! the current direction. Labels, compass corners and ordinals
9//! (`last`/`nth`) resolve against previously placed objects.
10//!
11//! Approximations (documented; refined later): `arc` renders a default quarter
12//! turn.
13
14use std::collections::{HashMap, HashSet};
15use std::f64::consts::{FRAC_1_SQRT_2, PI};
16
17use crate::ast::*;
18use crate::diagnostic::{Diagnostic, Span};
19use crate::geom::{Bbox, Point};
20use crate::ir::*;
21use crate::token::{self, Corner, Dir, EnvVar, Func1, Func2, LineType, Prim};
22
23/// An evaluation error. `info` carries the structured diagnostic when the
24/// failure site had one at hand (a deferred-parse error's full [`Diagnostic`],
25/// or a place reference's span); bindings surface it without re-deriving
26/// positions from the message.
27#[derive(Debug, Clone, PartialEq)]
28pub struct EvalError {
29    pub msg: String,
30    pub info: Option<Box<Diagnostic>>,
31}
32
33impl std::fmt::Display for EvalError {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "{}", self.msg)
36    }
37}
38
39type ER<T> = Result<T, EvalError>;
40
41// Text metrics derive from the shared source of truth in `ir` (#291) — the
42// SVG backend consumes the same constants, so the layout bbox and the
43// rendered geometry cannot silently desync.
44use crate::ir::{DP_TEXT_RATIO, TEXT_EM_IN as TEXT_EM};
45/// Label font size in points, matching the SVG backend's `FONT_PT`.
46const FONT_PT_MATH: f64 = crate::ir::FONT_PT_CLASSIC;
47const TEXT_CHAR_W: f64 = crate::ir::TEXT_CHAR_W_RATIO * TEXT_EM;
48const TEXT_LINE_H: f64 = crate::ir::TEXT_LINE_H_RATIO * TEXT_EM;
49const TEXT_XHEIGHT: f64 = DP_TEXT_RATIO * TEXT_EM;
50const DEFAULT_BRACE_DEPTH: f64 = 0.18;
51const DEFAULT_BRACE_POS: f64 = 0.5;
52const DEFAULT_HATCH_ANGLE: f64 = 45.0;
53const DEFAULT_HATCH_SEP: f64 = 0.08;
54const DEFAULT_HATCH_WIDTH: f64 = 0.8;
55
56fn err<T>(msg: impl Into<String>) -> ER<T> {
57    Err(EvalError {
58        msg: msg.into(),
59        info: None,
60    })
61}
62
63/// An error that keeps its structured diagnostic (position, kind, hints).
64fn err_diag<T>(d: Diagnostic) -> ER<T> {
65    Err(diag_error(d))
66}
67
68fn diag_error(d: Diagnostic) -> EvalError {
69    EvalError {
70        msg: d.message.clone(),
71        info: Some(Box::new(d)),
72    }
73}
74
75/// A deferred-parse failure (an `if`/`for` body, `exec`, `sprintf` re-parse)
76/// keeps the original ParseError's full diagnostic instead of flattening it
77/// to a string.
78fn parse_eval_error(e: crate::parser::ParseError) -> EvalError {
79    EvalError {
80        msg: e.to_string(),
81        info: Some(Box::new(e.diagnostic())),
82    }
83}
84
85fn unknown_label_error(name: &str, span: Option<&Span>) -> EvalError {
86    let mut d = Diagnostic::new("unknown_label", format!("unknown label `{name}`")).found(name);
87    if let Some(s) = span {
88        d = d.at(s.clone());
89    }
90    diag_error(d)
91}
92
93fn ordinal_diagnostic(n: i64, available: usize, span: Option<&Span>) -> Diagnostic {
94    let mut d = Diagnostic::new(
95        "ordinal_out_of_range",
96        format!("ordinal {n} out of range (available {available})"),
97    )
98    .found(n.to_string())
99    .expected(format!("1..{available}"));
100    if let Some(s) = span {
101        d = d.at(s.clone());
102    }
103    d
104}
105
106fn finite(v: f64, context: &str) -> ER<f64> {
107    if v.is_finite() {
108        Ok(v)
109    } else {
110        err(format!("{context} produced non-finite numeric value"))
111    }
112}
113
114/// Evaluate a parsed picture into a [`Drawing`].
115pub fn eval(pic: &Picture) -> ER<Drawing> {
116    eval_with_limits(pic, EvalLimits::default())
117}
118
119/// Evaluate a parsed picture with host-provided resource limits.
120pub fn eval_with_limits(pic: &Picture, limits: EvalLimits) -> ER<Drawing> {
121    let limits = limits.validate()?;
122    let mut st = State::with_limits(limits);
123    st.macros = pic.macros.clone();
124    st.includes = pic.includes.clone();
125    st.eval_stmts(&pic.stmts)?;
126    let want_w = match &pic.width {
127        Some(e) => Some(st.eval_expr(e)?),
128        None => None,
129    };
130    let want_h = match &pic.height {
131        Some(e) => Some(st.eval_expr(e)?),
132        None => None,
133    };
134    let (maxw, maxh) = (st.env.get(EnvVar::Maxpswid), st.env.get(EnvVar::Maxpsht));
135    let canvas_margin = st.canvas_margin()?;
136    let mut d = Drawing {
137        shapes: st.shapes,
138        shape_layers: st.shape_layers,
139        shape_classes: st.shape_classes,
140        shape_links: st.shape_links,
141        shape_spans: st.shape_spans,
142        bbox: st.bbox,
143        prelude_thick: st.env.get(EnvVar::Linethick),
144        canvas_margin,
145        canvas: st.canvas,
146        anims: st.anims,
147        interactions: st.interactions,
148        anim_scroll: st.anim_scroll,
149        diagnostics: st.diagnostics,
150        warnings: st.warnings,
151    };
152    apply_ps_size(&mut d, want_w, want_h);
153    clamp_to_maxps(&mut d, maxw, maxh);
154    Ok(d)
155}
156
157/// Clamp the drawing to the `maxpswid`/`maxpsht` page bounds: if it exceeds
158/// either, scale the whole picture down uniformly to fit (never up), matching
159/// pic's PostScript page-fit behaviour.
160fn clamp_to_maxps(d: &mut Drawing, maxw: f64, maxh: f64) {
161    for _ in 0..4 {
162        if d.bbox.is_empty() {
163            return;
164        }
165        let (w, h) = (canvas_width(d), canvas_height(d));
166        let mut factor = 1.0_f64;
167        if maxw > 0.0 && w > maxw {
168            factor = factor.min(maxw / w);
169        }
170        if maxh > 0.0 && h > maxh {
171            factor = factor.min(maxh / h);
172        }
173        if factor >= 1.0 - 1e-9 {
174            return;
175        }
176        for sh in &mut d.shapes {
177            scale_shape(sh, factor);
178        }
179        scale_canvas(d, factor);
180        d.prelude_thick *= factor;
181        d.canvas_margin.scale_by(factor);
182        d.bbox = drawing_painted_bbox(&d.shapes);
183    }
184}
185
186fn scale_canvas(d: &mut Drawing, factor: f64) {
187    if let Some(c) = d.canvas {
188        let mut bb = Bbox::new();
189        bb.add(Point::new(c.min.x * factor, c.min.y * factor));
190        bb.add(Point::new(c.max.x * factor, c.max.y * factor));
191        d.canvas = Some(bb);
192    }
193}
194
195/// Apply `.PS <width> [<height>]` sizing: uniformly scale the whole drawing so
196/// it matches the requested width (or height if only height is given). Font size
197/// is left unchanged.
198fn apply_ps_size(d: &mut Drawing, want_w: Option<f64>, want_h: Option<f64>) {
199    if d.bbox.is_empty() {
200        return;
201    }
202    let factor = match (want_w, want_h) {
203        (Some(w), _) if w > 0.0 && d.bbox.width() > 0.0 => w / d.bbox.width(),
204        (None, Some(h)) if h > 0.0 && d.bbox.height() > 0.0 => h / d.bbox.height(),
205        _ => return,
206    };
207    if (factor - 1.0).abs() < 1e-9 {
208        return;
209    }
210    for sh in &mut d.shapes {
211        scale_shape(sh, factor);
212    }
213    scale_canvas(d, factor);
214    d.prelude_thick *= factor;
215    d.canvas_margin.scale_by(factor);
216    d.bbox = drawing_painted_bbox(&d.shapes);
217}
218
219fn canvas_width(d: &Drawing) -> f64 {
220    let raw = d.canvas.map_or_else(|| d.bbox.width(), |c| c.width());
221    raw + d.canvas_margin.horizontal()
222}
223
224fn canvas_height(d: &Drawing) -> f64 {
225    let raw = d.canvas.map_or_else(|| d.bbox.height(), |c| c.height());
226    raw + d.canvas_margin.vertical()
227}
228
229/// The dimension variables that track `scale`.
230const SCALED_VARS: [EnvVar; 23] = [
231    EnvVar::Arcrad,
232    EnvVar::Arrowht,
233    EnvVar::Arrowwid,
234    EnvVar::Boxht,
235    EnvVar::Boxrad,
236    EnvVar::Boxwid,
237    EnvVar::Circlerad,
238    EnvVar::Dashwid,
239    EnvVar::Ellipseht,
240    EnvVar::Ellipsewid,
241    EnvVar::Lineht,
242    EnvVar::Linewid,
243    EnvVar::Moveht,
244    EnvVar::Movewid,
245    EnvVar::Textht,
246    EnvVar::Textwid,
247    EnvVar::Textoffset,
248    EnvVar::Margin,
249    EnvVar::Topmargin,
250    EnvVar::Rightmargin,
251    EnvVar::Bottommargin,
252    EnvVar::Leftmargin,
253    EnvVar::Dotrad,
254];
255
256// ---- environment variables -------------------------------------------------
257
258#[derive(Debug, Clone, Copy, PartialEq)]
259pub struct EvalLimits {
260    pub max_animation_seconds: f64,
261    pub max_animation_repeat: i64,
262    pub max_loop_iterations: u64,
263    pub max_shapes: usize,
264}
265
266pub const DEFAULT_MAX_ANIMATION_SECONDS: f64 = 1_000_000.0;
267pub const DEFAULT_MAX_ANIMATION_REPEAT: i64 = 1_000_000;
268pub const DEFAULT_MAX_LOOP_ITERATIONS: u64 = 1_000_000;
269pub const DEFAULT_MAX_SHAPES: usize = 1_000_000;
270
271impl Default for EvalLimits {
272    fn default() -> Self {
273        Self {
274            max_animation_seconds: DEFAULT_MAX_ANIMATION_SECONDS,
275            max_animation_repeat: DEFAULT_MAX_ANIMATION_REPEAT,
276            max_loop_iterations: DEFAULT_MAX_LOOP_ITERATIONS,
277            max_shapes: DEFAULT_MAX_SHAPES,
278        }
279    }
280}
281
282impl EvalLimits {
283    fn validate(self) -> ER<Self> {
284        if !self.max_animation_seconds.is_finite() {
285            return err("max_animation_seconds option must be finite");
286        }
287        if self.max_animation_seconds < 0.0 {
288            return err("max_animation_seconds option must be non-negative");
289        }
290        if self.max_animation_repeat < 0 {
291            return err("max_animation_repeat option must be non-negative");
292        }
293        Ok(self)
294    }
295}
296
297#[derive(Clone)]
298struct EnvVars {
299    v: HashMap<u8, f64>,
300}
301
302fn ev_key(e: EnvVar) -> u8 {
303    e as u8
304}
305
306impl EnvVars {
307    fn new(limits: EvalLimits) -> Self {
308        use EnvVar::*;
309        let defaults = [
310            (Arcrad, 0.25),
311            (Arrowht, 0.1),
312            (Arrowwid, 0.05),
313            (Boxht, 0.5),
314            (Boxrad, 0.0),
315            (Boxwid, 0.75),
316            (Circlerad, 0.25),
317            (Dashwid, 0.05),
318            (Ellipseht, 0.5),
319            (Ellipsewid, 0.75),
320            (Lineht, 0.5),
321            (Linewid, 0.5),
322            (Moveht, 0.5),
323            (Movewid, 0.5),
324            (Textht, (11.0 / 72.0) * DP_TEXT_RATIO),
325            (Textoffset, 2.0 / 72.0),
326            (Textwid, 0.0),
327            (Arrowhead, 1.0),
328            (Fillval, 0.5),
329            (Linethick, 0.8),
330            (Maxpsht, 11.0),
331            (Maxpswid, 8.5),
332            (Scale, 1.0),
333            (Margin, 0.0),
334            (Topmargin, 0.0),
335            (Rightmargin, 0.0),
336            (Bottommargin, 0.0),
337            (Leftmargin, 0.0),
338            (Texlabels, 0.0),
339            (Dotrad, 0.035),
340            (Maxanimrepeat, limits.max_animation_repeat as f64),
341            (Maxanimseconds, limits.max_animation_seconds),
342        ];
343        let mut v = HashMap::new();
344        for (e, d) in defaults {
345            v.insert(ev_key(e), d);
346        }
347        EnvVars { v }
348    }
349    fn get(&self, e: EnvVar) -> f64 {
350        *self.v.get(&ev_key(e)).unwrap_or(&0.0)
351    }
352    fn set(&mut self, e: EnvVar, val: f64) {
353        self.v.insert(ev_key(e), val);
354    }
355}
356
357// ---- placed-object bookkeeping ---------------------------------------------
358
359#[derive(Clone, Copy, PartialEq, Eq)]
360enum PKind {
361    Box,
362    Circle,
363    Ellipse,
364    Line,
365    Move,
366    Spline,
367    Arc,
368    Brace,
369    Block,
370    Text,
371}
372
373#[derive(Clone)]
374struct Placed {
375    kind: PKind,
376    center: Point,
377    bbox: Bbox,
378    start: Point,
379    end: Point,
380    thick: f64,
381    points: Vec<Point>,
382    radius: f64,
383    box_rad: f64,
384    line_wid: f64,
385    line_ht: f64,
386    closed_path: bool,
387    layer: i32,
388    /// Index of the primary shape in `shapes` (None for point-only labels).
389    shape: Option<usize>,
390    /// For blocks: inner labels (sub-objects), translated into parent
391    /// coordinates, so `B.A` / `last [].Outer` resolve. Empty otherwise.
392    members: HashMap<String, Placed>,
393    /// For blocks: the half-open range of child shape indices `[lo, hi)` the
394    /// block drew, so `animate <block> … stagger` can fan across them. `None`
395    /// for non-blocks and empty blocks.
396    block_shapes: Option<(usize, usize)>,
397}
398
399impl Placed {
400    fn corner(&self, c: Corner) -> Point {
401        match self.kind {
402            PKind::Circle | PKind::Ellipse => self.ellipse_corner(c),
403            PKind::Line | PKind::Move | PKind::Spline => self.linear_corner(c),
404            PKind::Brace => self.brace_corner(c),
405            PKind::Arc => self.arc_corner(c),
406            PKind::Box => self.box_corner(c),
407            PKind::Block | PKind::Text => self.bbox_corner(c),
408        }
409    }
410
411    fn bbox_corner(&self, c: Corner) -> Point {
412        let (lo, hi) = (self.bbox.min, self.bbox.max);
413        let mid = self.center;
414        match c {
415            Corner::N => Point::new(mid.x, hi.y),
416            Corner::S => Point::new(mid.x, lo.y),
417            Corner::E => Point::new(hi.x, mid.y),
418            Corner::W => Point::new(lo.x, mid.y),
419            Corner::Ne => Point::new(hi.x, hi.y),
420            Corner::Se => Point::new(hi.x, lo.y),
421            Corner::Nw => Point::new(lo.x, hi.y),
422            Corner::Sw => Point::new(lo.x, lo.y),
423            Corner::Center => mid,
424            Corner::Start => self.start,
425            Corner::End => self.end,
426        }
427    }
428
429    fn ellipse_corner(&self, c: Corner) -> Point {
430        let (rx, ry) = (self.bbox.width() / 2.0, self.bbox.height() / 2.0);
431        let diag = |sx: f64, sy: f64| Point::new(sx * rx * FRAC_1_SQRT_2, sy * ry * FRAC_1_SQRT_2);
432        let off = match c {
433            Corner::N => Point::new(0.0, ry),
434            Corner::S => Point::new(0.0, -ry),
435            Corner::E => Point::new(rx, 0.0),
436            Corner::W => Point::new(-rx, 0.0),
437            Corner::Ne => diag(1.0, 1.0),
438            Corner::Se => diag(1.0, -1.0),
439            Corner::Nw => diag(-1.0, 1.0),
440            Corner::Sw => diag(-1.0, -1.0),
441            Corner::Center => Point::ZERO,
442            Corner::Start => return self.start,
443            Corner::End => return self.end,
444        };
445        self.center + off
446    }
447
448    fn linear_corner(&self, c: Corner) -> Point {
449        if self.points.is_empty() {
450            return self.bbox_corner(c);
451        }
452        if self.closed_path {
453            return match c {
454                Corner::Start => self.points[0],
455                Corner::End => *self.points.last().unwrap(),
456                _ => self.bbox_corner(c),
457            };
458        }
459        match c {
460            Corner::Center => (self.points[0] + *self.points.last().unwrap()) * 0.5,
461            Corner::Start => self.points[0],
462            Corner::End => *self.points.last().unwrap(),
463            _ => {
464                let mut best = self.points[0];
465                for p in self.points.iter().skip(1) {
466                    let better = match c {
467                        Corner::N => p.y > best.y,
468                        Corner::S => p.y < best.y,
469                        Corner::E => p.x > best.x,
470                        Corner::W => p.x < best.x,
471                        Corner::Ne => {
472                            (p.y > best.y && p.x >= best.x) || (p.y >= best.y && p.x > best.x)
473                        }
474                        Corner::Se => {
475                            (p.y < best.y && p.x >= best.x) || (p.y <= best.y && p.x > best.x)
476                        }
477                        Corner::Sw => {
478                            (p.y < best.y && p.x <= best.x) || (p.y <= best.y && p.x < best.x)
479                        }
480                        Corner::Nw => {
481                            (p.y > best.y && p.x <= best.x) || (p.y >= best.y && p.x < best.x)
482                        }
483                        Corner::Center | Corner::Start | Corner::End => false,
484                    };
485                    if better {
486                        best = *p;
487                    }
488                }
489                best
490            }
491        }
492    }
493
494    fn brace_corner(&self, c: Corner) -> Point {
495        match c {
496            Corner::Center => self.center,
497            Corner::Start => self.start,
498            Corner::End => self.end,
499            _ => {
500                let mut bb = Bbox::new();
501                for p in &self.points {
502                    bb.add(*p);
503                }
504                if bb.is_empty() {
505                    return self.bbox_corner(c);
506                }
507                bbox_point(&bb, c)
508            }
509        }
510    }
511
512    fn arc_corner(&self, c: Corner) -> Point {
513        let diag = self.radius * FRAC_1_SQRT_2;
514        let off = match c {
515            Corner::N => Point::new(0.0, self.radius),
516            Corner::S => Point::new(0.0, -self.radius),
517            Corner::E => Point::new(self.radius, 0.0),
518            Corner::W => Point::new(-self.radius, 0.0),
519            Corner::Ne => Point::new(diag, diag),
520            Corner::Se => Point::new(diag, -diag),
521            Corner::Nw => Point::new(-diag, diag),
522            Corner::Sw => Point::new(-diag, -diag),
523            Corner::Center => Point::ZERO,
524            Corner::Start => return self.start,
525            Corner::End => return self.end,
526        };
527        self.center + off
528    }
529
530    fn box_corner(&self, c: Corner) -> Point {
531        match c {
532            Corner::Ne | Corner::Se | Corner::Nw | Corner::Sw if self.box_rad > 0.0 => {
533                let inset = self
534                    .box_rad
535                    .min(self.bbox.width().abs().min(self.bbox.height().abs()) / 2.0)
536                    * (1.0 - FRAC_1_SQRT_2);
537                let x = self.bbox.width() / 2.0 - inset;
538                let y = self.bbox.height() / 2.0 - inset;
539                let off = match c {
540                    Corner::Ne => Point::new(x, y),
541                    Corner::Se => Point::new(x, -y),
542                    Corner::Nw => Point::new(-x, y),
543                    Corner::Sw => Point::new(-x, -y),
544                    _ => Point::ZERO,
545                };
546                self.center + off
547            }
548            _ => self.bbox_corner(c),
549        }
550    }
551
552    fn attr_width(&self) -> f64 {
553        match self.kind {
554            PKind::Brace => self.bbox.width(),
555            PKind::Line | PKind::Move | PKind::Spline => self.line_wid,
556            _ => self.bbox.width(),
557        }
558    }
559
560    fn attr_height(&self) -> f64 {
561        match self.kind {
562            PKind::Brace => self.bbox.height(),
563            PKind::Line | PKind::Move | PKind::Spline => self.line_ht,
564            _ => self.bbox.height(),
565        }
566    }
567
568    fn attr_radius(&self) -> f64 {
569        match self.kind {
570            PKind::Box => self.box_rad,
571            PKind::Circle => self.bbox.width() / 2.0,
572            PKind::Arc => self.radius,
573            _ => 0.0,
574        }
575    }
576
577    fn attr_diameter(&self) -> f64 {
578        match self.kind {
579            PKind::Circle => self.bbox.width(),
580            PKind::Arc => self.radius * 2.0,
581            _ => 0.0,
582        }
583    }
584
585    fn attr_length(&self) -> f64 {
586        match self.kind {
587            PKind::Line | PKind::Move | PKind::Spline | PKind::Brace => self.start.dist(self.end),
588            _ => 0.0,
589        }
590    }
591}
592
593fn bbox_point(bb: &Bbox, c: Corner) -> Point {
594    let lo = bb.min;
595    let hi = bb.max;
596    let mid = (lo + hi) * 0.5;
597    match c {
598        Corner::N => Point::new(mid.x, hi.y),
599        Corner::S => Point::new(mid.x, lo.y),
600        Corner::E => Point::new(hi.x, mid.y),
601        Corner::W => Point::new(lo.x, mid.y),
602        Corner::Ne => Point::new(hi.x, hi.y),
603        Corner::Se => Point::new(hi.x, lo.y),
604        Corner::Nw => Point::new(lo.x, hi.y),
605        Corner::Sw => Point::new(lo.x, lo.y),
606        Corner::Center => mid,
607        Corner::Start => lo,
608        Corner::End => hi,
609    }
610}
611
612// ---- evaluator state -------------------------------------------------------
613
614struct State {
615    pos: Point,
616    dir: Dir,
617    vars: HashMap<String, f64>,
618    inherited_vars: HashSet<String>,
619    export_vars: HashSet<String>,
620    env: EnvVars,
621    macros: Macros,
622    includes: IncludeCtx,
623    /// Labels visible from an enclosing scope (read-only, in absolute parent
624    /// coordinates). A block may reference outer labels but must not let them
625    /// affect its own `last`/nth/bbox, so they live here, not in `placed`.
626    outer_labels: HashMap<String, Placed>,
627    shapes: Vec<Shape>,
628    shape_layers: Vec<i32>,
629    shape_classes: Vec<Option<String>>,
630    shape_links: Vec<Option<String>>,
631    shape_spans: Vec<Option<Span>>,
632    /// Span of the object statement currently being evaluated (attached to
633    /// every shape it pushes).
634    current_span: Option<Span>,
635    /// Fixed page rectangle from `canvas from … to …` (last statement wins).
636    canvas: Option<Bbox>,
637    placed: Vec<Placed>,
638    labels: HashMap<String, usize>,
639    /// Visible geometry and text only; this becomes the final drawing/viewBox.
640    bbox: Bbox,
641    /// All evaluated geometry, including invisible helpers, for block sizing
642    /// and anchor placement.
643    layout_bbox: Bbox,
644    // animation state
645    anims: Vec<Anim>,
646    interactions: Vec<Interaction>,
647    anim_scroll: bool,
648    diagnostics: Vec<String>,
649    warnings: Vec<Diagnostic>,
650    anim_cursor: f64,
651    anim_end: HashMap<usize, f64>,
652    limits: EvalLimits,
653    rng: GlibcRand,
654}
655
656const DEFAULT_ANIM_DUR: f64 = 0.6;
657
658fn checked_anim_time(name: &str, value: f64, max: f64) -> ER<f64> {
659    if !value.is_finite() {
660        return err(format!("animation {name} must be finite"));
661    }
662    if value < 0.0 {
663        return err(format!("animation {name} must be non-negative"));
664    }
665    if value > max {
666        return err(format!("animation {name} must be at most {max} seconds"));
667    }
668    Ok(value)
669}
670
671fn checked_anim_seconds_limit(value: f64, host_max: f64) -> ER<f64> {
672    if !value.is_finite() {
673        return err("maxanimseconds must be finite");
674    }
675    if value < 0.0 {
676        return err("maxanimseconds must be non-negative");
677    }
678    if value > host_max {
679        return err(format!("maxanimseconds must be at most {host_max}"));
680    }
681    Ok(value)
682}
683
684fn checked_anim_repeat(value: f64, max: i64) -> ER<i64> {
685    if !value.is_finite() {
686        return err("animation repeat must be finite");
687    }
688    if value < 0.0 && (value + 1.0).abs() > f64::EPSILON {
689        return err("animation repeat must be -1 or non-negative");
690    }
691
692    let repeat = value.round();
693    if repeat > max as f64 {
694        return err(format!("animation repeat must be at most {max}"));
695    }
696    Ok(repeat as i64)
697}
698
699fn checked_anim_repeat_limit(value: f64, host_max: i64) -> ER<i64> {
700    if !value.is_finite() {
701        return err("maxanimrepeat must be finite");
702    }
703    if value < 0.0 {
704        return err("maxanimrepeat must be non-negative");
705    }
706
707    let max = value.round();
708    if max > host_max as f64 {
709        return err(format!("maxanimrepeat must be at most {host_max}"));
710    }
711    Ok(max as i64)
712}
713
714fn additive_loop_iterations(from: f64, to: f64, by: f64) -> Option<u64> {
715    const EPS: f64 = 1e-9;
716
717    if !from.is_finite() || !to.is_finite() || !by.is_finite() {
718        return None;
719    }
720
721    let cont = if by >= 0.0 {
722        from <= to + EPS
723    } else {
724        from >= to - EPS
725    };
726    if !cont {
727        return Some(0);
728    }
729    if by.abs() < f64::EPSILON {
730        return Some(1);
731    }
732
733    let span = if by > 0.0 {
734        to + EPS - from
735    } else {
736        from - (to - EPS)
737    };
738    let step = by.abs();
739    let iterations = (span / step).floor() + 1.0;
740    if !iterations.is_finite() || iterations >= u64::MAX as f64 {
741        return Some(u64::MAX);
742    }
743
744    Some(iterations.max(0.0) as u64)
745}
746
747fn dir_unit(d: Dir) -> Point {
748    match d {
749        Dir::Right => Point::new(1.0, 0.0),
750        Dir::Left => Point::new(-1.0, 0.0),
751        Dir::Up => Point::new(0.0, 1.0),
752        Dir::Down => Point::new(0.0, -1.0),
753    }
754}
755fn horizontal(d: Dir) -> bool {
756    matches!(d, Dir::Right | Dir::Left)
757}
758
759impl State {
760    #[cfg(test)]
761    fn new() -> Self {
762        Self::with_limits(EvalLimits::default())
763    }
764
765    fn with_limits(limits: EvalLimits) -> Self {
766        let mut vars = HashMap::new();
767        install_dpic_compat_vars(&mut vars);
768        State {
769            pos: Point::ZERO,
770            dir: Dir::Right,
771            vars,
772            inherited_vars: HashSet::new(),
773            export_vars: HashSet::new(),
774            env: EnvVars::new(limits),
775            macros: HashMap::new(),
776            includes: IncludeCtx::default(),
777            outer_labels: HashMap::new(),
778            shapes: Vec::new(),
779            shape_layers: Vec::new(),
780            shape_classes: Vec::new(),
781            shape_links: Vec::new(),
782            shape_spans: Vec::new(),
783            current_span: None,
784            canvas: None,
785            placed: Vec::new(),
786            labels: HashMap::new(),
787            bbox: Bbox::new(),
788            layout_bbox: Bbox::new(),
789            anims: Vec::new(),
790            interactions: Vec::new(),
791            anim_scroll: false,
792            diagnostics: Vec::new(),
793            warnings: Vec::new(),
794            anim_cursor: 0.0,
795            anim_end: HashMap::new(),
796            limits,
797            rng: GlibcRand::new(1),
798        }
799    }
800
801    fn eval_stmts(&mut self, stmts: &[Stmt]) -> ER<()> {
802        for s in stmts {
803            self.eval_stmt(s)?;
804        }
805        Ok(())
806    }
807
808    fn checked_env_value(&self, e: EnvVar, value: f64) -> ER<f64> {
809        match e {
810            EnvVar::Maxanimrepeat => {
811                Ok(checked_anim_repeat_limit(value, self.limits.max_animation_repeat)? as f64)
812            }
813            EnvVar::Maxanimseconds => {
814                checked_anim_seconds_limit(value, self.limits.max_animation_seconds)
815            }
816            _ => Ok(value),
817        }
818    }
819
820    fn max_anim_seconds(&self) -> ER<f64> {
821        checked_anim_seconds_limit(
822            self.env.get(EnvVar::Maxanimseconds),
823            self.limits.max_animation_seconds,
824        )
825    }
826
827    fn max_anim_repeat(&self) -> ER<i64> {
828        checked_anim_repeat_limit(
829            self.env.get(EnvVar::Maxanimrepeat),
830            self.limits.max_animation_repeat,
831        )
832    }
833
834    /// Parse a deferred `if`/`for` body now, expanding macros along this path.
835    fn parse_body(&mut self, body: &Body) -> ER<Vec<Stmt>> {
836        crate::parser::parse_body_tokens(body, &mut self.macros, &self.includes)
837            .map_err(parse_eval_error)
838    }
839
840    fn eval_stmt(&mut self, s: &Stmt) -> ER<()> {
841        match s {
842            Stmt::Direction(d) => {
843                self.dir = *d;
844            }
845            Stmt::Assign(list) => {
846                for a in list {
847                    self.eval_assignment(a)?;
848                }
849            }
850            Stmt::Place { label, pos } => {
851                let p = self.eval_pos(pos)?;
852                let key = self.label_key(label)?;
853                let mut bb = Bbox::new();
854                bb.add(p);
855                let idx = self.placed.len();
856                self.placed.push(Placed {
857                    kind: PKind::Text,
858                    center: p,
859                    bbox: bb,
860                    start: p,
861                    end: p,
862                    thick: 0.0,
863                    points: Vec::new(),
864                    radius: 0.0,
865                    box_rad: 0.0,
866                    line_wid: 0.0,
867                    line_ht: 0.0,
868                    closed_path: false,
869                    layer: 0,
870                    shape: None,
871                    members: HashMap::new(),
872                    block_shapes: None,
873                });
874                self.labels.insert(key, idx);
875            }
876            Stmt::Group(stmts) => {
877                let (pos, dir) = (self.pos, self.dir);
878                self.eval_stmts(stmts)?;
879                self.pos = pos;
880                self.dir = dir;
881            }
882            Stmt::Object { label, object } => {
883                let idx = self.eval_object(object)?;
884                if let Some(l) = label {
885                    let key = self.label_key(l)?;
886                    self.labels.insert(key, idx);
887                }
888            }
889            Stmt::Animate(a) => self.eval_animate(a)?,
890            Stmt::AnimateScroll => self.anim_scroll = true,
891            Stmt::Draggable(d) => self.eval_draggable(d)?,
892            Stmt::Class { target, class } => {
893                let idx = self.place_index(target)?;
894                let name = self.eval_stringexpr(class)?;
895                self.append_class_at(idx, &name)?;
896            }
897            Stmt::Link { target, url } => {
898                let idx = self.place_index(target)?;
899                let url = self.eval_stringexpr(url)?;
900                self.set_link_at(idx, &url)?;
901            }
902            Stmt::Canvas { from, to } => {
903                let a = self.eval_pos(from)?;
904                let b = self.eval_pos(to)?;
905                let mut bb = Bbox::new();
906                bb.add(a);
907                bb.add(b);
908                if bb.width() <= 0.0 || bb.height() <= 0.0 {
909                    return err("canvas must have positive width and height");
910                }
911                self.canvas = Some(bb);
912            }
913            Stmt::If {
914                cond,
915                then_body,
916                else_body,
917            } => {
918                // only the taken branch is parsed (dead branches may be
919                // syntactically invalid, e.g. an empty default-argument body)
920                if self.eval_expr(cond)? != 0.0 {
921                    let stmts = self.parse_body(then_body)?;
922                    self.eval_stmts(&stmts)?;
923                } else if let Some(e) = else_body {
924                    let stmts = self.parse_body(e)?;
925                    self.eval_stmts(&stmts)?;
926                }
927            }
928            Stmt::For {
929                var,
930                subscript,
931                from,
932                to,
933                by,
934                mult,
935                body,
936            } => {
937                let from = self.eval_expr(from)?;
938                let to = self.eval_expr(to)?;
939                let by = self.eval_expr(by)?;
940                let mut v = from;
941                let mut iters = 0u64;
942                if !*mult
943                    && let Some(total) = additive_loop_iterations(from, to, by)
944                    && total > self.limits.max_loop_iterations
945                {
946                    return err(format!(
947                        "for loop exceeded {} iterations",
948                        self.limits.max_loop_iterations
949                    ));
950                }
951                // Parsed lazily on the first iteration that actually runs, so
952                // a zero-iteration loop never parses (or macro-expands) its
953                // body — same deferred rule as dead `if` branches (#196).
954                let mut parsed: Option<Vec<Stmt>> = None;
955                const EPS: f64 = 1e-9;
956                loop {
957                    let cont = if *mult {
958                        if by >= 1.0 {
959                            v <= to + EPS
960                        } else {
961                            v >= to - EPS
962                        }
963                    } else if by >= 0.0 {
964                        v <= to + EPS
965                    } else {
966                        v >= to - EPS
967                    };
968                    if !cont {
969                        break;
970                    }
971                    if iters >= self.limits.max_loop_iterations {
972                        return err(format!(
973                            "for loop exceeded {} iterations",
974                            self.limits.max_loop_iterations
975                        ));
976                    }
977                    iters += 1;
978                    let key = self.indexed_name(var, subscript.as_ref())?;
979                    self.vars.insert(key, v);
980                    if parsed.is_none() {
981                        parsed = Some(self.parse_body(body)?);
982                    }
983                    self.eval_stmts(parsed.as_deref().unwrap())?;
984                    let prev = v;
985                    v = if *mult { v * by } else { v + by };
986                    if (v - prev).abs() < f64::EPSILON {
987                        break; // no progress (by 0, or *1) — avoid infinite loop
988                    }
989                }
990            }
991            Stmt::Print(item) => match item {
992                PrintItem::Expr(e) => {
993                    let v = self.eval_expr(e)?;
994                    self.diagnostics.push(fmt_num(v));
995                }
996                PrintItem::Str(se) => {
997                    let s = self.eval_stringexpr(se)?;
998                    self.diagnostics.push(s);
999                }
1000            },
1001            Stmt::Exec { command, arg_frame } => {
1002                let src = unescape_exec_source(&self.eval_stringexpr(command)?);
1003                let stmts = crate::parser::parse_exec_source(
1004                    &src,
1005                    &mut self.macros,
1006                    &self.includes,
1007                    arg_frame.as_ref().map(|a| a.as_slice()),
1008                )
1009                .map_err(parse_eval_error)?;
1010                self.eval_stmts(&stmts)?;
1011            }
1012            Stmt::Reset(list) => {
1013                if list.is_empty() {
1014                    self.env = EnvVars::new(self.limits);
1015                } else {
1016                    let d = EnvVars::new(self.limits);
1017                    for e in list {
1018                        self.env.set(*e, d.get(*e));
1019                    }
1020                }
1021            }
1022        }
1023        Ok(())
1024    }
1025
1026    fn eval_animate(&mut self, a: &Animate) -> ER<()> {
1027        let idx = self.place_index(&a.target)?;
1028        let shape = self.placed[idx].shape.ok_or_else(|| EvalError {
1029            msg: "cannot animate a point (no drawn shape)".into(),
1030            info: None,
1031        })?;
1032        let max_seconds = self.max_anim_seconds()?;
1033        let dur = match &a.duration {
1034            Some(e) => checked_anim_time("duration", self.eval_expr(e)?, max_seconds)?,
1035            None => checked_anim_time("duration", DEFAULT_ANIM_DUR, max_seconds)?,
1036        };
1037        let mut start = match &a.timing {
1038            Timing::Sequential => self.anim_cursor,
1039            Timing::At(e) => self.eval_expr(e)?,
1040            Timing::After(p) => {
1041                let i = self.place_index(p)?;
1042                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
1043                    msg: "`after` target has no animation".into(),
1044                    info: None,
1045                })?;
1046                *self.anim_end.get(&sh).unwrap_or(&0.0)
1047            }
1048        };
1049        start = checked_anim_time("start time", start, max_seconds)?;
1050        if let Some(d) = &a.delay {
1051            let delay = checked_anim_time("delay", self.eval_expr(d)?, max_seconds)?;
1052            start = checked_anim_time("start time", start + delay, max_seconds)?;
1053        }
1054        // Sequential/`after` timing tracks the *first* iteration's end, not the
1055        // repeated total — an ambient `repeat` loop (or an infinite one) must
1056        // not stall everything declared after it.
1057        let end = checked_anim_time("end time", start + dur, max_seconds)?;
1058        let max_repeat = self.max_anim_repeat()?;
1059        let repeat = match &a.repeat {
1060            Some(e) => checked_anim_repeat(self.eval_expr(e)?, max_repeat)?,
1061            None => 0,
1062        };
1063        self.anim_cursor = end;
1064        self.anim_end.insert(shape, end);
1065        if a.yoyo && repeat == 0 {
1066            let mut warning = Diagnostic::new(
1067                "yoyo_without_repeat",
1068                "`yoyo` has no effect without `repeat` (there is nothing to reverse)",
1069            );
1070            if let Some(span) = &a.effect_span {
1071                warning = warning.at(span.clone());
1072            }
1073            self.warnings.push(warning);
1074        }
1075        let ease = a.ease.as_ref().map(stringexpr_lit);
1076        let effect = stringexpr_lit(&a.effect);
1077        let is_move = effect == "move";
1078        let is_highlight = effect == "highlight";
1079        let is_slide = effect == "slide";
1080        let is_morph = effect == "morph";
1081        let is_draw = effect == "draw";
1082        let from = a.slide_from.map(|d| {
1083            match d {
1084                Dir::Up => "up",
1085                Dir::Down => "down",
1086                Dir::Left => "left",
1087                Dir::Right => "right",
1088            }
1089            .to_string()
1090        });
1091        // Resolve the `along` path (a drawn object) to its shape index.
1092        let path = match &a.along {
1093            Some(p) => {
1094                let i = self.place_index(p)?;
1095                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
1096                    msg: "`along` target has no drawn path".into(),
1097                    info: None,
1098                })?;
1099                Some(sh)
1100            }
1101            None => None,
1102        };
1103        // Resolve the `into` morph target (a drawn object) to its shape index.
1104        let morph = match &a.morph_into {
1105            Some(p) => {
1106                let i = self.place_index(p)?;
1107                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
1108                    msg: "`into` target has no drawn shape".into(),
1109                    info: None,
1110                })?;
1111                Some(sh)
1112            }
1113            None => None,
1114        };
1115        // Resolve the `to` colour (any rpic colour form) to a CSS string. No
1116        // per-colour span is threaded through the animate directive, so this
1117        // warning stays position-less (the object-attribute colours carry one).
1118        let color = match &a.color {
1119            Some(se) => Some(self.eval_color_expr(se, None)?),
1120            None => None,
1121        };
1122        if is_move && path.is_none() {
1123            return Err(EvalError {
1124                msg: "`move` needs a path: `animate <obj> with \"move\" along <path>`".into(),
1125                info: None,
1126            });
1127        }
1128        if path.is_some() && !is_move {
1129            let mut warning = Diagnostic::new(
1130                "along_without_move",
1131                "`along` only applies to the `move` effect and is ignored here",
1132            );
1133            if let Some(span) = &a.effect_span {
1134                warning = warning.at(span.clone());
1135            }
1136            self.warnings.push(warning);
1137        }
1138        if color.is_some() && !is_highlight {
1139            let mut warning = Diagnostic::new(
1140                "to_without_highlight",
1141                "`to <colour>` only applies to the `highlight` effect and is ignored here",
1142            );
1143            if let Some(span) = &a.effect_span {
1144                warning = warning.at(span.clone());
1145            }
1146            self.warnings.push(warning);
1147        }
1148        if is_slide && from.is_none() {
1149            return Err(EvalError {
1150                msg: "`slide` needs a direction: `animate <obj> with \"slide\" from <dir>`".into(),
1151                info: None,
1152            });
1153        }
1154        if from.is_some() && !is_slide {
1155            let mut warning = Diagnostic::new(
1156                "from_without_slide",
1157                "`from <dir>` only applies to the `slide` effect and is ignored here",
1158            );
1159            if let Some(span) = &a.effect_span {
1160                warning = warning.at(span.clone());
1161            }
1162            self.warnings.push(warning);
1163        }
1164        if is_morph && morph.is_none() {
1165            return Err(EvalError {
1166                msg: "`morph` needs a target: `animate <obj> with \"morph\" into <shape>`".into(),
1167                info: None,
1168            });
1169        }
1170        if morph.is_some() && !is_morph {
1171            let mut warning = Diagnostic::new(
1172                "into_without_morph",
1173                "`into <shape>` only applies to the `morph` effect and is ignored here",
1174            );
1175            if let Some(span) = &a.effect_span {
1176                warning = warning.at(span.clone());
1177            }
1178            self.warnings.push(warning);
1179        }
1180        let is_type = effect == "type";
1181        let is_scramble = effect == "scramble";
1182        if a.type_unit.is_some() && !is_type {
1183            let mut warning = Diagnostic::new(
1184                "by_without_type",
1185                "`by word`/`by char` only applies to the `type` effect and is ignored here",
1186            );
1187            if let Some(span) = &a.effect_span {
1188                warning = warning.at(span.clone());
1189            }
1190            self.warnings.push(warning);
1191        }
1192        if a.scramble_chars.is_some() && !is_scramble {
1193            let mut warning = Diagnostic::new(
1194                "by_without_scramble",
1195                "`by \"<chars>\"` only applies to the `scramble` effect and is ignored here",
1196            );
1197            if let Some(span) = &a.effect_span {
1198                warning = warning.at(span.clone());
1199            }
1200            self.warnings.push(warning);
1201        }
1202        let is_wiggle = effect == "wiggle";
1203        if a.wiggles.is_some() && !is_wiggle {
1204            let mut warning = Diagnostic::new(
1205                "wiggles_without_wiggle",
1206                "`wiggles <n>` only applies to the `wiggle` effect and is ignored here",
1207            );
1208            if let Some(span) = &a.effect_span {
1209                warning = warning.at(span.clone());
1210            }
1211            self.warnings.push(warning);
1212        }
1213        // `draw from <a> to <b>` reveals only the [a,b] fraction of the stroke.
1214        // The `from`/`to` clauses are only parsed as fractions for a literal
1215        // `"draw"` effect (elsewhere they read a direction/colour), so a range on
1216        // the wrong effect can't be expressed — no wrong-effect guard is needed.
1217        // Resolve to fractions and clamp to [0,1]; each rides the manifest only
1218        // when given, so a plain `draw` stays byte-identical.
1219        let (draw_from, draw_to) = if is_draw {
1220            let f = match &a.draw_from {
1221                Some(e) => Some(self.eval_expr(e)?.clamp(0.0, 1.0)),
1222                None => None,
1223            };
1224            let t = match &a.draw_to {
1225                Some(e) => Some(self.eval_expr(e)?.clamp(0.0, 1.0)),
1226                None => None,
1227            };
1228            (f, t)
1229        } else {
1230            (None, None)
1231        };
1232        if !matches!(
1233            effect.as_str(),
1234            "draw"
1235                | "fade"
1236                | "pop"
1237                | "move"
1238                | "highlight"
1239                | "slide"
1240                | "morph"
1241                | "type"
1242                | "scramble"
1243                | "wiggle"
1244        ) {
1245            let mut warning = Diagnostic::new(
1246                "unknown_animation_effect",
1247                format!(
1248                    "unknown animation effect `{effect}`; supported effects are `draw`, `fade`, `pop`, `move`, `highlight`, `slide`, `morph`, `type`, `scramble`, and `wiggle`"
1249                ),
1250            )
1251            .found(effect.clone())
1252            .expected("draw, fade, pop, move, highlight, slide, morph, type, scramble, or wiggle");
1253            if let Some(span) = &a.effect_span {
1254                warning = warning.at(span.clone());
1255            }
1256            self.warnings.push(warning);
1257        }
1258        let path = if is_move { path } else { None };
1259        let color = if is_highlight { color } else { None };
1260        let from = if is_slide { from } else { None };
1261        let morph = if is_morph { morph } else { None };
1262        let type_word = is_type && matches!(a.type_unit, Some(TypeUnit::Word));
1263        let scramble_chars = if is_scramble {
1264            a.scramble_chars.as_ref().map(stringexpr_lit)
1265        } else {
1266            None
1267        };
1268        let wiggles = match (is_wiggle, &a.wiggles) {
1269            (true, Some(e)) => Some(self.eval_expr(e)?.round() as i64),
1270            _ => None,
1271        };
1272        let make = |shape: usize, start: f64| Anim {
1273            shape,
1274            effect: effect.clone(),
1275            start,
1276            duration: dur,
1277            repeat,
1278            yoyo: a.yoyo,
1279            ease: ease.clone(),
1280            path,
1281            color: color.clone(),
1282            out: a.out,
1283            from: from.clone(),
1284            morph,
1285            type_word,
1286            scramble_chars: scramble_chars.clone(),
1287            wiggles,
1288            draw_from,
1289            draw_to,
1290        };
1291        // `stagger <d>` on a block fans the effect across its *visible*
1292        // children (skipping `move`/invis spines), offset by d seconds each,
1293        // in source order — one manifest entry per child.
1294        if let Some(se) = &a.stagger {
1295            let step = checked_anim_time("stagger", self.eval_expr(se)?, max_seconds)?;
1296            let children: Vec<usize> = self.placed[idx]
1297                .block_shapes
1298                .map(|(lo, hi)| (lo..hi).filter(|&i| self.shapes[i].is_visible()).collect())
1299                .unwrap_or_default();
1300            if !children.is_empty() {
1301                let mut last_start = start;
1302                for (k, &child) in children.iter().enumerate() {
1303                    let child_start =
1304                        checked_anim_time("start time", start + k as f64 * step, max_seconds)?;
1305                    last_start = child_start;
1306                    self.anims.push(make(child, child_start));
1307                }
1308                let last_end = checked_anim_time("end time", last_start + dur, max_seconds)?;
1309                self.anim_cursor = last_end;
1310                // `after <block>` resolves to the block's own shape index, so
1311                // record the whole-stagger end there (line 863 seeded it with a
1312                // single-iteration end); recording under `children[0]` missed it
1313                // whenever the block leads with an invisible spine (audit).
1314                self.anim_end.insert(shape, last_end);
1315                return Ok(());
1316            }
1317            let mut warning = Diagnostic::new(
1318                "stagger_without_block",
1319                "`stagger` only applies to a block target with drawn children; animating the single object instead",
1320            );
1321            if let Some(span) = &a.effect_span {
1322                warning = warning.at(span.clone());
1323            }
1324            self.warnings.push(warning);
1325        }
1326        self.anims.push(make(shape, start));
1327        Ok(())
1328    }
1329
1330    /// Record a `draggable` interaction: resolve the target (and optional
1331    /// `bounds`) to shape indices for the host to wire GSAP Draggable.
1332    fn eval_draggable(&mut self, d: &Draggable) -> ER<()> {
1333        let idx = self.place_index(&d.target)?;
1334        let shape = self.placed[idx].shape.ok_or_else(|| EvalError {
1335            msg: "cannot make a point draggable (no drawn shape)".into(),
1336            info: None,
1337        })?;
1338        let bounds = match &d.bounds {
1339            Some(p) => {
1340                let i = self.place_index(p)?;
1341                let sh = self.placed[i].shape.ok_or_else(|| EvalError {
1342                    msg: "`bounds` target has no drawn shape".into(),
1343                    info: None,
1344                })?;
1345                Some(sh)
1346            }
1347            None => None,
1348        };
1349        let axis = d.axis.map(|a| match a {
1350            DragAxis::X => "x",
1351            DragAxis::Y => "y",
1352        });
1353        self.interactions.push(Interaction {
1354            shape,
1355            inertia: d.inertia,
1356            bounds,
1357            axis,
1358        });
1359        Ok(())
1360    }
1361
1362    /// Append a validated CSS class to the shape behind `placed_idx` (rpic
1363    /// `class` extension). Multiple applications compose: `class="a b"`.
1364    fn append_class_at(&mut self, placed_idx: usize, name: &str) -> ER<()> {
1365        let name = name.trim();
1366        validate_class(name)?;
1367        let pl = &self.placed[placed_idx];
1368        if matches!(pl.kind, PKind::Block) {
1369            return err("`class` on a block is not supported yet; class its inner objects");
1370        }
1371        let Some(sh) = pl.shape else {
1372            return err("cannot attach a class to a point (no drawn shape)");
1373        };
1374        match &mut self.shape_classes[sh] {
1375            Some(existing) => {
1376                existing.push(' ');
1377                existing.push_str(name);
1378            }
1379            slot @ None => *slot = Some(name.to_string()),
1380        }
1381        Ok(())
1382    }
1383
1384    /// Set the hyperlink on the shape behind `placed_idx` (rpic `link`
1385    /// extension). Reapplying replaces the URL — last one wins.
1386    fn set_link_at(&mut self, placed_idx: usize, url: &str) -> ER<()> {
1387        let url = url.trim();
1388        validate_link(url)?;
1389        let pl = &self.placed[placed_idx];
1390        if matches!(pl.kind, PKind::Block) {
1391            return err("`link` on a block is not supported yet; link its inner objects");
1392        }
1393        let Some(sh) = pl.shape else {
1394            return err("cannot attach a link to a point (no drawn shape)");
1395        };
1396        self.shape_links[sh] = Some(url.to_string());
1397        Ok(())
1398    }
1399
1400    /// rpic `texlabels` extension: typeset a fully `$…$`-delimited label as
1401    /// math via the registered renderer. Any failure — extension off, no
1402    /// renderer in this build, not fully delimited, or a TeX parse error —
1403    /// falls back to the literal text (a parse error also leaves a
1404    /// diagnostic). The picture itself never fails because of a math label.
1405    fn math_span_for(&mut self, s: &str) -> Option<crate::math::MathSpan> {
1406        if self.env.get(EnvVar::Texlabels) == 0.0 {
1407            return None;
1408        }
1409        let t = s.trim();
1410        let inner = t.strip_prefix('$')?.strip_suffix('$')?;
1411        if inner.is_empty() || inner.contains('$') {
1412            return None;
1413        }
1414        let Some(render) = crate::math::math_renderer() else {
1415            self.diagnostics.push(format!(
1416                "texlabels: no math renderer in this build; `{t}` kept literal"
1417            ));
1418            return None;
1419        };
1420        match render(inner, FONT_PT_MATH) {
1421            Ok(span) => Some(span),
1422            Err(e) => {
1423                self.diagnostics.push(format!(
1424                    "texlabels: `{t}` is not valid TeX math ({e}); label kept literal"
1425                ));
1426                None
1427            }
1428        }
1429    }
1430
1431    fn scale_value(&self) -> ER<f64> {
1432        let scale = self.env.get(EnvVar::Scale);
1433        if scale.abs() < 1e-12 {
1434            return err("scale must be non-zero");
1435        }
1436        Ok(scale)
1437    }
1438
1439    /// Convert a pic dimension from the current user units to internal inches.
1440    fn to_internal_dim(&self, v: f64) -> ER<f64> {
1441        Ok(v / self.scale_value()?)
1442    }
1443
1444    fn env_dim(&self, e: EnvVar) -> ER<f64> {
1445        self.to_internal_dim(self.env.get(e))
1446    }
1447
1448    fn canvas_margin(&self) -> ER<CanvasMargin> {
1449        let all = self.env_dim(EnvVar::Margin)?;
1450        Ok(CanvasMargin {
1451            top: all + self.env_dim(EnvVar::Topmargin)?,
1452            right: all + self.env_dim(EnvVar::Rightmargin)?,
1453            bottom: all + self.env_dim(EnvVar::Bottommargin)?,
1454            left: all + self.env_dim(EnvVar::Leftmargin)?,
1455        })
1456    }
1457
1458    fn expr_dim(&mut self, e: &Expr) -> ER<f64> {
1459        let v = self.eval_expr(e)?;
1460        self.to_internal_dim(v)
1461    }
1462
1463    /// Convert an internal geometric length back to pic's current user units.
1464    fn to_user_dim(&self, v: f64) -> f64 {
1465        v * self.env.get(EnvVar::Scale)
1466    }
1467
1468    fn eval_assignment(&mut self, a: &Assignment) -> ER<()> {
1469        let rhs = self.eval_expr(&a.value)?;
1470        match &a.target {
1471            AssignTarget::Var(name, subscript) => {
1472                let key = self.indexed_name(name, subscript.as_ref())?;
1473                let cur = match self.vars.get(&key).copied() {
1474                    Some(v) => v,
1475                    None if matches!(a.op, AssignOp::Set) => 0.0,
1476                    None => return err(format!("variable not found `{key}`")),
1477                };
1478                let val = apply_op(a.op, cur, rhs)?;
1479                if !matches!(a.op, AssignOp::Set) && self.inherited_vars.contains(&key) {
1480                    self.export_vars.insert(key.clone());
1481                }
1482                self.vars.insert(key, val);
1483            }
1484            AssignTarget::Env(e) => {
1485                let cur = self.env.get(*e);
1486                let val = self.checked_env_value(*e, apply_op(a.op, cur, rhs)?)?;
1487                if matches!(e, EnvVar::Scale) {
1488                    if val.abs() < 1e-12 {
1489                        return err("scale must be non-zero");
1490                    }
1491                    if cur.abs() >= 1e-12 {
1492                        self.scale_existing_geometry(cur / val);
1493                    }
1494                    // Changing `scale` rescales all scaled dimension variables by
1495                    // the ratio (dpic semantics). They remain in user units;
1496                    // geometry converts them to internal inches by dividing by
1497                    // the current scale at use sites.
1498                    let ratio = if cur != 0.0 { val / cur } else { val };
1499                    for sv in SCALED_VARS {
1500                        let v = self.env.get(sv);
1501                        self.env.set(sv, v * ratio);
1502                    }
1503                }
1504                self.env.set(*e, val);
1505            }
1506        }
1507        Ok(())
1508    }
1509
1510    fn scale_existing_geometry(&mut self, factor: f64) {
1511        if (factor - 1.0).abs() < 1e-12 {
1512            return;
1513        }
1514        self.pos = self.pos * factor;
1515        for sh in &mut self.shapes {
1516            scale_shape(sh, factor);
1517        }
1518        for pl in &mut self.placed {
1519            scale_placed(pl, factor);
1520        }
1521        scale_bbox_in_place(&mut self.bbox, factor);
1522        scale_bbox_in_place(&mut self.layout_bbox, factor);
1523    }
1524
1525    // ---- objects -----------------------------------------------------------
1526
1527    fn eval_object(&mut self, obj: &Object) -> ER<usize> {
1528        if !object_uses_bare_distance(&obj.kind) {
1529            self.warn_ignored_dist_attrs(obj);
1530        }
1531        // Blocks evaluate nested objects (which set their own spans) before
1532        // this frame finishes — save/restore keeps attribution per statement.
1533        let saved_span = std::mem::replace(&mut self.current_span, obj.span.clone());
1534        let result = self.eval_object_inner(obj);
1535        self.current_span = saved_span;
1536        let idx = result?;
1537        for a in &obj.attrs {
1538            if let Attr::Class(se) = a {
1539                let name = self.eval_stringexpr(se)?;
1540                self.append_class_at(idx, &name)?;
1541            }
1542            if let Attr::Link(se) = a {
1543                let url = self.eval_stringexpr(se)?;
1544                self.set_link_at(idx, &url)?;
1545            }
1546        }
1547        Ok(idx)
1548    }
1549
1550    fn warn_ignored_dist_attrs(&mut self, obj: &Object) {
1551        for attr in &obj.attrs {
1552            let Attr::Dist(expr, span) = attr else {
1553                continue;
1554            };
1555            let found = expr_bare_name(expr).unwrap_or("bare distance");
1556            let mut warning = Diagnostic::new(
1557                "ignored_attribute",
1558                format!("ignored `{found}` because this object does not accept a bare distance"),
1559            )
1560            .found(found)
1561            .expected("an attribute");
1562            if let Some(hint) = suggest_attribute(found) {
1563                warning = warning.hint(format!("did you mean `{hint}`?"));
1564            }
1565            if let Some(span) = span {
1566                warning = warning.at(span.clone());
1567            }
1568            self.warnings.push(warning);
1569        }
1570    }
1571
1572    fn eval_object_inner(&mut self, obj: &Object) -> ER<usize> {
1573        match &obj.kind {
1574            ObjectKind::Primitive(p) => match p {
1575                Prim::Box | Prim::Circle | Prim::Ellipse => self.closed(*p, obj),
1576                Prim::Line | Prim::Arrow | Prim::Move | Prim::Spline => self.open(*p, obj),
1577                Prim::Arc => self.arc(obj),
1578            },
1579            ObjectKind::Text => self.text_obj(obj),
1580            ObjectKind::Brace => self.brace(obj),
1581            ObjectKind::Dot => self.closed_dot(obj),
1582            ObjectKind::Block(stmts) => self.block(stmts, obj),
1583            ObjectKind::Empty => self.block(&[], obj),
1584            ObjectKind::Continue => self.continue_obj(obj),
1585        }
1586    }
1587
1588    // ---- attribute helpers -------------------------------------------------
1589
1590    fn dim(&mut self, obj: &Object, kind: DimKind) -> ER<Option<f64>> {
1591        for a in &obj.attrs {
1592            if let Attr::Dim(k, e) = a
1593                && *k == kind
1594            {
1595                return Ok(Some(match kind {
1596                    DimKind::Thick | DimKind::Scaled => self.eval_expr(e)?,
1597                    DimKind::Ht | DimKind::Wid | DimKind::Rad | DimKind::Diam => {
1598                        self.expr_dim(e)?
1599                    }
1600                }));
1601            }
1602        }
1603        Ok(None)
1604    }
1605
1606    fn has_dim(&self, obj: &Object, kind: DimKind) -> bool {
1607        obj.attrs
1608            .iter()
1609            .any(|a| matches!(a, Attr::Dim(k, _) if *k == kind))
1610    }
1611
1612    fn scale_of(&mut self, obj: &Object) -> ER<f64> {
1613        Ok(self.dim(obj, DimKind::Scaled)?.unwrap_or(1.0))
1614    }
1615
1616    fn dir_of(&self, obj: &Object) -> Dir {
1617        obj.attrs
1618            .iter()
1619            .rev()
1620            .find_map(|a| match a {
1621                Attr::Direction(d, _) => Some(*d),
1622                _ => None,
1623            })
1624            .unwrap_or(self.dir)
1625    }
1626
1627    fn find_from(&mut self, obj: &Object) -> ER<Option<Point>> {
1628        for a in &obj.attrs {
1629            if let Attr::From(pos) = a {
1630                return Ok(Some(self.eval_pos(pos)?));
1631            }
1632        }
1633        Ok(None)
1634    }
1635
1636    fn at_of(&mut self, obj: &Object) -> ER<Option<Point>> {
1637        for a in &obj.attrs {
1638            if let Attr::At(pos) = a {
1639                return Ok(Some(self.eval_pos(pos)?));
1640            }
1641        }
1642        Ok(None)
1643    }
1644
1645    fn dest_of(&mut self, obj: &Object) -> ER<Option<Point>> {
1646        for a in &obj.attrs {
1647            if let Attr::To(pos) = a {
1648                return Ok(Some(self.eval_pos(pos)?));
1649            }
1650        }
1651        Ok(None)
1652    }
1653
1654    /// The start/end `chop` amounts. `chop r1 chop r2` trims each end
1655    /// independently; a single `chop` applies to both ends.
1656    fn chop_of(&mut self, obj: &Object) -> ER<Option<(f64, f64)>> {
1657        let mut vals = Vec::new();
1658        for a in &obj.attrs {
1659            if let Attr::Chop(opt) = a {
1660                let amt = match opt {
1661                    Some(e) => self.expr_dim(e)?,
1662                    None => self.env_dim(EnvVar::Circlerad)?,
1663                };
1664                vals.push(amt);
1665            }
1666        }
1667        Ok(match vals.as_slice() {
1668            [] => None,
1669            [one] => Some((*one, *one)),
1670            [start, end, ..] => Some((*start, *end)),
1671        })
1672    }
1673
1674    /// Width/height of the last placed object of the same closed kind (for `same`).
1675    fn last_dims_of(&self, p: Prim) -> Option<(f64, f64)> {
1676        let want = match p {
1677            Prim::Box => PKind::Box,
1678            Prim::Circle => PKind::Circle,
1679            Prim::Ellipse => PKind::Ellipse,
1680            _ => return None,
1681        };
1682        self.placed
1683            .iter()
1684            .rev()
1685            .find(|pl| pl.kind == want)
1686            .map(|pl| (pl.bbox.width(), pl.bbox.height()))
1687    }
1688
1689    /// End-to-end vector of the last placed open object of the same kind.
1690    fn last_open_vector(&self, p: Prim) -> Option<Point> {
1691        let want = match p {
1692            Prim::Move => PKind::Move,
1693            Prim::Spline => PKind::Spline,
1694            Prim::Line | Prim::Arrow => PKind::Line,
1695            _ => return None,
1696        };
1697        self.placed
1698            .iter()
1699            .rev()
1700            .find(|pl| pl.kind == want)
1701            .map(|pl| pl.end - pl.start)
1702    }
1703
1704    /// Compute the center of a closed object given direction and extents.
1705    fn place_center(&mut self, obj: &Object, dir: Dir, extent: f64, w: f64, h: f64) -> ER<Point> {
1706        self.place_with_corner_offset(obj, dir, extent, w, h, corner_offset)
1707    }
1708
1709    fn place_closed_center(
1710        &mut self,
1711        p: Prim,
1712        obj: &Object,
1713        dir: Dir,
1714        extent: f64,
1715        dims: (f64, f64),
1716        rad: f64,
1717    ) -> ER<Point> {
1718        let (w, h) = dims;
1719        self.place_with_corner_offset(obj, dir, extent, w, h, |c, w, h| {
1720            closed_corner_offset(p, c, w, h, rad)
1721        })
1722    }
1723
1724    fn place_with_corner_offset(
1725        &mut self,
1726        obj: &Object,
1727        dir: Dir,
1728        extent: f64,
1729        w: f64,
1730        h: f64,
1731        corner: impl Fn(Corner, f64, f64) -> Point,
1732    ) -> ER<Point> {
1733        if let Some(at) = self.at_of(obj)? {
1734            return Ok(at);
1735        }
1736        for a in &obj.attrs {
1737            if let Attr::With { anchor, at } = a {
1738                let ap = self.eval_pos(at)?;
1739                let off = match anchor {
1740                    WithAnchor::Corner(c) => corner(dir_start_end_corner(*c, dir), w, h),
1741                    WithAnchor::Pair(x, y) => Point::new(self.expr_dim(x)?, self.expr_dim(y)?),
1742                    WithAnchor::Place(_) => {
1743                        return err("`with .label` anchors are only valid on blocks");
1744                    }
1745                    WithAnchor::Plain => Point::ZERO,
1746                };
1747                return Ok(ap - off);
1748            }
1749        }
1750        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
1751    }
1752
1753    #[allow(clippy::too_many_arguments)]
1754    fn block_center(
1755        &mut self,
1756        obj: &Object,
1757        dir: Dir,
1758        extent: f64,
1759        w: f64,
1760        h: f64,
1761        local_center: Point,
1762        sub: &mut State,
1763    ) -> ER<Point> {
1764        if let Some(at) = self.at_of(obj)? {
1765            return Ok(at);
1766        }
1767        for a in &obj.attrs {
1768            if let Attr::With { anchor, at } = a {
1769                let ap = self.eval_pos(at)?;
1770                let off = match anchor {
1771                    WithAnchor::Corner(c) => corner_offset(dir_start_end_corner(*c, dir), w, h),
1772                    WithAnchor::Pair(x, y) => {
1773                        Point::new(self.expr_dim(x)?, self.expr_dim(y)?) - local_center
1774                    }
1775                    WithAnchor::Place(place) => sub.place_point(place)? - local_center,
1776                    WithAnchor::Plain => Point::ZERO,
1777                };
1778                return Ok(ap - off);
1779            }
1780        }
1781        Ok(self.pos + dir_unit(dir) * (extent / 2.0))
1782    }
1783
1784    fn arrows_of(&self, obj: &Object, default_end: bool) -> Arrowheads {
1785        let mut found = None;
1786        for a in &obj.attrs {
1787            if let Attr::Arrowhead(h, _) = a {
1788                found = Some(match h {
1789                    token::Arrow::Left => Arrowheads::Start,
1790                    token::Arrow::Right => Arrowheads::End,
1791                    token::Arrow::Double => Arrowheads::Both,
1792                });
1793            }
1794        }
1795        found.unwrap_or(if default_end {
1796            Arrowheads::End
1797        } else {
1798            Arrowheads::None
1799        })
1800    }
1801
1802    fn style_of(&mut self, obj: &Object) -> ER<Style> {
1803        // arrowhead dimensions follow the current `arrowht`/`arrowwid` globals
1804        let mut s = Style {
1805            arrow_ht: self.env_dim(EnvVar::Arrowht)?,
1806            arrow_wid: self.env_dim(EnvVar::Arrowwid)?,
1807            // `arrowhead = 0` draws an open (two-stroke) head; anything else
1808            // (default 2) is a filled triangle.
1809            arrow_filled: self.env.get(EnvVar::Arrowhead).round() as i64 != 0,
1810            ..Default::default()
1811        };
1812        let lt = self.env.get(EnvVar::Linethick);
1813        if lt > 0.0 {
1814            s.thick = Some(lt);
1815        }
1816        for a in &obj.attrs {
1817            match a {
1818                Attr::LineStyle(lt, opt) => match lt {
1819                    LineType::Solid => s.dash = Dash::Solid,
1820                    LineType::Dashed => {
1821                        let w = match opt {
1822                            Some(e) => self.expr_dim(e)?,
1823                            None => self.env_dim(EnvVar::Dashwid)?,
1824                        };
1825                        // a non-positive pitch would emit an invalid negative
1826                        // `stroke-dasharray` (#291); fall back to the default
1827                        let w = if w.is_finite() && w > 0.0 {
1828                            w
1829                        } else {
1830                            self.env_dim(EnvVar::Dashwid)?
1831                        };
1832                        s.dash = Dash::Dashed(w);
1833                    }
1834                    LineType::Dotted => {
1835                        // a non-positive pitch would emit an invalid dot gap
1836                        // (#291); drop it so the default spacing is used
1837                        let pitch = match opt {
1838                            Some(e) => {
1839                                let w = self.expr_dim(e)?;
1840                                (w.is_finite() && w > 0.0).then_some(w)
1841                            }
1842                            None => None,
1843                        };
1844                        s.dash = Dash::Dotted(pitch);
1845                    }
1846                    LineType::Invis => s.invis = true,
1847                },
1848                Attr::Fill(opt) => {
1849                    let g = match opt {
1850                        Some(e) => self.eval_expr(e)?,
1851                        None => self.env.get(EnvVar::Fillval),
1852                    };
1853                    s.fill = Some(Fill::Gray(g));
1854                    s.fill_open = true;
1855                }
1856                Attr::Color(kind, se, span) => {
1857                    let name = self.eval_color_expr(se, span.as_ref())?;
1858                    match kind {
1859                        token::Color::Outlined => s.stroke = Some(name),
1860                        token::Color::Colored => {
1861                            s.stroke = Some(name.clone());
1862                            s.fill = Some(Fill::Color(name));
1863                        }
1864                        token::Color::Shaded => {
1865                            s.fill = Some(Fill::Color(name));
1866                            s.fill_open = true;
1867                        }
1868                    }
1869                }
1870                Attr::Hatch(kind) => {
1871                    let h = ensure_hatch(&mut s);
1872                    h.cross = matches!(kind, HatchKind::Cross);
1873                    s.fill_open = true;
1874                }
1875                Attr::HatchAngle(e) => ensure_hatch(&mut s).angle = self.eval_expr(e)?,
1876                Attr::HatchSep(e) => {
1877                    let sep = self.expr_dim(e)?;
1878                    if sep <= 0.0 {
1879                        return err("hatchsep must be positive");
1880                    }
1881                    ensure_hatch(&mut s).sep = sep;
1882                    s.fill_open = true;
1883                }
1884                Attr::HatchWidth(e) => {
1885                    let width = self.eval_expr(e)?;
1886                    if width < 0.0 {
1887                        return err("hatchwidth must be non-negative");
1888                    }
1889                    ensure_hatch(&mut s).width = width;
1890                    s.fill_open = true;
1891                }
1892                Attr::HatchColor(se, span) => {
1893                    let name = self.eval_color_expr(se, span.as_ref())?;
1894                    ensure_hatch(&mut s).color = name;
1895                    s.fill_open = true;
1896                }
1897                Attr::Gradient(a, a_span, b, b_span) => {
1898                    let from = self.eval_color_expr(a, a_span.as_ref())?;
1899                    let to = self.eval_color_expr(b, b_span.as_ref())?;
1900                    let g = ensure_gradient(&mut s);
1901                    g.from = from;
1902                    g.to = to;
1903                    s.fill_open = true;
1904                }
1905                Attr::GradientAngle(e) => {
1906                    ensure_gradient(&mut s).angle = self.eval_expr(e)?;
1907                    s.fill_open = true;
1908                }
1909                Attr::Opacity(e) => {
1910                    let opacity = self.eval_expr(e)?;
1911                    if !(0.0..=1.0).contains(&opacity) {
1912                        return err("opacity must be between 0 and 1");
1913                    }
1914                    s.fill_opacity = Some(opacity);
1915                }
1916                Attr::Dim(DimKind::Thick, e) => s.thick = Some(self.eval_expr(e)?),
1917                // pikchr-flavoured `thin`: a lighter stroke, ⅔ of `linethick`.
1918                Attr::Thin => s.thick = Some(self.env.get(EnvVar::Linethick) * 2.0 / 3.0),
1919                Attr::Arrowhead(_, Some(e)) => {
1920                    s.arrow_filled = self.eval_expr(e)?.round() as i64 != 0;
1921                }
1922                _ => {}
1923            }
1924        }
1925        Ok(s)
1926    }
1927
1928    fn eval_color_expr(&mut self, se: &StringExpr, span: Option<&Span>) -> ER<String> {
1929        if let StringExpr::Lit(name) = se {
1930            // A bareword in colour position that names a variable resolves to
1931            // its value as a numeric colour (`c = 0xRRGGBB; … colored c`), so
1932            // colours can be held in variables and computed. Variables win over
1933            // a same-named macro; a bareword that is neither stays a literal
1934            // colour name (`colored crimson`), so existing sources are inert.
1935            if let Some(&v) = self.vars.get(name) {
1936                return num_to_color(v);
1937            }
1938            if let Some(body) = self.macros.get(name).cloned() {
1939                let s = if let Some(lit) = single_token_macro_string(&body) {
1940                    lit
1941                } else {
1942                    let parsed = crate::parser::parse_stringexpr_tokens(
1943                        &body,
1944                        &mut self.macros,
1945                        &self.includes,
1946                    )
1947                    .map_err(parse_eval_error)?;
1948                    self.eval_stringexpr(&parsed)?
1949                };
1950                return self.checked_color(normalize_color_string(s), span);
1951            }
1952        }
1953        let color = normalize_color_string(self.eval_stringexpr(se)?);
1954        self.checked_color(color, span)
1955    }
1956
1957    /// Reject active CSS/SVG paint forms, then warn (once) if a resolved colour
1958    /// string isn't a form any SVG renderer understands. Plain unknown names
1959    /// still pass through unchanged for dpic compatibility.
1960    fn checked_color(&mut self, color: String, span: Option<&Span>) -> ER<String> {
1961        if let Some(reason) = unsafe_svg_colour_reason(&color) {
1962            return err(reason);
1963        }
1964        if !crate::color::is_valid_color(&color) {
1965            let mut warning = Diagnostic::new(
1966                "invalid_color",
1967                format!("`{color}` is not a known colour name or hex/rgb() value"),
1968            )
1969            .found(color.clone())
1970            .expected("a CSS/xcolor colour name, #hex, or rgb(...)");
1971            if let Some(hint) = crate::color::suggest(&color) {
1972                warning = warning.hint(format!("did you mean `{hint}`?"));
1973            }
1974            if let Some(span) = span {
1975                warning = warning.at(span.clone());
1976            }
1977            self.warnings.push(warning);
1978        }
1979        Ok(color)
1980    }
1981
1982    fn text_of(&mut self, obj: &Object) -> ER<Vec<TextLine>> {
1983        Ok(self.text_and_fit_text_of(obj)?.0)
1984    }
1985
1986    fn text_and_fit_text_of(&mut self, obj: &Object) -> ER<(Vec<TextLine>, Option<Vec<TextLine>>)> {
1987        let mut lines: Vec<TextLine> = Vec::new();
1988        let mut fit_lines = None;
1989        let mut pending_halign = 0i8;
1990        let mut pending_valign = 0i8;
1991        // rpic font attributes bind like ljust/rjust: to the preceding string,
1992        // or — written before any string — to the next one.
1993        let mut pending_style = PendingStyle::default();
1994        for a in &obj.attrs {
1995            match a {
1996                Attr::TextPos(tp) => {
1997                    if let Some(line) = lines.last_mut() {
1998                        apply_text_pos(&mut line.halign, &mut line.valign, *tp);
1999                    } else {
2000                        apply_text_pos(&mut pending_halign, &mut pending_valign, *tp);
2001                    }
2002                }
2003                Attr::Bold => match lines.last_mut() {
2004                    Some(line) => line.bold = true,
2005                    None => pending_style.bold = true,
2006                },
2007                Attr::Italic => match lines.last_mut() {
2008                    Some(line) => line.italic = true,
2009                    None => pending_style.italic = true,
2010                },
2011                Attr::Mono => match lines.last_mut() {
2012                    Some(line) => line.family = Some("monospace".into()),
2013                    None => pending_style.family = Some("monospace".into()),
2014                },
2015                Attr::Font(se) => {
2016                    let family = self.eval_stringexpr(se)?;
2017                    match lines.last_mut() {
2018                        Some(line) => line.family = Some(family),
2019                        None => pending_style.family = Some(family),
2020                    }
2021                }
2022                Attr::FontSize(e) => {
2023                    let pt = self.eval_expr(e)?;
2024                    if !pt.is_finite() || pt <= 0.0 {
2025                        return err("fontsize must be a positive number of points");
2026                    }
2027                    match lines.last_mut() {
2028                        Some(line) => line.size_pt = Some(pt),
2029                        None => pending_style.size_pt = Some(pt),
2030                    }
2031                }
2032                Attr::Rotated(e) => {
2033                    let deg = self.eval_expr(e)?;
2034                    if !deg.is_finite() {
2035                        return err("rotated angle must be finite");
2036                    }
2037                    match lines.last_mut() {
2038                        Some(line) => line.rotate = Some(deg),
2039                        None => pending_style.rotate = Some(deg),
2040                    }
2041                }
2042                Attr::Aligned => match lines.last_mut() {
2043                    Some(line) => line.aligned = true,
2044                    None => pending_style.aligned = true,
2045                },
2046                Attr::Sized(big) => {
2047                    // pikchr big/small: 1.5× / 0.7× of the classic 11 pt
2048                    let pt = FONT_PT_CLASSIC * if *big { 1.5 } else { 0.7 };
2049                    match lines.last_mut() {
2050                        Some(line) => line.size_pt = Some(pt),
2051                        None => pending_style.size_pt = Some(pt),
2052                    }
2053                }
2054                Attr::Text(se) => {
2055                    let s = self.eval_stringexpr(se)?;
2056                    let math = self.math_span_for(&s);
2057                    lines.push(TextLine {
2058                        s,
2059                        math,
2060                        halign: pending_halign,
2061                        valign: pending_valign,
2062                        text_offset: self.env_dim(EnvVar::Textoffset)?,
2063                        bold: pending_style.bold,
2064                        italic: pending_style.italic,
2065                        family: pending_style.family.take(),
2066                        size_pt: pending_style.size_pt,
2067                        rotate: pending_style.rotate,
2068                        aligned: pending_style.aligned,
2069                    });
2070                    pending_halign = 0;
2071                    pending_valign = 0;
2072                    pending_style = PendingStyle::default();
2073                }
2074                Attr::Fit if fit_lines.is_none() => {
2075                    fit_lines = Some(lines.clone());
2076                }
2077                _ => {}
2078            }
2079        }
2080        Ok((lines, fit_lines))
2081    }
2082}
2083
2084mod build;
2085mod helpers;
2086mod resolve;
2087use helpers::*;
2088
2089#[cfg(test)]
2090mod tests;