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::{FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform};
8
9use crate::unicode_fallback::unicode_fallback_font_bytes;
10
11#[cfg(feature = "embed-fonts")]
12#[derive(rust_embed::Embed)]
13#[folder = "../../fonts/"]
14struct Fonts;
15
16pub struct RenderOptions {
17    pub font_size: f32,
18    pub padding: f32,
19    /// Directory containing KaTeX `*.ttf` files (see `load_all_fonts`). Each file that exists is
20    /// loaded; missing files (e.g. no `KaTeX_Fraktur-Bold.ttf`) are skipped and that face falls back.
21    pub font_dir: String,
22    /// Multiplies pixels-per-em (and padding) so the same layout renders at higher resolution
23    /// (e.g. 2.0 to align RaTeX PNG pixel density with Puppeteer `deviceScaleFactor: 2` refs).
24    pub device_pixel_ratio: f32,
25}
26
27impl Default for RenderOptions {
28    fn default() -> Self {
29        Self {
30            font_size: 40.0,
31            padding: 10.0,
32            font_dir: String::new(),
33            device_pixel_ratio: 1.0,
34        }
35    }
36}
37
38pub fn render_to_png(
39    display_list: &DisplayList,
40    options: &RenderOptions,
41) -> Result<Vec<u8>, String> {
42    let em = options.font_size;
43    let pad = options.padding;
44    let dpr = options.device_pixel_ratio.clamp(0.01, 16.0);
45    let em_px = em * dpr;
46    let pad_px = pad * dpr;
47
48    let total_h = display_list.height + display_list.depth;
49    let img_w = (display_list.width as f32 * em_px + 2.0 * pad_px).ceil() as u32;
50    let img_h = (total_h as f32 * em_px + 2.0 * pad_px).ceil() as u32;
51
52    let img_w = img_w.max(1);
53    let img_h = img_h.max(1);
54
55    let mut pixmap = Pixmap::new(img_w, img_h)
56        .ok_or_else(|| format!("Failed to create pixmap {}x{}", img_w, img_h))?;
57
58    pixmap.fill(tiny_skia::Color::WHITE);
59
60    let font_data = load_all_fonts(&options.font_dir)?;
61    let font_cache = build_font_cache(&font_data)?;
62
63    for item in &display_list.items {
64        match item {
65            DisplayItem::GlyphPath {
66                x,
67                y,
68                scale,
69                font,
70                char_code,
71                commands: _,
72                color,
73            } => {
74                let glyph_em = em_px * *scale as f32;
75                render_glyph(
76                    &mut pixmap,
77                    *x as f32 * em_px + pad_px,
78                    *y as f32 * em_px + pad_px,
79                    font,
80                    *char_code,
81                    color,
82                    &font_cache,
83                    glyph_em,
84                );
85            }
86            DisplayItem::Line {
87                x,
88                y,
89                width,
90                thickness,
91                color,
92                dashed,
93            } => {
94                render_line(
95                    &mut pixmap,
96                    *x as f32 * em_px + pad_px,
97                    *y as f32 * em_px + pad_px,
98                    *width as f32 * em_px,
99                    *thickness as f32 * em_px,
100                    color,
101                    *dashed,
102                );
103            }
104            DisplayItem::Rect {
105                x,
106                y,
107                width,
108                height,
109                color,
110            } => {
111                render_rect(
112                    &mut pixmap,
113                    *x as f32 * em_px + pad_px,
114                    *y as f32 * em_px + pad_px,
115                    *width as f32 * em_px,
116                    *height as f32 * em_px,
117                    color,
118                );
119            }
120            DisplayItem::Path {
121                x,
122                y,
123                commands,
124                fill,
125                color,
126            } => {
127                render_path(
128                    &mut pixmap,
129                    *x as f32 * em_px + pad_px,
130                    *y as f32 * em_px + pad_px,
131                    commands,
132                    *fill,
133                    color,
134                    em_px,
135                    1.5 * dpr,
136                );
137            }
138        }
139    }
140
141    encode_png(&pixmap)
142}
143
144/// Load KaTeX TTFs from disk. Only existing paths are inserted; callers should point [RenderOptions::font_dir]
145/// at a folder that includes every face the layout may emit (e.g. repo root `fonts/`).
146#[allow(unused_variables)]
147fn load_all_fonts(font_dir: &str) -> Result<HashMap<FontId, Vec<u8>>, String> {
148    let mut data = HashMap::new();
149    let font_map = [
150        (FontId::MainRegular, "KaTeX_Main-Regular.ttf"),
151        (FontId::MainBold, "KaTeX_Main-Bold.ttf"),
152        (FontId::MainItalic, "KaTeX_Main-Italic.ttf"),
153        (FontId::MainBoldItalic, "KaTeX_Main-BoldItalic.ttf"),
154        (FontId::MathItalic, "KaTeX_Math-Italic.ttf"),
155        (FontId::MathBoldItalic, "KaTeX_Math-BoldItalic.ttf"),
156        (FontId::AmsRegular, "KaTeX_AMS-Regular.ttf"),
157        (FontId::CaligraphicRegular, "KaTeX_Caligraphic-Regular.ttf"),
158        (FontId::FrakturRegular, "KaTeX_Fraktur-Regular.ttf"),
159        (FontId::FrakturBold, "KaTeX_Fraktur-Bold.ttf"),
160        (FontId::SansSerifRegular, "KaTeX_SansSerif-Regular.ttf"),
161        (FontId::SansSerifBold, "KaTeX_SansSerif-Bold.ttf"),
162        (FontId::SansSerifItalic, "KaTeX_SansSerif-Italic.ttf"),
163        (FontId::ScriptRegular, "KaTeX_Script-Regular.ttf"),
164        (FontId::TypewriterRegular, "KaTeX_Typewriter-Regular.ttf"),
165        (FontId::Size1Regular, "KaTeX_Size1-Regular.ttf"),
166        (FontId::Size2Regular, "KaTeX_Size2-Regular.ttf"),
167        (FontId::Size3Regular, "KaTeX_Size3-Regular.ttf"),
168        (FontId::Size4Regular, "KaTeX_Size4-Regular.ttf"),
169    ];
170
171    #[cfg(not(feature = "embed-fonts"))]
172    {
173        let dir = std::path::Path::new(font_dir);
174        for (id, filename) in &font_map {
175            let path = dir.join(filename);
176            if path.exists() {
177                let bytes = std::fs::read(&path)
178                    .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
179                data.insert(*id, bytes);
180            }
181        }
182
183        if data.is_empty() {
184            return Err(format!("No fonts found in {font_dir}"));
185        }
186    }
187
188    #[cfg(feature = "embed-fonts")]
189    {
190        for (id, filename) in &font_map {
191            let font = Fonts::get(filename)
192                .ok_or_else(|| format!("Failed to get embeded font {filename}"))?;
193            data.insert(*id, font.data.to_vec());
194        }
195    }
196
197    Ok(data)
198}
199
200fn build_font_cache(data: &HashMap<FontId, Vec<u8>>) -> Result<HashMap<FontId, FontRef<'_>>, String> {
201    let mut cache = HashMap::new();
202    for (id, bytes) in data {
203        let font = FontRef::try_from_slice(bytes)
204            .map_err(|e| format!("Failed to parse font {:?}: {}", id, e))?;
205        cache.insert(*id, font);
206    }
207    Ok(cache)
208}
209
210#[allow(clippy::too_many_arguments)]
211fn render_glyph(
212    pixmap: &mut Pixmap,
213    px: f32,
214    py: f32,
215    font_name: &str,
216    char_code: u32,
217    color: &Color,
218    font_cache: &HashMap<FontId, FontRef<'_>>,
219    em: f32,
220) {
221    let font_id = FontId::parse(font_name).unwrap_or(FontId::MainRegular);
222    let font = match font_cache.get(&font_id) {
223        Some(f) => f,
224        None => match font_cache.get(&FontId::MainRegular) {
225            Some(f) => f,
226            None => return,
227        },
228    };
229
230    let ch = ratex_font::katex_ttf_glyph_char(font_id, char_code);
231    let glyph_id = font.glyph_id(ch);
232
233    if glyph_id.0 == 0 {
234        if let Some(fallback) = font_cache.get(&FontId::MainRegular) {
235            let fid = fallback.glyph_id(ch);
236            if fid.0 != 0 {
237                return render_glyph_with_font(pixmap, px, py, fallback, fid, color, em);
238            }
239        }
240        // KaTeX TTFs omit many BMP symbols (e.g. U+263A from `\char`). Browsers use system fonts;
241        // load one Unicode-capable face via `RATEX_UNICODE_FONT` or fontdb / common paths.
242        if let Some(bytes) = unicode_fallback_font_bytes() {
243            if let Ok(fb) = FontRef::try_from_slice(bytes) {
244                let fid = fb.glyph_id(ch);
245                if fid.0 != 0 {
246                    return render_glyph_with_font(pixmap, px, py, &fb, fid, color, em);
247                }
248            }
249        }
250        return;
251    }
252
253    render_glyph_with_font(pixmap, px, py, font, glyph_id, color, em);
254}
255
256fn render_glyph_with_font(
257    pixmap: &mut Pixmap,
258    px: f32,
259    py: f32,
260    font: &FontRef<'_>,
261    glyph_id: ab_glyph::GlyphId,
262    color: &Color,
263    em: f32,
264) {
265    let outline = match font.outline(glyph_id) {
266        Some(o) => o,
267        None => return,
268    };
269
270    let units_per_em = font.units_per_em().unwrap_or(1000.0);
271    let scale = em / units_per_em;
272
273    let mut builder = PathBuilder::new();
274    let mut last_end: Option<(f32, f32)> = None;
275
276    for curve in &outline.curves {
277        use ab_glyph::OutlineCurve;
278        let (start, end) = match curve {
279            OutlineCurve::Line(p0, p1) => {
280                let sx = px + p0.x * scale;
281                let sy = py - p0.y * scale;
282                let ex = px + p1.x * scale;
283                let ey = py - p1.y * scale;
284                ((sx, sy), (ex, ey))
285            }
286            OutlineCurve::Quad(p0, _, p2) => {
287                let sx = px + p0.x * scale;
288                let sy = py - p0.y * scale;
289                let ex = px + p2.x * scale;
290                let ey = py - p2.y * scale;
291                ((sx, sy), (ex, ey))
292            }
293            OutlineCurve::Cubic(p0, _, _, p3) => {
294                let sx = px + p0.x * scale;
295                let sy = py - p0.y * scale;
296                let ex = px + p3.x * scale;
297                let ey = py - p3.y * scale;
298                ((sx, sy), (ex, ey))
299            }
300        };
301
302        // New contour if start doesn't match previous end
303        let need_move = match last_end {
304            None => true,
305            Some((lx, ly)) => (lx - start.0).abs() > 0.01 || (ly - start.1).abs() > 0.01,
306        };
307
308        if need_move {
309            if last_end.is_some() {
310                builder.close();
311            }
312            builder.move_to(start.0, start.1);
313        }
314
315        match curve {
316            OutlineCurve::Line(_, p1) => {
317                builder.line_to(px + p1.x * scale, py - p1.y * scale);
318            }
319            OutlineCurve::Quad(_, p1, p2) => {
320                builder.quad_to(
321                    px + p1.x * scale,
322                    py - p1.y * scale,
323                    px + p2.x * scale,
324                    py - p2.y * scale,
325                );
326            }
327            OutlineCurve::Cubic(_, p1, p2, p3) => {
328                builder.cubic_to(
329                    px + p1.x * scale,
330                    py - p1.y * scale,
331                    px + p2.x * scale,
332                    py - p2.y * scale,
333                    px + p3.x * scale,
334                    py - p3.y * scale,
335                );
336            }
337        }
338
339        last_end = Some(end);
340    }
341
342    if last_end.is_some() {
343        builder.close();
344    }
345
346    if let Some(path) = builder.finish() {
347        let mut paint = Paint::default();
348        paint.set_color_rgba8(
349            (color.r * 255.0) as u8,
350            (color.g * 255.0) as u8,
351            (color.b * 255.0) as u8,
352            255,
353        );
354        paint.anti_alias = true;
355        pixmap.fill_path(
356            &path,
357            &paint,
358            tiny_skia::FillRule::EvenOdd,
359            Transform::identity(),
360            None,
361        );
362    }
363}
364
365fn render_line(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, thickness: f32, color: &Color, dashed: bool) {
366    let t = thickness.max(1.0);
367    let mut paint = Paint::default();
368    paint.set_color_rgba8(
369        (color.r * 255.0) as u8,
370        (color.g * 255.0) as u8,
371        (color.b * 255.0) as u8,
372        255,
373    );
374
375    if dashed {
376        // Draw a dashed line: dash length = 4t, gap = 4t.
377        let dash_len = (4.0 * t).max(2.0);
378        let gap_len = (4.0 * t).max(2.0);
379        let period = dash_len + gap_len;
380        let top = y - t / 2.0;
381        let mut cur_x = x;
382        while cur_x < x + width {
383            let seg_width = (dash_len).min(x + width - cur_x);
384            let seg_width = seg_width.max(2.0);
385            if let Some(rect) = tiny_skia::Rect::from_xywh(cur_x, top, seg_width, t) {
386                pixmap.fill_rect(rect, &paint, Transform::identity(), None);
387            }
388            cur_x += period;
389        }
390    } else {
391        if let Some(rect) = tiny_skia::Rect::from_xywh(x, y - t / 2.0, width, t) {
392            pixmap.fill_rect(rect, &paint, Transform::identity(), None);
393        }
394    }
395}
396
397fn render_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: &Color) {
398    // Clamp to at least 2px: with width=1px at a fractional pixel position, fill_dot8's
399    // dot-8 fixed-point arithmetic can produce inner_width=0 and trigger a debug_assert.
400    // 2px guarantees at least 1 full interior pixel regardless of sub-pixel alignment.
401    let width = width.max(2.0);
402    let height = height.max(2.0);
403    let rect = tiny_skia::Rect::from_xywh(x, y, width, height);
404    if let Some(rect) = rect {
405        let mut paint = Paint::default();
406        paint.set_color_rgba8(
407            (color.r * 255.0) as u8,
408            (color.g * 255.0) as u8,
409            (color.b * 255.0) as u8,
410            255,
411        );
412        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
413    }
414}
415
416#[allow(clippy::too_many_arguments)]
417fn render_path(
418    pixmap: &mut Pixmap,
419    x: f32,
420    y: f32,
421    commands: &[ratex_types::path_command::PathCommand],
422    fill: bool,
423    color: &Color,
424    em: f32,
425    stroke_width_px: f32,
426) {
427    // For filled paths, render each subpath (delimited by MoveTo) as a separate
428    // fill_path call.  KaTeX stretchy arrows are assembled from multiple path
429    // components (e.g. "lefthook" + "rightarrow") whose winding directions can
430    // be opposite.  Combining them into a single fill_path with FillRule::Winding
431    // causes the shaft region to cancel out (net winding = 0 → unfilled).
432    // Drawing each subpath independently avoids cross-component winding interactions.
433        if fill {
434            let mut start = 0;
435            for i in 1..commands.len() {
436                if matches!(commands[i], ratex_types::path_command::PathCommand::MoveTo { .. }) {
437                    render_path_segment(pixmap, x, y, &commands[start..i], fill, color, em, stroke_width_px);
438                    start = i;
439                }
440            }
441            render_path_segment(pixmap, x, y, &commands[start..], fill, color, em, stroke_width_px);
442            return;
443        }
444        render_path_segment(pixmap, x, y, commands, fill, color, em, stroke_width_px);
445}
446
447#[allow(clippy::too_many_arguments)]
448fn render_path_segment(
449    pixmap: &mut Pixmap,
450    x: f32,
451    y: f32,
452    commands: &[ratex_types::path_command::PathCommand],
453    fill: bool,
454    color: &Color,
455    em: f32,
456    stroke_width_px: f32,
457) {
458    let mut builder = PathBuilder::new();
459    for cmd in commands {
460        match cmd {
461            ratex_types::path_command::PathCommand::MoveTo { x: cx, y: cy } => {
462                builder.move_to(x + *cx as f32 * em, y + *cy as f32 * em);
463            }
464            ratex_types::path_command::PathCommand::LineTo { x: cx, y: cy } => {
465                builder.line_to(x + *cx as f32 * em, y + *cy as f32 * em);
466            }
467            ratex_types::path_command::PathCommand::CubicTo {
468                x1,
469                y1,
470                x2,
471                y2,
472                x: cx,
473                y: cy,
474            } => {
475                builder.cubic_to(
476                    x + *x1 as f32 * em,
477                    y + *y1 as f32 * em,
478                    x + *x2 as f32 * em,
479                    y + *y2 as f32 * em,
480                    x + *cx as f32 * em,
481                    y + *cy as f32 * em,
482                );
483            }
484            ratex_types::path_command::PathCommand::QuadTo { x1, y1, x: cx, y: cy } => {
485                builder.quad_to(
486                    x + *x1 as f32 * em,
487                    y + *y1 as f32 * em,
488                    x + *cx as f32 * em,
489                    y + *cy as f32 * em,
490                );
491            }
492            ratex_types::path_command::PathCommand::Close => {
493                builder.close();
494            }
495        }
496    }
497
498    if let Some(path) = builder.finish() {
499        let mut paint = Paint::default();
500        paint.set_color_rgba8(
501            (color.r * 255.0) as u8,
502            (color.g * 255.0) as u8,
503            (color.b * 255.0) as u8,
504            255,
505        );
506        if fill {
507            paint.anti_alias = true;
508            // Even-odd: KaTeX `tallDelim` vert uses two subpaths (outline + stem); nonzero winding
509            // double-fills the stem and inflates ink vs reference PNGs.
510            pixmap.fill_path(
511                &path,
512                &paint,
513                FillRule::EvenOdd,
514                Transform::identity(),
515                None,
516            );
517        } else {
518            let stroke = Stroke {
519                width: stroke_width_px,
520                ..Default::default()
521            };
522            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
523        }
524    }
525}
526
527fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
528    let mut buf = Vec::new();
529    {
530        let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
531        encoder.set_color(png::ColorType::Rgba);
532        encoder.set_depth(png::BitDepth::Eight);
533        let mut writer = encoder
534            .write_header()
535            .map_err(|e| format!("PNG header error: {}", e))?;
536        writer
537            .write_image_data(pixmap.data())
538            .map_err(|e| format!("PNG write error: {}", e))?;
539    }
540    Ok(buf)
541}