Skip to main content

ratex_render/
renderer.rs

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