Skip to main content

ratex_render/
renderer.rs

1use std::collections::HashMap;
2
3use ab_glyph::{Font, FontRef};
4use ratex_font::FontId;
5use ratex_font_loader::FontSet;
6use ratex_types::color::Color;
7use ratex_types::display_item::{DisplayItem, DisplayList};
8use tiny_skia::{
9    FillRule, FilterQuality, Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform,
10};
11
12/// Options controlling PNG output.
13pub struct RenderOptions {
14    pub font_size: f32,
15    pub padding: f32,
16    /// Background fill color for the output PNG. Set alpha to 0.0 for transparency.
17    pub background_color: Color,
18    /// Directory containing KaTeX `.ttf` files. Used only when `embed-fonts` is disabled.
19    pub font_dir: String,
20    /// Multiplies pixels-per-em (and padding) so the same layout renders at higher resolution
21    /// (e.g. 2.0 to align RaTeX PNG pixel density with Puppeteer `deviceScaleFactor: 2` refs).
22    pub device_pixel_ratio: f32,
23}
24
25impl Default for RenderOptions {
26    fn default() -> Self {
27        Self {
28            font_size: 40.0,
29            padding: 10.0,
30            background_color: Color::WHITE,
31            font_dir: String::new(),
32            device_pixel_ratio: 1.0,
33        }
34    }
35}
36
37pub fn render_to_png(
38    display_list: &DisplayList,
39    options: &RenderOptions,
40) -> Result<Vec<u8>, String> {
41    let em = options.font_size;
42    let pad = options.padding;
43    let dpr = options.device_pixel_ratio.clamp(0.01, 16.0);
44    let em_px = em * dpr;
45    let pad_px = pad * dpr;
46
47    let total_h = display_list.height + display_list.depth;
48    let img_w = (display_list.width as f32 * em_px + 2.0 * pad_px).ceil() as u32;
49    let img_h = (total_h as f32 * em_px + 2.0 * pad_px).ceil() as u32;
50
51    let img_w = img_w.max(1);
52    let img_h = img_h.max(1);
53
54    let mut pixmap = Pixmap::new(img_w, img_h)
55        .ok_or_else(|| format!("Failed to create pixmap {}x{}", img_w, img_h))?;
56
57    pixmap.fill(to_tiny_skia_color(options.background_color));
58
59    // Lazy font loading is shared across renderers and source-aware by font_dir.
60    render_with_fonts(&mut pixmap, display_list, options, em_px, pad_px, dpr)?;
61
62    encode_png(&pixmap)
63}
64
65/// Load fonts lazily and render the DisplayList.
66fn render_with_fonts(
67    pixmap: &mut Pixmap,
68    display_list: &DisplayList,
69    options: &RenderOptions,
70    em_px: f32,
71    pad_px: f32,
72    dpr: f32,
73) -> Result<(), String> {
74    let fonts = ratex_font_loader::load_fonts_for_items(&options.font_dir, &display_list.items)?;
75    let font_refs = build_font_refs(&fonts)?;
76    render_display_list(pixmap, display_list, &font_refs, em_px, pad_px, dpr);
77    Ok(())
78}
79
80fn to_tiny_skia_color(color: Color) -> tiny_skia::Color {
81    tiny_skia::Color::from_rgba(
82        color.r.clamp(0.0, 1.0),
83        color.g.clamp(0.0, 1.0),
84        color.b.clamp(0.0, 1.0),
85        color.a.clamp(0.0, 1.0),
86    )
87    .unwrap_or(tiny_skia::Color::TRANSPARENT)
88}
89
90/// Build a `FontId → FontRef` map from the raw font data (borrowed from the cache lock).
91fn build_font_refs(data: &FontSet) -> Result<HashMap<FontId, FontRef<'_>>, String> {
92    let mut font_refs = HashMap::new();
93    for (id, bytes) in data.iter() {
94        let font = FontRef::try_from_slice_and_index(bytes, sfnt_collection_index(*id))
95            .map_err(|e| format!("Failed to parse font {:?}: {}", id, e))?;
96        font_refs.insert(*id, font);
97    }
98
99    if !font_refs.contains_key(&FontId::MainRegular) {
100        return Err("Main-Regular font not found".to_string());
101    }
102
103    Ok(font_refs)
104}
105
106/// Render all items in the DisplayList using the given font cache.
107fn render_display_list(
108    pixmap: &mut Pixmap,
109    display_list: &DisplayList,
110    font_cache: &HashMap<FontId, FontRef<'_>>,
111    em_px: f32,
112    pad_px: f32,
113    dpr: f32,
114) {
115    let mut font_id_cache: HashMap<&str, FontId> = HashMap::new();
116    for item in &display_list.items {
117        match item {
118            DisplayItem::GlyphPath {
119                x,
120                y,
121                scale,
122                font,
123                char_code,
124                color,
125            } => {
126                let glyph_em = em_px * *scale as f32;
127                let font_id = *font_id_cache
128                    .entry(font.as_str())
129                    .or_insert_with(|| FontId::parse(font).unwrap_or(FontId::MainRegular));
130                render_glyph(
131                    pixmap,
132                    *x as f32 * em_px + pad_px,
133                    *y as f32 * em_px + pad_px,
134                    font_id,
135                    *char_code,
136                    color,
137                    font_cache,
138                    glyph_em,
139                );
140            }
141            DisplayItem::Line {
142                x,
143                y,
144                width,
145                thickness,
146                color,
147                dashed,
148            } => {
149                render_line(
150                    pixmap,
151                    *x as f32 * em_px + pad_px,
152                    *y as f32 * em_px + pad_px,
153                    *width as f32 * em_px,
154                    *thickness as f32 * em_px,
155                    color,
156                    *dashed,
157                );
158            }
159            DisplayItem::Rect {
160                x,
161                y,
162                width,
163                height,
164                color,
165            } => {
166                render_rect(
167                    pixmap,
168                    *x as f32 * em_px + pad_px,
169                    *y as f32 * em_px + pad_px,
170                    *width as f32 * em_px,
171                    *height as f32 * em_px,
172                    color,
173                );
174            }
175            DisplayItem::Path {
176                x,
177                y,
178                commands,
179                fill,
180                color,
181            } => {
182                render_path(
183                    pixmap,
184                    *x as f32 * em_px + pad_px,
185                    *y as f32 * em_px + pad_px,
186                    commands,
187                    *fill,
188                    color,
189                    em_px,
190                    1.5 * dpr,
191                );
192            }
193        }
194    }
195}
196
197fn sfnt_collection_index(id: FontId) -> u32 {
198    match id {
199        FontId::EmojiFallback => ratex_unicode_font::emoji_font_face_index().unwrap_or(0),
200        FontId::CjkRegular => ratex_unicode_font::unicode_font_face_index().unwrap_or(0),
201        FontId::CjkFallback => ratex_unicode_font::fallback_font_face_index().unwrap_or(0),
202        _ => 0,
203    }
204}
205
206/// After `.notdef` or a cmap slot with **no drawable outline** (common for emoji in text fonts),
207/// try KaTeX Main → `CjkRegular` → **Emoji** (color font, vector + sbix bitmap) → `CjkFallback`.
208///
209/// Emoji is tried **before** the broad text fallback so supplementary-plane / color glyphs are not
210/// stuck behind Arial-style faces that often lack drawable outlines for emoji.
211///
212/// When `skip_main_regular` is `true`, skips `Main-Regular` (caller already tried that face).
213#[allow(clippy::too_many_arguments)]
214fn try_system_unicode_fallback(
215    pixmap: &mut Pixmap,
216    px: f32,
217    py: f32,
218    ch: char,
219    color: &Color,
220    em: f32,
221    font_cache: &HashMap<FontId, FontRef<'_>>,
222    skip_main_regular: bool,
223) -> bool {
224    if !skip_main_regular {
225        if let Some(fallback) = font_cache.get(&FontId::MainRegular) {
226            let fid = fallback.glyph_id(ch);
227            if fid.0 != 0
228                && render_glyph_with_font(
229                    pixmap,
230                    px,
231                    py,
232                    FontGlyph {
233                        font_id: FontId::MainRegular,
234                        font: fallback,
235                        glyph_id: fid,
236                    },
237                    color,
238                    em,
239                )
240            {
241                return true;
242            }
243        }
244    }
245    if let Some(cjk_font) = font_cache.get(&FontId::CjkRegular) {
246        let fid = cjk_font.glyph_id(ch);
247        if fid.0 != 0
248            && render_glyph_with_font(
249                pixmap,
250                px,
251                py,
252                FontGlyph {
253                    font_id: FontId::CjkRegular,
254                    font: cjk_font,
255                    glyph_id: fid,
256                },
257                color,
258                em,
259            )
260        {
261            return true;
262        }
263    }
264    if try_emoji_vector_then_bitmap(pixmap, px, py, ch, color, em, font_cache) {
265        return true;
266    }
267    if let Some(fb_font) = font_cache.get(&FontId::CjkFallback) {
268        let fid = fb_font.glyph_id(ch);
269        if fid.0 != 0
270            && render_glyph_with_font(
271                pixmap,
272                px,
273                py,
274                FontGlyph {
275                    font_id: FontId::CjkFallback,
276                    font: fb_font,
277                    glyph_id: fid,
278                },
279                color,
280                em,
281            )
282        {
283            return true;
284        }
285    }
286    false
287}
288
289/// Color fonts (e.g. Apple Color Emoji) often expose a minimal `glyf` outline for COLR masking
290/// while the visible glyph lives in `sbix` / `CBDT`. `ab_glyph` then "succeeds" with an
291/// effectively invisible path — so **raster strike first**, then outline.
292#[allow(clippy::too_many_arguments)]
293fn try_emoji_vector_then_bitmap(
294    pixmap: &mut Pixmap,
295    px: f32,
296    py: f32,
297    ch: char,
298    color: &Color,
299    em: f32,
300    font_cache: &HashMap<FontId, FontRef<'_>>,
301) -> bool {
302    if try_blit_emoji_raster_fallback(pixmap, px, py, em, ch) {
303        return true;
304    }
305    if let Some(emoji_font) = font_cache.get(&FontId::EmojiFallback) {
306        let eid = emoji_font.glyph_id(ch);
307        if eid.0 != 0
308            && render_glyph_with_font(
309                pixmap,
310                px,
311                py,
312                FontGlyph {
313                    font_id: FontId::EmojiFallback,
314                    font: emoji_font,
315                    glyph_id: eid,
316                },
317                color,
318                em,
319            )
320        {
321            return true;
322        }
323    }
324    false
325}
326
327#[allow(clippy::too_many_arguments)]
328fn render_glyph(
329    pixmap: &mut Pixmap,
330    px: f32,
331    py: f32,
332    font_id: FontId,
333    char_code: u32,
334    color: &Color,
335    font_cache: &HashMap<FontId, FontRef<'_>>,
336    em: f32,
337) {
338    let font = match font_cache.get(&font_id) {
339        Some(f) => f,
340        None => match font_cache.get(&FontId::MainRegular) {
341            Some(f) => f,
342            None => return,
343        },
344    };
345
346    let ch = ratex_font::katex_ttf_glyph_char(font_id, char_code);
347    let glyph_id = font.glyph_id(ch);
348
349    if glyph_id.0 == 0 {
350        let _ = try_system_unicode_fallback(pixmap, px, py, ch, color, em, font_cache, false);
351        return;
352    }
353
354    if font_id == FontId::EmojiFallback {
355        if try_blit_emoji_raster_fallback(pixmap, px, py, em, ch) {
356            return;
357        }
358        let _ = render_glyph_with_font(
359            pixmap,
360            px,
361            py,
362            FontGlyph {
363                font_id,
364                font,
365                glyph_id,
366            },
367            color,
368            em,
369        );
370        return;
371    }
372
373    // `RATEX_UNICODE_FONT` may map a codepoint to a non-.notdef glyph with no outlines; try system fallback.
374    if font_id == FontId::CjkRegular {
375        if render_glyph_with_font(
376            pixmap,
377            px,
378            py,
379            FontGlyph {
380                font_id: FontId::CjkRegular,
381                font,
382                glyph_id,
383            },
384            color,
385            em,
386        ) {
387            return;
388        }
389        if try_emoji_vector_then_bitmap(pixmap, px, py, ch, color, em, font_cache) {
390            return;
391        }
392        if let Some(fb_font) = font_cache.get(&FontId::CjkFallback) {
393            let fid = fb_font.glyph_id(ch);
394            if fid.0 != 0
395                && render_glyph_with_font(
396                    pixmap,
397                    px,
398                    py,
399                    FontGlyph {
400                        font_id: FontId::CjkFallback,
401                        font: fb_font,
402                        glyph_id: fid,
403                    },
404                    color,
405                    em,
406                )
407            {
408                return;
409            }
410        }
411        return;
412    }
413
414    if font_id == FontId::CjkFallback {
415        if render_glyph_with_font(
416            pixmap,
417            px,
418            py,
419            FontGlyph {
420                font_id: FontId::CjkFallback,
421                font,
422                glyph_id,
423            },
424            color,
425            em,
426        ) {
427            return;
428        }
429        let _ = try_emoji_vector_then_bitmap(pixmap, px, py, ch, color, em, font_cache);
430        return;
431    }
432
433    if render_glyph_with_font(
434        pixmap,
435        px,
436        py,
437        FontGlyph {
438            font_id,
439            font,
440            glyph_id,
441        },
442        color,
443        em,
444    ) {
445        return;
446    }
447    // cmap had a non-zero GID but no `glyf` outline (e.g. blank text-font slot for emoji).
448    let skip_main = font_id == FontId::MainRegular;
449    let _ = try_system_unicode_fallback(pixmap, px, py, ch, color, em, font_cache, skip_main);
450}
451
452struct FontGlyph<'a> {
453    font_id: FontId,
454    font: &'a FontRef<'a>,
455    glyph_id: ab_glyph::GlyphId,
456}
457
458fn render_glyph_with_font(
459    pixmap: &mut Pixmap,
460    px: f32,
461    py: f32,
462    g: FontGlyph<'_>,
463    color: &Color,
464    em: f32,
465) -> bool {
466    let curves = match ratex_font_loader::outline_cache::get_or_compute_outline(
467        g.font_id, g.font, g.glyph_id,
468    ) {
469        Some(c) => c,
470        None => return false,
471    };
472    if curves.is_empty() {
473        return false;
474    }
475
476    let units_per_em = g.font.units_per_em().unwrap_or(1000.0);
477    let mut scale = em / units_per_em;
478
479    // Emoji outline fallback has no KaTeX metrics; scale it to the 1.0em width that layout
480    // allocates for missing emoji so Windows vector fallback does not overflow.
481    if g.font_id == FontId::EmojiFallback {
482        let actual_advance = g.font.h_advance_unscaled(g.glyph_id);
483        let actual_advance_em = actual_advance / units_per_em;
484        let assumed_width = 1.0;
485        if actual_advance_em > 0.01 && actual_advance_em > assumed_width * 1.01 {
486            scale *= assumed_width / actual_advance_em;
487        }
488    }
489
490    let mut builder = PathBuilder::new();
491    let mut last_end: Option<(f32, f32)> = None;
492
493    for curve in curves.iter() {
494        use ab_glyph::OutlineCurve;
495        let (start, end) = match curve {
496            OutlineCurve::Line(p0, p1) => {
497                let sx = px + p0.x * scale;
498                let sy = py - p0.y * scale;
499                let ex = px + p1.x * scale;
500                let ey = py - p1.y * scale;
501                ((sx, sy), (ex, ey))
502            }
503            OutlineCurve::Quad(p0, _, p2) => {
504                let sx = px + p0.x * scale;
505                let sy = py - p0.y * scale;
506                let ex = px + p2.x * scale;
507                let ey = py - p2.y * scale;
508                ((sx, sy), (ex, ey))
509            }
510            OutlineCurve::Cubic(p0, _, _, p3) => {
511                let sx = px + p0.x * scale;
512                let sy = py - p0.y * scale;
513                let ex = px + p3.x * scale;
514                let ey = py - p3.y * scale;
515                ((sx, sy), (ex, ey))
516            }
517        };
518
519        // New contour if start doesn't match previous end
520        let need_move = match last_end {
521            None => true,
522            Some((lx, ly)) => (lx - start.0).abs() > 0.01 || (ly - start.1).abs() > 0.01,
523        };
524
525        if need_move {
526            if last_end.is_some() {
527                builder.close();
528            }
529            builder.move_to(start.0, start.1);
530        }
531
532        match curve {
533            OutlineCurve::Line(_, p1) => {
534                builder.line_to(px + p1.x * scale, py - p1.y * scale);
535            }
536            OutlineCurve::Quad(_, p1, p2) => {
537                builder.quad_to(
538                    px + p1.x * scale,
539                    py - p1.y * scale,
540                    px + p2.x * scale,
541                    py - p2.y * scale,
542                );
543            }
544            OutlineCurve::Cubic(_, p1, p2, p3) => {
545                builder.cubic_to(
546                    px + p1.x * scale,
547                    py - p1.y * scale,
548                    px + p2.x * scale,
549                    py - p2.y * scale,
550                    px + p3.x * scale,
551                    py - p3.y * scale,
552                );
553            }
554        }
555
556        last_end = Some(end);
557    }
558
559    if last_end.is_some() {
560        builder.close();
561    }
562
563    if let Some(path) = builder.finish() {
564        let mut paint = Paint::default();
565        paint.set_color_rgba8(
566            (color.r * 255.0) as u8,
567            (color.g * 255.0) as u8,
568            (color.b * 255.0) as u8,
569            255,
570        );
571        paint.anti_alias = true;
572        pixmap.fill_path(
573            &path,
574            &paint,
575            tiny_skia::FillRule::Winding,
576            Transform::identity(),
577            None,
578        );
579        true
580    } else {
581        false
582    }
583}
584
585/// Color emoji (sbix / CBDT / etc.) often have no `glyf` outlines; `ttf-parser` embedded strikes + PNG.
586fn try_blit_emoji_raster_fallback(
587    pixmap: &mut Pixmap,
588    px: f32,
589    py: f32,
590    em: f32,
591    ch: char,
592) -> bool {
593    let Some(bytes) = ratex_unicode_font::load_emoji_font_arc() else {
594        return false;
595    };
596    let idx = ratex_unicode_font::emoji_font_face_index().unwrap_or(0);
597    try_blit_raster_glyph(pixmap, px, py, em, ch, bytes.as_slice(), idx)
598}
599
600fn try_blit_raster_glyph(
601    pixmap: &mut Pixmap,
602    px: f32,
603    py: f32,
604    em: f32,
605    ch: char,
606    font_bytes: &[u8],
607    face_index: u32,
608) -> bool {
609    let face = match ttf_parser::Face::parse(font_bytes, face_index) {
610        Ok(f) => f,
611        Err(_) => return false,
612    };
613    let gid = match face.glyph_index(ch) {
614        Some(g) => g,
615        None => return false,
616    };
617    let strike = em.round().clamp(8.0, 256.0) as u16;
618    let img = face
619        .glyph_raster_image(gid, strike)
620        .or_else(|| face.glyph_raster_image(gid, u16::MAX));
621    let Some(img) = img else {
622        return false;
623    };
624    let glyph_pm = match raster_glyph_image_to_pixmap(&img) {
625        Some(p) => p,
626        None => return false,
627    };
628    let ppm = f32::from(img.pixels_per_em.max(1));
629    let mut scale = em / ppm;
630    // Scale emoji to fit 1.0em layout width if it's wider (prevents overflow).
631    let actual_width_em = f32::from(img.width) / ppm;
632    let assumed_width = 1.0;
633    if actual_width_em > 0.01 && actual_width_em > assumed_width * 1.01 {
634        scale *= assumed_width / actual_width_em;
635    }
636    let top_x = px + f32::from(img.x) * scale;
637    // `ttf-parser` / OpenType: `RasterGlyphImage::{x,y}` are in strike pixels; `y` is the
638    // **bottom** edge of the bitmap in y-up coordinates (sbix yOffset to bottom; CBDT normalized
639    // the same way). Top edge = y + height — using `y` alone shifts the glyph down by ~full height.
640    let mut top_y = py - (f32::from(img.y) + f32::from(img.height)) * scale;
641    // sbix places the bitmap bottom on the math baseline, but tall (~1em) color strikes put the
642    // ink centroid near 0.5em above baseline. Binary/relation glyphs (+, =) are centered on the
643    // math axis (~0.25em). Nudge the bitmap so its vertical center matches the axis — matches
644    // mixed `\text{emoji} … formula` rows without changing layout baselines.
645    let center_strike = (f32::from(img.y) + f32::from(img.height) / 2.0) / ppm;
646    let axis = ratex_font::get_global_metrics(0).axis_height as f32;
647    top_y += (center_strike - axis) * em;
648    let paint = PixmapPaint {
649        quality: FilterQuality::Bilinear,
650        ..Default::default()
651    };
652    let transform = Transform::from_row(scale, 0.0, 0.0, scale, top_x, top_y);
653    pixmap.draw_pixmap(0, 0, glyph_pm.as_ref(), &paint, transform, None);
654    true
655}
656
657fn raster_glyph_image_to_pixmap(img: &ttf_parser::RasterGlyphImage<'_>) -> Option<Pixmap> {
658    use ttf_parser::RasterImageFormat;
659    let w = u32::from(img.width);
660    let h = u32::from(img.height);
661    let size = tiny_skia::IntSize::from_wh(w, h)?;
662    match img.format {
663        RasterImageFormat::PNG => Pixmap::decode_png(img.data).ok(),
664        RasterImageFormat::BitmapPremulBgra32 => {
665            let expected = 4usize * w as usize * h as usize;
666            if img.data.len() != expected {
667                return None;
668            }
669            let mut v = Vec::with_capacity(expected);
670            for px in img.data.chunks_exact(4) {
671                let b = px[0];
672                let g = px[1];
673                let r = px[2];
674                let a = px[3];
675                v.extend_from_slice(&[r, g, b, a]);
676            }
677            Pixmap::from_vec(v, size)
678        }
679        RasterImageFormat::BitmapGray8 => {
680            let mut v = Vec::with_capacity(4 * img.data.len());
681            for &g in img.data {
682                v.extend_from_slice(&[g, g, g, 255]);
683            }
684            Pixmap::from_vec(v, size)
685        }
686        _ => None,
687    }
688}
689
690fn render_line(
691    pixmap: &mut Pixmap,
692    x: f32,
693    y: f32,
694    width: f32,
695    thickness: f32,
696    color: &Color,
697    dashed: bool,
698) {
699    let t = thickness.max(1.0);
700    let mut paint = Paint::default();
701    paint.set_color_rgba8(
702        (color.r * 255.0) as u8,
703        (color.g * 255.0) as u8,
704        (color.b * 255.0) as u8,
705        255,
706    );
707
708    if dashed {
709        // Draw a dashed line: dash length = 4t, gap = 4t.
710        let dash_len = (4.0 * t).max(2.0);
711        let gap_len = (4.0 * t).max(2.0);
712        let period = dash_len + gap_len;
713        let top = y - t / 2.0;
714        let mut cur_x = x;
715        while cur_x < x + width {
716            let seg_width = (dash_len).min(x + width - cur_x);
717            let seg_width = seg_width.max(2.0);
718            if let Some(rect) = tiny_skia::Rect::from_xywh(cur_x, top, seg_width, t) {
719                pixmap.fill_rect(rect, &paint, Transform::identity(), None);
720            }
721            cur_x += period;
722        }
723    } else if let Some(rect) = tiny_skia::Rect::from_xywh(x, y - t / 2.0, width, t) {
724        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
725    }
726}
727
728fn render_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: &Color) {
729    // Clamp to at least 2px: with width=1px at a fractional pixel position, fill_dot8's
730    // dot-8 fixed-point arithmetic can produce inner_width=0 and trigger a debug_assert.
731    // 2px guarantees at least 1 full interior pixel regardless of sub-pixel alignment.
732    let width = width.max(2.0);
733    let height = height.max(2.0);
734    let rect = tiny_skia::Rect::from_xywh(x, y, width, height);
735    if let Some(rect) = rect {
736        let mut paint = Paint::default();
737        paint.set_color_rgba8(
738            (color.r * 255.0) as u8,
739            (color.g * 255.0) as u8,
740            (color.b * 255.0) as u8,
741            255,
742        );
743        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
744    }
745}
746
747#[allow(clippy::too_many_arguments)]
748fn render_path(
749    pixmap: &mut Pixmap,
750    x: f32,
751    y: f32,
752    commands: &[ratex_types::path_command::PathCommand],
753    fill: bool,
754    color: &Color,
755    em: f32,
756    stroke_width_px: f32,
757) {
758    // For filled paths, render each subpath (delimited by MoveTo) as a separate
759    // fill_path call.  KaTeX stretchy arrows are assembled from multiple path
760    // components (e.g. "lefthook" + "rightarrow") whose winding directions can
761    // be opposite.  Combining them into a single fill_path with FillRule::Winding
762    // causes the shaft region to cancel out (net winding = 0 → unfilled).
763    // Drawing each subpath independently avoids cross-component winding interactions.
764    if fill {
765        let mut start = 0;
766        for i in 1..commands.len() {
767            if matches!(
768                commands[i],
769                ratex_types::path_command::PathCommand::MoveTo { .. }
770            ) {
771                render_path_segment(
772                    pixmap,
773                    x,
774                    y,
775                    &commands[start..i],
776                    fill,
777                    color,
778                    em,
779                    stroke_width_px,
780                );
781                start = i;
782            }
783        }
784        render_path_segment(
785            pixmap,
786            x,
787            y,
788            &commands[start..],
789            fill,
790            color,
791            em,
792            stroke_width_px,
793        );
794        return;
795    }
796    render_path_segment(pixmap, x, y, commands, fill, color, em, stroke_width_px);
797}
798
799#[allow(clippy::too_many_arguments)]
800fn render_path_segment(
801    pixmap: &mut Pixmap,
802    x: f32,
803    y: f32,
804    commands: &[ratex_types::path_command::PathCommand],
805    fill: bool,
806    color: &Color,
807    em: f32,
808    stroke_width_px: f32,
809) {
810    let mut builder = PathBuilder::new();
811    for cmd in commands {
812        match cmd {
813            ratex_types::path_command::PathCommand::MoveTo { x: cx, y: cy } => {
814                builder.move_to(x + *cx as f32 * em, y + *cy as f32 * em);
815            }
816            ratex_types::path_command::PathCommand::LineTo { x: cx, y: cy } => {
817                builder.line_to(x + *cx as f32 * em, y + *cy as f32 * em);
818            }
819            ratex_types::path_command::PathCommand::CubicTo {
820                x1,
821                y1,
822                x2,
823                y2,
824                x: cx,
825                y: cy,
826            } => {
827                builder.cubic_to(
828                    x + *x1 as f32 * em,
829                    y + *y1 as f32 * em,
830                    x + *x2 as f32 * em,
831                    y + *y2 as f32 * em,
832                    x + *cx as f32 * em,
833                    y + *cy as f32 * em,
834                );
835            }
836            ratex_types::path_command::PathCommand::QuadTo {
837                x1,
838                y1,
839                x: cx,
840                y: cy,
841            } => {
842                builder.quad_to(
843                    x + *x1 as f32 * em,
844                    y + *y1 as f32 * em,
845                    x + *cx as f32 * em,
846                    y + *cy as f32 * em,
847                );
848            }
849            ratex_types::path_command::PathCommand::Close => {
850                builder.close();
851            }
852        }
853    }
854
855    if let Some(path) = builder.finish() {
856        let mut paint = Paint::default();
857        paint.set_color_rgba8(
858            (color.r * 255.0) as u8,
859            (color.g * 255.0) as u8,
860            (color.b * 255.0) as u8,
861            255,
862        );
863        if fill {
864            paint.anti_alias = true;
865            // Even-odd: KaTeX `tallDelim` vert uses two subpaths (outline + stem); nonzero winding
866            // double-fills the stem and inflates ink vs reference PNGs.
867            pixmap.fill_path(
868                &path,
869                &paint,
870                FillRule::EvenOdd,
871                Transform::identity(),
872                None,
873            );
874        } else {
875            let stroke = Stroke {
876                width: stroke_width_px,
877                ..Default::default()
878            };
879            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
880        }
881    }
882}
883
884fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
885    let mut buf = Vec::new();
886    {
887        let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
888        encoder.set_color(png::ColorType::Rgba);
889        encoder.set_depth(png::BitDepth::Eight);
890        let mut writer = encoder
891            .write_header()
892            .map_err(|e| format!("PNG header error: {}", e))?;
893        writer
894            .write_image_data(pixmap.data())
895            .map_err(|e| format!("PNG write error: {}", e))?;
896    }
897    Ok(buf)
898}