rpic_core/ir.rs
1//! Intermediate representation: the placed-primitive tree produced by the
2//! evaluator and consumed by the render backends. All coordinates are absolute,
3//! in pic units (inches), y pointing up.
4
5use crate::diagnostic::Diagnostic;
6use crate::geom::{Bbox, Point};
7
8/// A fully evaluated drawing.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Drawing {
11 pub shapes: Vec<Shape>,
12 /// Render layer per shape. Lower layers are emitted first; equal layers keep
13 /// source order and shape ids stable.
14 pub shape_layers: Vec<i32>,
15 /// rpic extension: CSS class hook per shape, emitted on the shape's
16 /// `<g id="sN">` group. Inert unless the host document styles it; `None`
17 /// keeps the group byte-identical to classic output.
18 pub shape_classes: Vec<Option<String>>,
19 /// rpic extension: hyperlink per shape. When set, the shape's
20 /// `<g id="sN">` group is wrapped in `<a href="…">` in the SVG output;
21 /// raster/PDF backends ignore it (usvg flattens `<a>` to a group). `None`
22 /// keeps the output byte-identical to classic pic.
23 pub shape_links: Vec<Option<String>>,
24 /// Source span of the statement that produced each shape (`None` for
25 /// shapes without one). Drives the per-object geometry export; never
26 /// affects rendering.
27 pub shape_spans: Vec<Option<crate::diagnostic::Span>>,
28 pub bbox: Bbox,
29 /// Global `linethick` in points after picture-wide sizing, used only for
30 /// dpic-style backend prelude padding. Per-shape strokes keep their own
31 /// unscaled point thickness.
32 pub prelude_thick: f64,
33 /// Extra canvas whitespace in inches. This is an rpic extension inspired by
34 /// Pikchr: it affects native backend framing only, not pic geometry.
35 pub canvas_margin: CanvasMargin,
36 /// rpic extension: a fixed page rectangle (`canvas from … to …`) in model
37 /// space. When set, the SVG viewBox is derived from it instead of the
38 /// content bounds; content outside is clipped by the viewBox.
39 pub canvas: Option<Bbox>,
40 pub anims: Vec<Anim>,
41 /// rpic extension (`draggable`): objects the host should make interactively
42 /// draggable (GSAP Draggable). Surfaced in the compile bundle as top-level
43 /// `interactions`; empty for the vast majority of drawings.
44 pub interactions: Vec<Interaction>,
45 /// rpic extension (`animate scroll`): a timeline-level hint that the host
46 /// should scrub the animation on scroll rather than autoplay. Surfaced in
47 /// the compile bundle as top-level `scroll`; the host wires ScrollTrigger.
48 pub anim_scroll: bool,
49 /// Lines emitted by pic `print` statements, without trailing newlines.
50 pub diagnostics: Vec<String>,
51 /// Non-fatal compiler warnings for accepted but likely unintended input.
52 pub warnings: Vec<Diagnostic>,
53}
54
55/// Extra whitespace around the rendered canvas, in internal inches.
56#[derive(Debug, Clone, Copy, PartialEq, Default)]
57pub struct CanvasMargin {
58 pub top: f64,
59 pub right: f64,
60 pub bottom: f64,
61 pub left: f64,
62}
63
64impl CanvasMargin {
65 pub fn horizontal(self) -> f64 {
66 self.left + self.right
67 }
68
69 pub fn vertical(self) -> f64 {
70 self.top + self.bottom
71 }
72
73 pub fn scale_by(&mut self, factor: f64) {
74 self.top *= factor;
75 self.right *= factor;
76 self.bottom *= factor;
77 self.left *= factor;
78 }
79}
80
81/// A resolved animation entry. `shape` indexes into [`Drawing::shapes`]; the
82/// SVG backend gives that shape the id `s{shape}` so the player can target it.
83#[derive(Debug, Clone, PartialEq)]
84pub struct Anim {
85 pub shape: usize,
86 pub effect: String,
87 /// Absolute start time in seconds.
88 pub start: f64,
89 pub duration: f64,
90 /// GSAP `repeat`: `-1` loops forever, `0` plays once.
91 pub repeat: i64,
92 /// GSAP `yoyo`: alternate direction each repeat.
93 pub yoyo: bool,
94 /// GSAP easing name overriding the per-effect default, if given.
95 pub ease: Option<String>,
96 /// For the `move` effect: index of the shape whose geometry to follow.
97 /// The SVG backend gives it the id `s{path}`, the MotionPath target.
98 pub path: Option<usize>,
99 /// For the `highlight` effect: the CSS target colour (`to <colour>`).
100 pub color: Option<String>,
101 /// Play as an exit (reverse) rather than an entrance.
102 pub out: bool,
103 /// For the `slide` effect: the direction it enters from
104 /// (`"left"`/`"right"`/`"up"`/`"down"`).
105 pub from: Option<String>,
106 /// For the `morph` effect: index of the shape whose geometry to morph into.
107 /// The SVG backend gives it the id `s{morph}`, the MorphSVG target.
108 pub morph: Option<usize>,
109 /// For the `type` effect: split the label by whole words (`by word`) rather
110 /// than by character. The SVG backend wraps each unit in a `.rpic-ch` tspan
111 /// the player staggers.
112 pub type_word: bool,
113 /// For the `scramble` effect: a custom scramble charset (`by "01"`); `None`
114 /// uses the plugin's `upperCase`. Present in the manifest only when set.
115 pub scramble_chars: Option<String>,
116 /// For the `wiggle` effect: the oscillation count (`wiggles <n>`); `None`
117 /// lets the player default it. Present in the manifest only when set.
118 pub wiggles: Option<i64>,
119 /// For the `draw` effect: reveal only the `[from,to]` sub-segment of the
120 /// stroke, as fractions in `[0,1]` (`draw from 40% to 60%`). Each rides the
121 /// manifest only when set; absent means the segment's natural endpoint.
122 pub draw_from: Option<f64>,
123 pub draw_to: Option<f64>,
124}
125
126/// A `draggable` interaction (rpic extension): the host makes shape `s{shape}`
127/// grabbable via GSAP Draggable. Interaction, not a timeline effect, so it
128/// rides its own `interactions` manifest, not `animations`.
129#[derive(Debug, Clone, PartialEq)]
130pub struct Interaction {
131 pub shape: usize,
132 /// Throw with momentum (GSAP InertiaPlugin).
133 pub inertia: bool,
134 /// Constrain dragging to another shape's box (`s{bounds}`).
135 pub bounds: Option<usize>,
136 /// Axis lock: `"x"` or `"y"`; `None` drags freely.
137 pub axis: Option<&'static str>,
138}
139
140/// Line dash style.
141#[derive(Debug, Clone, Copy, PartialEq, Default)]
142pub enum Dash {
143 #[default]
144 Solid,
145 /// Dash/gap base length in inches.
146 Dashed(f64),
147 /// Explicit dot spacing in inches; `None` means dpic's stroke-relative default.
148 Dotted(Option<f64>),
149}
150
151/// Fill specification.
152#[derive(Debug, Clone, PartialEq)]
153pub enum Fill {
154 /// Gray level, pic convention: 0 = black, 1 = white.
155 Gray(f64),
156 /// A named/explicit color.
157 Color(String),
158}
159
160/// Hatch fill pattern.
161#[derive(Debug, Clone, PartialEq)]
162pub struct Hatch {
163 pub cross: bool,
164 /// Line angle in degrees, measured in pic coordinates.
165 pub angle: f64,
166 /// Distance between hatch lines in inches.
167 pub sep: f64,
168 /// Hatch stroke width in points.
169 pub width: f64,
170 pub color: String,
171}
172
173/// Two-stop linear gradient fill (rpic extension, PSTricks-inspired).
174#[derive(Debug, Clone, PartialEq)]
175pub struct Gradient {
176 pub from: String,
177 pub to: String,
178 /// Angle in degrees, measured in pic coordinates: 0 = left to right,
179 /// 90 = bottom to top (matching how `hatchangle` measures).
180 pub angle: f64,
181}
182
183/// Visual style shared by all shapes.
184#[derive(Debug, Clone, PartialEq)]
185pub struct Style {
186 /// Stroke color (CSS), or `None` to use the default.
187 pub stroke: Option<String>,
188 pub fill: Option<Fill>,
189 pub hatch: Option<Hatch>,
190 pub gradient: Option<Gradient>,
191 /// Fill opacity, applied only to filled or hatched regions.
192 pub fill_opacity: Option<f64>,
193 /// Whether open paths/splines/arcs should emit a filled area. `color` on an
194 /// open object only changes the stroke; `fill` and `shaded` fill.
195 pub fill_open: bool,
196 pub dash: Dash,
197 /// Stroke thickness in points; `None` = backend default.
198 pub thick: Option<f64>,
199 /// Invisible (used by `move` and `invis`): geometry is still available for
200 /// placement and anchors, but is not drawn.
201 pub invis: bool,
202 /// Invisible geometry that still contributes to output bounds. Dpic uses
203 /// this for `move`; explicit `invis` helpers remain bounds-neutral.
204 pub invis_bounds: bool,
205 /// Arrowhead dimensions in inches (`arrowht`/`arrowwid`), used when this
206 /// shape carries arrowheads.
207 pub arrow_ht: f64,
208 pub arrow_wid: f64,
209 /// Filled (solid triangle, `arrowhead=2`) vs open (two strokes,
210 /// `arrowhead=0`).
211 pub arrow_filled: bool,
212}
213
214impl Default for Style {
215 fn default() -> Self {
216 Style {
217 stroke: None,
218 fill: None,
219 hatch: None,
220 gradient: None,
221 fill_opacity: None,
222 fill_open: false,
223 dash: Dash::Solid,
224 thick: None,
225 invis: false,
226 invis_bounds: false,
227 arrow_ht: 0.1,
228 arrow_wid: 0.05,
229 arrow_filled: true,
230 }
231 }
232}
233
234/// Which ends of a path carry an arrowhead.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
236pub enum Arrowheads {
237 #[default]
238 None,
239 Start,
240 End,
241 Both,
242}
243
244/// A line of attached text with placement hints.
245#[derive(Debug, Clone, PartialEq)]
246pub struct TextLine {
247 pub s: String,
248 /// rpic `texlabels` extension: when set, this line is a typeset math
249 /// formula and `s` keeps the original literal for fallback/diagnostics.
250 pub math: Option<crate::math::MathSpan>,
251 /// horizontal: -1 = ljust, 0 = center, +1 = rjust.
252 pub halign: i8,
253 /// vertical: +1 = above, 0 = center, -1 = below.
254 pub valign: i8,
255 /// Extra text-position offset in inches (`textoffset`).
256 pub text_offset: f64,
257 /// rpic extension: bold face (`bold`).
258 pub bold: bool,
259 /// rpic extension: italic face (`italic`).
260 pub italic: bool,
261 /// rpic extension: font family — `Some("monospace")` from `mono`, or the
262 /// family given to `font "…"`. `None` follows the root `<svg>` family.
263 pub family: Option<String>,
264 /// rpic extension: explicit size in points (`fontsize`); `None` keeps
265 /// classic sizing (11 pt attached, height-derived standalone).
266 pub size_pt: Option<f64>,
267 /// rpic extension: rotation in degrees, CCW (`rotated`); applied about
268 /// the line's anchor point in the SVG.
269 pub rotate: Option<f64>,
270 /// rpic extension: `aligned` — rotate to the host segment's angle. Set
271 /// during text collection; the linear-object eval resolves it into
272 /// `rotate` once the segment's start/end are known.
273 pub aligned: bool,
274}
275
276/// Classic label size in points — the baseline `fontsize` scales against.
277pub(crate) const FONT_PT_CLASSIC: f64 = 11.0;
278
279// ---- shared text metrics (#291) ---------------------------------------------
280// The single source of truth for label sizing, consumed by BOTH the evaluator
281// (layout/canvas bbox) and the SVG backend (rendered ink bounds). They must
282// agree, or label bounds silently desync from label geometry — keep any font
283// sizing change here, never in a per-module copy.
284
285/// dpic's x-height : em ratio.
286pub(crate) const DP_TEXT_RATIO: f64 = 0.66;
287/// The classic label em in inches (11 pt at 72 pt/in).
288pub(crate) const TEXT_EM_IN: f64 = FONT_PT_CLASSIC / 72.0;
289/// Estimated average glyph advance, as a fraction of the em.
290pub(crate) const TEXT_CHAR_W_RATIO: f64 = 0.6;
291/// Line height, as a fraction of the em.
292pub(crate) const TEXT_LINE_H_RATIO: f64 = 1.2;
293
294impl TextLine {
295 /// Width scale vs. the classic 11 pt regular estimate: explicit
296 /// `fontsize` scales linearly; bold glyphs run ~5% wider.
297 pub(crate) fn width_factor(&self) -> f64 {
298 let size = self.size_pt.map_or(1.0, |pt| pt / FONT_PT_CLASSIC);
299 let weight = if self.bold { 1.05 } else { 1.0 };
300 size * weight
301 }
302
303 /// Height scale (explicit `fontsize` vs. classic 11 pt).
304 pub(crate) fn height_factor(&self) -> f64 {
305 self.size_pt.map_or(1.0, |pt| pt / FONT_PT_CLASSIC)
306 }
307
308 /// Estimated ink width in inches: a math span's measured width, or
309 /// chars × average advance × the size/weight factor — the one estimator
310 /// behind the evaluator's layout bbox and the SVG backend's ink bounds.
311 pub(crate) fn ink_width_in(&self) -> f64 {
312 match &self.math {
313 Some(m) => m.width,
314 None => {
315 self.s.chars().count() as f64 * TEXT_CHAR_W_RATIO * TEXT_EM_IN * self.width_factor()
316 }
317 }
318 }
319}
320
321/// A placed drawing primitive.
322#[derive(Debug, Clone, PartialEq)]
323pub enum Shape {
324 Box {
325 c: Point,
326 w: f64,
327 h: f64,
328 rad: f64,
329 style: Style,
330 text: Vec<TextLine>,
331 },
332 Circle {
333 c: Point,
334 r: f64,
335 style: Style,
336 text: Vec<TextLine>,
337 },
338 Ellipse {
339 c: Point,
340 w: f64,
341 h: f64,
342 style: Style,
343 text: Vec<TextLine>,
344 },
345 /// Straight polyline (line / arrow / move).
346 Path {
347 pts: Vec<Point>,
348 /// rpic extension: this path was closed with the `close` attribute.
349 closed: bool,
350 arrows: Arrowheads,
351 style: Style,
352 text: Vec<TextLine>,
353 },
354 Spline {
355 pts: Vec<Point>,
356 /// dpic spline tension. `None` = the classic pic quadratic B-spline
357 /// (straight first/last half-segments, tangent at segment midpoints);
358 /// `Some(t)` = dpic's tensioned cubic spline through `t`.
359 tension: Option<f64>,
360 arrows: Arrowheads,
361 style: Style,
362 text: Vec<TextLine>,
363 },
364 Arc {
365 c: Point,
366 r: f64,
367 /// start and end angles in radians.
368 a0: f64,
369 a1: f64,
370 cw: bool,
371 arrows: Arrowheads,
372 style: Style,
373 text: Vec<TextLine>,
374 },
375 Brace {
376 a: Point,
377 b: Point,
378 cubics: Vec<[Point; 4]>,
379 label_at: Point,
380 style: Style,
381 text: Vec<TextLine>,
382 },
383 Text {
384 at: Point,
385 text: Vec<TextLine>,
386 bbox: Bbox,
387 w: f64,
388 h: f64,
389 standalone: bool,
390 },
391}
392
393impl Shape {
394 /// Whether the shape paints anything. `false` for `move`/`invis` helpers
395 /// (their `style.invis` is set) and empty text. Used by `animate … stagger`
396 /// to fan only across a block's visible children, skipping spines.
397 pub fn is_visible(&self) -> bool {
398 match self {
399 Shape::Box { style, .. }
400 | Shape::Circle { style, .. }
401 | Shape::Ellipse { style, .. }
402 | Shape::Path { style, .. }
403 | Shape::Spline { style, .. }
404 | Shape::Arc { style, .. }
405 | Shape::Brace { style, .. } => !style.invis,
406 Shape::Text { text, .. } => !text.is_empty(),
407 }
408 }
409}