Skip to main content

siglus_scene_vm/
text_render.rs

1//! Text rendering helpers.
2//!
3//! TTF/OTF fonts are preferred. The lookup order mirrors the engine use case:
4//! game-local fonts first, then engine-local fonts, then the compile-time
5//! embedded default font, then platform fonts. If no font can be loaded, a
6//! small ASCII bitmap fallback is used only to keep debug text visible.
7
8use crate::assets::RgbaImage;
9use crate::image_manager::{ImageId, ImageManager};
10use ab_glyph::{point, Font, FontArc, PxScale, ScaleFont};
11use std::path::{Path, PathBuf};
12
13mod embedded_font {
14    pub const EMBEDDED_DEFAULT_FONT: Option<&'static [u8]> =
15        Some(include_bytes!("../assets/fonts/default.ttf") as &'static [u8]);
16    pub const EMBEDDED_DEFAULT_FONT_SOURCE: Option<&'static str> = Some("assets/fonts/default.ttf");
17    pub const EMBEDDED_DEFAULT_FONT_ALIASES: &[&str] = &[
18        "MS Pゴシック",
19        "MS PGothic",
20        "MS-PGothic",
21        "MSPGothic",
22        "msgothic",
23        "default",
24    ];
25}
26
27#[derive(Debug, Clone, Copy)]
28pub struct TextStyle {
29    pub color: (u8, u8, u8),
30    pub shadow_color: (u8, u8, u8),
31    pub fuchi_color: (u8, u8, u8),
32    pub shadow: bool,
33    pub fuchi: bool,
34    pub bold: bool,
35}
36
37impl Default for TextStyle {
38    fn default() -> Self {
39        Self {
40            color: (255, 255, 255),
41            shadow_color: (0, 0, 0),
42            fuchi_color: (0, 0, 0),
43            shadow: true,
44            fuchi: false,
45            bold: false,
46        }
47    }
48}
49
50
51#[derive(Debug, Default)]
52pub struct FontCache {
53    font: Option<FontArc>,
54    loaded_from: Option<PathBuf>,
55}
56
57impl FontCache {
58    pub fn new() -> Self {
59        Self {
60            font: None,
61            loaded_from: None,
62        }
63    }
64
65    pub fn is_loaded(&self) -> bool {
66        self.font.is_some()
67    }
68
69    pub fn loaded_from(&self) -> Option<&Path> {
70        self.loaded_from.as_deref()
71    }
72
73    pub fn load_for_project(&mut self, project_dir: &Path) -> bool {
74        if self.font.is_some() {
75            return true;
76        }
77
78        let mut dirs = Vec::new();
79        dirs.push(project_dir.join("font"));
80        dirs.push(project_dir.join("fonts"));
81
82        if let Ok(exe) = std::env::current_exe() {
83            if let Some(exe_dir) = exe.parent() {
84                dirs.push(exe_dir.join("font"));
85                dirs.push(exe_dir.join("fonts"));
86            }
87        }
88
89        let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
90        dirs.push(manifest_dir.join("assets").join("font"));
91        dirs.push(manifest_dir.join("assets").join("fonts"));
92
93        for dir in dirs {
94            if self.load_from_font_dir(&dir) {
95                return true;
96            }
97        }
98
99        if self.try_load_embedded_default_font() {
100            return true;
101        }
102
103        for path in platform_font_candidates() {
104            if self.try_load_font_file(&path) {
105                return true;
106            }
107        }
108
109        false
110    }
111
112    pub fn load_from_font_dir(&mut self, font_dir: &Path) -> bool {
113        if self.font.is_some() {
114            return true;
115        }
116        let Ok(entries) = std::fs::read_dir(font_dir) else {
117            return false;
118        };
119
120        let mut files = Vec::new();
121        for entry in entries.flatten() {
122            let path = entry.path();
123            if path.is_file() && is_supported_font_path(&path) {
124                files.push(path);
125            }
126        }
127        files.sort_by_key(|path| font_path_priority(path));
128
129        for path in files {
130            if self.try_load_font_file(&path) {
131                return true;
132            }
133        }
134        false
135    }
136
137    fn try_load_font_file(&mut self, path: &Path) -> bool {
138        if self.font.is_some() {
139            return true;
140        }
141        if !path.is_file() || !is_supported_font_path(path) {
142            return false;
143        }
144        let Ok(bytes) = std::fs::read(path) else {
145            return false;
146        };
147        match FontArc::try_from_vec(bytes) {
148            Ok(font) => {
149                self.font = Some(font);
150                self.loaded_from = Some(path.to_path_buf());
151                true
152            }
153            Err(_) => false,
154        }
155    }
156
157    fn try_load_embedded_default_font(&mut self) -> bool {
158        if self.font.is_some() {
159            return true;
160        }
161        let Some(bytes) = embedded_font::EMBEDDED_DEFAULT_FONT else {
162            return false;
163        };
164        match FontArc::try_from_vec(bytes.to_vec()) {
165            Ok(font) => {
166                self.font = Some(font);
167                let source = embedded_font::EMBEDDED_DEFAULT_FONT_SOURCE.unwrap_or("embedded:default-font");
168                self.loaded_from = Some(PathBuf::from(source));
169                true
170            }
171            Err(_) => false,
172        }
173    }
174
175    pub fn render_text(
176        &self,
177        images: &mut ImageManager,
178        text: &str,
179        font_px: f32,
180        max_w: u32,
181        max_h: u32,
182    ) -> Option<ImageId> {
183        self.render_text_into(images, None, text, font_px, max_w, max_h)
184    }
185
186    pub fn render_mwnd_text(
187        &self,
188        images: &mut ImageManager,
189        text: &str,
190        font_px: f32,
191        max_w: u32,
192        max_h: u32,
193        moji_space: Option<(i64, i64)>,
194    ) -> Option<ImageId> {
195        let img = self.render_mwnd_text_rgba(text, font_px, max_w, max_h, moji_space)?;
196        Some(images.insert_image(img))
197    }
198
199    pub fn render_mwnd_text_styled(
200        &self,
201        images: &mut ImageManager,
202        text: &str,
203        font_px: f32,
204        max_w: u32,
205        max_h: u32,
206        moji_space: Option<(i64, i64)>,
207        style: TextStyle,
208    ) -> Option<ImageId> {
209        self.render_mwnd_text_styled_into(images, None, text, font_px, max_w, max_h, moji_space, style)
210    }
211
212    pub fn render_mwnd_text_styled_into(
213        &self,
214        images: &mut ImageManager,
215        target: Option<ImageId>,
216        text: &str,
217        font_px: f32,
218        max_w: u32,
219        max_h: u32,
220        moji_space: Option<(i64, i64)>,
221        style: TextStyle,
222    ) -> Option<ImageId> {
223        let img = self.render_mwnd_text_rgba_styled(text, font_px, max_w, max_h, moji_space, style)?;
224        match target {
225            Some(id) => {
226                images.replace_image(id, img).ok()?;
227                Some(id)
228            }
229            None => Some(images.insert_image(img)),
230        }
231    }
232
233    pub fn render_text_into(
234        &self,
235        images: &mut ImageManager,
236        target: Option<ImageId>,
237        text: &str,
238        font_px: f32,
239        max_w: u32,
240        max_h: u32,
241    ) -> Option<ImageId> {
242        let img = self.render_text_rgba(text, font_px, max_w, max_h)?;
243        match target {
244            Some(id) => {
245                images.replace_image(id, img).ok()?;
246                Some(id)
247            }
248            None => Some(images.insert_image(img)),
249        }
250    }
251
252    pub fn render_text_rgba(
253        &self,
254        text: &str,
255        font_px: f32,
256        max_w: u32,
257        max_h: u32,
258    ) -> Option<RgbaImage> {
259        let Some(font) = self.font.as_ref() else {
260            return render_text_image_basic_rgba(text, font_px as u32, max_w, max_h);
261        };
262        render_text_ab_glyph_rgba(font, text, font_px, max_w, max_h)
263    }
264
265    pub fn render_mwnd_text_rgba(
266        &self,
267        text: &str,
268        font_px: f32,
269        max_w: u32,
270        max_h: u32,
271        moji_space: Option<(i64, i64)>,
272    ) -> Option<RgbaImage> {
273        self.render_mwnd_text_rgba_styled(text, font_px, max_w, max_h, moji_space, TextStyle::default())
274    }
275
276    pub fn render_mwnd_text_rgba_styled(
277        &self,
278        text: &str,
279        font_px: f32,
280        max_w: u32,
281        max_h: u32,
282        moji_space: Option<(i64, i64)>,
283        style: TextStyle,
284    ) -> Option<RgbaImage> {
285        let Some(font) = self.font.as_ref() else {
286            return render_text_image_basic_rgba(text, font_px as u32, max_w, max_h);
287        };
288        render_mwnd_text_ab_glyph_rgba_styled(font, text, font_px, max_w, max_h, moji_space, style)
289    }
290}
291
292pub fn render_text_image_basic(
293    images: &mut ImageManager,
294    text: &str,
295    font_px: u32,
296    max_w: u32,
297    max_h: u32,
298) -> Option<ImageId> {
299    let img = render_text_image_basic_rgba(text, font_px, max_w, max_h)?;
300    Some(images.insert_image(img))
301}
302
303pub fn render_text_image_basic_rgba(
304    text: &str,
305    font_px: u32,
306    max_w: u32,
307    max_h: u32,
308) -> Option<RgbaImage> {
309    if text.is_empty() || max_w == 0 || max_h == 0 {
310        return None;
311    }
312    let scale = (font_px / 7).max(1);
313    let glyph_w = 5 * scale;
314    let glyph_h = 7 * scale;
315    let advance = glyph_w + scale;
316    let line_height = glyph_h + scale;
317
318    let mut rgba = vec![0u8; (max_w * max_h * 4) as usize];
319    let mut x = 0u32;
320    let mut y = 0u32;
321
322    for ch in text.chars() {
323        if ch == '\n' {
324            x = 0;
325            y = y.saturating_add(line_height);
326            if y >= max_h {
327                break;
328            }
329            continue;
330        }
331        if ch == '\t' {
332            x = x.saturating_add(advance * 2);
333            continue;
334        }
335        if x + glyph_w > max_w {
336            x = 0;
337            y = y.saturating_add(line_height);
338            if y >= max_h {
339                break;
340            }
341        }
342        draw_glyph_5x7(&mut rgba, max_w, max_h, x, y, ch, scale);
343        x = x.saturating_add(advance);
344    }
345
346    Some(RgbaImage {
347        width: max_w,
348        height: max_h,
349        center_x: 0,
350        center_y: 0,
351        rgba,
352    })
353}
354
355
356#[derive(Debug, Clone)]
357struct RasterGlyph {
358    width: usize,
359    height: usize,
360    xmin: i32,
361    ymin: i32,
362    bitmap: Vec<u8>,
363}
364
365fn rasterize_ab_glyph(font: &FontArc, ch: char, font_px: f32) -> RasterGlyph {
366    let scale = PxScale::from(font_px.max(1.0));
367    let scaled = font.as_scaled(scale);
368    let glyph_id = scaled.glyph_id(ch);
369    let glyph = glyph_id.with_scale_and_position(scale, point(0.0, 0.0));
370    let Some(outlined) = scaled.outline_glyph(glyph) else {
371        return RasterGlyph {
372            width: 0,
373            height: 0,
374            xmin: 0,
375            ymin: 0,
376            bitmap: Vec::new(),
377        };
378    };
379
380    let bounds = outlined.px_bounds();
381    let xmin = bounds.min.x.floor() as i32;
382    let ymin = bounds.min.y.floor() as i32;
383    let xmax = bounds.max.x.ceil() as i32;
384    let ymax = bounds.max.y.ceil() as i32;
385    let width = (xmax - xmin).max(0) as usize;
386    let height = (ymax - ymin).max(0) as usize;
387    if width == 0 || height == 0 {
388        return RasterGlyph {
389            width: 0,
390            height: 0,
391            xmin,
392            ymin,
393            bitmap: Vec::new(),
394        };
395    }
396
397    let shifted_glyph = glyph_id.with_scale_and_position(scale, point((-xmin) as f32, (-ymin) as f32));
398    let Some(shifted) = scaled.outline_glyph(shifted_glyph) else {
399        return RasterGlyph {
400            width: 0,
401            height: 0,
402            xmin,
403            ymin,
404            bitmap: Vec::new(),
405        };
406    };
407
408    let mut bitmap = vec![0u8; width * height];
409    shifted.draw(|gx, gy, cov| {
410        let x = gx as usize;
411        let y = gy as usize;
412        if x < width && y < height {
413            bitmap[y * width + x] = (cov * 255.0).round().clamp(0.0, 255.0) as u8;
414        }
415    });
416
417    RasterGlyph {
418        width,
419        height,
420        xmin,
421        ymin,
422        bitmap,
423    }
424}
425
426
427fn render_mwnd_text_ab_glyph_rgba(
428    font: &FontArc,
429    text: &str,
430    font_px: f32,
431    max_w: u32,
432    max_h: u32,
433    moji_space: Option<(i64, i64)>,
434) -> Option<RgbaImage> {
435    render_mwnd_text_ab_glyph_rgba_styled(font, text, font_px, max_w, max_h, moji_space, TextStyle::default())
436}
437
438fn render_mwnd_text_ab_glyph_rgba_styled(
439    font: &FontArc,
440    text: &str,
441    font_px: f32,
442    max_w: u32,
443    max_h: u32,
444    moji_space: Option<(i64, i64)>,
445    style: TextStyle,
446) -> Option<RgbaImage> {
447    if text.is_empty() || max_w == 0 || max_h == 0 {
448        return None;
449    }
450
451    let (space_x, space_y) = moji_space.unwrap_or((-1, 10));
452    let font_cell = font_px.round().max(1.0) as i32;
453    let line_h = (font_cell + space_y as i32).max(font_cell).max(1);
454    let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
455    let baseline_y = scaled.ascent().ceil().max(1.0) as i32;
456    let effect_pad = text_effect_padding(font_cell, style);
457    let render_w = max_w.saturating_add(effect_pad.max(0) as u32 + 2);
458    let render_h = max_h.saturating_add((baseline_y + effect_pad).max(font_cell / 4 + effect_pad + 2).max(0) as u32);
459    let mut rgba = vec![0u8; (render_w * render_h * 4) as usize];
460
461    for placed in layout_mwnd_text(text, font_cell, space_x as i32, line_h, max_w, max_h) {
462        let glyph = rasterize_ab_glyph(font, placed.ch, font_px);
463        if glyph.width == 0 || glyph.height == 0 {
464            continue;
465        }
466
467        let draw_x = placed.x + glyph.xmin;
468        let draw_y = placed.y + baseline_y + glyph.ymin;
469
470        if style.fuchi {
471            for (ox, oy) in [
472                (-1, -1), (0, -1), (1, -1),
473                (-1,  0),          (1,  0),
474                (-1,  1), (0,  1), (1,  1),
475            ] {
476                draw_glyph_bitmap(
477                    &mut rgba,
478                    render_w,
479                    render_h,
480                    draw_x + ox,
481                    draw_y + oy,
482                    glyph.width,
483                    glyph.height,
484                    &glyph.bitmap,
485                    (style.fuchi_color.0, style.fuchi_color.1, style.fuchi_color.2, 255),
486                );
487            }
488        }
489        if style.shadow {
490            let shadow_offset = shadow_offset_for_size(font_cell);
491            draw_glyph_bitmap(
492                &mut rgba,
493                render_w,
494                render_h,
495                draw_x + shadow_offset,
496                draw_y + shadow_offset,
497                glyph.width,
498                glyph.height,
499                &glyph.bitmap,
500                (style.shadow_color.0, style.shadow_color.1, style.shadow_color.2, 255),
501            );
502        }
503        draw_glyph_bitmap(
504            &mut rgba,
505            render_w,
506            render_h,
507            draw_x,
508            draw_y,
509            glyph.width,
510            glyph.height,
511            &glyph.bitmap,
512            (style.color.0, style.color.1, style.color.2, 255),
513        );
514        if style.bold {
515            draw_glyph_bitmap(
516                &mut rgba,
517                render_w,
518                render_h,
519                draw_x + 1,
520                draw_y,
521                glyph.width,
522                glyph.height,
523                &glyph.bitmap,
524                (style.color.0, style.color.1, style.color.2, 220),
525            );
526        }
527    }
528
529    Some(RgbaImage {
530        width: render_w,
531        height: render_h,
532        center_x: 0,
533        center_y: 0,
534        rgba,
535    })
536}
537
538#[derive(Debug, Clone, Copy)]
539struct MwndPlacedChar {
540    ch: char,
541    x: i32,
542    y: i32,
543    cell_w: i32,
544}
545
546fn layout_mwnd_text(
547    text: &str,
548    font_cell: i32,
549    space_x: i32,
550    line_h: i32,
551    max_w: u32,
552    max_h: u32,
553) -> Vec<MwndPlacedChar> {
554    let full_cell_w = font_cell.max(1);
555    let half_cell_w = ((font_cell + 1) / 2).max(1);
556    let max_w = max_w as i32;
557    let max_h = max_h as i32;
558    let mut out = Vec::new();
559    let mut x = 0i32;
560    let mut y = 0i32;
561    let mut indent_x = 0i32;
562    let mut line_head = true;
563
564    for ch in text.chars() {
565        match ch {
566            '\r' => continue,
567            '\n' => {
568                x = indent_x;
569                y += line_h;
570                line_head = true;
571                if y >= max_h {
572                    break;
573                }
574                continue;
575            }
576            '\u{0007}' => {
577                indent_x = 0;
578                x = 0;
579                y += line_h;
580                line_head = true;
581                if y >= max_h {
582                    break;
583                }
584                continue;
585            }
586            '\t' => {
587                x += (full_cell_w + space_x).max(1) * 2;
588                line_head = false;
589                continue;
590            }
591            _ => {}
592        }
593
594        let cell_w = if is_hankaku(ch) { half_cell_w } else { full_cell_w };
595        let check_size = cell_w + space_x;
596        let force_wrap = x > 0 && x + check_size > max_w + full_cell_w;
597        let soft_wrap = x > 0 && x + check_size > max_w && !is_siglus_forbidden_line_head(ch);
598        if force_wrap || soft_wrap {
599            x = indent_x;
600            y += line_h;
601            line_head = true;
602            if y >= max_h {
603                break;
604            }
605            if ch == ' ' || ch == '\u{3000}' {
606                continue;
607            }
608        }
609
610        if line_head {
611            if is_siglus_indent_open(ch) {
612                indent_x = full_cell_w;
613            } else if is_siglus_indent_close(ch) {
614                indent_x = 0;
615            }
616        }
617
618        out.push(MwndPlacedChar { ch, x, y, cell_w });
619        x += (cell_w + space_x).max(1);
620        line_head = false;
621    }
622    out
623}
624
625fn is_siglus_indent_open(ch: char) -> bool {
626    matches!(ch, '「' | '『' | '(')
627}
628
629fn is_siglus_indent_close(ch: char) -> bool {
630    matches!(ch, '」' | '』' | ')')
631}
632
633fn is_siglus_forbidden_line_head(ch: char) -> bool {
634    matches!(
635        ch,
636        '、' | '。' | ',' | '.' | '・' | ':' | ';' | '?' | '!' |
637        '」' | '』' | ')' | ']' | '}' | '〉' | '》' | '】' | '〕' |
638        'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' |
639        'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' |
640        'ー' | 'ー' | '~' | '…' | '‥'
641    )
642}
643
644fn shadow_offset_for_size(size: i32) -> i32 {
645    if size <= 0 {
646        return 1;
647    }
648    // Original shadow offset is linear_limit(size, 0, 0.5, 32, 2.0).
649    let t = (size as f32 / 32.0).clamp(0.0, 1.0);
650    (0.5 + (2.0 - 0.5) * t).round().max(1.0) as i32
651}
652
653fn text_effect_padding(size: i32, style: TextStyle) -> i32 {
654    let mut pad = 1;
655    if style.fuchi {
656        pad = pad.max(1);
657    }
658    if style.shadow {
659        pad = pad.max(shadow_offset_for_size(size));
660    }
661    pad + 1
662}
663
664fn is_hankaku(ch: char) -> bool {
665    ch.is_ascii() || matches!(ch as u32, 0xFF61..=0xFF9F)
666}
667
668fn draw_glyph_bitmap(
669    rgba: &mut [u8],
670    w: u32,
671    h: u32,
672    x: i32,
673    y: i32,
674    glyph_w: usize,
675    glyph_h: usize,
676    glyph: &[u8],
677    color: (u8, u8, u8, u8),
678) {
679    for gy in 0..glyph_h {
680        let py = y + gy as i32;
681        if py < 0 || py as u32 >= h {
682            continue;
683        }
684        for gx in 0..glyph_w {
685            let px = x + gx as i32;
686            if px < 0 || px as u32 >= w {
687                continue;
688            }
689            let src = glyph[gy * glyph_w + gx];
690            if src == 0 {
691                continue;
692            }
693            let src_a = ((src as u16 * color.3 as u16) / 255) as u8;
694            blend_rgba_pixel(rgba, w, px as u32, py as u32, color.0, color.1, color.2, src_a);
695        }
696    }
697}
698
699fn blend_rgba_pixel(
700    rgba: &mut [u8],
701    w: u32,
702    x: u32,
703    y: u32,
704    sr: u8,
705    sg: u8,
706    sb: u8,
707    sa: u8,
708) {
709    let idx = ((y * w + x) * 4) as usize;
710    let da = rgba[idx + 3] as u16;
711    let sa_u = sa as u16;
712    let inv_sa = 255u16.saturating_sub(sa_u);
713    let out_a = sa_u + (da * inv_sa + 127) / 255;
714    if out_a == 0 {
715        rgba[idx] = 0;
716        rgba[idx + 1] = 0;
717        rgba[idx + 2] = 0;
718        rgba[idx + 3] = 0;
719        return;
720    }
721    let blend = |src: u8, dst: u8| -> u8 {
722        let src_p = src as u16 * sa_u;
723        let dst_p = dst as u16 * da * inv_sa / 255;
724        ((src_p + dst_p + out_a / 2) / out_a).min(255) as u8
725    };
726    rgba[idx] = blend(sr, rgba[idx]);
727    rgba[idx + 1] = blend(sg, rgba[idx + 1]);
728    rgba[idx + 2] = blend(sb, rgba[idx + 2]);
729    rgba[idx + 3] = out_a.min(255) as u8;
730}
731
732fn render_text_ab_glyph_rgba(
733    font: &FontArc,
734    text: &str,
735    font_px: f32,
736    max_w: u32,
737    max_h: u32,
738) -> Option<RgbaImage> {
739    if text.is_empty() || max_w == 0 || max_h == 0 {
740        return None;
741    }
742    let mut rgba = vec![0u8; (max_w * max_h * 4) as usize];
743
744    let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
745    let ascent = scaled.ascent().max(1.0);
746    let line_height = (scaled.height() + scaled.line_gap()).max(1.0);
747
748    let mut x = 0.0f32;
749    let mut baseline_y = ascent.max(1.0);
750
751    for ch in text.chars() {
752        match ch {
753            '\r' => continue,
754            '\n' => {
755                x = 0.0;
756                baseline_y += line_height;
757                if baseline_y - ascent >= max_h as f32 {
758                    break;
759                }
760                continue;
761            }
762            '\t' => {
763                x += scaled.h_advance(scaled.glyph_id(' ')).max(0.0) * 2.0;
764                continue;
765            }
766            _ => {}
767        }
768
769        let advance = scaled.h_advance(scaled.glyph_id(ch)).max(0.0);
770        if x > 0.0 && x + advance > max_w as f32 {
771            x = 0.0;
772            baseline_y += line_height;
773            if baseline_y - ascent >= max_h as f32 {
774                break;
775            }
776        }
777
778        let glyph = rasterize_ab_glyph(font, ch, font_px);
779        let gx = x + glyph.xmin as f32;
780        let gy = baseline_y + glyph.ymin as f32;
781        for gy_i in 0..glyph.height {
782            let py = gy as i32 + gy_i as i32;
783            if py < 0 || py as u32 >= max_h {
784                continue;
785            }
786            for gx_i in 0..glyph.width {
787                let px = gx as i32 + gx_i as i32;
788                if px < 0 || px as u32 >= max_w {
789                    continue;
790                }
791                let src = glyph.bitmap[gy_i * glyph.width + gx_i];
792                if src == 0 {
793                    continue;
794                }
795                let idx = ((py as u32 * max_w + px as u32) * 4) as usize;
796                rgba[idx] = 255;
797                rgba[idx + 1] = 255;
798                rgba[idx + 2] = 255;
799                rgba[idx + 3] = src;
800            }
801        }
802        x += advance;
803    }
804
805    Some(RgbaImage {
806        width: max_w,
807        height: max_h,
808        center_x: 0,
809        center_y: 0,
810        rgba,
811    })
812}
813
814pub fn embedded_default_font_available() -> bool {
815    embedded_font::EMBEDDED_DEFAULT_FONT.is_some()
816}
817
818pub fn embedded_default_font_names() -> &'static [&'static str] {
819    if embedded_default_font_available() {
820        embedded_font::EMBEDDED_DEFAULT_FONT_ALIASES
821    } else {
822        &[]
823    }
824}
825
826pub fn font_name_matches_embedded_default(name: &str) -> bool {
827    if !embedded_default_font_available() {
828        return false;
829    }
830    let needle = normalize_font_name_for_match(name);
831    if needle.is_empty() {
832        return false;
833    }
834    embedded_font::EMBEDDED_DEFAULT_FONT_ALIASES
835        .iter()
836        .any(|alias| normalize_font_name_for_match(alias) == needle)
837}
838
839fn normalize_font_name_for_match(name: &str) -> String {
840    name.chars()
841        .filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != '.')
842        .flat_map(|ch| ch.to_lowercase())
843        .collect()
844}
845
846fn is_supported_font_path(path: &Path) -> bool {
847    matches!(
848        path.extension()
849            .and_then(|s| s.to_str())
850            .map(|s| s.to_ascii_lowercase())
851            .as_deref(),
852        Some("ttf" | "otf" | "ttc")
853    )
854}
855
856fn font_path_priority(path: &Path) -> (u8, u8, String) {
857    let name_original = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
858    let name = name_original.to_ascii_lowercase();
859    let ext_score = match path
860        .extension()
861        .and_then(|s| s.to_str())
862        .map(|s| s.to_ascii_lowercase())
863        .as_deref()
864    {
865        Some("ttf") | Some("otf") => 0,
866        Some("ttc") => 1,
867        _ => 2,
868    };
869    let family_score = if name.contains("ms pgothic")
870        || name.contains("mspgothic")
871        || name.contains("ms-pgothic")
872        || name.contains("msgothic")
873        || name_original.contains("MS Pゴシック")
874        || name_original.contains("MS PGothic")
875    {
876        0
877    } else if name.contains("pgothic") || name_original.contains("Pゴシック") {
878        1
879    } else if name.contains("gothic") || name_original.contains("ゴシック") {
880        2
881    } else {
882        3
883    };
884    (family_score, ext_score, name)
885}
886
887fn platform_font_candidates() -> Vec<PathBuf> {
888    let mut out = Vec::new();
889
890    #[cfg(target_os = "windows")]
891    {
892        let windir = std::env::var_os("WINDIR")
893            .map(PathBuf::from)
894            .unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
895        let fonts = windir.join("Fonts");
896        out.push(fonts.join("msgothic.ttc"));
897        out.push(fonts.join("msgothic.ttf"));
898        out.push(fonts.join("YuGothM.ttc"));
899        out.push(fonts.join("YuGothR.ttc"));
900    }
901
902    #[cfg(target_os = "macos")]
903    {
904        out.push(PathBuf::from("/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc"));
905        out.push(PathBuf::from("/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc"));
906        out.push(PathBuf::from("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"));
907        out.push(PathBuf::from("/System/Library/Fonts/Supplemental/Osaka.ttf"));
908    }
909
910    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "android"))]
911    {
912        out.push(PathBuf::from("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"));
913        out.push(PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"));
914        out.push(PathBuf::from("/usr/share/fonts/opentype/noto/NotoSansCJKjp-Regular.otf"));
915        out.push(PathBuf::from("/usr/share/fonts/truetype/fonts-japanese-gothic.ttf"));
916    }
917
918    out
919}
920
921fn draw_glyph_5x7(rgba: &mut [u8], w: u32, h: u32, x: u32, y: u32, ch: char, scale: u32) {
922    let glyph = glyph_5x7(ch);
923    for (row, bits) in glyph.iter().enumerate() {
924        for col in 0..5 {
925            if (bits >> (4 - col)) & 1 == 0 {
926                continue;
927            }
928            let px = x + col as u32 * scale;
929            let py = y + row as u32 * scale;
930            for sy in 0..scale {
931                let yy = py + sy;
932                if yy >= h {
933                    continue;
934                }
935                for sx in 0..scale {
936                    let xx = px + sx;
937                    if xx >= w {
938                        continue;
939                    }
940                    let idx = ((yy * w + xx) * 4) as usize;
941                    rgba[idx] = 255;
942                    rgba[idx + 1] = 255;
943                    rgba[idx + 2] = 255;
944                    rgba[idx + 3] = 255;
945                }
946            }
947        }
948    }
949}
950
951fn glyph_5x7(ch: char) -> [u8; 7] {
952    match ch.to_ascii_uppercase() {
953        'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
954        'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
955        'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
956        'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
957        'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
958        'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
959        'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],
960        'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
961        'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
962        'J' => [0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E],
963        'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
964        'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
965        'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
966        'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
967        'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
968        'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
969        'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
970        'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
971        'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
972        'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
973        'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
974        'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
975        'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
976        'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
977        'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
978        'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
979        '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
980        '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
981        '2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
982        '3' => [0x1E, 0x01, 0x01, 0x06, 0x01, 0x01, 0x1E],
983        '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
984        '5' => [0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E],
985        '6' => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],
986        '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
987        '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
988        '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E],
989        ':' => [0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00],
990        '.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06],
991        ',' => [0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x04],
992        '-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
993        '_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
994        '/' => [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
995        '\\' => [0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00],
996        '[' => [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E],
997        ']' => [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E],
998        '(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
999        ')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
1000        '#' => [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A],
1001        '+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
1002        '=' => [0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x00],
1003        '*' => [0x00, 0x11, 0x0A, 0x1F, 0x0A, 0x11, 0x00],
1004        '?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
1005        '!' => [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
1006        '>' => [0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10],
1007        '<' => [0x01, 0x02, 0x04, 0x08, 0x04, 0x02, 0x01],
1008        '|' => [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
1009        ' ' => [0x00; 7],
1010        _ => [0x1F, 0x11, 0x15, 0x15, 0x15, 0x11, 0x1F],
1011    }
1012}