Skip to main content

chartml_core/
element.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// The output of any ChartRenderer. Framework adapters walk this tree
6/// and produce framework-specific DOM/view output.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(tag = "type", rename_all = "camelCase")]
9pub enum ChartElement {
10    Svg {
11        viewbox: ViewBox,
12        width: Option<f64>,
13        height: Option<f64>,
14        class: String,
15        children: Vec<ChartElement>,
16    },
17    Group {
18        class: String,
19        transform: Option<Transform>,
20        children: Vec<ChartElement>,
21    },
22    Rect {
23        x: f64,
24        y: f64,
25        width: f64,
26        height: f64,
27        fill: String,
28        stroke: Option<String>,
29        /// Corner radius on the x axis. When `None`, no `rx` attribute is
30        /// emitted (preserving byte-identical output for un-themed charts).
31        /// Wired from `Theme::bar_corner_radius` in Phase 5 — bars emit
32        /// `Some(v)` when `bar_corner_radius` is `Uniform(v)` with `v > 0.0`,
33        /// else `None`. Top-only rounding is emitted as a `Path` instead of
34        /// a `Rect` (see `BarCornerRadius::Top`).
35        #[serde(default, skip_serializing_if = "Option::is_none")]
36        rx: Option<f64>,
37        /// Corner radius on the y axis. See `rx`.
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        ry: Option<f64>,
40        class: String,
41        data: Option<ElementData>,
42        /// CSS `transform-origin` anchor for entrance animations, in the
43        /// element's own coordinate space (absolute SVG coords). Populated
44        /// by bar emission sites in `chartml-chart-cartesian/src/bar.rs` so
45        /// the renderer never has to guess orientation/sign. `None` for
46        /// every non-bar Rect — the renderer falls back to its legacy
47        /// behavior, preserving byte-identical SVG output for all
48        /// pre-existing baselines (see `backward_compat_goldens_byte_identical`).
49        #[serde(default, skip_serializing_if = "Option::is_none")]
50        animation_origin: Option<(f64, f64)>,
51    },
52    #[serde(rename_all = "camelCase")]
53    Path {
54        d: String,
55        fill: Option<String>,
56        stroke: Option<String>,
57        stroke_width: Option<f64>,
58        stroke_dasharray: Option<String>,
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        stroke_dashoffset: Option<String>,
61        opacity: Option<f64>,
62        class: String,
63        data: Option<ElementData>,
64        /// See [`ChartElement::Rect::animation_origin`]. Populated by
65        /// `build_bar_element` for top-rounded bars (`BarCornerRadius::Top`),
66        /// which are emitted as `Path` instead of `Rect` because SVG `<rect>`
67        /// can't round only two corners. `None` for every other Path.
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        animation_origin: Option<(f64, f64)>,
70    },
71    Circle {
72        cx: f64,
73        cy: f64,
74        r: f64,
75        fill: String,
76        stroke: Option<String>,
77        class: String,
78        data: Option<ElementData>,
79    },
80    #[serde(rename_all = "camelCase")]
81    Line {
82        x1: f64,
83        y1: f64,
84        x2: f64,
85        y2: f64,
86        stroke: String,
87        stroke_width: Option<f64>,
88        stroke_dasharray: Option<String>,
89        class: String,
90    },
91    #[serde(rename_all = "camelCase")]
92    Text {
93        x: f64,
94        y: f64,
95        content: String,
96        anchor: TextAnchor,
97        dominant_baseline: Option<String>,
98        transform: Option<Transform>,
99        font_family: Option<String>,
100        font_size: Option<String>,
101        font_weight: Option<String>,
102        letter_spacing: Option<String>,
103        text_transform: Option<String>,
104        fill: Option<String>,
105        class: String,
106        data: Option<ElementData>,
107    },
108    /// Non-SVG container (e.g., metric card uses div-based layout)
109    Div {
110        class: String,
111        style: HashMap<String, String>,
112        children: Vec<ChartElement>,
113    },
114    /// Raw text node (for metric values, labels in div-based charts)
115    Span {
116        class: String,
117        style: HashMap<String, String>,
118        content: String,
119    },
120}
121
122/// Data attached to interactive elements for tooltips.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct ElementData {
125    pub label: String,
126    pub value: String,
127    pub series: Option<String>,
128    pub raw: HashMap<String, serde_json::Value>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ViewBox {
133    pub x: f64,
134    pub y: f64,
135    pub width: f64,
136    pub height: f64,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub enum Transform {
141    Translate(f64, f64),
142    Rotate(f64, f64, f64),
143    Multiple(Vec<Transform>),
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub enum TextAnchor {
148    Start,
149    Middle,
150    End,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct Dimensions {
155    pub width: Option<f64>,
156    pub height: f64,
157}
158
159impl ViewBox {
160    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
161        Self { x, y, width, height }
162    }
163
164    /// Format as SVG viewBox attribute string: "x y width height"
165    pub fn to_svg_string(&self) -> String {
166        format!("{} {} {} {}", self.x, self.y, self.width, self.height)
167    }
168}
169
170impl std::fmt::Display for ViewBox {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "{} {} {} {}", self.x, self.y, self.width, self.height)
173    }
174}
175
176impl Transform {
177    /// Format as SVG transform attribute string.
178    pub fn to_svg_string(&self) -> String {
179        match self {
180            Transform::Translate(x, y) => format!("translate({},{})", x, y),
181            Transform::Rotate(angle, cx, cy) => format!("rotate({},{},{})", angle, cx, cy),
182            Transform::Multiple(transforms) => {
183                transforms.iter().map(|t| t.to_svg_string()).collect::<Vec<_>>().join(" ")
184            }
185        }
186    }
187}
188
189impl std::fmt::Display for Transform {
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        write!(f, "{}", self.to_svg_string())
192    }
193}
194
195impl std::fmt::Display for TextAnchor {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            TextAnchor::Start => write!(f, "start"),
199            TextAnchor::Middle => write!(f, "middle"),
200            TextAnchor::End => write!(f, "end"),
201        }
202    }
203}
204
205impl ElementData {
206    pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
207        Self {
208            label: label.into(),
209            value: value.into(),
210            series: None,
211            raw: HashMap::new(),
212        }
213    }
214
215    pub fn with_series(mut self, series: impl Into<String>) -> Self {
216        self.series = Some(series.into());
217        self
218    }
219}
220
221impl Dimensions {
222    pub fn new(height: f64) -> Self {
223        Self { width: None, height }
224    }
225
226    pub fn with_width(mut self, width: f64) -> Self {
227        self.width = Some(width);
228        self
229    }
230}
231
232/// Count elements in the tree matching a predicate.
233pub fn count_elements<F>(element: &ChartElement, predicate: &F) -> usize
234where
235    F: Fn(&ChartElement) -> bool,
236{
237    let mut count = if predicate(element) { 1 } else { 0 };
238    match element {
239        ChartElement::Svg { children, .. }
240        | ChartElement::Group { children, .. }
241        | ChartElement::Div { children, .. } => {
242            for child in children {
243                count += count_elements(child, predicate);
244            }
245        }
246        _ => {}
247    }
248    count
249}
250
251// =============================================================================
252// TextStyle — role-based typography resolution from a Theme.
253// =============================================================================
254//
255// Theme typography fields are wired into `ChartElement::Text` via `TextStyle`.
256// Each text emission site picks a `TextRole` (axis-label / tick-value /
257// legend-label), builds a `TextStyle` from the active theme, and plugs the
258// resulting `Option<String>` fields into the `Text` literal.
259//
260// ## Backward-compatibility sentinels
261//
262// Phase 4 must preserve byte-identical SVG output for the `Theme::default()`
263// case. The pre-existing emission uses hardcoded values that differ from
264// `Theme::default()`'s typography fields (for example, the SVG serializer
265// stamps `font-family="Inter, Liberation Sans, Arial, sans-serif"` on every
266// `<text>` element regardless of what the `Text` element carries). The
267// "legacy sentinel" for each attribute is the value that would otherwise be
268// emitted today. `TextStyle` returns `None` for any attribute whose theme
269// value equals its legacy sentinel, ensuring the attribute is omitted (for
270// `font-family` / `letter-spacing` / `text-transform` / `font-weight`) or
271// restated unchanged (for `font-size`).
272
273/// Legacy hardcoded font-size string for axis labels and tick labels.
274///
275/// Companion constants for the other legacy typography values are not
276/// exported because they have no code consumers: the legacy font-family
277/// (`"Inter, Liberation Sans, Arial, sans-serif"`, owned by
278/// `chartml-render/src/svg.rs`) and legacy font-style (`"normal"`) are
279/// handled purely by the `Theme::default()` sentinel comparison in
280/// `TextStyle::for_role`, not by explicit constants.
281pub const LEGACY_LABEL_FONT_SIZE: &str = "12px";
282
283/// Legacy hardcoded font-size string for legend labels. Note this differs
284/// from `LEGACY_LABEL_FONT_SIZE` — the legend has historically used a
285/// slightly smaller size (11px) while all other chrome uses 12px.
286pub const LEGACY_LEGEND_FONT_SIZE: &str = "11px";
287
288/// Legacy font-weight for all chrome text. `None` because no current
289/// emission site sets `font-weight` at all — the attribute is absent from
290/// every pre-Phase-4 baseline SVG.
291pub const LEGACY_FONT_WEIGHT: u16 = 400;
292
293/// Role of a text element, used to select the right bundle of typography
294/// fields from the active `Theme`.
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum TextRole {
297    /// Axis titles and category/axis tick labels. Reads `label_*` fields.
298    AxisLabel,
299    /// Numeric tick labels. Reads `numeric_*` for family/size; inherits
300    /// `label_font_weight` / `label_letter_spacing` / `label_text_transform`.
301    TickValue,
302    /// Legend labels. Reads `legend_*` fields.
303    LegendLabel,
304}
305
306/// Resolved typography for a single `ChartElement::Text` literal. All fields
307/// are already prepared as `Option<String>` — plug them directly into the
308/// `Text` enum variant.
309#[derive(Debug, Clone)]
310pub struct TextStyle {
311    pub font_family: Option<String>,
312    pub font_size: Option<String>,
313    pub font_weight: Option<String>,
314    pub letter_spacing: Option<String>,
315    pub text_transform: Option<String>,
316}
317
318impl TextStyle {
319    /// Build a `TextStyle` for the given role from a `Theme`.
320    ///
321    /// Returns `Some(...)` only for attributes whose theme value diverges
322    /// from its legacy sentinel (see the module-level constants). This keeps
323    /// byte-identical output whenever `Theme::default()` is in effect.
324    pub fn for_role(theme: &crate::theme::Theme, role: TextRole) -> Self {
325        use crate::theme::{TextTransform, Theme};
326
327        let default_theme = Theme::default();
328
329        let (family, default_family, size_px, default_size_px, legacy_size) = match role {
330            TextRole::AxisLabel => (
331                &theme.label_font_family,
332                &default_theme.label_font_family,
333                theme.label_font_size,
334                default_theme.label_font_size,
335                LEGACY_LABEL_FONT_SIZE,
336            ),
337            TextRole::TickValue => (
338                &theme.numeric_font_family,
339                &default_theme.numeric_font_family,
340                theme.numeric_font_size,
341                default_theme.numeric_font_size,
342                LEGACY_LABEL_FONT_SIZE,
343            ),
344            TextRole::LegendLabel => (
345                &theme.legend_font_family,
346                &default_theme.legend_font_family,
347                theme.legend_font_size,
348                default_theme.legend_font_size,
349                LEGACY_LEGEND_FONT_SIZE,
350            ),
351        };
352        let weight = match role {
353            TextRole::AxisLabel | TextRole::TickValue => theme.label_font_weight,
354            TextRole::LegendLabel => theme.legend_font_weight,
355        };
356
357        // Axis-label, tick-value, and legend-label all inherit
358        // `label_letter_spacing` / `label_text_transform` (no legend-specific
359        // overrides per Phase 4 mapping).
360        let letter_spacing = theme.label_letter_spacing;
361        let text_transform = &theme.label_text_transform;
362
363        // Emit `font-family` only when the user has overridden the theme
364        // default. When the theme's family equals the default theme's family,
365        // omit the attribute so the SVG serializer's legacy hardcoded
366        // `Inter, Liberation Sans, Arial, sans-serif` path is preserved and
367        // baseline output stays byte-identical.
368        let font_family = if family == default_family {
369            None
370        } else {
371            Some(family.clone())
372        };
373
374        // Font size: `font-size` is always emitted today. To preserve
375        // byte-identical output for `Theme::default()`, when the theme's
376        // size equals the default (i.e. the user hasn't overridden it),
377        // emit the legacy hardcoded string — which differs from the default
378        // for the legend role (11px vs. 12.0 default).
379        let font_size = if (size_px - default_size_px).abs() < f32::EPSILON {
380            Some(legacy_size.to_string())
381        } else {
382            Some(format!("{}px", format_px(size_px)))
383        };
384
385        let font_weight = if weight == LEGACY_FONT_WEIGHT {
386            None
387        } else {
388            Some(weight.to_string())
389        };
390
391        // Exact-equals on f32 is safe here: the sentinel is the literal
392        // `0.0_f32` produced by `Theme::default()`, never the result of
393        // arithmetic. Same reasoning as the `size_px == default_size_px`
394        // sentinel above.
395        let letter_spacing = if letter_spacing == 0.0 {
396            None
397        } else {
398            Some(format_px(letter_spacing))
399        };
400
401        let text_transform = match text_transform {
402            TextTransform::None => None,
403            TextTransform::Uppercase => Some("uppercase".to_string()),
404            TextTransform::Lowercase => Some("lowercase".to_string()),
405        };
406
407        Self {
408            font_family,
409            font_size,
410            font_weight,
411            letter_spacing,
412            text_transform,
413        }
414    }
415}
416
417// =============================================================================
418// Dot halo helper (Phase 8) — outer ring behind dot markers.
419// =============================================================================
420//
421// Every dot marker in ChartML (scatter points, line endpoint markers, combo
422// dot markers) is a `ChartElement::Circle`. When `theme.dot_halo_color` is
423// `Some`, a halo is emitted BEFORE the dot so it renders underneath. The
424// halo is a stroke-based ring at the same radius as the dot, which means the
425// stroke extends half-outside the dot — producing a crisp visible outline.
426//
427// The helper returns `None` when `theme.dot_halo_color` is `None`, which
428// keeps byte-identical emission for the default theme (Phase 0 contract).
429//
430// The halo is emitted as a `ChartElement::Path` (not `Circle`) because
431// `Circle` has no `stroke_width` field — `Path` supports `stroke_width`,
432// `stroke`, and `fill` directly and renders the same visual result. The
433// emitted class is `dot-halo`, which Phase 9 CSS can target regardless of
434// underlying SVG tag.
435
436/// Emit a halo element to render BEFORE a dot marker, or `None` when
437/// `theme.dot_halo_color` is `None`. Uses `theme.dot_halo_width` as the
438/// stroke width. The halo radius always matches the dot's own radius so the
439/// ring hugs the dot — bubble charts pass the per-point radius here, not a
440/// static theme default.
441pub fn emit_dot_halo_if_enabled(
442    theme: &crate::theme::Theme,
443    cx: f64,
444    cy: f64,
445    r: f64,
446) -> Option<ChartElement> {
447    let color = theme.dot_halo_color.as_ref()?;
448    // SVG circle-as-path: move to (cx-r, cy), arc 180° to (cx+r, cy), arc
449    // 180° back. Two half-arcs form a full circle.
450    let d = format!(
451        "M {cx},{cy} m -{r},0 a {r},{r} 0 1,0 {d2},0 a {r},{r} 0 1,0 -{d2},0",
452        cx = cx,
453        cy = cy,
454        r = r,
455        d2 = 2.0 * r,
456    );
457    Some(ChartElement::Path {
458        d,
459        fill: None,
460        stroke: Some(color.clone()),
461        stroke_width: Some(theme.dot_halo_width as f64),
462        stroke_dasharray: None,
463        stroke_dashoffset: None,
464        opacity: None,
465        class: "dot-halo".to_string(),
466        data: None,
467        animation_origin: None,
468    })
469}
470
471/// Format a pixel value, preferring an integer rendering when the value has
472/// no fractional part. Mirrors the pre-Phase-4 emission of `"12px"`
473/// (not `"12.0px"`), which every baseline snapshot asserts.
474fn format_px(v: f32) -> String {
475    if v.fract() == 0.0 {
476        format!("{}", v as i64)
477    } else {
478        format!("{}", v)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    #![allow(clippy::unwrap_used)]
485    use super::*;
486
487    #[test]
488    fn viewbox_display() {
489        let vb = ViewBox::new(0.0, 0.0, 800.0, 400.0);
490        assert_eq!(vb.to_string(), "0 0 800 400");
491    }
492
493    #[test]
494    fn transform_translate_display() {
495        let t = Transform::Translate(10.0, 20.0);
496        assert_eq!(t.to_string(), "translate(10,20)");
497    }
498
499    #[test]
500    fn transform_rotate_display() {
501        let t = Transform::Rotate(45.0, 100.0, 200.0);
502        assert_eq!(t.to_string(), "rotate(45,100,200)");
503    }
504
505    #[test]
506    fn transform_multiple_display() {
507        let t = Transform::Multiple(vec![
508            Transform::Translate(10.0, 20.0),
509            Transform::Rotate(45.0, 0.0, 0.0),
510        ]);
511        assert_eq!(t.to_string(), "translate(10,20) rotate(45,0,0)");
512    }
513
514    #[test]
515    fn text_anchor_display() {
516        assert_eq!(TextAnchor::Start.to_string(), "start");
517        assert_eq!(TextAnchor::Middle.to_string(), "middle");
518        assert_eq!(TextAnchor::End.to_string(), "end");
519    }
520
521    #[test]
522    fn element_data_builder() {
523        let data = ElementData::new("Jan", "1234")
524            .with_series("Revenue");
525        assert_eq!(data.label, "Jan");
526        assert_eq!(data.value, "1234");
527        assert_eq!(data.series, Some("Revenue".to_string()));
528    }
529
530    #[test]
531    fn count_rects_in_tree() {
532        let tree = ChartElement::Svg {
533            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
534            width: Some(800.0),
535            height: Some(400.0),
536            class: "chart".to_string(),
537            children: vec![
538                ChartElement::Group {
539                    class: "bars".to_string(),
540                    transform: None,
541                    children: vec![
542                        ChartElement::Rect {
543                            x: 0.0, y: 0.0, width: 50.0, height: 100.0,
544                            fill: "red".to_string(), stroke: None,
545                            rx: None, ry: None,
546                            class: "bar".to_string(), data: None,
547                            animation_origin: None,
548                        },
549                        ChartElement::Rect {
550                            x: 60.0, y: 0.0, width: 50.0, height: 150.0,
551                            fill: "blue".to_string(), stroke: None,
552                            rx: None, ry: None,
553                            class: "bar".to_string(), data: None,
554                            animation_origin: None,
555                        },
556                    ],
557                },
558                ChartElement::Text {
559                    x: 400.0, y: 20.0, content: "Title".to_string(),
560                    anchor: TextAnchor::Middle, dominant_baseline: None,
561                    transform: None, font_family: None, font_size: None, font_weight: None,
562                    letter_spacing: None, text_transform: None, fill: None,
563                    class: "title".to_string(),
564                    data: None,
565                },
566            ],
567        };
568        let rect_count = count_elements(&tree, &|e| matches!(e, ChartElement::Rect { .. }));
569        assert_eq!(rect_count, 2);
570    }
571
572    #[test]
573    fn dimensions_builder() {
574        let dims = Dimensions::new(400.0).with_width(800.0);
575        assert_eq!(dims.height, 400.0);
576        assert_eq!(dims.width, Some(800.0));
577    }
578
579    #[test]
580    fn serde_round_trip_chart_element_tree() {
581        let tree = ChartElement::Svg {
582            viewbox: ViewBox::new(0.0, 0.0, 800.0, 400.0),
583            width: Some(800.0),
584            height: Some(400.0),
585            class: "chart".to_string(),
586            children: vec![
587                ChartElement::Group {
588                    class: "bars".to_string(),
589                    transform: Some(Transform::Translate(50.0, 10.0)),
590                    children: vec![
591                        ChartElement::Rect {
592                            x: 0.0,
593                            y: 0.0,
594                            width: 50.0,
595                            height: 100.0,
596                            fill: "red".to_string(),
597                            stroke: None,
598                            rx: None,
599                            ry: None,
600                            class: "bar".to_string(),
601                            data: Some(
602                                ElementData::new("Jan", "1234").with_series("Revenue"),
603                            ),
604                            animation_origin: None,
605                        },
606                        ChartElement::Path {
607                            d: "M0,0 L10,10".to_string(),
608                            fill: None,
609                            stroke: Some("blue".to_string()),
610                            stroke_width: Some(2.0),
611                            stroke_dasharray: Some("4,2".to_string()),
612                            stroke_dashoffset: None,
613                            opacity: Some(0.8),
614                            class: "line".to_string(),
615                            data: None,
616                            animation_origin: None,
617                        },
618                    ],
619                },
620                ChartElement::Line {
621                    x1: 0.0,
622                    y1: 0.0,
623                    x2: 100.0,
624                    y2: 100.0,
625                    stroke: "black".to_string(),
626                    stroke_width: Some(1.0),
627                    stroke_dasharray: None,
628                    class: "axis".to_string(),
629                },
630                ChartElement::Text {
631                    x: 400.0,
632                    y: 20.0,
633                    content: "Title".to_string(),
634                    anchor: TextAnchor::Middle,
635                    dominant_baseline: Some("central".to_string()),
636                    transform: Some(Transform::Rotate(45.0, 400.0, 20.0)),
637                    font_family: None,
638                    font_size: Some("14px".to_string()),
639                    font_weight: Some("bold".to_string()),
640                    letter_spacing: None,
641                    text_transform: None,
642                    fill: Some("black".to_string()),
643                    class: "title".to_string(),
644                    data: None,
645                },
646                ChartElement::Circle {
647                    cx: 50.0,
648                    cy: 50.0,
649                    r: 5.0,
650                    fill: "green".to_string(),
651                    stroke: None,
652                    class: "dot".to_string(),
653                    data: None,
654                },
655                ChartElement::Div {
656                    class: "metric-card".to_string(),
657                    style: HashMap::from([
658                        ("display".to_string(), "flex".to_string()),
659                    ]),
660                    children: vec![ChartElement::Span {
661                        class: "value".to_string(),
662                        style: HashMap::from([
663                            ("font-size".to_string(), "24px".to_string()),
664                        ]),
665                        content: "$1,234".to_string(),
666                    }],
667                },
668            ],
669        };
670
671        let json = serde_json::to_string(&tree).expect("serialize");
672        let deserialized: ChartElement =
673            serde_json::from_str(&json).expect("deserialize");
674
675        // Re-serialize to confirm structural equality
676        let json2 = serde_json::to_string(&deserialized).expect("re-serialize");
677        assert_eq!(json, json2);
678
679        // Verify the tag format is correct
680        let value: serde_json::Value =
681            serde_json::from_str(&json).expect("parse as Value");
682        assert_eq!(value["type"], "svg");
683        assert_eq!(value["children"][0]["type"], "group");
684        assert_eq!(value["children"][0]["children"][1]["type"], "path");
685        assert_eq!(
686            value["children"][0]["children"][1]["strokeWidth"],
687            serde_json::json!(2.0)
688        );
689        assert_eq!(
690            value["children"][2]["dominantBaseline"],
691            serde_json::json!("central")
692        );
693    }
694}