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
12pub struct RenderOptions {
13    pub font_size: f32,
14    pub padding: f32,
15    /// Background fill color for the output PNG. Set alpha to 0.0 for transparency.
16    pub background_color: Color,
17    /// Directory containing KaTeX `*.ttf` files. Required KaTeX faces are loaded lazily;
18    /// rendering fails if a face referenced by the display list is missing.
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 scale = em / units_per_em;
478
479    let mut builder = PathBuilder::new();
480    let mut last_end: Option<(f32, f32)> = None;
481
482    for curve in curves.iter() {
483        use ab_glyph::OutlineCurve;
484        let (start, end) = match curve {
485            OutlineCurve::Line(p0, p1) => {
486                let sx = px + p0.x * scale;
487                let sy = py - p0.y * scale;
488                let ex = px + p1.x * scale;
489                let ey = py - p1.y * scale;
490                ((sx, sy), (ex, ey))
491            }
492            OutlineCurve::Quad(p0, _, p2) => {
493                let sx = px + p0.x * scale;
494                let sy = py - p0.y * scale;
495                let ex = px + p2.x * scale;
496                let ey = py - p2.y * scale;
497                ((sx, sy), (ex, ey))
498            }
499            OutlineCurve::Cubic(p0, _, _, p3) => {
500                let sx = px + p0.x * scale;
501                let sy = py - p0.y * scale;
502                let ex = px + p3.x * scale;
503                let ey = py - p3.y * scale;
504                ((sx, sy), (ex, ey))
505            }
506        };
507
508        // New contour if start doesn't match previous end
509        let need_move = match last_end {
510            None => true,
511            Some((lx, ly)) => (lx - start.0).abs() > 0.01 || (ly - start.1).abs() > 0.01,
512        };
513
514        if need_move {
515            if last_end.is_some() {
516                builder.close();
517            }
518            builder.move_to(start.0, start.1);
519        }
520
521        match curve {
522            OutlineCurve::Line(_, p1) => {
523                builder.line_to(px + p1.x * scale, py - p1.y * scale);
524            }
525            OutlineCurve::Quad(_, p1, p2) => {
526                builder.quad_to(
527                    px + p1.x * scale,
528                    py - p1.y * scale,
529                    px + p2.x * scale,
530                    py - p2.y * scale,
531                );
532            }
533            OutlineCurve::Cubic(_, p1, p2, p3) => {
534                builder.cubic_to(
535                    px + p1.x * scale,
536                    py - p1.y * scale,
537                    px + p2.x * scale,
538                    py - p2.y * scale,
539                    px + p3.x * scale,
540                    py - p3.y * scale,
541                );
542            }
543        }
544
545        last_end = Some(end);
546    }
547
548    if last_end.is_some() {
549        builder.close();
550    }
551
552    if let Some(path) = builder.finish() {
553        let mut paint = Paint::default();
554        paint.set_color_rgba8(
555            (color.r * 255.0) as u8,
556            (color.g * 255.0) as u8,
557            (color.b * 255.0) as u8,
558            255,
559        );
560        paint.anti_alias = true;
561        pixmap.fill_path(
562            &path,
563            &paint,
564            tiny_skia::FillRule::Winding,
565            Transform::identity(),
566            None,
567        );
568        true
569    } else {
570        false
571    }
572}
573
574/// Color emoji (sbix / CBDT / etc.) often have no `glyf` outlines; `ttf-parser` embedded strikes + PNG.
575fn try_blit_emoji_raster_fallback(
576    pixmap: &mut Pixmap,
577    px: f32,
578    py: f32,
579    em: f32,
580    ch: char,
581) -> bool {
582    let Some((bytes, idx)) = ratex_unicode_font::load_emoji_font_with_index() else {
583        return false;
584    };
585    try_blit_raster_glyph(pixmap, px, py, em, ch, bytes, idx)
586}
587
588fn try_blit_raster_glyph(
589    pixmap: &mut Pixmap,
590    px: f32,
591    py: f32,
592    em: f32,
593    ch: char,
594    font_bytes: &[u8],
595    face_index: u32,
596) -> bool {
597    let face = match ttf_parser::Face::parse(font_bytes, face_index) {
598        Ok(f) => f,
599        Err(_) => return false,
600    };
601    let gid = match face.glyph_index(ch) {
602        Some(g) => g,
603        None => return false,
604    };
605    let strike = em.round().clamp(8.0, 256.0) as u16;
606    let img = face
607        .glyph_raster_image(gid, strike)
608        .or_else(|| face.glyph_raster_image(gid, u16::MAX));
609    let Some(img) = img else {
610        return false;
611    };
612    let glyph_pm = match raster_glyph_image_to_pixmap(&img) {
613        Some(p) => p,
614        None => return false,
615    };
616    let scale = em / f32::from(img.pixels_per_em.max(1));
617    let top_x = px + f32::from(img.x) * scale;
618    // `ttf-parser` / OpenType: `RasterGlyphImage::{x,y}` are in strike pixels; `y` is the
619    // **bottom** edge of the bitmap in y-up coordinates (sbix yOffset to bottom; CBDT normalized
620    // the same way). Top edge = y + height — using `y` alone shifts the glyph down by ~full height.
621    let mut top_y = py - (f32::from(img.y) + f32::from(img.height)) * scale;
622    // sbix places the bitmap bottom on the math baseline, but tall (~1em) color strikes put the
623    // ink centroid near 0.5em above baseline. Binary/relation glyphs (+, =) are centered on the
624    // math axis (~0.25em). Nudge the bitmap so its vertical center matches the axis — matches
625    // mixed `\text{emoji} … formula` rows without changing layout baselines.
626    let ppem = f32::from(img.pixels_per_em.max(1));
627    let center_strike = (f32::from(img.y) + f32::from(img.height) / 2.0) / ppem;
628    let axis = ratex_font::get_global_metrics(0).axis_height as f32;
629    top_y += (center_strike - axis) * em;
630    let paint = PixmapPaint {
631        quality: FilterQuality::Bilinear,
632        ..Default::default()
633    };
634    let transform = Transform::from_row(scale, 0.0, 0.0, scale, top_x, top_y);
635    pixmap.draw_pixmap(0, 0, glyph_pm.as_ref(), &paint, transform, None);
636    true
637}
638
639fn raster_glyph_image_to_pixmap(img: &ttf_parser::RasterGlyphImage<'_>) -> Option<Pixmap> {
640    use ttf_parser::RasterImageFormat;
641    let w = u32::from(img.width);
642    let h = u32::from(img.height);
643    let size = tiny_skia::IntSize::from_wh(w, h)?;
644    match img.format {
645        RasterImageFormat::PNG => Pixmap::decode_png(img.data).ok(),
646        RasterImageFormat::BitmapPremulBgra32 => {
647            let expected = 4usize * w as usize * h as usize;
648            if img.data.len() != expected {
649                return None;
650            }
651            let mut v = Vec::with_capacity(expected);
652            for px in img.data.chunks_exact(4) {
653                let b = px[0];
654                let g = px[1];
655                let r = px[2];
656                let a = px[3];
657                v.extend_from_slice(&[r, g, b, a]);
658            }
659            Pixmap::from_vec(v, size)
660        }
661        RasterImageFormat::BitmapGray8 => {
662            let mut v = Vec::with_capacity(4 * img.data.len());
663            for &g in img.data {
664                v.extend_from_slice(&[g, g, g, 255]);
665            }
666            Pixmap::from_vec(v, size)
667        }
668        _ => None,
669    }
670}
671
672fn render_line(
673    pixmap: &mut Pixmap,
674    x: f32,
675    y: f32,
676    width: f32,
677    thickness: f32,
678    color: &Color,
679    dashed: bool,
680) {
681    let t = thickness.max(1.0);
682    let mut paint = Paint::default();
683    paint.set_color_rgba8(
684        (color.r * 255.0) as u8,
685        (color.g * 255.0) as u8,
686        (color.b * 255.0) as u8,
687        255,
688    );
689
690    if dashed {
691        // Draw a dashed line: dash length = 4t, gap = 4t.
692        let dash_len = (4.0 * t).max(2.0);
693        let gap_len = (4.0 * t).max(2.0);
694        let period = dash_len + gap_len;
695        let top = y - t / 2.0;
696        let mut cur_x = x;
697        while cur_x < x + width {
698            let seg_width = (dash_len).min(x + width - cur_x);
699            let seg_width = seg_width.max(2.0);
700            if let Some(rect) = tiny_skia::Rect::from_xywh(cur_x, top, seg_width, t) {
701                pixmap.fill_rect(rect, &paint, Transform::identity(), None);
702            }
703            cur_x += period;
704        }
705    } else if let Some(rect) = tiny_skia::Rect::from_xywh(x, y - t / 2.0, width, t) {
706        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
707    }
708}
709
710fn render_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: &Color) {
711    // Clamp to at least 2px: with width=1px at a fractional pixel position, fill_dot8's
712    // dot-8 fixed-point arithmetic can produce inner_width=0 and trigger a debug_assert.
713    // 2px guarantees at least 1 full interior pixel regardless of sub-pixel alignment.
714    let width = width.max(2.0);
715    let height = height.max(2.0);
716    let rect = tiny_skia::Rect::from_xywh(x, y, width, height);
717    if let Some(rect) = rect {
718        let mut paint = Paint::default();
719        paint.set_color_rgba8(
720            (color.r * 255.0) as u8,
721            (color.g * 255.0) as u8,
722            (color.b * 255.0) as u8,
723            255,
724        );
725        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
726    }
727}
728
729#[allow(clippy::too_many_arguments)]
730fn render_path(
731    pixmap: &mut Pixmap,
732    x: f32,
733    y: f32,
734    commands: &[ratex_types::path_command::PathCommand],
735    fill: bool,
736    color: &Color,
737    em: f32,
738    stroke_width_px: f32,
739) {
740    // For filled paths, render each subpath (delimited by MoveTo) as a separate
741    // fill_path call.  KaTeX stretchy arrows are assembled from multiple path
742    // components (e.g. "lefthook" + "rightarrow") whose winding directions can
743    // be opposite.  Combining them into a single fill_path with FillRule::Winding
744    // causes the shaft region to cancel out (net winding = 0 → unfilled).
745    // Drawing each subpath independently avoids cross-component winding interactions.
746    if fill {
747        let mut start = 0;
748        for i in 1..commands.len() {
749            if matches!(
750                commands[i],
751                ratex_types::path_command::PathCommand::MoveTo { .. }
752            ) {
753                render_path_segment(
754                    pixmap,
755                    x,
756                    y,
757                    &commands[start..i],
758                    fill,
759                    color,
760                    em,
761                    stroke_width_px,
762                );
763                start = i;
764            }
765        }
766        render_path_segment(
767            pixmap,
768            x,
769            y,
770            &commands[start..],
771            fill,
772            color,
773            em,
774            stroke_width_px,
775        );
776        return;
777    }
778    render_path_segment(pixmap, x, y, commands, fill, color, em, stroke_width_px);
779}
780
781#[allow(clippy::too_many_arguments)]
782fn render_path_segment(
783    pixmap: &mut Pixmap,
784    x: f32,
785    y: f32,
786    commands: &[ratex_types::path_command::PathCommand],
787    fill: bool,
788    color: &Color,
789    em: f32,
790    stroke_width_px: f32,
791) {
792    let mut builder = PathBuilder::new();
793    for cmd in commands {
794        match cmd {
795            ratex_types::path_command::PathCommand::MoveTo { x: cx, y: cy } => {
796                builder.move_to(x + *cx as f32 * em, y + *cy as f32 * em);
797            }
798            ratex_types::path_command::PathCommand::LineTo { x: cx, y: cy } => {
799                builder.line_to(x + *cx as f32 * em, y + *cy as f32 * em);
800            }
801            ratex_types::path_command::PathCommand::CubicTo {
802                x1,
803                y1,
804                x2,
805                y2,
806                x: cx,
807                y: cy,
808            } => {
809                builder.cubic_to(
810                    x + *x1 as f32 * em,
811                    y + *y1 as f32 * em,
812                    x + *x2 as f32 * em,
813                    y + *y2 as f32 * em,
814                    x + *cx as f32 * em,
815                    y + *cy as f32 * em,
816                );
817            }
818            ratex_types::path_command::PathCommand::QuadTo {
819                x1,
820                y1,
821                x: cx,
822                y: cy,
823            } => {
824                builder.quad_to(
825                    x + *x1 as f32 * em,
826                    y + *y1 as f32 * em,
827                    x + *cx as f32 * em,
828                    y + *cy as f32 * em,
829                );
830            }
831            ratex_types::path_command::PathCommand::Close => {
832                builder.close();
833            }
834        }
835    }
836
837    if let Some(path) = builder.finish() {
838        let mut paint = Paint::default();
839        paint.set_color_rgba8(
840            (color.r * 255.0) as u8,
841            (color.g * 255.0) as u8,
842            (color.b * 255.0) as u8,
843            255,
844        );
845        if fill {
846            paint.anti_alias = true;
847            // Even-odd: KaTeX `tallDelim` vert uses two subpaths (outline + stem); nonzero winding
848            // double-fills the stem and inflates ink vs reference PNGs.
849            pixmap.fill_path(
850                &path,
851                &paint,
852                FillRule::EvenOdd,
853                Transform::identity(),
854                None,
855            );
856        } else {
857            let stroke = Stroke {
858                width: stroke_width_px,
859                ..Default::default()
860            };
861            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
862        }
863    }
864}
865
866fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
867    let mut buf = Vec::new();
868    {
869        let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
870        encoder.set_color(png::ColorType::Rgba);
871        encoder.set_depth(png::BitDepth::Eight);
872        let mut writer = encoder
873            .write_header()
874            .map_err(|e| format!("PNG header error: {}", e))?;
875        writer
876            .write_image_data(pixmap.data())
877            .map_err(|e| format!("PNG write error: {}", e))?;
878    }
879    Ok(buf)
880}