Skip to main content

ratex_render/
renderer.rs

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