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
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    Ok(data)
193}
194
195fn build_font_cache(data: &HashMap<FontId, Vec<u8>>) -> Result<HashMap<FontId, FontRef<'_>>, String> {
196    let mut cache = HashMap::new();
197    for (id, bytes) in data {
198        let font = FontRef::try_from_slice(bytes)
199            .map_err(|e| format!("Failed to parse font {:?}: {}", id, e))?;
200        cache.insert(*id, font);
201    }
202    Ok(cache)
203}
204
205#[allow(clippy::too_many_arguments)]
206fn render_glyph(
207    pixmap: &mut Pixmap,
208    px: f32,
209    py: f32,
210    font_name: &str,
211    char_code: u32,
212    color: &Color,
213    font_cache: &HashMap<FontId, FontRef<'_>>,
214    em: f32,
215) {
216    let font_id = FontId::parse(font_name).unwrap_or(FontId::MainRegular);
217    let font = match font_cache.get(&font_id) {
218        Some(f) => f,
219        None => match font_cache.get(&FontId::MainRegular) {
220            Some(f) => f,
221            None => return,
222        },
223    };
224
225    let ch = ratex_font::katex_ttf_glyph_char(font_id, char_code);
226    let glyph_id = font.glyph_id(ch);
227
228    if glyph_id.0 == 0 {
229        if let Some(fallback) = font_cache.get(&FontId::MainRegular) {
230            let fid = fallback.glyph_id(ch);
231            if fid.0 != 0 {
232                return render_glyph_with_font(pixmap, px, py, fallback, fid, color, em);
233            }
234        }
235        // KaTeX TTFs omit many BMP symbols (e.g. U+263A from `\char`). Browsers use system fonts;
236        // load one Unicode-capable face via `RATEX_UNICODE_FONT` or fontdb / common paths.
237        if let Some(bytes) = unicode_fallback_font_bytes() {
238            if let Ok(fb) = FontRef::try_from_slice(bytes) {
239                let fid = fb.glyph_id(ch);
240                if fid.0 != 0 {
241                    return render_glyph_with_font(pixmap, px, py, &fb, fid, color, em);
242                }
243            }
244        }
245        return;
246    }
247
248    render_glyph_with_font(pixmap, px, py, font, glyph_id, color, em);
249}
250
251fn render_glyph_with_font(
252    pixmap: &mut Pixmap,
253    px: f32,
254    py: f32,
255    font: &FontRef<'_>,
256    glyph_id: ab_glyph::GlyphId,
257    color: &Color,
258    em: f32,
259) {
260    let outline = match font.outline(glyph_id) {
261        Some(o) => o,
262        None => return,
263    };
264
265    let units_per_em = font.units_per_em().unwrap_or(1000.0);
266    let scale = em / units_per_em;
267
268    let mut builder = PathBuilder::new();
269    let mut last_end: Option<(f32, f32)> = None;
270
271    for curve in &outline.curves {
272        use ab_glyph::OutlineCurve;
273        let (start, end) = match curve {
274            OutlineCurve::Line(p0, p1) => {
275                let sx = px + p0.x * scale;
276                let sy = py - p0.y * scale;
277                let ex = px + p1.x * scale;
278                let ey = py - p1.y * scale;
279                ((sx, sy), (ex, ey))
280            }
281            OutlineCurve::Quad(p0, _, p2) => {
282                let sx = px + p0.x * scale;
283                let sy = py - p0.y * scale;
284                let ex = px + p2.x * scale;
285                let ey = py - p2.y * scale;
286                ((sx, sy), (ex, ey))
287            }
288            OutlineCurve::Cubic(p0, _, _, p3) => {
289                let sx = px + p0.x * scale;
290                let sy = py - p0.y * scale;
291                let ex = px + p3.x * scale;
292                let ey = py - p3.y * scale;
293                ((sx, sy), (ex, ey))
294            }
295        };
296
297        // New contour if start doesn't match previous end
298        let need_move = match last_end {
299            None => true,
300            Some((lx, ly)) => (lx - start.0).abs() > 0.01 || (ly - start.1).abs() > 0.01,
301        };
302
303        if need_move {
304            if last_end.is_some() {
305                builder.close();
306            }
307            builder.move_to(start.0, start.1);
308        }
309
310        match curve {
311            OutlineCurve::Line(_, p1) => {
312                builder.line_to(px + p1.x * scale, py - p1.y * scale);
313            }
314            OutlineCurve::Quad(_, p1, p2) => {
315                builder.quad_to(
316                    px + p1.x * scale,
317                    py - p1.y * scale,
318                    px + p2.x * scale,
319                    py - p2.y * scale,
320                );
321            }
322            OutlineCurve::Cubic(_, p1, p2, p3) => {
323                builder.cubic_to(
324                    px + p1.x * scale,
325                    py - p1.y * scale,
326                    px + p2.x * scale,
327                    py - p2.y * scale,
328                    px + p3.x * scale,
329                    py - p3.y * scale,
330                );
331            }
332        }
333
334        last_end = Some(end);
335    }
336
337    if last_end.is_some() {
338        builder.close();
339    }
340
341    if let Some(path) = builder.finish() {
342        let mut paint = Paint::default();
343        paint.set_color_rgba8(
344            (color.r * 255.0) as u8,
345            (color.g * 255.0) as u8,
346            (color.b * 255.0) as u8,
347            255,
348        );
349        paint.anti_alias = true;
350        pixmap.fill_path(
351            &path,
352            &paint,
353            tiny_skia::FillRule::EvenOdd,
354            Transform::identity(),
355            None,
356        );
357    }
358}
359
360fn render_line(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, thickness: f32, color: &Color, dashed: bool) {
361    let t = thickness.max(1.0);
362    let mut paint = Paint::default();
363    paint.set_color_rgba8(
364        (color.r * 255.0) as u8,
365        (color.g * 255.0) as u8,
366        (color.b * 255.0) as u8,
367        255,
368    );
369
370    if dashed {
371        // Draw a dashed line: dash length = 4t, gap = 4t.
372        let dash_len = (4.0 * t).max(2.0);
373        let gap_len = (4.0 * t).max(2.0);
374        let period = dash_len + gap_len;
375        let top = y - t / 2.0;
376        let mut cur_x = x;
377        while cur_x < x + width {
378            let seg_width = (dash_len).min(x + width - cur_x);
379            let seg_width = seg_width.max(2.0);
380            if let Some(rect) = tiny_skia::Rect::from_xywh(cur_x, top, seg_width, t) {
381                pixmap.fill_rect(rect, &paint, Transform::identity(), None);
382            }
383            cur_x += period;
384        }
385    } else {
386        if let Some(rect) = tiny_skia::Rect::from_xywh(x, y - t / 2.0, width, t) {
387            pixmap.fill_rect(rect, &paint, Transform::identity(), None);
388        }
389    }
390}
391
392fn render_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: &Color) {
393    // Clamp to at least 2px: with width=1px at a fractional pixel position, fill_dot8's
394    // dot-8 fixed-point arithmetic can produce inner_width=0 and trigger a debug_assert.
395    // 2px guarantees at least 1 full interior pixel regardless of sub-pixel alignment.
396    let width = width.max(2.0);
397    let height = height.max(2.0);
398    let rect = tiny_skia::Rect::from_xywh(x, y, width, height);
399    if let Some(rect) = rect {
400        let mut paint = Paint::default();
401        paint.set_color_rgba8(
402            (color.r * 255.0) as u8,
403            (color.g * 255.0) as u8,
404            (color.b * 255.0) as u8,
405            255,
406        );
407        pixmap.fill_rect(rect, &paint, Transform::identity(), None);
408    }
409}
410
411#[allow(clippy::too_many_arguments)]
412fn render_path(
413    pixmap: &mut Pixmap,
414    x: f32,
415    y: f32,
416    commands: &[ratex_types::path_command::PathCommand],
417    fill: bool,
418    color: &Color,
419    em: f32,
420    stroke_width_px: f32,
421) {
422    // For filled paths, render each subpath (delimited by MoveTo) as a separate
423    // fill_path call.  KaTeX stretchy arrows are assembled from multiple path
424    // components (e.g. "lefthook" + "rightarrow") whose winding directions can
425    // be opposite.  Combining them into a single fill_path with FillRule::Winding
426    // causes the shaft region to cancel out (net winding = 0 → unfilled).
427    // Drawing each subpath independently avoids cross-component winding interactions.
428        if fill {
429            let mut start = 0;
430            for i in 1..commands.len() {
431                if matches!(commands[i], ratex_types::path_command::PathCommand::MoveTo { .. }) {
432                    render_path_segment(pixmap, x, y, &commands[start..i], fill, color, em, stroke_width_px);
433                    start = i;
434                }
435            }
436            render_path_segment(pixmap, x, y, &commands[start..], fill, color, em, stroke_width_px);
437            return;
438        }
439        render_path_segment(pixmap, x, y, commands, fill, color, em, stroke_width_px);
440}
441
442#[allow(clippy::too_many_arguments)]
443fn render_path_segment(
444    pixmap: &mut Pixmap,
445    x: f32,
446    y: f32,
447    commands: &[ratex_types::path_command::PathCommand],
448    fill: bool,
449    color: &Color,
450    em: f32,
451    stroke_width_px: f32,
452) {
453    let mut builder = PathBuilder::new();
454    for cmd in commands {
455        match cmd {
456            ratex_types::path_command::PathCommand::MoveTo { x: cx, y: cy } => {
457                builder.move_to(x + *cx as f32 * em, y + *cy as f32 * em);
458            }
459            ratex_types::path_command::PathCommand::LineTo { x: cx, y: cy } => {
460                builder.line_to(x + *cx as f32 * em, y + *cy as f32 * em);
461            }
462            ratex_types::path_command::PathCommand::CubicTo {
463                x1,
464                y1,
465                x2,
466                y2,
467                x: cx,
468                y: cy,
469            } => {
470                builder.cubic_to(
471                    x + *x1 as f32 * em,
472                    y + *y1 as f32 * em,
473                    x + *x2 as f32 * em,
474                    y + *y2 as f32 * em,
475                    x + *cx as f32 * em,
476                    y + *cy as f32 * em,
477                );
478            }
479            ratex_types::path_command::PathCommand::QuadTo { x1, y1, x: cx, y: cy } => {
480                builder.quad_to(
481                    x + *x1 as f32 * em,
482                    y + *y1 as f32 * em,
483                    x + *cx as f32 * em,
484                    y + *cy as f32 * em,
485                );
486            }
487            ratex_types::path_command::PathCommand::Close => {
488                builder.close();
489            }
490        }
491    }
492
493    if let Some(path) = builder.finish() {
494        let mut paint = Paint::default();
495        paint.set_color_rgba8(
496            (color.r * 255.0) as u8,
497            (color.g * 255.0) as u8,
498            (color.b * 255.0) as u8,
499            255,
500        );
501        if fill {
502            paint.anti_alias = true;
503            // Even-odd: KaTeX `tallDelim` vert uses two subpaths (outline + stem); nonzero winding
504            // double-fills the stem and inflates ink vs reference PNGs.
505            pixmap.fill_path(
506                &path,
507                &paint,
508                FillRule::EvenOdd,
509                Transform::identity(),
510                None,
511            );
512        } else {
513            let stroke = Stroke {
514                width: stroke_width_px,
515                ..Default::default()
516            };
517            pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None);
518        }
519    }
520}
521
522fn encode_png(pixmap: &Pixmap) -> Result<Vec<u8>, String> {
523    let mut buf = Vec::new();
524    {
525        let mut encoder = png::Encoder::new(&mut buf, pixmap.width(), pixmap.height());
526        encoder.set_color(png::ColorType::Rgba);
527        encoder.set_depth(png::BitDepth::Eight);
528        let mut writer = encoder
529            .write_header()
530            .map_err(|e| format!("PNG header error: {}", e))?;
531        writer
532            .write_image_data(pixmap.data())
533            .map_err(|e| format!("PNG write error: {}", e))?;
534    }
535    Ok(buf)
536}