Skip to main content

blockworx/canvas/
svg.rs

1//! SVG export backend.
2//!
3//! [`SvgRenderer`] implements the [`Renderer`] trait,
4//! so the exact same render path that draws the diagram on screen can be pointed
5//! at it to produce a standalone `.svg`. Geometry has a single source of truth;
6//! this module only translates the primitive calls into SVG nodes (built with
7//! the `svg` crate). Like the on-screen [`Painter`](crate::canvas::painter::Painter)
8//! it holds a [`Palette`] and resolves each [`Swatch`] to a color at draw time —
9//! the canvas never sees an app-level role.
10//!
11//! Text layout comes from epaint — the very engine that lays the diagram out on
12//! screen — driven through a standalone [`Fonts`] built from
13//! the same [`FontDefinitions`](egui::FontDefinitions) the app installs. Shaping,
14//! kerning and line breaking are therefore identical to the screen's, so an
15//! export never drifts from what the user was looking at. Only rasterization is
16//! ours: each glyph the galley places is traced to a filled `<path>` via
17//! `ttf-parser`, which leaves the output free of any font dependency.
18//!
19//! A galley records the *character* under each glyph but not the glyph the
20//! shaper chose, so ligatures and other substitutions are unrecoverable from it
21//! alone; each run is therefore re-shaped here — with epaint's own shaper, over
22//! the same bytes — for identity only. See [`SvgRenderer::identify`].
23//!
24//! Coordinates are world space and written out directly (no zoom/pan); the
25//! final `<svg viewBox>` frames whatever was drawn. Layout runs at
26//! `pixels_per_point = 1.0` so no display DPI is baked into a resolution-
27//! independent format.
28
29use std::cell::{Cell, RefCell};
30use std::mem;
31use std::sync::Arc;
32
33use blockworx_doc::block_model::Asset;
34use blockworx_geom::{Align2, Pos2, Rect, Vec2, WorldPx, vec2};
35use blockworx_paint::{Color, Font};
36use egui::Stroke;
37use egui::epaint::text::Glyph;
38use egui::text::{Fonts, Galley};
39use svg::Document;
40use svg::node::Node;
41use svg::node::element::path::Data;
42use svg::node::element::{Circle, Group, Image, Line, Path, Polygon, Polyline, Rectangle};
43
44use blockworx_paint::{FontChoice, Palette, PaletteStroke, Renderer, Swatch, extent::Bounds};
45
46use crate::canvas::egui_compat::{IntoEgui as _, IntoGeom as _};
47/// SVG is resolution independent, so layout must not inherit a display's DPI.
48const PIXELS_PER_POINT: f32 = 1.0;
49
50pub struct SvgRenderer {
51    palette: Palette,
52    face: ttf_parser::Face<'static>,
53    units_per_em: f32,
54    /// The same bytes `face` wraps, seen through epaint's font reader — what
55    /// [`Self::shape`] builds its shaper over.
56    font_ref: skrifa::FontRef<'static>,
57    /// The shaper's parsed layout tables. Cached per face, as epaint caches
58    /// them: building a `Shaper` from them is cheap, parsing them is not.
59    shaper_data: harfrust::ShaperData,
60    /// Layout engine, shared with the on-screen path. Laying out needs `&mut`,
61    /// hence the cell; the font atlas it fills along the way is never read.
62    fonts: RefCell<Fonts>,
63    /// The document under construction; draw calls `append` nodes to it.
64    doc: RefCell<Document>,
65    /// Running world-space bounding box of everything drawn so far.
66    bounds: Bounds,
67    /// One [`Identity::ByChar`] warning per export, not per run.
68    warned: Cell<bool>,
69}
70
71impl SvgRenderer {
72    /// `font` is the active UI font choice: its definitions lay the text out and
73    /// its outlines draw it, so the export matches the on-screen typeface.
74    #[expect(clippy::expect_used)]
75    pub fn new(palette: Palette, font: FontChoice) -> Self {
76        let bytes = font.bytes();
77        let face = ttf_parser::Face::parse(bytes, 0).expect("embedded font is valid");
78        let units_per_em = face.units_per_em() as f32;
79        let font_ref = skrifa::FontRef::from_index(bytes, 0).expect("embedded font is valid");
80        let shaper_data = harfrust::ShaperData::new(&font_ref);
81        let fonts = Fonts::new(
82            egui::epaint::text::TextOptions::default(),
83            crate::font::build_fonts(font),
84        );
85        Self {
86            palette,
87            face,
88            units_per_em,
89            font_ref,
90            shaper_data,
91            fonts: RefCell::new(fonts),
92            doc: RefCell::new(Document::new()),
93            bounds: Bounds::default(),
94            warned: Cell::new(false),
95        }
96    }
97
98    /// Resolve a [`Swatch`] to a concrete color through the palette.
99    fn color(&self, swatch: impl Into<Swatch>) -> Color {
100        self.palette.resolve(swatch.into())
101    }
102
103    /// Resolve a palette-based stroke to a concrete egui `Stroke` (color resolved
104    /// through the palette).
105    fn resolve_stroke(&self, stroke: impl Into<PaletteStroke>) -> Stroke {
106        let s = stroke.into();
107        Stroke::new(s.width.get(), self.color(s.color).egui())
108    }
109
110    /// Finalize the `<svg>` root: set its `viewBox`/size to frame the content
111    /// (with a small margin) and serialize it, with the viewBox rect — the
112    /// exact world-space region the SVG depicts, for callers that place the
113    /// output back into world coordinates (the PDF export's page fit).
114    pub fn finish(self) -> (String, Rect) {
115        const MARGIN: f32 = crate::grid::GRID_SIZE;
116        let view = self
117            .bounds
118            .get()
119            .map_or(Rect::from_min_size(Pos2::ZERO, vec2(1.0, 1.0)), |r| {
120                r.expand(MARGIN)
121            });
122        let svg = self
123            .doc
124            .into_inner()
125            .set(
126                "viewBox",
127                (view.min.x, view.min.y, view.width(), view.height()),
128            )
129            .set("width", view.width())
130            .set("height", view.height())
131            .to_string();
132        (svg, view)
133    }
134
135    fn append(&self, node: impl Into<Box<dyn Node>>) {
136        self.doc.borrow_mut().append(node);
137    }
138
139    /// Lay `text` out through epaint at [`PIXELS_PER_POINT`], word-wrapped to
140    /// `max_width`.
141    fn layout(&self, text: String, font: &Font, max_width: WorldPx) -> Arc<Galley> {
142        self.fonts
143            .borrow_mut()
144            .with_pixels_per_point(PIXELS_PER_POINT)
145            .layout(
146                text,
147                font.egui(),
148                Color::PLACEHOLDER.egui(),
149                max_width.get(),
150            )
151    }
152
153    /// Append each glyph of `galley`, anchored at world `pos`, as a filled
154    /// `<path>`. When `angle` is non-zero the run is wrapped in a `rotate(...)`
155    /// group about `pos`, matching egui's rotated-text behaviour. Returns the
156    /// unrotated anchored bounding rect.
157    // Mirrors egui's `Painter::text(pos, anchor, text, font, color)`, extended with
158    // the rotation this canvas needs. Grouping these into a struct would make
159    // every call site diverge from the API it wraps.
160    #[expect(clippy::too_many_arguments)]
161    fn draw_galley(
162        &self,
163        pos: Pos2,
164        anchor: Align2,
165        galley: &Galley,
166        font: &Font,
167        color: Color,
168        angle: f32,
169    ) -> Rect {
170        let scale = font.size / self.units_per_em;
171        let rect = anchor.anchor_size(pos, galley.size().geom());
172
173        let mut glyphs: Vec<Path> = Vec::new();
174        // epaint shapes one paragraph at a time, so re-shape by paragraph too:
175        // `ends_with_newline` closes one, whatever wrapping did to its rows.
176        for paragraph in galley.rows.split_inclusive(|row| row.ends_with_newline) {
177            let run: Vec<Placed<'_>> = paragraph
178                .iter()
179                .flat_map(|row| {
180                    row.glyphs.iter().map(|glyph| Placed {
181                        pen: rect.min + row.pos.to_vec2().geom() + glyph.pos.to_vec2().geom(),
182                        glyph,
183                    })
184                })
185                .collect();
186            for (placed, gid) in run.iter().zip(self.identify(&run)) {
187                let Some(gid) = gid else {
188                    continue;
189                };
190                let mut builder = GlyphPath {
191                    data: Data::new(),
192                    ox: placed.pen.x,
193                    baseline: placed.pen.y,
194                    scale,
195                };
196                if self.face.outline_glyph(gid, &mut builder).is_some() && !builder.data.is_empty()
197                {
198                    let mut path = Path::new().set("d", builder.data);
199                    apply_fill(&mut path, color);
200                    glyphs.push(path);
201                }
202            }
203        }
204
205        if !glyphs.is_empty() {
206            if angle == 0.0 {
207                for path in glyphs {
208                    self.append(path);
209                }
210            } else {
211                let mut group = Group::new().set(
212                    "transform",
213                    format!("rotate({} {} {})", angle.to_degrees(), pos.x, pos.y),
214                );
215                for path in glyphs {
216                    group = group.add(path);
217                }
218                self.append(group);
219                // Rotated extent is awkward to compute exactly; the anchor point
220                // and the unrotated rect together bound it well enough.
221                self.bounds.point(pos);
222            }
223            self.bounds.rect(rect);
224        }
225        rect
226    }
227
228    /// Which outline each glyph of `run` draws, in order; `None` draws nothing.
229    ///
230    /// A galley records the *character* behind each glyph, never the glyph the
231    /// shaper picked, so `fi` in a font with `liga` arrives as a ligature filed
232    /// under `'f'` plus a continuation glyph for `'i'` — and outlining that
233    /// `'i'` by cmap stacks it on the next character. Re-shaping recovers the
234    /// identity the galley dropped; the positions stay the galley's.
235    fn identify(&self, run: &[Placed<'_>]) -> Vec<Option<ttf_parser::GlyphId>> {
236        let text: String = run.iter().map(|p| p.glyph.chr).collect();
237        let clusters: Vec<Cluster> = run.iter().map(|p| Cluster::of(p.glyph)).collect();
238        match Identity::zip(&self.shape(&text), &clusters) {
239            Identity::Shaped(ids) => ids,
240            Identity::ByChar => {
241                if !self.warned.replace(true) {
242                    tracing::warn!(
243                        text,
244                        "export: re-shaping disagreed with the layout; \
245                         glyph identity falls back to the character map, \
246                         which draws ligature clusters stacked"
247                    );
248                }
249                run.iter()
250                    .map(|p| self.face.glyph_index(p.glyph.chr))
251                    .collect()
252            }
253        }
254    }
255
256    /// Shape one paragraph the way epaint's `shape_text` shapes a single-face
257    /// run: same engine, same font bytes, same features and flags, so the
258    /// glyphs chosen here are the glyphs the canvas rasterized.
259    fn shape(&self, text: &str) -> Vec<ttf_parser::GlyphId> {
260        let shaper = self.shaper_data.shaper(&self.font_ref).build();
261        let mut buffer = harfrust::UnicodeBuffer::new();
262        buffer.set_flags(
263            harfrust::BufferFlags::BEGINNING_OF_TEXT | harfrust::BufferFlags::END_OF_TEXT,
264        );
265        buffer.push_str(text);
266        buffer.guess_segment_properties();
267        shaper
268            .shape(buffer, harfrust::ShapeOptions::new())
269            .glyph_infos()
270            .iter()
271            // harfrust guarantees a shaped `glyph_id` fits in a `u16`.
272            .map(|info| ttf_parser::GlyphId(info.glyph_id as u16))
273            .collect()
274    }
275}
276
277/// A galley glyph with its pen position already resolved into world space.
278struct Placed<'a> {
279    pen: Pos2,
280    glyph: &'a Glyph,
281}
282
283/// A galley glyph's place in its shaping cluster. epaint keeps one glyph per
284/// *character* so cursor arithmetic stays simple, padding a many-to-one
285/// substitution with zero-advance continuations — so a row can hold more glyphs
286/// than the shaper produced.
287#[derive(Clone, Copy, PartialEq, Eq, Debug)]
288enum Cluster {
289    /// Carries the cluster's advance, and so the cluster's shaped glyph.
290    Head,
291    /// Padding for a character folded into the head's glyph; draws nothing.
292    Continuation,
293}
294
295impl Cluster {
296    fn of(glyph: &Glyph) -> Self {
297        if glyph.advance_width > 0.0 {
298            Self::Head
299        } else {
300            Self::Continuation
301        }
302    }
303}
304
305/// Where a run's glyph identity came from.
306enum Identity {
307    /// One entry per galley glyph, zipped onto the shaper's output in visual
308    /// order: the glyph to outline, or `None` at a continuation, whose cluster
309    /// is already drawn at its head.
310    Shaped(Vec<Option<ttf_parser::GlyphId>>),
311    /// The shaper and the layout disagree on how many glyphs the run holds, so
312    /// there is nothing to zip. The caller falls back to a per-character
313    /// lookup: wrong for ligature clusters, but it never drops text.
314    ByChar,
315}
316
317impl Identity {
318    fn zip(shaped: &[ttf_parser::GlyphId], clusters: &[Cluster]) -> Self {
319        if shaped.len() != clusters.iter().filter(|c| **c == Cluster::Head).count() {
320            return Self::ByChar;
321        }
322        let mut shaped = shaped.iter();
323        Self::Shaped(
324            clusters
325                .iter()
326                .map(|cluster| match cluster {
327                    Cluster::Head => shaped.next().copied(),
328                    Cluster::Continuation => None,
329                })
330                .collect(),
331        )
332    }
333}
334
335impl Renderer for SvgRenderer {
336    fn rect(
337        &self,
338        rect: Rect,
339        rounding: WorldPx,
340        fill: impl Into<Swatch>,
341        stroke: impl Into<PaletteStroke>,
342    ) {
343        let stroke = stroke.into();
344        self.bounds.stroked(rect, &stroke);
345        let stroke = self.resolve_stroke(stroke);
346        let mut el = Rectangle::new()
347            .set("x", rect.min.x)
348            .set("y", rect.min.y)
349            .set("width", rect.width())
350            .set("height", rect.height())
351            .set("rx", rounding.get());
352        apply_fill(&mut el, self.color(fill));
353        apply_stroke(&mut el, stroke);
354        self.append(el);
355    }
356
357    fn line_segment(&self, points: [Pos2; 2], stroke: impl Into<PaletteStroke>) {
358        let stroke = self.resolve_stroke(stroke);
359        self.bounds.path(&points);
360        let mut el = Line::new()
361            .set("x1", points[0].x)
362            .set("y1", points[0].y)
363            .set("x2", points[1].x)
364            .set("y2", points[1].y)
365            .set("stroke-linecap", "round");
366        apply_stroke(&mut el, stroke);
367        self.append(el);
368    }
369
370    fn line(&self, points: Vec<Pos2>, stroke: impl Into<PaletteStroke>) {
371        let stroke = self.resolve_stroke(stroke);
372        self.bounds.path(&points);
373        let mut el = Polyline::new()
374            .set("points", point_list(&points))
375            .set("fill", "none")
376            .set("stroke-linecap", "round")
377            .set("stroke-linejoin", "round");
378        apply_stroke(&mut el, stroke);
379        self.append(el);
380    }
381
382    fn circle(
383        &self,
384        center: Pos2,
385        radius: WorldPx,
386        fill: impl Into<Swatch>,
387        stroke: impl Into<PaletteStroke>,
388    ) {
389        let stroke = stroke.into();
390        self.bounds.disc(center, radius, &stroke);
391        let stroke = self.resolve_stroke(stroke);
392        let mut el = Circle::new()
393            .set("cx", center.x)
394            .set("cy", center.y)
395            .set("r", radius.get());
396        apply_fill(&mut el, self.color(fill));
397        apply_stroke(&mut el, stroke);
398        self.append(el);
399    }
400
401    fn add_convex_polygon(
402        &self,
403        points: Vec<Pos2>,
404        fill: impl Into<Swatch>,
405        stroke: impl Into<PaletteStroke>,
406    ) {
407        let stroke = self.resolve_stroke(stroke);
408        self.bounds.path(&points);
409        let mut el = Polygon::new().set("points", point_list(&points));
410        apply_fill(&mut el, self.color(fill));
411        apply_stroke(&mut el, stroke);
412        self.append(el);
413    }
414
415    fn text(
416        &self,
417        pos: Pos2,
418        anchor: Align2,
419        text: impl ToString,
420        font: &Font,
421        color: impl Into<Swatch>,
422    ) -> Rect {
423        let color = self.color(color);
424        let galley = self.layout(text.to_string(), font, WorldPx::UNBOUNDED);
425        self.draw_galley(pos, anchor, &galley, font, color, 0.0)
426    }
427
428    fn rotated_text(
429        &self,
430        pos: Pos2,
431        anchor: Align2,
432        text: impl ToString,
433        font: &Font,
434        color: impl Into<Swatch>,
435        angle: f32,
436    ) {
437        let color = self.color(color);
438        let galley = self.layout(text.to_string(), font, WorldPx::UNBOUNDED);
439        self.draw_galley(pos, anchor, &galley, font, color, angle);
440    }
441
442    fn text_wrapped(
443        &self,
444        pos: Pos2,
445        anchor: Align2,
446        text: impl ToString,
447        font: &Font,
448        color: impl Into<Swatch>,
449        max_width: WorldPx,
450    ) -> Rect {
451        let color = self.color(color);
452        let galley = self.layout(text.to_string(), font, max_width);
453        self.draw_galley(pos, anchor, &galley, font, color, 0.0)
454    }
455
456    fn text_size(&self, text: impl ToString, font: &Font) -> Vec2 {
457        self.layout(text.to_string(), font, WorldPx::UNBOUNDED)
458            .size()
459            .geom()
460    }
461
462    fn text_size_wrapped(&self, text: impl ToString, font: &Font, max_width: WorldPx) -> Vec2 {
463        self.layout(text.to_string(), font, max_width).size().geom()
464    }
465
466    fn draw_image(&self, rect: Rect, image: &Asset) {
467        use base64::Engine as _;
468        self.bounds.rect(rect);
469        // Embed the image as a base64 data URI so the export is self-contained,
470        // with the MIME type matching the payload. `rect` is already
471        // aspect-correct; `meet` keeps the image centered and undistorted within
472        // it.
473        let mime = match image {
474            Asset::Svg(_) => "image/svg+xml",
475            Asset::Png(_) => "image/png",
476        };
477        let href = format!(
478            "data:{mime};base64,{}",
479            base64::engine::general_purpose::STANDARD.encode(image.bytes())
480        );
481        let el = Image::new()
482            .set("x", rect.min.x)
483            .set("y", rect.min.y)
484            .set("width", rect.width())
485            .set("height", rect.height())
486            .set("preserveAspectRatio", "xMidYMid meet")
487            .set("href", href.as_str());
488        self.append(el);
489    }
490
491    fn image_intrinsic_size(&self, image: &Asset) -> Option<Vec2> {
492        crate::canvas::image::image_intrinsic_size(image).ok()
493    }
494}
495
496/// The leftmost x of every glyph `<path>` in an exported SVG, in draw order —
497/// enough to tell whether a run's glyphs march rightwards or stack. Lives here
498/// so the exporter's own tests and the end-to-end render-path tests read the
499/// output the same way; the exporter never reads what it writes.
500#[cfg(test)]
501pub(crate) fn glyph_pen_xs(svg: &str) -> Vec<f32> {
502    svg.match_indices("d=\"")
503        .map(|(at, _)| {
504            let d = &svg[at + 3..];
505            let d = &d[..d.find('"').expect("a path's d attribute is closed")];
506            d.split([' ', ','])
507                .filter_map(|t| {
508                    t.trim_start_matches(char::is_alphabetic)
509                        .parse::<f32>()
510                        .ok()
511                })
512                .step_by(2)
513                .fold(f32::INFINITY, f32::min)
514        })
515        .collect()
516}
517
518/// Builds a `<path>` `d` from a glyph outline, applying the world-space
519/// transform (pen origin, baseline, scale) and the font's Y-up → SVG Y-down
520/// flip as it goes.
521struct GlyphPath {
522    data: Data,
523    ox: f32,
524    baseline: f32,
525    scale: f32,
526}
527
528impl GlyphPath {
529    fn tx(&self, x: f32) -> f32 {
530        self.ox + x * self.scale
531    }
532    fn ty(&self, y: f32) -> f32 {
533        self.baseline - y * self.scale
534    }
535}
536
537impl ttf_parser::OutlineBuilder for GlyphPath {
538    fn move_to(&mut self, x: f32, y: f32) {
539        let p = (self.tx(x), self.ty(y));
540        self.data = mem::take(&mut self.data).move_to(p);
541    }
542    fn line_to(&mut self, x: f32, y: f32) {
543        let p = (self.tx(x), self.ty(y));
544        self.data = mem::take(&mut self.data).line_to(p);
545    }
546    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
547        let p = (self.tx(x1), self.ty(y1), self.tx(x), self.ty(y));
548        self.data = mem::take(&mut self.data).quadratic_curve_to(p);
549    }
550    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
551        let p = (
552            self.tx(x1),
553            self.ty(y1),
554            self.tx(x2),
555            self.ty(y2),
556            self.tx(x),
557            self.ty(y),
558        );
559        self.data = mem::take(&mut self.data).cubic_curve_to(p);
560    }
561    fn close(&mut self) {
562        self.data = mem::take(&mut self.data).close();
563    }
564}
565
566/// Build the `points` attribute value for a polyline/polygon. `Vec<(f32, f32)>`
567/// serializes to `"x y x y …"`, which SVG accepts.
568fn point_list(points: &[Pos2]) -> Vec<(f32, f32)> {
569    points.iter().map(|p| (p.x, p.y)).collect()
570}
571
572fn color_hex(c: Color) -> String {
573    let [r, g, b, _] = c.to_srgba_unmultiplied();
574    format!("#{r:02x}{g:02x}{b:02x}")
575}
576
577/// Set `fill`/`fill-opacity`, or `fill="none"` when fully transparent.
578fn apply_fill(node: &mut impl Node, c: Color) {
579    let [.., a] = c.to_srgba_unmultiplied();
580    if a == 0 {
581        node.assign("fill", "none");
582        return;
583    }
584    node.assign("fill", color_hex(c));
585    if a != 255 {
586        node.assign("fill-opacity", a as f32 / 255.0);
587    }
588}
589
590/// Set `stroke`/`stroke-width`(+opacity); no-op when there's no visible stroke.
591fn apply_stroke(node: &mut impl Node, s: Stroke) {
592    let color = s.color.geom();
593    let [.., a] = color.to_srgba_unmultiplied();
594    if s.width <= 0.0 || a == 0 {
595        return;
596    }
597    node.assign("stroke", color_hex(color));
598    node.assign("stroke-width", s.width);
599    if a != 255 {
600        node.assign("stroke-opacity", a as f32 / 255.0);
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use blockworx_paint::Base;
608
609    fn px(v: f32) -> WorldPx {
610        WorldPx::new(v)
611    }
612
613    fn palette() -> Palette {
614        Palette::tokyo_night_moon()
615    }
616
617    fn renderer() -> SvgRenderer {
618        SvgRenderer::new(palette(), FontChoice::Sketchy)
619    }
620
621    fn draw(choice: FontChoice, text: &str) -> Vec<f32> {
622        let r = SvgRenderer::new(palette(), choice);
623        r.text(Pos2::ZERO, Align2::LEFT_TOP, text, &font(), Base::B00);
624        let (svg, _) = r.finish();
625        glyph_pen_xs(&svg)
626    }
627
628    fn assert_marches_right(xs: &[f32], what: &str) {
629        assert!(xs.len() > 1, "{what}: expected glyphs, got {xs:?}");
630        for pair in xs.windows(2) {
631            assert!(
632                pair[0] < pair[1],
633                "{what}: glyphs must march rightwards, got {xs:?}",
634            );
635        }
636    }
637
638    #[test]
639    fn a_ligature_draws_once_and_its_cluster_does_not_stack() {
640        // Roboto's `liga` shapes "fi" into a single glyph. epaint files it under
641        // `chr: 'f'` and pads the cluster with a zero-advance continuation glyph
642        // for 'i' parked at the *next* pen position — 'g''s. Looking identity up
643        // by character therefore drew a standalone 'i' on top of the 'g'.
644        let xs = draw(FontChoice::Basic, "Config");
645        assert_eq!(
646            xs.len(),
647            5,
648            "C o n <fi> g is five outlines, not six: {xs:?}",
649        );
650        assert_marches_right(&xs, "Config");
651    }
652
653    #[test]
654    fn ligature_rich_text_places_every_glyph_in_order() {
655        // Latin ligatures and Cyrillic, both covered by Roboto. Seventeen
656        // non-space characters, but `ffl`/`fi`/`fl`/`ffi` are four glyphs, so
657        // eleven outlines — one per shaped glyph, none per continuation.
658        let xs = draw(FontChoice::Basic, "waffle офис fi fl ffi");
659        assert_eq!(xs.len(), 11, "one outline per shaped glyph: {xs:?}");
660        assert_marches_right(&xs, "liga");
661    }
662
663    #[test]
664    fn every_paragraph_is_shaped_on_its_own() {
665        // Re-shaping is per paragraph, so a second line must resolve the same
666        // ligature as the first and place it at the same pen positions.
667        let xs = draw(FontChoice::Basic, "Config\nConfig");
668        assert_eq!(xs.len(), 10, "five outlines per line: {xs:?}");
669        assert_eq!(xs[..5], xs[5..], "both lines start at the same left edge");
670        assert_marches_right(&xs[..5], "line 1");
671    }
672
673    #[test]
674    fn a_font_that_substitutes_nothing_is_unaffected() {
675        // Iosevka ships `calt` but no default-on code ligatures, so "->" stays
676        // two glyphs: the guard is that re-shaping leaves such a font exactly
677        // where the character map left it.
678        let xs = draw(FontChoice::Monospace, "a -> b");
679        assert_eq!(xs.len(), 4, "a - > b, spaces drawing nothing: {xs:?}");
680        assert_marches_right(&xs, "calt");
681    }
682
683    #[test]
684    fn a_count_disagreement_falls_back_to_the_character_map() {
685        let gid = ttf_parser::GlyphId;
686        let clusters = [Cluster::Head, Cluster::Continuation, Cluster::Head];
687        assert!(matches!(
688            Identity::zip(&[gid(7), gid(8), gid(9)], &clusters),
689            Identity::ByChar,
690        ));
691        let Identity::Shaped(ids) = Identity::zip(&[gid(7), gid(8)], &clusters) else {
692            panic!("two shaped glyphs match the run's two cluster heads");
693        };
694        assert_eq!(ids, vec![Some(gid(7)), None, Some(gid(8))]);
695    }
696
697    /// The family the canvas actually draws in — the one `renderer`'s outlines
698    /// come from, so layout and glyphs agree.
699    fn font() -> Font {
700        Font::canvas(12.0)
701    }
702
703    #[test]
704    fn text_becomes_glyph_paths() {
705        let r = renderer();
706        let rect = r.text(
707            Pos2::new(0.0, 0.0),
708            Align2::LEFT_TOP,
709            "Ab",
710            &font(),
711            // B00 of Tokyo Night Moon (#222436).
712            Base::B00,
713        );
714        let (svg, _) = r.finish();
715        // Two letters with outlines → at least one filled path with a move command.
716        assert!(svg.contains("<path"), "expected a glyph path:\n{svg}");
717        assert!(
718            svg.contains("d=\"M"),
719            "path should start with a move command"
720        );
721        assert!(
722            svg.contains("fill=\"#222436\""),
723            "resolved palette fill expected"
724        );
725        assert!(rect.width() > 0.0, "measured width should be positive");
726    }
727
728    #[test]
729    fn multiline_text_stacks_lines() {
730        let r = renderer();
731        let font = font();
732        // Two `\n`-separated lines measure two rows tall, one row wide.
733        let one = r.text_size("aaaa", &font);
734        let two = r.text_size("aa\naa", &font);
735        assert!(
736            (two.y - 2.0 * one.y).abs() < 0.001,
737            "two lines should be twice as tall: {} vs {}",
738            two.y,
739            one.y
740        );
741        assert!(
742            two.x < one.x + 0.001,
743            "width is the widest line, not the sum: {} vs {}",
744            two.x,
745            one.x
746        );
747        // A trailing newline keeps an empty final row (matching egui).
748        assert!((r.text_size("aa\n", &font).y - two.y).abs() < 0.001);
749    }
750
751    #[test]
752    fn wrapped_text_breaks_long_lines_within_the_width() {
753        let r = renderer();
754        let font = font();
755        let line = "the quick brown fox jumps over the lazy dog";
756        let unwrapped = r.text_size(line, &font);
757        // Wrap to a third of the unwrapped width: it must take more rows, and no
758        // row may exceed the wrap width.
759        let max = unwrapped.x / 3.0;
760        let wrapped = r.text_size_wrapped(line, &font, px(max));
761        assert!(
762            wrapped.y > unwrapped.y,
763            "wrapping a long line adds rows: {} vs {}",
764            wrapped.y,
765            unwrapped.y
766        );
767        assert!(
768            wrapped.x <= max + 0.001,
769            "no wrapped row exceeds the wrap width: {} vs {}",
770            wrapped.x,
771            max
772        );
773        // An infinite width is a no-op (matches the no-wrap default).
774        assert!(
775            (r.text_size_wrapped(line, &font, WorldPx::UNBOUNDED).x - unwrapped.x).abs() < 0.001
776        );
777    }
778
779    #[test]
780    fn primitives_and_viewbox() {
781        let r = renderer();
782        r.rect(
783            Rect::from_min_size(Pos2::new(10.0, 10.0), vec2(30.0, 20.0)),
784            px(3.0),
785            Base::B01,
786            (1.0, Base::B0D),
787        );
788        r.line(
789            vec![Pos2::new(0.0, 0.0), Pos2::new(40.0, 0.0)],
790            (1.7, Base::B0B),
791        );
792        let (svg, _) = r.finish();
793        assert!(svg.contains("<svg"));
794        assert!(svg.contains("viewBox="));
795        assert!(svg.contains("<rect"));
796        assert!(svg.contains("<polyline"));
797        assert!(svg.contains("</svg>"));
798    }
799
800    #[test]
801    fn image_embeds_as_data_uri() {
802        let r = renderer();
803        r.draw_image(
804            Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
805            &Asset::Svg(r#"<svg viewBox="0 0 1 1"/>"#.as_bytes().into()),
806        );
807        let (svg, _) = r.finish();
808        assert!(
809            svg.contains("<image"),
810            "image exports as an <image>:\n{svg}"
811        );
812        assert!(
813            svg.contains("data:image/svg+xml;base64,"),
814            "image href is a base64 SVG data URI"
815        );
816        assert!(svg.contains(r#"preserveAspectRatio="xMidYMid meet""#));
817    }
818
819    #[test]
820    fn png_image_embeds_as_png_data_uri() {
821        let r = renderer();
822        r.draw_image(
823            Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(10.0, 10.0)),
824            &Asset::Png(vec![0x89, b'P', b'N', b'G', 1, 2, 3].into()),
825        );
826        let (svg, _) = r.finish();
827        assert!(
828            svg.contains("data:image/png;base64,"),
829            "PNG image href is a base64 PNG data URI:\n{svg}"
830        );
831    }
832}