Skip to main content

rustyfi_backend/
graphics.rs

1//! The drawing data model — paths, colors, and `graphics` elements; the
2//! analog of upstream's `GraphicBase`/`PrePath`/`GraphicD`. Everything here is
3//! already-resolved coordinates/data: no lang-side closure or deferred
4//! computation crosses into this module.
5
6use crate::hbox::PureHorzBox;
7use crate::length::Length;
8
9/// A point in graphics space (upstream `point`; matches the runtime
10/// `Value::Tuple([Length, Length])` representation). Graphics space is
11/// y-**up** (PDF-native); the PDF writer's `place_graphics` flips
12/// page-layout's y-down convention when placing a graphics box on a line.
13pub type Point = (Length, Length);
14
15/// A dash pattern (`dashed-stroke`'s 2nd argument; upstream `graphicD.ml`'s
16/// `type dash = length * length * length`, `(d1, d2, d0)` = on-length,
17/// off-length, phase).
18pub type Dash = (Length, Length, Length);
19
20/// `color.satyh`'s `Gray`/`RGB`/`CMYK` after extraction by `as_color`
21/// (mirrors `evalUtil.ml`'s `get_color` → `DeviceGray`/`DeviceRGB`/
22/// `DeviceCMYK`).
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum Color {
25    Gray(f64),
26    Rgb(f64, f64, f64),
27    Cmyk(f64, f64, f64, f64),
28}
29
30/// One path element: a control-point-free straight segment, or a cubic
31/// Bézier (2 control points + destination) — `graphicBase.ml`'s
32/// `point path_element`.
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub enum PathSeg {
35    Line(Point),
36    Bezier(Point, Point, Point),
37}
38
39/// How a subpath closes (`graphicBase.ml`'s `path`'s `cycleopt`): left open,
40/// closed with a straight segment back to the start (`close-with-line`), or
41/// closed with a cubic (`close-with-bezier` — the destination is always the
42/// subpath's own `start`, so only the two control points are stored).
43#[derive(Clone, Copy, Debug, PartialEq)]
44pub enum Closing {
45    Open,
46    Line,
47    Bezier(Point, Point),
48}
49
50/// One `GraphicBase.GeneralPath(start, elems, closing)`.
51#[derive(Clone, Debug, PartialEq)]
52pub struct Subpath {
53    pub start: Point,
54    pub segs: Vec<PathSeg>,
55    pub closing: Closing,
56}
57
58/// The `path` value = upstream `path list` (`unite-path` appends subpath
59/// lists).
60#[derive(Clone, Debug, PartialEq)]
61pub struct Path {
62    pub subpaths: Vec<Subpath>,
63}
64
65/// The `pre-path` value (`PrePath.t`): a start point plus forward-accumulated
66/// segments, before a `terminate-path`/`close-with-line` fixes a closing.
67/// Upstream accumulates in reverse and flips at close time; this port pushes
68/// forward directly, which is unobservable.
69#[derive(Clone, Debug, PartialEq)]
70pub struct PrePath {
71    pub start: Point,
72    pub segs: Vec<PathSeg>,
73}
74
75/// One `graphics` element (`GraphicD.element`). `place_graphics`
76/// (rustyfi-pdf) matches this exhaustively, without a wildcard arm.
77///
78/// See [`crate::hbox::PureHorzBox`] for what the `#[subast]` list means and
79/// what checks it.
80#[derive(Clone, Debug, PartialEq, syan::visit::Ast)]
81#[subast(crate::graphics::GraphicsElem, crate::hbox::PureHorzBox)]
82pub enum GraphicsElem {
83    /// Filled region, even-odd rule (upstream's `op_f'`).
84    Fill(Color, Path),
85    /// Stroked outline at the given line width.
86    Stroke(Length, Color, Path),
87    /// Dashed stroked outline (`dashed-stroke`), rendered with a PDF `d`
88    /// dash-array op alongside the same stroke ops as `Stroke`.
89    DashedStroke(Length, Dash, Color, Path),
90    /// `draw-text`: a text run anchored at `pt` (box-local, y-up; the run's
91    /// leftmost baseline point). `contents` is the run laid out at NATURAL
92    /// width (upstream `LineBreak.natural` = `determine_widths None`,
93    /// `widperfil = 0`; here `fit_cell(boxes, natural_width)`), each box with
94    /// its x offset from `pt`. `width`/`height`/`depth` are the run's
95    /// `natural_metrics`, stored at construction so `graphics_bbox` needs no
96    /// re-measure. Rendered by each PDF writer re-entering its own per-box
97    /// emission at `pt + dx` INSIDE `place_graphics`'s box-local `cm` frame.
98    Text {
99        pt: Point,
100        contents: Vec<(Length, PureHorzBox)>,
101        width: Length,
102        height: Length,
103        depth: Length,
104        /// The accumulated 2×2 linear transform (`linear-transform-graphics`,
105        /// row-major `(a, b, c, d)` — same convention as
106        /// `linear_transform_point`) applied to the run about its local
107        /// origin BEFORE the `pt` translation. `None` means identity: the run
108        /// is drawn upright at `pt`. `Some` appears once
109        /// `rotate-graphics`/`scale-graphics` is composed onto a `draw-text`;
110        /// the writer then emits the run under a `cm` carrying this matrix
111        /// (upstream's lazy `LinearTrans` render-time `cm`).
112        transform: Option<(f64, f64, f64, f64)>,
113    },
114    /// 0.1 collection node (`GraphicD.concat`, dev-0-1-0 `graphicD.ml:23`):
115    /// `unite-graphics`' payload. No 0.0.6-visible primitive builds one, so it
116    /// is unreachable from 0.0.6 rendering by construction.
117    Group(Vec<GraphicsElem>),
118    /// 0.1 clip node (`GraphicD.make_clip`, `graphicD.ml:97-98`): render
119    /// `contents` clipped to `clip` (even-odd, `Op_W'` — `graphicD.ml:331`).
120    /// The port's `Path` already carries N subpaths, standing in for
121    /// upstream's `path list`. Never constructed by any 0.0.6 path, as `Group`.
122    Clip(Path, Vec<GraphicsElem>),
123    /// A DEFERRED `register-destination` call, carrying NO ink. `pt` is
124    /// box-local in the same y-**up** frame as every other element's
125    /// coordinates, so the existing transform pipeline carries it, and
126    /// `rustyfi-lang`'s `fire_hooks` replays it once the box has a page and a
127    /// placed point.
128    ///
129    /// It exists because this port applies an `inline-graphics` callback
130    /// eagerly at construction time (`prim_inline_graphics`) rather than during
131    /// page breaking as upstream does, so a `register-destination` inside one
132    /// has no page and `annotation.ml:15`'s gate — faithfully — refuses it.
133    ///
134    /// KNOWN GAP: `math_boxes_of_inline_boxes` (rustyfi-lang) harvests a
135    /// graphics box's elements into a `PureHorzBox::Math`'s `rules` and
136    /// `fire_hooks` has no `Math` arm, so an anchor inside a `make_paren`
137    /// delimiter closure never fires. `shift_graphics` carries the point
138    /// correctly, so closing it is one arm and no arithmetic.
139    Destination { key: String, pt: Point },
140}
141
142// `shift-path`/`shift-graphics`/`linear-transform-path`/
143// `linear-transform-graphics` are all EAGER point remaps — no lazy
144// `LinearTrans`-wrapper element: every point is rewritten up front, mirroring
145// `graphicBase.ml`'s `shift_path`/`linear_transform_path` (`(x, y) ->
146// (x*a + y*b, x*c + y*d)` for the 2x2 matrix `((a, b), (c, d))`).
147
148/// `shift_path v pt` (`graphicBase.ml`'s `(+@%)`).
149fn shift_point(v: Point, pt: Point) -> Point {
150    (pt.0 + v.0, pt.1 + v.1)
151}
152
153/// `graphicBase.ml`'s `linear_transform_point`: `(x, y) |-> (x*a + y*b, x*c +
154/// y*d)` for matrix `mat = (a, b, c, d)`.
155fn linear_transform_point(mat: (f64, f64, f64, f64), pt: Point) -> Point {
156    let (a, b, c, d) = mat;
157    (pt.0 * a + pt.1 * b, pt.0 * c + pt.1 * d)
158}
159
160/// Map `f` over every point of `path` (subpath starts, every segment's
161/// points — including Bézier control points — and any closing control
162/// points), preserving structure.
163fn map_path(path: &Path, f: impl Fn(Point) -> Point) -> Path {
164    Path {
165        subpaths: path
166            .subpaths
167            .iter()
168            .map(|sub| Subpath {
169                start: f(sub.start),
170                segs: sub
171                    .segs
172                    .iter()
173                    .map(|seg| match *seg {
174                        PathSeg::Line(p) => PathSeg::Line(f(p)),
175                        PathSeg::Bezier(c1, c2, p) => PathSeg::Bezier(f(c1), f(c2), f(p)),
176                    })
177                    .collect(),
178                closing: match sub.closing {
179                    Closing::Open => Closing::Open,
180                    Closing::Line => Closing::Line,
181                    Closing::Bezier(c1, c2) => Closing::Bezier(f(c1), f(c2)),
182                },
183            })
184            .collect(),
185    }
186}
187
188/// `shift-path : point -> path -> path` (vminst.ml:663) — translate every
189/// point of `path` by `v`.
190pub fn shift_path(v: Point, path: &Path) -> Path {
191    map_path(path, |p| shift_point(v, p))
192}
193
194/// `linear-transform-path : float -> float -> float -> float -> path ->
195/// path` (vminst.ml:678) — apply the 2x2 matrix `mat` to every point.
196pub fn linear_transform_path(mat: (f64, f64, f64, f64), path: &Path) -> Path {
197    map_path(path, |p| linear_transform_point(mat, p))
198}
199
200/// One block frame's own decoration, captured at its natural size so a
201/// backend with no page grid can still draw it.
202///
203/// The graphics are BOX-LOCAL (origin at the frame's bottom-left, y up) and
204/// span `width` x `height`, which is what lets a reflowable renderer scale
205/// them to whatever width the reader's window gives the frame. `fire_hooks`
206/// records one per `DecoId` the first time that frame fires as a SINGLE
207/// fragment (`decoS`); a frame split across pages has no whole-frame drawing
208/// and records nothing.
209#[derive(Clone, Debug, PartialEq)]
210pub struct FrameDecoration {
211    pub width: crate::Length,
212    pub height: crate::Length,
213    /// The frame's own paddings, `(left, right, top, bottom)`.
214    ///
215    /// Three of the four are already visible in the flow — `indent_left`
216    /// folds `left` into every contained line's x offset, and the two
217    /// vertical ones arrive as `VertBox::FramePad` — so a reflowing renderer
218    /// needs only `right`, which nothing else records: the content is simply
219    /// laid out narrower, and once the frame is redrawn at the reader's own
220    /// width a right-aligned line lands on its border.
221    pub pads: (crate::Length, crate::Length, crate::Length, crate::Length),
222    pub elems: Vec<GraphicsElem>,
223}
224
225/// `shift-graphics : point -> graphics -> graphics` (vminst.ml:2451) —
226/// `graphicD.ml`'s `shift_element`.
227pub fn shift_graphics(v: Point, elem: &GraphicsElem) -> GraphicsElem {
228    match elem {
229        GraphicsElem::Fill(c, p) => GraphicsElem::Fill(*c, shift_path(v, p)),
230        GraphicsElem::Stroke(w, c, p) => GraphicsElem::Stroke(*w, *c, shift_path(v, p)),
231        GraphicsElem::DashedStroke(w, d, c, p) => {
232            GraphicsElem::DashedStroke(*w, *d, *c, shift_path(v, p))
233        }
234        GraphicsElem::Text { pt, contents, width, height, depth, transform } => {
235            GraphicsElem::Text {
236                pt: shift_point(v, *pt),
237                contents: contents.clone(),
238                width: *width,
239                height: *height,
240                depth: *depth,
241                // A pure translation leaves the run's own 2×2 transform intact
242                // (only `pt` moves) — the affine is `transform·l + pt`.
243                transform: *transform,
244            }
245        }
246        // `graphicD.ml:38`: `Group` maps every child; `Clip` shifts its own
247        // clip path AND recurses into its contents.
248        GraphicsElem::Group(gs) => {
249            GraphicsElem::Group(gs.iter().map(|g| shift_graphics(v, g)).collect())
250        }
251        GraphicsElem::Clip(path, gs) => GraphicsElem::Clip(
252            shift_path(v, path),
253            gs.iter().map(|g| shift_graphics(v, g)).collect(),
254        ),
255        // The anchor point is an ordinary box-local coordinate: it moves with
256        // the ink around it.
257        GraphicsElem::Destination { key, pt } => GraphicsElem::Destination {
258            key: key.clone(),
259            pt: shift_point(v, *pt),
260        },
261    }
262}
263
264/// `linear-transform-graphics : float -> float -> float -> float ->
265/// graphics -> graphics` (vminst.ml:2432) — `graphicD.ml`'s
266/// `make_linear_trans`, applied eagerly.
267pub fn linear_transform_graphics(mat: (f64, f64, f64, f64), elem: &GraphicsElem) -> GraphicsElem {
268    match elem {
269        GraphicsElem::Fill(c, p) => GraphicsElem::Fill(*c, linear_transform_path(mat, p)),
270        GraphicsElem::Stroke(w, c, p) => GraphicsElem::Stroke(*w, *c, linear_transform_path(mat, p)),
271        GraphicsElem::DashedStroke(w, d, c, p) => {
272            GraphicsElem::DashedStroke(*w, *d, *c, linear_transform_path(mat, p))
273        }
274        // A `draw-text` run carries the composed 2×2 matrix so the writer can
275        // rotate/scale the glyphs/image at render time (upstream's lazy
276        // `LinearTrans` `cm`). The affine is `transform·l + pt`; pre-composing
277        // `mat` gives `mat·(transform·l + pt) = (mat·transform)·l + mat·pt`, so
278        // `transform ↦ mat·transform` and `pt ↦ mat·pt`. Matrices are row-major
279        // `(a, b, c, d)` = `[[a, b], [c, d]]` (the `linear_transform_point`
280        // convention), so the product below is the standard 2×2 multiply.
281        GraphicsElem::Text { pt, contents, width, height, depth, transform } => {
282            let (ma, mb, mc, md) = mat;
283            let (ta, tb, tc, td) = transform.unwrap_or((1.0, 0.0, 0.0, 1.0));
284            let composed = (
285                ma * ta + mb * tc,
286                ma * tb + mb * td,
287                mc * ta + md * tc,
288                mc * tb + md * td,
289            );
290            GraphicsElem::Text {
291                pt: linear_transform_point(mat, *pt),
292                contents: contents.clone(),
293                width: *width,
294                height: *height,
295                depth: *depth,
296                transform: Some(composed),
297            }
298        }
299        GraphicsElem::Group(gs) => GraphicsElem::Group(
300            gs.iter().map(|g| linear_transform_graphics(mat, g)).collect(),
301        ),
302        GraphicsElem::Clip(path, gs) => GraphicsElem::Clip(
303            linear_transform_path(mat, path),
304            gs.iter().map(|g| linear_transform_graphics(mat, g)).collect(),
305        ),
306        // As in `shift_graphics`: an ordinary box-local coordinate.
307        GraphicsElem::Destination { key, pt } => GraphicsElem::Destination {
308            key: key.clone(),
309            pt: linear_transform_point(mat, *pt),
310        },
311    }
312}
313
314/// One axis (x or y) of a cubic Bézier's EXACT extrema (`graphicBase.ml:88`
315/// `bezier_bbox`'s per-axis `aux`): for the cubic from `r0` (current point)
316/// through controls `r1`, `r2` to `r3`, the derivative's roots give the
317/// interior extrema; candidates are `{r0, r3, B(t+), B(t-)}` with `t` clamped
318/// to `[0, 1]` (`bezier_point`'s convention: `t < 0` snaps to `r0`, `t > 1`
319/// snaps to `r3`). Returns `(min, max)` over that candidate set.
320fn bezier_axis_extent(r0: f64, r1: f64, r2: f64, r3: f64) -> (f64, f64) {
321    // B(t) = (1-t)^3 r0 + 3(1-t)^2 t r1 + 3(1-t) t^2 r2 + t^3 r3
322    // B'(t)/3 = a t^2 + b t + c, with:
323    let a = -r0 + 3.0 * (r1 - r2) + r3;
324    let b = 2.0 * (r0 - 2.0 * r1 + r2);
325    let c = r1 - r0;
326    let bezier_point = |t: f64| -> f64 {
327        if t < 0.0 {
328            r0
329        } else if t > 1.0 {
330            r3
331        } else {
332            let u = 1.0 - t;
333            u * u * u * r0 + 3.0 * u * u * t * r1 + 3.0 * u * t * t * r2 + t * t * t * r3
334        }
335    };
336    let mut candidates = vec![r0, r3];
337    if a.abs() < 1e-12 {
338        // Linear derivative (or degenerate): at most one root, `-c/b`.
339        if b.abs() > 1e-12 {
340            candidates.push(bezier_point(-c / b));
341        }
342    } else {
343        let disc = b * b - 4.0 * a * c;
344        if disc >= 0.0 {
345            let sq = disc.sqrt();
346            candidates.push(bezier_point((-b + sq) / (2.0 * a)));
347            candidates.push(bezier_point((-b - sq) / (2.0 * a)));
348        }
349    }
350    let min = candidates.iter().cloned().fold(f64::INFINITY, f64::min);
351    let max = candidates.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
352    (min, max)
353}
354
355/// `get_path_bbox`/`bezier_bbox` (`graphicBase.ml:88-127,148-171`) — the
356/// EXACT bounding box of `path`: walks each subpath tracking the current
357/// point (`start`; each `Line` contributes its endpoint; each
358/// `Bezier(c1,c2,p)` contributes the cubic extrema of `(cur, c1, c2, p)`; a
359/// `Closing::Bezier(c1,c2)` contributes the extrema of `(cur, c1, c2,
360/// start)`), taking each axis's true curve extent via
361/// `bezier_axis_extent` rather than the (looser) control-point hull.
362pub fn path_bbox(path: &Path) -> (Point, Point) {
363    fn include(bounds: &mut (f64, f64, f64, f64), p: Point) {
364        bounds.0 = bounds.0.min(p.0 .0);
365        bounds.1 = bounds.1.max(p.0 .0);
366        bounds.2 = bounds.2.min(p.1 .0);
367        bounds.3 = bounds.3.max(p.1 .0);
368    }
369    fn include_axis_extents(bounds: &mut (f64, f64, f64, f64), ex: (f64, f64), ey: (f64, f64)) {
370        bounds.0 = bounds.0.min(ex.0);
371        bounds.1 = bounds.1.max(ex.1);
372        bounds.2 = bounds.2.min(ey.0);
373        bounds.3 = bounds.3.max(ey.1);
374    }
375    // (min_x, max_x, min_y, max_y).
376    let mut bounds = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY);
377    for sub in &path.subpaths {
378        include(&mut bounds, sub.start);
379        let mut cur = sub.start;
380        for seg in &sub.segs {
381            match *seg {
382                PathSeg::Line(p) => {
383                    include(&mut bounds, p);
384                    cur = p;
385                }
386                PathSeg::Bezier(c1, c2, p) => {
387                    let ex = bezier_axis_extent(cur.0 .0, c1.0 .0, c2.0 .0, p.0 .0);
388                    let ey = bezier_axis_extent(cur.1 .0, c1.1 .0, c2.1 .0, p.1 .0);
389                    include_axis_extents(&mut bounds, ex, ey);
390                    cur = p;
391                }
392            }
393        }
394        if let Closing::Bezier(c1, c2) = sub.closing {
395            let ex = bezier_axis_extent(cur.0 .0, c1.0 .0, c2.0 .0, sub.start.0 .0);
396            let ey = bezier_axis_extent(cur.1 .0, c1.1 .0, c2.1 .0, sub.start.1 .0);
397            include_axis_extents(&mut bounds, ex, ey);
398        }
399    }
400    let (min_x, max_x, min_y, max_y) = bounds;
401    if min_x.is_infinite() {
402        return ((Length::ZERO, Length::ZERO), (Length::ZERO, Length::ZERO));
403    }
404    (
405        (Length(min_x), Length(min_y)),
406        (Length(max_x), Length(max_y)),
407    )
408}
409
410fn union_bbox((amin, amax): (Point, Point), (bmin, bmax): (Point, Point)) -> (Point, Point) {
411    (
412        (
413            Length(amin.0 .0.min(bmin.0 .0)),
414            Length(amin.1 .0.min(bmin.1 .0)),
415        ),
416        (
417            Length(amax.0 .0.max(bmax.0 .0)),
418            Length(amax.1 .0.max(bmax.1 .0)),
419        ),
420    )
421}
422
423/// `get-graphics-bbox : graphics -> point * point` (v0.0.6 vminst.ml:2466) /
424/// `graphics -> option (point * point)` (dev-0-1-0 vminst.ml:2301, the
425/// "version-blind fix") — `graphicD.ml`'s `get_bbox`/`get_element_bbox`,
426/// ignoring stroke thickness (upstream's own documented simplification).
427/// `Clip(paths, _)` returns the CLIP PATHS' own bbox, ignoring `contents`
428/// (upstream `graphicD.ml:50-52` — deliberate: the clip boundary, not what is
429/// inside it, bounds the visible ink). `Group` union-folds its children
430/// (`graphicD.ml:61-74`); `None` for an empty `Group` or an empty top-level
431/// list, which v0.0.6 could never produce.
432pub fn graphics_bbox(elem: &GraphicsElem) -> Option<(Point, Point)> {
433    match elem {
434        GraphicsElem::Fill(_, p)
435        | GraphicsElem::Stroke(_, _, p)
436        | GraphicsElem::DashedStroke(_, _, _, p) => Some(path_bbox(p)),
437        GraphicsElem::Text { pt, width, height, depth, transform, .. } => {
438            match transform {
439                // Upright run: the axis-aligned `[0,width]×[-depth, height]`
440                // extent translated to `pt`.
441                None => Some(((pt.0, pt.1 - *depth), (pt.0 + *width, pt.1 + *height))),
442                // Rotated/scaled run: transform the four local corners, translate
443                // by `pt`, take the axis-aligned hull — so a `rotate`d figbox
444                // reserves the correct (rotated) inline size.
445                Some(mat) => {
446                    let corners = [
447                        (Length::ZERO, -*depth),
448                        (*width, -*depth),
449                        (*width, *height),
450                        (Length::ZERO, *height),
451                    ];
452                    let mut min = (f64::INFINITY, f64::INFINITY);
453                    let mut max = (f64::NEG_INFINITY, f64::NEG_INFINITY);
454                    for c in corners {
455                        let t = linear_transform_point(*mat, c);
456                        let (x, y) = (t.0 .0 + pt.0 .0, t.1 .0 + pt.1 .0);
457                        min = (min.0.min(x), min.1.min(y));
458                        max = (max.0.max(x), max.1.max(y));
459                    }
460                    Some((
461                        (Length(min.0), Length(min.1)),
462                        (Length(max.0), Length(max.1)),
463                    ))
464                }
465            }
466        }
467        GraphicsElem::Clip(path, _) => Some(path_bbox(path)),
468        GraphicsElem::Group(gs) => gs
469            .iter()
470            .filter_map(graphics_bbox)
471            .reduce(union_bbox),
472        // No ink: an anchor must not inflate its box's bbox (it is typically a
473        // `0pt 0pt 0pt` `inline-graphics`).
474        GraphicsElem::Destination { .. } => None,
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481
482    fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Path {
483        Path {
484            subpaths: vec![Subpath {
485                start: (Length(x0), Length(y0)),
486                segs: vec![
487                    PathSeg::Line((Length(x1), Length(y0))),
488                    PathSeg::Line((Length(x1), Length(y1))),
489                    PathSeg::Line((Length(x0), Length(y1))),
490                ],
491                closing: Closing::Line,
492            }],
493        }
494    }
495
496    /// Over a `Clip`/`Group` both move the clip path AND the contents
497    /// (the `graphicD.ml:38` recursing-arm contract).
498    #[test]
499    fn shift_and_transform_recurse_into_clip_and_group() {
500        let fill = GraphicsElem::Fill(Color::Gray(0.0), rect(0.0, 0.0, 1.0, 1.0));
501        let group = GraphicsElem::Group(vec![fill.clone(), fill.clone()]);
502        let shifted_group = shift_graphics((Length(2.0), Length(3.0)), &group);
503        match &shifted_group {
504            GraphicsElem::Group(gs) => {
505                assert_eq!(gs.len(), 2);
506                for g in gs {
507                    assert_eq!(
508                        graphics_bbox(g),
509                        Some(((Length(2.0), Length(3.0)), (Length(3.0), Length(4.0))))
510                    );
511                }
512            }
513            other => panic!("expected Group, got {other:?}"),
514        }
515
516        let clip = GraphicsElem::Clip(rect(0.0, 0.0, 5.0, 5.0), vec![fill.clone()]);
517        let shifted_clip = shift_graphics((Length(1.0), Length(1.0)), &clip);
518        match &shifted_clip {
519            GraphicsElem::Clip(path, inner) => {
520                assert_eq!(
521                    path_bbox(path),
522                    ((Length(1.0), Length(1.0)), (Length(6.0), Length(6.0)))
523                );
524                assert_eq!(
525                    graphics_bbox(&inner[0]),
526                    Some(((Length(1.0), Length(1.0)), (Length(2.0), Length(2.0))))
527                );
528            }
529            other => panic!("expected Clip, got {other:?}"),
530        }
531
532        // `linear-transform-graphics` (scale by 2 on both axes) also
533        // recurses into both the clip path AND the contents.
534        let scaled_clip = linear_transform_graphics((2.0, 0.0, 0.0, 2.0), &clip);
535        match &scaled_clip {
536            GraphicsElem::Clip(path, inner) => {
537                assert_eq!(
538                    path_bbox(path),
539                    ((Length(0.0), Length(0.0)), (Length(10.0), Length(10.0)))
540                );
541                assert_eq!(
542                    graphics_bbox(&inner[0]),
543                    Some(((Length(0.0), Length(0.0)), (Length(2.0), Length(2.0))))
544                );
545            }
546            other => panic!("expected Clip, got {other:?}"),
547        }
548    }
549
550    /// `get-graphics-bbox` `Option` semantics: an empty `Group` has no
551    /// ink and returns `None`; a `Group` of two fills union-folds; a `Clip`
552    /// returns the CLIP PATH's own bbox, ignoring `contents`.
553    #[test]
554    fn bbox_option_semantics() {
555        assert_eq!(graphics_bbox(&GraphicsElem::Group(vec![])), None);
556
557        let a = GraphicsElem::Fill(Color::Gray(0.0), rect(0.0, 0.0, 1.0, 1.0));
558        let b = GraphicsElem::Fill(Color::Gray(0.0), rect(2.0, 2.0, 3.0, 3.0));
559        let group = GraphicsElem::Group(vec![a.clone(), b.clone()]);
560        assert_eq!(
561            graphics_bbox(&group),
562            Some(((Length(0.0), Length(0.0)), (Length(3.0), Length(3.0))))
563        );
564
565        let clip = GraphicsElem::Clip(rect(10.0, 10.0, 20.0, 20.0), vec![a]);
566        assert_eq!(
567            graphics_bbox(&clip),
568            Some(((Length(10.0), Length(10.0)), (Length(20.0), Length(20.0))))
569        );
570    }
571}