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::{ImageHandle, ImageManager};
10use ab_glyph::{Font, FontArc, FontRef, PxScale, ScaleFont, point};
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, OnceLock};
14
15static MISSING_CONFIGURED_FONTS_LOGGED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
16
17// Share one embedded font allocation between game text and desktop dialogs.
18pub(crate) static DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../assets/fonts/default.ttf");
19
20fn log_missing_configured_font_once(requested_name: &str, fallback: Option<&Path>) {
21    let key = normalize_font_name_for_match(requested_name.trim_start_matches('@'));
22    if key.is_empty() {
23        return;
24    }
25
26    let seen = MISSING_CONFIGURED_FONTS_LOGGED.get_or_init(|| Mutex::new(HashSet::new()));
27    let first = match seen.lock() {
28        Ok(mut seen) => seen.insert(key),
29        Err(poisoned) => poisoned.into_inner().insert(key),
30    };
31    if !first {
32        return;
33    }
34
35    match fallback {
36        Some(path) => log::error!(
37            "configured font {:?} was not found; using fallback {:?}",
38            requested_name,
39            path
40        ),
41        None => log::error!(
42            "configured font {:?} was not found; using embedded default font",
43            requested_name
44        ),
45    }
46}
47
48mod embedded_font {
49    pub const EMBEDDED_DEFAULT_FONT: Option<&'static [u8]> = Some(super::DEFAULT_FONT_BYTES);
50    pub const EMBEDDED_DEFAULT_FONT_SOURCE: Option<&'static str> = Some("assets/fonts/default.ttf");
51    pub const EMBEDDED_DEFAULT_FONT_ALIASES: &[&str] = &[
52        "MS Pゴシック",
53        "MS PGothic",
54        "MS-PGothic",
55        "MSPGothic",
56        "msgothic",
57        "default",
58    ];
59}
60
61pub const TNM_FONT_SHADOW_MODE_NONE: i64 = 0;
62pub const TNM_FONT_SHADOW_MODE_SHADOW: i64 = 1;
63pub const TNM_FONT_SHADOW_MODE_FUCHI: i64 = 2;
64pub const TNM_FONT_SHADOW_MODE_FUCHI_SHADOW: i64 = 3;
65
66pub fn normalize_font_shadow_mode(mode: i64) -> i64 {
67    mode.clamp(TNM_FONT_SHADOW_MODE_NONE, TNM_FONT_SHADOW_MODE_FUCHI_SHADOW)
68}
69
70pub fn font_shadow_mode_flags(mode: i64) -> (bool, bool) {
71    match normalize_font_shadow_mode(mode) {
72        TNM_FONT_SHADOW_MODE_SHADOW => (true, false),
73        TNM_FONT_SHADOW_MODE_FUCHI => (false, true),
74        TNM_FONT_SHADOW_MODE_FUCHI_SHADOW => (true, true),
75        _ => (false, false),
76    }
77}
78
79#[derive(Debug, Clone, Copy)]
80pub struct TextStyle {
81    pub color: (u8, u8, u8),
82    pub shadow_color: (u8, u8, u8),
83    pub fuchi_color: (u8, u8, u8),
84    /// Original TNM_FONT_SHADOW_MODE_* value (0..=3).
85    pub shadow_mode: i64,
86    pub shadow: bool,
87    pub fuchi: bool,
88    pub bold: bool,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum TextSpriteLayer {
93    Shadow,
94    Fuchi,
95    Body,
96}
97
98#[derive(Debug, Clone, Copy)]
99pub struct PositionedTextGlyph {
100    pub ch: char,
101    pub x: i32,
102    pub y: i32,
103    pub size: f32,
104    /// Render through the original vertical-font path.  Siglus enumerates an
105    /// '@' face and asks GDI for a rotated outline; this flag carries that
106    /// semantic into the cross-platform rasterizer instead of only changing
107    /// layout coordinates.
108    pub vertical: bool,
109    pub style: TextStyle,
110}
111
112#[derive(Debug, Clone)]
113pub struct PositionedTextRender {
114    pub image: ImageHandle,
115    pub offset_x: i32,
116    pub offset_y: i32,
117}
118
119impl Default for TextStyle {
120    fn default() -> Self {
121        Self {
122            color: (255, 255, 255),
123            shadow_color: (0, 0, 0),
124            fuchi_color: (0, 0, 0),
125            shadow_mode: TNM_FONT_SHADOW_MODE_SHADOW,
126            shadow: true,
127            fuchi: false,
128            bold: false,
129        }
130    }
131}
132
133#[derive(Debug, Default)]
134pub struct FontCache {
135    font: Option<FontArc>,
136    loaded_from: Option<PathBuf>,
137    loaded_face_index: u32,
138    /// Normalized engine-visible face name used to select `font`.
139    ///
140    /// The original engine clears its glyph cache whenever the effective
141    /// SCRIPT/SYSCOM font changes.  Keeping the request here gives this port
142    /// the same invalidation boundary instead of pinning the first loaded face
143    /// for the lifetime of the process.
144    requested_name: String,
145    /// Faces already resolved, by normalized request: switching back to a
146    /// face neither rescans the font directories nor reads a font file
147    /// again (each scan read whole files, tens of MiB, per switch).
148    resolved: std::collections::HashMap<String, (FontArc, Option<PathBuf>, u32)>,
149}
150
151impl FontCache {
152    pub fn new() -> Self {
153        Self {
154            font: None,
155            loaded_from: None,
156            loaded_face_index: 0,
157            requested_name: String::new(),
158            resolved: std::collections::HashMap::new(),
159        }
160    }
161
162    pub fn is_loaded(&self) -> bool {
163        self.font.is_some()
164    }
165
166    pub fn loaded_from(&self) -> Option<&Path> {
167        self.loaded_from.as_deref()
168    }
169
170    pub fn loaded_face_index(&self) -> u32 {
171        self.loaded_face_index
172    }
173
174    pub fn load_for_project(&mut self, project_dir: &Path) -> bool {
175        self.load_for_project_named(project_dir, "")
176    }
177
178    /// Select the effective engine font and load it if necessary.
179    ///
180    /// Siglus resolves the active face in this order: local SCRIPT override,
181    /// then the current SYSCOM configuration.  `tnm_update_font()` clears the
182    /// original glyph manager when that face changes.  This method mirrors the
183    /// same boundary and deliberately does not retain the first face forever.
184    pub fn load_for_project_named(&mut self, project_dir: &Path, requested_name: &str) -> bool {
185        let normalized = normalize_font_name_for_match(requested_name.trim_start_matches('@'));
186        if self.font.is_some() && self.requested_name == normalized {
187            return true;
188        }
189
190        if let Some((font, path, face)) = self.resolved.get(&normalized) {
191            self.font = Some(font.clone());
192            self.loaded_from = path.clone();
193            self.loaded_face_index = *face;
194            self.requested_name = normalized;
195            return true;
196        }
197        let found = self.resolve_for_project_named(project_dir, requested_name, &normalized);
198        if found && let Some(font) = &self.font {
199            self.resolved.insert(
200                normalized,
201                (
202                    font.clone(),
203                    self.loaded_from.clone(),
204                    self.loaded_face_index,
205                ),
206            );
207        }
208        found
209    }
210
211    fn resolve_for_project_named(
212        &mut self,
213        project_dir: &Path,
214        requested_name: &str,
215        normalized: &str,
216    ) -> bool {
217        let normalized = normalized.to_owned();
218        self.font = None;
219        self.loaded_from = None;
220        self.loaded_face_index = 0;
221        self.requested_name = normalized.clone();
222
223        let dirs = project_font_dirs(project_dir);
224
225        // A configured face is resolved before generic fallback.  Local game
226        // fonts are matched by file/family spelling, including the '@' prefix
227        // used by the original vertical-font enumeration.
228        if !normalized.is_empty() {
229            for dir in &dirs {
230                if self.load_named_from_font_dir(dir, &normalized) {
231                    return true;
232                }
233            }
234            if font_name_matches_embedded_default(requested_name.trim_start_matches('@'))
235                && self.try_load_embedded_default_font()
236            {
237                return true;
238            }
239            for path in platform_font_candidates_for_name(requested_name) {
240                if self.try_load_font_file_named(&path, &normalized) {
241                    return true;
242                }
243            }
244        }
245
246        // Preserve the old project-first fallback for an empty or unavailable
247        // configured face.  The original opens its font selection warning UI;
248        // this cross-platform port logs and falls back rather than silently
249        // continuing with the previously selected font.
250        for dir in dirs {
251            if self.load_from_font_dir(&dir) {
252                if !normalized.is_empty() {
253                    log_missing_configured_font_once(requested_name, self.loaded_from());
254                }
255                return true;
256            }
257        }
258
259        if self.try_load_embedded_default_font() {
260            if !normalized.is_empty() {
261                log_missing_configured_font_once(requested_name, None);
262            }
263            return true;
264        }
265
266        for path in platform_font_candidates() {
267            if self.try_load_font_file(&path) {
268                return true;
269            }
270        }
271
272        false
273    }
274
275    pub fn requested_name(&self) -> &str {
276        &self.requested_name
277    }
278
279    fn load_named_from_font_dir(&mut self, font_dir: &Path, normalized_name: &str) -> bool {
280        let Some(font_dir) = crate::resource::resolve_game_path(font_dir).ok().flatten() else {
281            return false;
282        };
283        let Ok(entries) = std::fs::read_dir(&font_dir) else {
284            return false;
285        };
286        let mut exact_file = Vec::new();
287        let mut other = Vec::new();
288        for entry in entries.flatten() {
289            let path = entry.path();
290            if !path.is_file() || !is_supported_font_path(&path) {
291                continue;
292            }
293            let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
294            let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
295            let file_key = normalize_font_name_for_match(file_name.trim_start_matches('@'));
296            let stem_key = normalize_font_name_for_match(stem.trim_start_matches('@'));
297            if file_key == normalized_name || stem_key == normalized_name {
298                exact_file.push(path);
299            } else {
300                other.push(path);
301            }
302        }
303        exact_file.sort_by_key(|path| font_path_priority(path));
304        other.sort_by_key(|path| font_path_priority(path));
305
306        // A TTC filename identifies the collection, not the face.  Inspect the
307        // OpenType naming table for every face and match the engine-visible
308        // family, full, typographic-family, WWS-family, or PostScript name.
309        exact_file
310            .into_iter()
311            .chain(other)
312            .any(|path| self.try_load_font_file_named(&path, normalized_name))
313    }
314
315    pub fn load_from_font_dir(&mut self, font_dir: &Path) -> bool {
316        if self.font.is_some() {
317            return true;
318        }
319        let Some(font_dir) = crate::resource::resolve_game_path(font_dir).ok().flatten() else {
320            return false;
321        };
322        let Ok(entries) = std::fs::read_dir(&font_dir) else {
323            return false;
324        };
325
326        let mut files = Vec::new();
327        for entry in entries.flatten() {
328            let path = entry.path();
329            if path.is_file() && is_supported_font_path(&path) {
330                files.push(path);
331            }
332        }
333        files.sort_by_key(|path| font_path_priority(path));
334
335        for path in files {
336            if self.try_load_font_file(&path) {
337                return true;
338            }
339        }
340        false
341    }
342
343    fn install_font_face(&mut self, path: &Path, data: &'static [u8], face_index: u32) -> bool {
344        match FontRef::try_from_slice_and_index(data, face_index) {
345            Ok(font) => {
346                self.font = Some(FontArc::from(font));
347                self.loaded_from = Some(path.to_path_buf());
348                self.loaded_face_index = face_index;
349                true
350            }
351            Err(_) => false,
352        }
353    }
354
355    fn try_load_font_file_named(&mut self, path: &Path, normalized_name: &str) -> bool {
356        if self.font.is_some() {
357            return true;
358        }
359        if !crate::resource::game_file_exists(path) || !is_supported_font_path(path) {
360            return false;
361        }
362        let Some((data, face_index)) = font_file_face(path, normalized_name) else {
363            return false;
364        };
365        self.install_font_face(path, data, face_index)
366    }
367
368    fn try_load_font_file(&mut self, path: &Path) -> bool {
369        if self.font.is_some() {
370            return true;
371        }
372        if !crate::resource::game_file_exists(path) || !is_supported_font_path(path) {
373            return false;
374        }
375        let Some((data, face_index)) = font_file_face(path, "") else {
376            return false;
377        };
378        self.install_font_face(path, data, face_index)
379    }
380
381    fn try_load_embedded_default_font(&mut self) -> bool {
382        if self.font.is_some() {
383            return true;
384        }
385        let Some(bytes) = embedded_font::EMBEDDED_DEFAULT_FONT else {
386            return false;
387        };
388        // Parsed in place: the font lives in the executable, so it costs no
389        // heap (a copy was 7.5 MiB, repeated on every font switch).
390        match FontRef::try_from_slice(bytes) {
391            Ok(font) => {
392                self.font = Some(FontArc::from(font));
393                let source =
394                    embedded_font::EMBEDDED_DEFAULT_FONT_SOURCE.unwrap_or("embedded:default-font");
395                self.loaded_from = Some(PathBuf::from(source));
396                self.loaded_face_index = 0;
397                true
398            }
399            Err(_) => false,
400        }
401    }
402
403    pub fn render_text(
404        &self,
405        images: &mut ImageManager,
406        text: &str,
407        font_px: f32,
408        max_w: u32,
409        max_h: u32,
410    ) -> Option<ImageHandle> {
411        self.render_text_into(images, None, text, font_px, max_w, max_h)
412    }
413
414    pub fn render_mwnd_text(
415        &self,
416        images: &mut ImageManager,
417        text: &str,
418        font_px: f32,
419        max_w: u32,
420        max_h: u32,
421        moji_space: Option<(i64, i64)>,
422    ) -> Option<ImageHandle> {
423        let img = self.render_mwnd_text_rgba(text, font_px, max_w, max_h, moji_space)?;
424        Some(images.insert_image(img))
425    }
426
427    pub fn render_mwnd_text_styled(
428        &self,
429        images: &mut ImageManager,
430        text: &str,
431        font_px: f32,
432        max_w: u32,
433        max_h: u32,
434        moji_space: Option<(i64, i64)>,
435        style: TextStyle,
436    ) -> Option<ImageHandle> {
437        self.render_mwnd_text_styled_into(
438            images, None, text, font_px, max_w, max_h, moji_space, style,
439        )
440    }
441
442    pub fn render_mwnd_text_styled_into(
443        &self,
444        images: &mut ImageManager,
445        target: Option<ImageHandle>,
446        text: &str,
447        font_px: f32,
448        max_w: u32,
449        max_h: u32,
450        moji_space: Option<(i64, i64)>,
451        style: TextStyle,
452    ) -> Option<ImageHandle> {
453        let img =
454            self.render_mwnd_text_rgba_styled(text, font_px, max_w, max_h, moji_space, style)?;
455        match target {
456            Some(id) => {
457                images.replace_image(&id, img).ok()?;
458                Some(id)
459            }
460            None => Some(images.insert_image(img)),
461        }
462    }
463
464    pub fn render_positioned_glyphs_into(
465        &self,
466        images: &mut ImageManager,
467        target: Option<ImageHandle>,
468        glyphs: &[PositionedTextGlyph],
469        min_w: u32,
470        min_h: u32,
471    ) -> Option<PositionedTextRender> {
472        self.render_positioned_glyph_layer_into(images, target, glyphs, min_w, min_h, None)
473    }
474
475    pub fn render_positioned_glyph_layer_into(
476        &self,
477        images: &mut ImageManager,
478        target: Option<ImageHandle>,
479        glyphs: &[PositionedTextGlyph],
480        min_w: u32,
481        min_h: u32,
482        layer: Option<TextSpriteLayer>,
483    ) -> Option<PositionedTextRender> {
484        if glyphs.is_empty() || min_w == 0 || min_h == 0 {
485            return None;
486        }
487        let (img, offset_x, offset_y) =
488            render_positioned_glyphs_rgba(self.font.as_ref(), glyphs, min_w, min_h, layer)?;
489        let image = match target {
490            Some(id) => {
491                images.replace_image(&id, img).ok()?;
492                id
493            }
494            None => images.insert_image(img),
495        };
496        Some(PositionedTextRender {
497            image,
498            offset_x,
499            offset_y,
500        })
501    }
502
503    /// Rasterize one glyph into a tight texture for the original
504    /// shadow/fuchi/body-per-glyph sprite model.  The returned offsets are
505    /// relative to the requested glyph anchor and include outline/shadow
506    /// padding and font bearing.
507    pub fn render_single_glyph_layer_into(
508        &self,
509        images: &mut ImageManager,
510        target: Option<ImageHandle>,
511        glyph: PositionedTextGlyph,
512        layer: TextSpriteLayer,
513    ) -> Option<PositionedTextRender> {
514        let mut local = glyph;
515        local.x = 0;
516        local.y = 0;
517        self.render_positioned_glyph_layer_into(images, target, &[local], 1, 1, Some(layer))
518    }
519
520    pub fn render_text_into(
521        &self,
522        images: &mut ImageManager,
523        target: Option<ImageHandle>,
524        text: &str,
525        font_px: f32,
526        max_w: u32,
527        max_h: u32,
528    ) -> Option<ImageHandle> {
529        let img = self.render_text_rgba(text, font_px, max_w, max_h)?;
530        match target {
531            Some(id) => {
532                images.replace_image(&id, img).ok()?;
533                Some(id)
534            }
535            None => Some(images.insert_image(img)),
536        }
537    }
538
539    #[allow(clippy::too_many_arguments)]
540    pub fn render_editbox_into(
541        &self,
542        images: &mut ImageManager,
543        target: Option<ImageHandle>,
544        text: &str,
545        cursor_pos: usize,
546        selection: Option<(usize, usize)>,
547        composition_text: &str,
548        composition_cursor: Option<(usize, usize)>,
549        composition_range: Option<(usize, usize)>,
550        scroll_x_px: i32,
551        caret_visible: bool,
552        font_px: f32,
553        max_w: u32,
554        max_h: u32,
555    ) -> Option<ImageHandle> {
556        let img = render_editbox_rgba(
557            self.font.as_ref(),
558            text,
559            cursor_pos,
560            selection,
561            composition_text,
562            composition_cursor,
563            composition_range,
564            scroll_x_px,
565            caret_visible,
566            font_px,
567            max_w,
568            max_h,
569        )?;
570        match target {
571            Some(id) => {
572                images.replace_image(&id, img).ok()?;
573                Some(id)
574            }
575            None => Some(images.insert_image(img)),
576        }
577    }
578
579    pub fn render_text_rgba(
580        &self,
581        text: &str,
582        font_px: f32,
583        max_w: u32,
584        max_h: u32,
585    ) -> Option<RgbaImage> {
586        let Some(font) = self.font.as_ref() else {
587            return render_text_image_basic_rgba(text, font_px as u32, max_w, max_h);
588        };
589        render_text_ab_glyph_rgba(font, text, font_px, max_w, max_h)
590    }
591
592    /// Rendered line count and line pitch of the layout [`Self::render_text_into`]
593    /// would produce for the same arguments.
594    ///
595    /// Text drawn as one image has no per-row rectangles, so hit-testing has to be
596    /// derived from the layout. Reporting it from the renderer keeps the drawn
597    /// rows and the clickable rows identical (an estimate such as "40 + lines * 30"
598    /// drifts by rows as soon as the pitch or the wrapped line count differs).
599    pub fn text_line_metrics(
600        &self,
601        text: &str,
602        font_px: f32,
603        max_w: u32,
604        max_h: u32,
605    ) -> (usize, f32) {
606        match self.font.as_ref() {
607            Some(font) => text_line_metrics_ab_glyph(font, text, font_px, max_w, max_h),
608            // The basic fallback renderer breaks on '\n' only; approximate its pitch.
609            None => (
610                text.lines().count().max(1),
611                (font_px.max(1.0) * 1.3).max(1.0),
612            ),
613        }
614    }
615
616    pub fn render_mwnd_text_rgba(
617        &self,
618        text: &str,
619        font_px: f32,
620        max_w: u32,
621        max_h: u32,
622        moji_space: Option<(i64, i64)>,
623    ) -> Option<RgbaImage> {
624        self.render_mwnd_text_rgba_styled(
625            text,
626            font_px,
627            max_w,
628            max_h,
629            moji_space,
630            TextStyle::default(),
631        )
632    }
633
634    pub fn render_mwnd_text_rgba_styled(
635        &self,
636        text: &str,
637        font_px: f32,
638        max_w: u32,
639        max_h: u32,
640        moji_space: Option<(i64, i64)>,
641        style: TextStyle,
642    ) -> Option<RgbaImage> {
643        self.render_mwnd_text_rgba_layer_styled(
644            text, font_px, max_w, max_h, moji_space, style, false, None,
645        )
646    }
647
648    pub fn render_mwnd_text_layer_styled_into(
649        &self,
650        images: &mut ImageManager,
651        target: Option<ImageHandle>,
652        text: &str,
653        font_px: f32,
654        max_w: u32,
655        max_h: u32,
656        moji_space: Option<(i64, i64)>,
657        style: TextStyle,
658        vertical: bool,
659        layer: TextSpriteLayer,
660    ) -> Option<ImageHandle> {
661        let img = self.render_mwnd_text_rgba_layer_styled(
662            text,
663            font_px,
664            max_w,
665            max_h,
666            moji_space,
667            style,
668            vertical,
669            Some(layer),
670        )?;
671        match target {
672            Some(id) => {
673                images.replace_image(&id, img).ok()?;
674                Some(id)
675            }
676            None => Some(images.insert_image(img)),
677        }
678    }
679
680    fn render_mwnd_text_rgba_layer_styled(
681        &self,
682        text: &str,
683        font_px: f32,
684        max_w: u32,
685        max_h: u32,
686        moji_space: Option<(i64, i64)>,
687        style: TextStyle,
688        vertical: bool,
689        layer: Option<TextSpriteLayer>,
690    ) -> Option<RgbaImage> {
691        let Some(font) = self.font.as_ref() else {
692            if layer.is_none() || layer == Some(TextSpriteLayer::Body) {
693                return render_text_image_basic_rgba(text, font_px as u32, max_w, max_h);
694            }
695            return Some(RgbaImage {
696                width: max_w.max(1),
697                height: max_h.max(1),
698                center_x: 0,
699                center_y: 0,
700                rgba: vec![0; max_w.max(1) as usize * max_h.max(1) as usize * 4],
701            });
702        };
703        render_mwnd_text_ab_glyph_rgba_styled(
704            font, text, font_px, max_w, max_h, moji_space, style, layer, vertical,
705        )
706    }
707}
708
709pub fn editbox_cell_width_px(ch: char, font_px: i32) -> i32 {
710    let full = font_px.max(1);
711    if is_hankaku(ch) {
712        ((full + 1) / 2).max(1)
713    } else {
714        full
715    }
716}
717
718#[allow(clippy::too_many_arguments)]
719fn render_editbox_rgba(
720    font: Option<&FontArc>,
721    text: &str,
722    cursor_pos: usize,
723    selection: Option<(usize, usize)>,
724    composition_text: &str,
725    composition_cursor: Option<(usize, usize)>,
726    composition_range: Option<(usize, usize)>,
727    scroll_x_px: i32,
728    caret_visible: bool,
729    font_px: f32,
730    max_w: u32,
731    max_h: u32,
732) -> Option<RgbaImage> {
733    if max_w == 0 || max_h == 0 {
734        return None;
735    }
736
737    const PAD_X: i32 = 3;
738    let mut rgba = vec![255u8; (max_w * max_h * 4) as usize];
739    let font_cell = font_px.round().max(1.0) as i32;
740    let (baseline_y, glyph_top, glyph_bottom) = if let Some(font) = font {
741        let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
742        let line_h = (scaled.height() + scaled.line_gap()).ceil().max(1.0) as i32;
743        let top = ((max_h as i32 - line_h) / 2).max(0);
744        let baseline = top + scaled.ascent().ceil() as i32;
745        (baseline, top, (top + line_h).min(max_h as i32))
746    } else {
747        let glyph_h = (font_cell * 7 / 8).max(7);
748        let top = ((max_h as i32 - glyph_h) / 2).max(0);
749        (top + glyph_h, top, (top + glyph_h).min(max_h as i32))
750    };
751
752    let normalize = |value: usize, source: &str| {
753        let mut value = value.min(source.len());
754        while value > 0 && !source.is_char_boundary(value) {
755            value -= 1;
756        }
757        value
758    };
759    let cursor_pos = normalize(cursor_pos, text);
760    let selection = selection.map(|(a, b)| {
761        let a = normalize(a, text);
762        let b = normalize(b, text);
763        if a <= b { (a, b) } else { (b, a) }
764    });
765    let composition_range = composition_range.map(|(a, b)| {
766        let a = normalize(a, text);
767        let b = normalize(b, text);
768        if a <= b { (a, b) } else { (b, a) }
769    });
770    let composition_cursor = composition_cursor.map(|(a, b)| {
771        let a = normalize(a, composition_text);
772        let b = normalize(b, composition_text);
773        if a <= b { (a, b) } else { (b, a) }
774    });
775
776    let mut x = PAD_X.saturating_sub(scroll_x_px.max(0));
777    let mut caret_x = None;
778
779    if let Some((comp_start, comp_end)) = composition_range {
780        x = draw_editbox_committed_segment(
781            &mut rgba,
782            font,
783            text,
784            0,
785            comp_start,
786            cursor_pos,
787            None,
788            &mut caret_x,
789            x,
790            font_px,
791            font_cell,
792            baseline_y,
793            glyph_top,
794            glyph_bottom,
795            max_w,
796            max_h,
797        );
798        let comp_caret = composition_cursor
799            .map(|(_, end)| end)
800            .unwrap_or(composition_text.len());
801        for (idx, ch) in composition_text.char_indices() {
802            if idx == comp_caret {
803                caret_x = Some(x);
804            }
805            let next = idx + ch.len_utf8();
806            let selected = composition_cursor
807                .map(|(start, end)| start != end && idx < end && next > start)
808                .unwrap_or(false);
809            let cell_w = editbox_cell_width_px(ch, font_cell);
810            draw_editbox_cell(
811                &mut rgba,
812                font,
813                ch,
814                x,
815                cell_w,
816                selected,
817                true,
818                font_px,
819                font_cell,
820                baseline_y,
821                glyph_top,
822                glyph_bottom,
823                max_w,
824                max_h,
825            );
826            x = x.saturating_add(cell_w);
827        }
828        if comp_caret == composition_text.len() {
829            caret_x = Some(x);
830        }
831        x = draw_editbox_committed_segment(
832            &mut rgba,
833            font,
834            text,
835            comp_end,
836            text.len(),
837            cursor_pos,
838            None,
839            &mut caret_x,
840            x,
841            font_px,
842            font_cell,
843            baseline_y,
844            glyph_top,
845            glyph_bottom,
846            max_w,
847            max_h,
848        );
849    } else {
850        x = draw_editbox_committed_segment(
851            &mut rgba,
852            font,
853            text,
854            0,
855            text.len(),
856            cursor_pos,
857            selection,
858            &mut caret_x,
859            x,
860            font_px,
861            font_cell,
862            baseline_y,
863            glyph_top,
864            glyph_bottom,
865            max_w,
866            max_h,
867        );
868        if cursor_pos == text.len() {
869            caret_x = Some(x);
870        }
871    }
872
873    if caret_visible {
874        let caret_x = caret_x.unwrap_or(PAD_X).clamp(0, max_w as i32 - 1);
875        fill_rgba_rect(
876            &mut rgba,
877            max_w,
878            max_h,
879            caret_x,
880            glyph_top.max(1),
881            (caret_x + 1).min(max_w as i32),
882            glyph_bottom.max(glyph_top + 1).min(max_h as i32 - 1),
883            (0, 0, 0, 255),
884        );
885    }
886
887    Some(RgbaImage {
888        width: max_w,
889        height: max_h,
890        center_x: 0,
891        center_y: 0,
892        rgba,
893    })
894}
895
896#[allow(clippy::too_many_arguments)]
897fn draw_editbox_committed_segment(
898    rgba: &mut [u8],
899    font: Option<&FontArc>,
900    text: &str,
901    range_start: usize,
902    range_end: usize,
903    cursor_pos: usize,
904    selection: Option<(usize, usize)>,
905    caret_x: &mut Option<i32>,
906    mut x: i32,
907    font_px: f32,
908    font_cell: i32,
909    baseline_y: i32,
910    glyph_top: i32,
911    glyph_bottom: i32,
912    max_w: u32,
913    max_h: u32,
914) -> i32 {
915    for (relative, ch) in text[range_start..range_end].char_indices() {
916        let idx = range_start + relative;
917        if idx == cursor_pos {
918            *caret_x = Some(x);
919        }
920        let next = idx + ch.len_utf8();
921        let selected = selection
922            .map(|(start, end)| idx < end && next > start)
923            .unwrap_or(false);
924        let cell_w = editbox_cell_width_px(ch, font_cell);
925        draw_editbox_cell(
926            rgba,
927            font,
928            ch,
929            x,
930            cell_w,
931            selected,
932            false,
933            font_px,
934            font_cell,
935            baseline_y,
936            glyph_top,
937            glyph_bottom,
938            max_w,
939            max_h,
940        );
941        x = x.saturating_add(cell_w);
942    }
943    x
944}
945
946#[allow(clippy::too_many_arguments)]
947fn draw_editbox_cell(
948    rgba: &mut [u8],
949    font: Option<&FontArc>,
950    ch: char,
951    cell_x: i32,
952    cell_w: i32,
953    selected: bool,
954    composing: bool,
955    font_px: f32,
956    font_cell: i32,
957    baseline_y: i32,
958    glyph_top: i32,
959    glyph_bottom: i32,
960    max_w: u32,
961    max_h: u32,
962) {
963    let left = cell_x.max(0);
964    let right = cell_x.saturating_add(cell_w).min(max_w as i32);
965    if selected && right > left {
966        fill_rgba_rect(
967            rgba,
968            max_w,
969            max_h,
970            left,
971            1,
972            right,
973            max_h as i32 - 1,
974            (0, 120, 215, 255),
975        );
976    }
977
978    let color = if selected {
979        (255, 255, 255, 255)
980    } else {
981        (0, 0, 0, 255)
982    };
983    if let Some(font) = font {
984        let glyph = rasterize_ab_glyph(font, ch, font_px);
985        if glyph.width > 0 && glyph.height > 0 {
986            let draw_x = cell_x + ((cell_w - glyph.width as i32) / 2).max(0);
987            let draw_y = baseline_y + glyph.ymin;
988            draw_glyph_bitmap(
989                rgba,
990                max_w,
991                max_h,
992                draw_x,
993                draw_y,
994                glyph.width,
995                glyph.height,
996                &glyph.bitmap,
997                color,
998            );
999        }
1000    } else {
1001        draw_basic_glyph_color(
1002            rgba,
1003            max_w,
1004            max_h,
1005            cell_x,
1006            glyph_top,
1007            ch,
1008            (font_cell / 7).max(1) as u32,
1009            color,
1010        );
1011    }
1012
1013    if composing && right > left {
1014        let underline_y = (glyph_bottom + 1).clamp(0, max_h as i32 - 1);
1015        fill_rgba_rect(
1016            rgba,
1017            max_w,
1018            max_h,
1019            left,
1020            underline_y,
1021            right,
1022            (underline_y + 1).min(max_h as i32),
1023            (0, 0, 0, 255),
1024        );
1025    }
1026}
1027
1028fn fill_rgba_rect(
1029    rgba: &mut [u8],
1030    width: u32,
1031    height: u32,
1032    x0: i32,
1033    y0: i32,
1034    x1: i32,
1035    y1: i32,
1036    color: (u8, u8, u8, u8),
1037) {
1038    let x0 = x0.clamp(0, width as i32);
1039    let y0 = y0.clamp(0, height as i32);
1040    let x1 = x1.clamp(x0, width as i32);
1041    let y1 = y1.clamp(y0, height as i32);
1042    for y in y0..y1 {
1043        for x in x0..x1 {
1044            let idx = (((y as u32 * width) + x as u32) * 4) as usize;
1045            rgba[idx] = color.0;
1046            rgba[idx + 1] = color.1;
1047            rgba[idx + 2] = color.2;
1048            rgba[idx + 3] = color.3;
1049        }
1050    }
1051}
1052
1053fn draw_basic_glyph_color(
1054    rgba: &mut [u8],
1055    width: u32,
1056    height: u32,
1057    x: i32,
1058    y: i32,
1059    ch: char,
1060    scale: u32,
1061    color: (u8, u8, u8, u8),
1062) {
1063    let glyph = glyph_5x7(ch);
1064    for (row, bits) in glyph.iter().enumerate() {
1065        for col in 0..5 {
1066            if (bits >> (4 - col)) & 1 == 0 {
1067                continue;
1068            }
1069            let px = x + col * scale as i32;
1070            let py = y + row as i32 * scale as i32;
1071            for sy in 0..scale as i32 {
1072                for sx in 0..scale as i32 {
1073                    let tx = px + sx;
1074                    let ty = py + sy;
1075                    if tx < 0 || ty < 0 || tx >= width as i32 || ty >= height as i32 {
1076                        continue;
1077                    }
1078                    blend_rgba_pixel(
1079                        rgba, width, tx as u32, ty as u32, color.0, color.1, color.2, color.3,
1080                    );
1081                }
1082            }
1083        }
1084    }
1085}
1086
1087fn draw_basic_glyph_face(
1088    rgba: &mut [u8],
1089    width: u32,
1090    height: u32,
1091    x: i32,
1092    y: i32,
1093    ch: char,
1094    scale: u32,
1095    face_extent: i32,
1096    color: (u8, u8, u8, u8),
1097) {
1098    for oy in 0..=face_extent.max(0) {
1099        for ox in 0..=face_extent.max(0) {
1100            draw_basic_glyph_color(rgba, width, height, x + ox, y + oy, ch, scale, color);
1101        }
1102    }
1103}
1104
1105pub fn render_text_image_basic(
1106    images: &mut ImageManager,
1107    text: &str,
1108    font_px: u32,
1109    max_w: u32,
1110    max_h: u32,
1111) -> Option<ImageHandle> {
1112    let img = render_text_image_basic_rgba(text, font_px, max_w, max_h)?;
1113    Some(images.insert_image(img))
1114}
1115
1116pub fn render_text_image_basic_rgba(
1117    text: &str,
1118    font_px: u32,
1119    max_w: u32,
1120    max_h: u32,
1121) -> Option<RgbaImage> {
1122    if text.is_empty() || max_w == 0 || max_h == 0 {
1123        return None;
1124    }
1125    let scale = (font_px / 7).max(1);
1126    let glyph_w = 5 * scale;
1127    let glyph_h = 7 * scale;
1128    let advance = glyph_w + scale;
1129    let line_height = glyph_h + scale;
1130
1131    let mut rgba = vec![0u8; (max_w * max_h * 4) as usize];
1132    let mut x = 0u32;
1133    let mut y = 0u32;
1134
1135    for ch in text.chars() {
1136        if ch == '\n' {
1137            x = 0;
1138            y = y.saturating_add(line_height);
1139            if y >= max_h {
1140                break;
1141            }
1142            continue;
1143        }
1144        if ch == '\t' {
1145            x = x.saturating_add(advance * 2);
1146            continue;
1147        }
1148        if x + glyph_w > max_w {
1149            x = 0;
1150            y = y.saturating_add(line_height);
1151            if y >= max_h {
1152                break;
1153            }
1154        }
1155        draw_glyph_5x7(&mut rgba, max_w, max_h, x, y, ch, scale);
1156        x = x.saturating_add(advance);
1157    }
1158
1159    Some(RgbaImage {
1160        width: max_w,
1161        height: max_h,
1162        center_x: 0,
1163        center_y: 0,
1164        rgba,
1165    })
1166}
1167
1168fn render_positioned_glyphs_rgba(
1169    font: Option<&FontArc>,
1170    glyphs: &[PositionedTextGlyph],
1171    min_w: u32,
1172    min_h: u32,
1173    layer: Option<TextSpriteLayer>,
1174) -> Option<(RgbaImage, i32, i32)> {
1175    if glyphs.is_empty() || min_w == 0 || min_h == 0 {
1176        return None;
1177    }
1178
1179    let mut min_x = 0i32;
1180    let mut min_y = 0i32;
1181    let mut max_x = min_w as i32;
1182    let mut max_y = min_h as i32;
1183
1184    if let Some(font) = font {
1185        for placed in glyphs {
1186            let size = placed.size.max(1.0);
1187            let scaled = font.as_scaled(PxScale::from(size));
1188            let baseline = scaled.ascent().ceil().max(1.0) as i32;
1189            let glyph = rasterize_ab_glyph_oriented(font, placed.ch, size, placed.vertical);
1190            let pad = text_effect_padding(size.round().max(1.0) as i32, placed.style);
1191            let (origin_x, origin_y) = positioned_glyph_origin(placed, &glyph, baseline);
1192            let x0 = origin_x.saturating_sub(pad);
1193            let y0 = origin_y.saturating_sub(pad);
1194            let x1 = origin_x
1195                .saturating_add(glyph.width as i32)
1196                .saturating_add(pad);
1197            let y1 = origin_y
1198                .saturating_add(glyph.height as i32)
1199                .saturating_add(pad);
1200            min_x = min_x.min(x0);
1201            min_y = min_y.min(y0);
1202            max_x = max_x.max(x1);
1203            max_y = max_y.max(y1);
1204        }
1205    } else {
1206        for placed in glyphs {
1207            let size = placed.size.round().max(1.0) as i32;
1208            let scale = ((size + 6) / 7).max(1);
1209            min_x = min_x.min(placed.x - 2);
1210            min_y = min_y.min(placed.y - 2);
1211            max_x = max_x.max(placed.x + 5 * scale + 3);
1212            max_y = max_y.max(placed.y + 7 * scale + 3);
1213        }
1214    }
1215
1216    let render_w = max_x.saturating_sub(min_x).max(1) as u32;
1217    let render_h = max_y.saturating_sub(min_y).max(1) as u32;
1218    let mut rgba = vec![
1219        0u8;
1220        (render_w as usize)
1221            .saturating_mul(render_h as usize)
1222            .saturating_mul(4)
1223    ];
1224
1225    if let Some(font) = font {
1226        for placed in glyphs {
1227            let size = placed.size.max(1.0);
1228            let scaled = font.as_scaled(PxScale::from(size));
1229            let baseline = scaled.ascent().ceil().max(1.0) as i32;
1230            let glyph = rasterize_ab_glyph_oriented(font, placed.ch, size, placed.vertical);
1231            if glyph.width == 0 || glyph.height == 0 {
1232                continue;
1233            }
1234            let (origin_x, origin_y) = positioned_glyph_origin(placed, &glyph, baseline);
1235            let draw_x = origin_x - min_x;
1236            let draw_y = origin_y - min_y;
1237            let style = placed.style;
1238            // Original sorter layers are shadow(3), fuchi(4), body(5).
1239            // Preserve that order when rendering either the combined compatibility image or one layer.
1240            if style.shadow && (layer.is_none() || layer == Some(TextSpriteLayer::Shadow)) {
1241                let off = shadow_offset_for_style(size.round().max(1.0) as i32, style);
1242                draw_glyph_bitmap_face(
1243                    &mut rgba,
1244                    render_w,
1245                    render_h,
1246                    draw_x + off,
1247                    draw_y + off,
1248                    glyph.width,
1249                    glyph.height,
1250                    &glyph.bitmap,
1251                    shadow_face_extent(style),
1252                    (
1253                        style.shadow_color.0,
1254                        style.shadow_color.1,
1255                        style.shadow_color.2,
1256                        255,
1257                    ),
1258                );
1259            }
1260            if style.fuchi && (layer.is_none() || layer == Some(TextSpriteLayer::Fuchi)) {
1261                let off = fuchi_texture_offset();
1262                draw_glyph_bitmap_face(
1263                    &mut rgba,
1264                    render_w,
1265                    render_h,
1266                    draw_x + off,
1267                    draw_y + off,
1268                    glyph.width,
1269                    glyph.height,
1270                    &glyph.bitmap,
1271                    fuchi_face_extent(style),
1272                    (
1273                        style.fuchi_color.0,
1274                        style.fuchi_color.1,
1275                        style.fuchi_color.2,
1276                        255,
1277                    ),
1278                );
1279            }
1280            if layer.is_none() || layer == Some(TextSpriteLayer::Body) {
1281                draw_glyph_bitmap_face(
1282                    &mut rgba,
1283                    render_w,
1284                    render_h,
1285                    draw_x,
1286                    draw_y,
1287                    glyph.width,
1288                    glyph.height,
1289                    &glyph.bitmap,
1290                    body_face_extent(style),
1291                    (style.color.0, style.color.1, style.color.2, 255),
1292                );
1293            }
1294        }
1295    } else {
1296        for placed in glyphs {
1297            let style = placed.style;
1298            let scale = ((placed.size.round().max(1.0) as i32 + 6) / 7).max(1) as u32;
1299            let x = placed.x - min_x;
1300            let y = placed.y - min_y;
1301            if style.shadow && (layer.is_none() || layer == Some(TextSpriteLayer::Shadow)) {
1302                let off = shadow_offset_for_style(placed.size.round().max(1.0) as i32, style);
1303                draw_basic_glyph_face(
1304                    &mut rgba,
1305                    render_w,
1306                    render_h,
1307                    x + off,
1308                    y + off,
1309                    placed.ch,
1310                    scale,
1311                    shadow_face_extent(style),
1312                    (
1313                        style.shadow_color.0,
1314                        style.shadow_color.1,
1315                        style.shadow_color.2,
1316                        255,
1317                    ),
1318                );
1319            }
1320            if style.fuchi && (layer.is_none() || layer == Some(TextSpriteLayer::Fuchi)) {
1321                let off = fuchi_texture_offset();
1322                draw_basic_glyph_face(
1323                    &mut rgba,
1324                    render_w,
1325                    render_h,
1326                    x + off,
1327                    y + off,
1328                    placed.ch,
1329                    scale,
1330                    fuchi_face_extent(style),
1331                    (
1332                        style.fuchi_color.0,
1333                        style.fuchi_color.1,
1334                        style.fuchi_color.2,
1335                        255,
1336                    ),
1337                );
1338            }
1339            if layer.is_none() || layer == Some(TextSpriteLayer::Body) {
1340                draw_basic_glyph_face(
1341                    &mut rgba,
1342                    render_w,
1343                    render_h,
1344                    x,
1345                    y,
1346                    placed.ch,
1347                    scale,
1348                    body_face_extent(style),
1349                    (style.color.0, style.color.1, style.color.2, 255),
1350                );
1351            }
1352        }
1353    }
1354
1355    Some((
1356        RgbaImage {
1357            width: render_w,
1358            height: render_h,
1359            center_x: 0,
1360            center_y: 0,
1361            rgba,
1362        },
1363        min_x,
1364        min_y,
1365    ))
1366}
1367
1368#[derive(Debug, Clone)]
1369struct RasterGlyph {
1370    width: usize,
1371    height: usize,
1372    xmin: i32,
1373    ymin: i32,
1374    bitmap: Vec<u8>,
1375}
1376
1377fn positioned_glyph_origin(
1378    placed: &PositionedTextGlyph,
1379    glyph: &RasterGlyph,
1380    horizontal_baseline: i32,
1381) -> (i32, i32) {
1382    if placed.vertical {
1383        let cell = placed.size.round().max(1.0) as i32;
1384        // GDI's tategaki path uses a baseline on the left edge and returns a
1385        // glyph inside the square character cell.  Centering the recovered
1386        // outline in that cell reproduces the observable placement without
1387        // applying the horizontal ascent a second time.
1388        (
1389            placed.x + (cell - glyph.width as i32) / 2,
1390            placed.y + (cell - glyph.height as i32) / 2,
1391        )
1392    } else {
1393        (
1394            placed.x + glyph.xmin,
1395            placed.y + horizontal_baseline + glyph.ymin,
1396        )
1397    }
1398}
1399
1400fn rasterize_ab_glyph_oriented(
1401    font: &FontArc,
1402    ch: char,
1403    font_px: f32,
1404    vertical: bool,
1405) -> RasterGlyph {
1406    if !vertical {
1407        return rasterize_ab_glyph(font, ch, font_px);
1408    }
1409
1410    let (vertical_ch, rotate) = vertical_glyph_mapping(font, ch);
1411    let mut glyph = rasterize_ab_glyph(font, vertical_ch, font_px);
1412    if rotate && glyph.width != 0 && glyph.height != 0 {
1413        glyph = rotate_raster_glyph_clockwise(glyph);
1414    }
1415    glyph.xmin = 0;
1416    glyph.ymin = 0;
1417    glyph
1418}
1419
1420fn vertical_glyph_mapping(font: &FontArc, ch: char) -> (char, bool) {
1421    let mapped = match ch {
1422        '、' => '︑',
1423        '。' | '.' => '︒',
1424        ',' => '︐',
1425        ':' | ':' => '︓',
1426        ';' | ';' => '︔',
1427        '!' | '!' => '︕',
1428        '?' | '?' => '︖',
1429        '…' => '︙',
1430        '—' | '―' | 'ー' => '︱',
1431        '_' => '︳',
1432        '(' | '(' => '︵',
1433        ')' | ')' => '︶',
1434        '{' | '{' => '︷',
1435        '}' | '}' => '︸',
1436        '〔' => '︹',
1437        '〕' => '︺',
1438        '【' => '︻',
1439        '】' => '︼',
1440        '《' => '︽',
1441        '》' => '︾',
1442        '〈' => '︿',
1443        '〉' => '﹀',
1444        '「' => '﹁',
1445        '」' => '﹂',
1446        '『' => '﹃',
1447        '』' => '﹄',
1448        '[' | '[' => '﹇',
1449        ']' | ']' => '﹈',
1450        _ => ch,
1451    };
1452    let mapped_available = font.glyph_id(mapped).0 != 0;
1453    if mapped != ch && mapped_available {
1454        return (mapped, false);
1455    }
1456
1457    // Windows '@' Japanese faces keep CJK glyphs upright, substitute vertical
1458    // punctuation, and rotate Latin runs.  `ab_glyph` exposes no GSUB `vert`
1459    // feature, so reproduce that visible orientation explicitly.
1460    let rotate = ch.is_ascii_alphanumeric()
1461        || matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9')
1462        || matches!(ch, '-' | '=' | '<' | '>' | '/' | '\\' | '~');
1463    (ch, rotate)
1464}
1465
1466fn rotate_raster_glyph_clockwise(src: RasterGlyph) -> RasterGlyph {
1467    let width = src.height;
1468    let height = src.width;
1469    let mut bitmap = vec![0u8; width.saturating_mul(height)];
1470    for y in 0..src.height {
1471        for x in 0..src.width {
1472            let dst_x = src.height - 1 - y;
1473            let dst_y = x;
1474            bitmap[dst_y * width + dst_x] = src.bitmap[y * src.width + x];
1475        }
1476    }
1477    RasterGlyph {
1478        width,
1479        height,
1480        xmin: 0,
1481        ymin: 0,
1482        bitmap,
1483    }
1484}
1485
1486fn rasterize_ab_glyph(font: &FontArc, ch: char, font_px: f32) -> RasterGlyph {
1487    let scale = PxScale::from(font_px.max(1.0));
1488    let scaled = font.as_scaled(scale);
1489    let glyph_id = scaled.glyph_id(ch);
1490    let glyph = glyph_id.with_scale_and_position(scale, point(0.0, 0.0));
1491    let Some(outlined) = scaled.outline_glyph(glyph) else {
1492        return RasterGlyph {
1493            width: 0,
1494            height: 0,
1495            xmin: 0,
1496            ymin: 0,
1497            bitmap: Vec::new(),
1498        };
1499    };
1500
1501    let bounds = outlined.px_bounds();
1502    let xmin = bounds.min.x.floor() as i32;
1503    let ymin = bounds.min.y.floor() as i32;
1504    let xmax = bounds.max.x.ceil() as i32;
1505    let ymax = bounds.max.y.ceil() as i32;
1506    let width = (xmax - xmin).max(0) as usize;
1507    let height = (ymax - ymin).max(0) as usize;
1508    if width == 0 || height == 0 {
1509        return RasterGlyph {
1510            width: 0,
1511            height: 0,
1512            xmin,
1513            ymin,
1514            bitmap: Vec::new(),
1515        };
1516    }
1517
1518    let shifted_glyph =
1519        glyph_id.with_scale_and_position(scale, point((-xmin) as f32, (-ymin) as f32));
1520    let Some(shifted) = scaled.outline_glyph(shifted_glyph) else {
1521        return RasterGlyph {
1522            width: 0,
1523            height: 0,
1524            xmin,
1525            ymin,
1526            bitmap: Vec::new(),
1527        };
1528    };
1529
1530    let mut bitmap = vec![0u8; width * height];
1531    shifted.draw(|gx, gy, cov| {
1532        let x = gx as usize;
1533        let y = gy as usize;
1534        if x < width && y < height {
1535            bitmap[y * width + x] = gdi_gray8_alpha_from_coverage(cov);
1536        }
1537    });
1538
1539    RasterGlyph {
1540        width,
1541        height,
1542        xmin,
1543        ymin,
1544        bitmap,
1545    }
1546}
1547
1548fn render_mwnd_text_ab_glyph_rgba(
1549    font: &FontArc,
1550    text: &str,
1551    font_px: f32,
1552    max_w: u32,
1553    max_h: u32,
1554    moji_space: Option<(i64, i64)>,
1555) -> Option<RgbaImage> {
1556    render_mwnd_text_ab_glyph_rgba_styled(
1557        font,
1558        text,
1559        font_px,
1560        max_w,
1561        max_h,
1562        moji_space,
1563        TextStyle::default(),
1564        None,
1565        false,
1566    )
1567}
1568
1569fn render_mwnd_text_ab_glyph_rgba_styled(
1570    font: &FontArc,
1571    text: &str,
1572    font_px: f32,
1573    max_w: u32,
1574    max_h: u32,
1575    moji_space: Option<(i64, i64)>,
1576    style: TextStyle,
1577    layer: Option<TextSpriteLayer>,
1578    vertical: bool,
1579) -> Option<RgbaImage> {
1580    if text.is_empty() || max_w == 0 || max_h == 0 {
1581        return None;
1582    }
1583
1584    let (space_x, space_y) = moji_space.unwrap_or((-1, 10));
1585    let font_cell = font_px.round().max(1.0) as i32;
1586    let line_h = (font_cell + space_y as i32).max(font_cell).max(1);
1587    let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
1588    let baseline_y = scaled.ascent().ceil().max(1.0) as i32;
1589    let effect_pad = text_effect_padding(font_cell, style);
1590    let render_w = max_w.saturating_add(effect_pad.max(0) as u32 + 2);
1591    let render_h = max_h.saturating_add(
1592        (baseline_y + effect_pad)
1593            .max(font_cell / 4 + effect_pad + 2)
1594            .max(0) as u32,
1595    );
1596    let mut rgba = vec![0u8; (render_w * render_h * 4) as usize];
1597
1598    let placed_chars = if vertical {
1599        layout_mwnd_text_vertical(text, font_cell, space_x as i32, line_h, max_w, max_h)
1600    } else {
1601        layout_mwnd_text(text, font_cell, space_x as i32, line_h, max_w, max_h)
1602    };
1603    for placed in placed_chars {
1604        let glyph = rasterize_ab_glyph_oriented(font, placed.ch, font_px, vertical);
1605        if glyph.width == 0 || glyph.height == 0 {
1606            continue;
1607        }
1608
1609        let (draw_x, draw_y) = if vertical {
1610            (
1611                placed.x + (font_cell - glyph.width as i32) / 2,
1612                placed.y + (font_cell - glyph.height as i32) / 2,
1613            )
1614        } else {
1615            (placed.x + glyph.xmin, placed.y + baseline_y + glyph.ymin)
1616        };
1617
1618        if style.shadow && (layer.is_none() || layer == Some(TextSpriteLayer::Shadow)) {
1619            let shadow_offset = shadow_offset_for_style(font_cell, style);
1620            draw_glyph_bitmap_face(
1621                &mut rgba,
1622                render_w,
1623                render_h,
1624                draw_x + shadow_offset,
1625                draw_y + shadow_offset,
1626                glyph.width,
1627                glyph.height,
1628                &glyph.bitmap,
1629                shadow_face_extent(style),
1630                (
1631                    style.shadow_color.0,
1632                    style.shadow_color.1,
1633                    style.shadow_color.2,
1634                    255,
1635                ),
1636            );
1637        }
1638        if style.fuchi && (layer.is_none() || layer == Some(TextSpriteLayer::Fuchi)) {
1639            draw_glyph_bitmap_face(
1640                &mut rgba,
1641                render_w,
1642                render_h,
1643                draw_x,
1644                draw_y,
1645                glyph.width,
1646                glyph.height,
1647                &glyph.bitmap,
1648                fuchi_face_extent(style),
1649                (
1650                    style.fuchi_color.0,
1651                    style.fuchi_color.1,
1652                    style.fuchi_color.2,
1653                    255,
1654                ),
1655            );
1656        }
1657        if layer.is_none() || layer == Some(TextSpriteLayer::Body) {
1658            draw_glyph_bitmap_face(
1659                &mut rgba,
1660                render_w,
1661                render_h,
1662                draw_x,
1663                draw_y,
1664                glyph.width,
1665                glyph.height,
1666                &glyph.bitmap,
1667                body_face_extent(style),
1668                (style.color.0, style.color.1, style.color.2, 255),
1669            );
1670        }
1671    }
1672
1673    Some(RgbaImage {
1674        width: render_w,
1675        height: render_h,
1676        center_x: 0,
1677        center_y: 0,
1678        rgba,
1679    })
1680}
1681
1682#[derive(Debug, Clone, Copy)]
1683struct MwndPlacedChar {
1684    ch: char,
1685    x: i32,
1686    y: i32,
1687    cell_w: i32,
1688}
1689
1690fn layout_mwnd_text(
1691    text: &str,
1692    font_cell: i32,
1693    space_x: i32,
1694    line_h: i32,
1695    max_w: u32,
1696    max_h: u32,
1697) -> Vec<MwndPlacedChar> {
1698    let full_cell_w = font_cell.max(1);
1699    let half_cell_w = ((font_cell + 1) / 2).max(1);
1700    let max_w = max_w as i32;
1701    let max_h = max_h as i32;
1702    let mut out = Vec::new();
1703    let mut x = 0i32;
1704    let mut y = 0i32;
1705    let mut indent_x = 0i32;
1706    let mut line_head = true;
1707
1708    for ch in text.chars() {
1709        match ch {
1710            '\r' => continue,
1711            '\n' => {
1712                x = indent_x;
1713                y += line_h;
1714                line_head = true;
1715                if y >= max_h {
1716                    break;
1717                }
1718                continue;
1719            }
1720            '\u{0007}' => {
1721                indent_x = 0;
1722                x = 0;
1723                y += line_h;
1724                line_head = true;
1725                if y >= max_h {
1726                    break;
1727                }
1728                continue;
1729            }
1730            '\t' => {
1731                x += (full_cell_w + space_x).max(1) * 2;
1732                line_head = false;
1733                continue;
1734            }
1735            _ => {}
1736        }
1737
1738        let cell_w = if is_hankaku(ch) {
1739            half_cell_w
1740        } else {
1741            full_cell_w
1742        };
1743        let check_size = cell_w + space_x;
1744        let force_wrap = x > 0 && x + check_size > max_w + full_cell_w;
1745        let soft_wrap = x > 0 && x + check_size > max_w && !is_siglus_forbidden_line_head(ch);
1746        if force_wrap || soft_wrap {
1747            x = indent_x;
1748            y += line_h;
1749            line_head = true;
1750            if y >= max_h {
1751                break;
1752            }
1753            if ch == ' ' || ch == '\u{3000}' {
1754                continue;
1755            }
1756        }
1757
1758        if line_head {
1759            if is_siglus_indent_open(ch) {
1760                indent_x = full_cell_w;
1761            } else if is_siglus_indent_close(ch) {
1762                indent_x = 0;
1763            }
1764        }
1765
1766        out.push(MwndPlacedChar { ch, x, y, cell_w });
1767        x += (cell_w + space_x).max(1);
1768        line_head = false;
1769    }
1770    out
1771}
1772
1773fn layout_mwnd_text_vertical(
1774    text: &str,
1775    font_cell: i32,
1776    space_y: i32,
1777    column_w: i32,
1778    max_w: u32,
1779    max_h: u32,
1780) -> Vec<MwndPlacedChar> {
1781    let full_cell_h = font_cell.max(1);
1782    let half_cell_h = ((font_cell + 1) / 2).max(1);
1783    let max_w = max_w as i32;
1784    let max_h = max_h as i32;
1785    let mut out = Vec::new();
1786    // Siglus starts a vertical run at x=0 and moves later columns toward
1787    // negative x.  Store the same right-to-left order inside a positive image.
1788    let mut x = (max_w - full_cell_h).max(0);
1789    let mut y = 0i32;
1790
1791    for ch in text.chars() {
1792        match ch {
1793            '\r' => continue,
1794            '\n' | '\u{0007}' => {
1795                x -= column_w.max(1);
1796                y = 0;
1797                if x + full_cell_h <= 0 {
1798                    break;
1799                }
1800                continue;
1801            }
1802            '\t' => {
1803                y += (full_cell_h + space_y).max(1) * 2;
1804                continue;
1805            }
1806            _ => {}
1807        }
1808
1809        let cell_h = if is_hankaku(ch) {
1810            half_cell_h
1811        } else {
1812            full_cell_h
1813        };
1814        let advance = (cell_h + space_y).max(1);
1815        if y > 0 && y + cell_h > max_h {
1816            x -= column_w.max(1);
1817            y = 0;
1818            if x + full_cell_h <= 0 {
1819                break;
1820            }
1821        }
1822        out.push(MwndPlacedChar {
1823            ch,
1824            x,
1825            y,
1826            cell_w: full_cell_h,
1827        });
1828        y += advance;
1829    }
1830    out
1831}
1832
1833fn is_siglus_indent_open(ch: char) -> bool {
1834    matches!(ch, '「' | '『' | '(')
1835}
1836
1837fn is_siglus_indent_close(ch: char) -> bool {
1838    matches!(ch, '」' | '』' | ')')
1839}
1840
1841fn is_siglus_forbidden_line_head(ch: char) -> bool {
1842    matches!(
1843        ch,
1844        '、' | '。'
1845            | ','
1846            | '.'
1847            | '・'
1848            | ':'
1849            | ';'
1850            | '?'
1851            | '!'
1852            | '」'
1853            | '』'
1854            | ')'
1855            | ']'
1856            | '}'
1857            | '〉'
1858            | '》'
1859            | '】'
1860            | '〕'
1861            | 'ぁ'
1862            | 'ぃ'
1863            | 'ぅ'
1864            | 'ぇ'
1865            | 'ぉ'
1866            | 'っ'
1867            | 'ゃ'
1868            | 'ゅ'
1869            | 'ょ'
1870            | 'ゎ'
1871            | 'ァ'
1872            | 'ィ'
1873            | 'ゥ'
1874            | 'ェ'
1875            | 'ォ'
1876            | 'ッ'
1877            | 'ャ'
1878            | 'ュ'
1879            | 'ョ'
1880            | 'ヮ'
1881            | 'ー'
1882            | 'ー'
1883            | '~'
1884            | '…'
1885            | '‥'
1886    )
1887}
1888
1889fn body_face_extent(style: TextStyle) -> i32 {
1890    if style.bold { 1 } else { 0 }
1891}
1892
1893/// C_elm_object::string_frame places the 3x3/4x4 fuchi texture one pixel
1894/// up-left after Cfont_copy::alphablend_copy_face expands it down-right.
1895/// Keeping that placement separate from the face extent is important: the
1896/// original outline is centred around the body, not shifted to its lower-right.
1897fn fuchi_texture_offset() -> i32 {
1898    -1
1899}
1900
1901fn fuchi_face_extent(style: TextStyle) -> i32 {
1902    if style.bold { 3 } else { 2 }
1903}
1904
1905fn shadow_face_extent(style: TextStyle) -> i32 {
1906    if style.shadow_mode == TNM_FONT_SHADOW_MODE_FUCHI_SHADOW {
1907        if style.bold { 3 } else { 2 }
1908    } else {
1909        body_face_extent(style)
1910    }
1911}
1912
1913fn shadow_offset_for_style(size: i32, style: TextStyle) -> i32 {
1914    let size = size.max(0);
1915    let t = (size as f32 / 32.0).clamp(0.0, 1.0);
1916    if style.shadow_mode == TNM_FONT_SHADOW_MODE_FUCHI_SHADOW {
1917        // C++: linear_limit(size, 0, 0.5, 32, 1.5) - 1.0.
1918        (0.5 + (1.5 - 0.5) * t - 1.0).round() as i32
1919    } else {
1920        // C++: linear_limit(size, 0, 0.5, 32, 2.0).
1921        (0.5 + (2.0 - 0.5) * t).round().max(1.0) as i32
1922    }
1923}
1924
1925fn text_effect_padding(size: i32, style: TextStyle) -> i32 {
1926    let mut pad = body_face_extent(style);
1927    if style.fuchi {
1928        pad = pad.max(fuchi_face_extent(style));
1929    }
1930    if style.shadow {
1931        pad = pad.max(
1932            shadow_offset_for_style(size, style)
1933                .max(0)
1934                .saturating_add(shadow_face_extent(style)),
1935        );
1936    }
1937    pad + 1
1938}
1939
1940pub fn is_hankaku(ch: char) -> bool {
1941    ch.is_ascii() || matches!(ch as u32, 0xFF61..=0xFF9F)
1942}
1943
1944fn draw_glyph_bitmap(
1945    rgba: &mut [u8],
1946    w: u32,
1947    h: u32,
1948    x: i32,
1949    y: i32,
1950    glyph_w: usize,
1951    glyph_h: usize,
1952    glyph: &[u8],
1953    color: (u8, u8, u8, u8),
1954) {
1955    for gy in 0..glyph_h {
1956        let py = y + gy as i32;
1957        if py < 0 || py as u32 >= h {
1958            continue;
1959        }
1960        for gx in 0..glyph_w {
1961            let px = x + gx as i32;
1962            if px < 0 || px as u32 >= w {
1963                continue;
1964            }
1965            let src = glyph[gy * glyph_w + gx];
1966            if src == 0 {
1967                continue;
1968            }
1969            let src_a = ((src as u16 * color.3 as u16) / 255) as u8;
1970            blend_rgba_pixel(
1971                rgba, w, px as u32, py as u32, color.0, color.1, color.2, src_a,
1972            );
1973        }
1974    }
1975}
1976
1977/// GetGlyphOutline(..., GGO_GRAY8_BITMAP, ...) produces a 65-level coverage
1978/// bitmap.  tona3's Cfont_copy::gray8_copy then indexes a 66-entry table and
1979/// computes alpha as `(source_alpha * level) / 65`.  Quantising the portable
1980/// rasteriser through the same table preserves the original edge-alpha
1981/// staircase instead of feeding an unrelated 256-level mask to the renderer.
1982fn gdi_gray8_alpha_from_coverage(coverage: f32) -> u8 {
1983    let level = (coverage.clamp(0.0, 1.0) * 64.0).round().clamp(0.0, 64.0) as u16;
1984    ((255u16 * level) / 65) as u8
1985}
1986
1987fn draw_glyph_bitmap_face(
1988    rgba: &mut [u8],
1989    w: u32,
1990    h: u32,
1991    x: i32,
1992    y: i32,
1993    glyph_w: usize,
1994    glyph_h: usize,
1995    glyph: &[u8],
1996    face_extent: i32,
1997    color: (u8, u8, u8, u8),
1998) {
1999    for oy in 0..=face_extent.max(0) {
2000        for ox in 0..=face_extent.max(0) {
2001            draw_glyph_bitmap(rgba, w, h, x + ox, y + oy, glyph_w, glyph_h, glyph, color);
2002        }
2003    }
2004}
2005
2006fn blend_rgba_pixel(rgba: &mut [u8], w: u32, x: u32, y: u32, sr: u8, sg: u8, sb: u8, sa: u8) {
2007    let idx = ((y * w + x) * 4) as usize;
2008    // Cfont_copy uses 32-bit `int` work tables/arithmetic. The destination
2009    // term can reach 255^3, so u16 is non-original and overflows in debug
2010    // builds for ordinary coloured glyph overlaps.
2011    let da = rgba[idx + 3] as u32;
2012    let sa_u = sa as u32;
2013    let inv_sa = 255u32.saturating_sub(sa_u);
2014    // Cfont_copy's alpha table uses integer truncation:
2015    //   src + dst - src * dst / 255
2016    // Do not round here; repeated face copies otherwise diverge from tona3.
2017    let out_a = sa_u + da - (sa_u * da) / 255;
2018    if out_a == 0 {
2019        rgba[idx] = 0;
2020        rgba[idx + 1] = 0;
2021        rgba[idx + 2] = 0;
2022        rgba[idx + 3] = 0;
2023        return;
2024    }
2025    let blend = |src: u8, dst: u8| -> u8 {
2026        let src_p = src as u32 * sa_u;
2027        let dst_p = dst as u32 * da * inv_sa / 255;
2028        ((src_p + dst_p + out_a / 2) / out_a).min(255) as u8
2029    };
2030    rgba[idx] = blend(sr, rgba[idx]);
2031    rgba[idx + 1] = blend(sg, rgba[idx + 1]);
2032    rgba[idx + 2] = blend(sb, rgba[idx + 2]);
2033    rgba[idx + 3] = out_a.min(255) as u8;
2034}
2035
2036/// Line count and pitch of the layout [`render_text_ab_glyph_rgba`] produces.
2037///
2038/// Mirrors that function's line breaking exactly (explicit `\n`, automatic wrap at
2039/// `max_w`, `line_height = scaled.height() + scaled.line_gap()`) without rasterizing,
2040/// so a caller that draws text as one image can still address individual rows.
2041pub fn text_line_metrics_ab_glyph(
2042    font: &FontArc,
2043    text: &str,
2044    font_px: f32,
2045    max_w: u32,
2046    max_h: u32,
2047) -> (usize, f32) {
2048    let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
2049    let ascent = scaled.ascent().max(1.0);
2050    let line_height = (scaled.height() + scaled.line_gap()).max(1.0);
2051    if text.is_empty() || max_w == 0 || max_h == 0 {
2052        return (0, line_height);
2053    }
2054
2055    let mut lines = 1usize;
2056    let mut x = 0.0f32;
2057    let mut baseline_y = ascent.max(1.0);
2058    for ch in text.chars() {
2059        match ch {
2060            '\r' => continue,
2061            '\n' => {
2062                x = 0.0;
2063                baseline_y += line_height;
2064                if baseline_y - ascent >= max_h as f32 {
2065                    break;
2066                }
2067                lines += 1;
2068                continue;
2069            }
2070            '\t' => {
2071                x += scaled.h_advance(scaled.glyph_id(' ')).max(0.0) * 2.0;
2072                continue;
2073            }
2074            _ => {}
2075        }
2076
2077        let advance = scaled.h_advance(scaled.glyph_id(ch)).max(0.0);
2078        if x > 0.0 && x + advance > max_w as f32 {
2079            x = 0.0;
2080            baseline_y += line_height;
2081            if baseline_y - ascent >= max_h as f32 {
2082                break;
2083            }
2084            lines += 1;
2085        }
2086        x += advance;
2087    }
2088    (lines, line_height)
2089}
2090
2091fn render_text_ab_glyph_rgba(
2092    font: &FontArc,
2093    text: &str,
2094    font_px: f32,
2095    max_w: u32,
2096    max_h: u32,
2097) -> Option<RgbaImage> {
2098    if text.is_empty() || max_w == 0 || max_h == 0 {
2099        return None;
2100    }
2101    let mut rgba = vec![0u8; (max_w * max_h * 4) as usize];
2102
2103    let scaled = font.as_scaled(PxScale::from(font_px.max(1.0)));
2104    let ascent = scaled.ascent().max(1.0);
2105    let line_height = (scaled.height() + scaled.line_gap()).max(1.0);
2106
2107    let mut x = 0.0f32;
2108    let mut baseline_y = ascent.max(1.0);
2109
2110    for ch in text.chars() {
2111        match ch {
2112            '\r' => continue,
2113            '\n' => {
2114                x = 0.0;
2115                baseline_y += line_height;
2116                if baseline_y - ascent >= max_h as f32 {
2117                    break;
2118                }
2119                continue;
2120            }
2121            '\t' => {
2122                x += scaled.h_advance(scaled.glyph_id(' ')).max(0.0) * 2.0;
2123                continue;
2124            }
2125            _ => {}
2126        }
2127
2128        let advance = scaled.h_advance(scaled.glyph_id(ch)).max(0.0);
2129        if x > 0.0 && x + advance > max_w as f32 {
2130            x = 0.0;
2131            baseline_y += line_height;
2132            if baseline_y - ascent >= max_h as f32 {
2133                break;
2134            }
2135        }
2136
2137        let glyph = rasterize_ab_glyph(font, ch, font_px);
2138        let gx = x + glyph.xmin as f32;
2139        let gy = baseline_y + glyph.ymin as f32;
2140        for gy_i in 0..glyph.height {
2141            let py = gy as i32 + gy_i as i32;
2142            if py < 0 || py as u32 >= max_h {
2143                continue;
2144            }
2145            for gx_i in 0..glyph.width {
2146                let px = gx as i32 + gx_i as i32;
2147                if px < 0 || px as u32 >= max_w {
2148                    continue;
2149                }
2150                let src = glyph.bitmap[gy_i * glyph.width + gx_i];
2151                if src == 0 {
2152                    continue;
2153                }
2154                let idx = ((py as u32 * max_w + px as u32) * 4) as usize;
2155                rgba[idx] = 255;
2156                rgba[idx + 1] = 255;
2157                rgba[idx + 2] = 255;
2158                rgba[idx + 3] = src;
2159            }
2160        }
2161        x += advance;
2162    }
2163
2164    Some(RgbaImage {
2165        width: max_w,
2166        height: max_h,
2167        center_x: 0,
2168        center_y: 0,
2169        rgba,
2170    })
2171}
2172
2173pub fn embedded_default_font_available() -> bool {
2174    embedded_font::EMBEDDED_DEFAULT_FONT.is_some()
2175}
2176
2177pub fn embedded_default_font_names() -> &'static [&'static str] {
2178    if embedded_default_font_available() {
2179        embedded_font::EMBEDDED_DEFAULT_FONT_ALIASES
2180    } else {
2181        &[]
2182    }
2183}
2184
2185pub fn font_name_matches_embedded_default(name: &str) -> bool {
2186    if !embedded_default_font_available() {
2187        return false;
2188    }
2189    let needle = normalize_font_name_for_match(name);
2190    if needle.is_empty() {
2191        return false;
2192    }
2193    embedded_font::EMBEDDED_DEFAULT_FONT_ALIASES
2194        .iter()
2195        .any(|alias| normalize_font_name_for_match(alias) == needle)
2196}
2197
2198pub fn normalized_font_name(name: &str) -> String {
2199    normalize_font_name_for_match(name.trim_start_matches('@'))
2200}
2201
2202fn normalize_font_name_for_match(name: &str) -> String {
2203    name.chars()
2204        .filter(|ch| !ch.is_whitespace() && *ch != '-' && *ch != '_' && *ch != '.')
2205        .flat_map(|ch| ch.to_lowercase())
2206        .collect()
2207}
2208
2209/// A font file seen by any `FontCache`: the normalized names of each face,
2210/// and the file's bytes once a face of it is used.
2211struct FontFileInfo {
2212    face_names: Vec<Vec<String>>,
2213    data: Option<&'static [u8]>,
2214}
2215
2216/// Font files by path, for the whole process. Matching a requested name
2217/// needs only the face names, so a file is not read again for each new
2218/// request (a 22 MiB TTC was read on every font switch, a second or more
2219/// on PS Vita). Used files stay loaded once and every face and cache
2220/// shares them: each switch used to keep its own copy, hundreds of MiB.
2221static FONT_FILES: OnceLock<Mutex<std::collections::HashMap<PathBuf, FontFileInfo>>> =
2222    OnceLock::new();
2223
2224/// The data of the font file at `path` and its face matching
2225/// `normalized_name` (the first face for an empty name).
2226fn font_file_face(path: &Path, normalized_name: &str) -> Option<(&'static [u8], u32)> {
2227    let files = FONT_FILES.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
2228    let mut files = files.lock().ok()?;
2229    if let Some(info) = files.get_mut(path) {
2230        let face_index = matching_face_in_names(&info.face_names, normalized_name)?;
2231        let data = match info.data {
2232            Some(data) => data,
2233            None => {
2234                let data: &'static [u8] = Box::leak(
2235                    crate::resource::read_file_bytes(path)
2236                        .ok()?
2237                        .into_boxed_slice(),
2238                );
2239                info.data = Some(data);
2240                data
2241            }
2242        };
2243        return Some((data, face_index));
2244    }
2245    let bytes = crate::resource::read_file_bytes(path).ok()?;
2246    let face_names = font_face_names(&bytes);
2247    let face_index = matching_face_in_names(&face_names, normalized_name);
2248    // Only files that are used are kept.
2249    let data = face_index.map(|_| &*Box::leak(bytes.into_boxed_slice()));
2250    files.insert(path.to_path_buf(), FontFileInfo { face_names, data });
2251    Some((data?, face_index?))
2252}
2253
2254/// The normalized engine-visible names (family, full, typographic family,
2255/// WWS family, PostScript) of every face in a font file or collection.
2256fn font_face_names(bytes: &[u8]) -> Vec<Vec<String>> {
2257    let face_count = ttf_parser::fonts_in_collection(bytes).unwrap_or(1);
2258    (0..face_count)
2259        .map(|face_index| {
2260            let Ok(face) = ttf_parser::Face::parse(bytes, face_index) else {
2261                return Vec::new();
2262            };
2263            face.names()
2264                .into_iter()
2265                .filter(|name| {
2266                    matches!(
2267                        name.name_id,
2268                        ttf_parser::name::name_id::FAMILY
2269                            | ttf_parser::name::name_id::FULL_NAME
2270                            | ttf_parser::name::name_id::POST_SCRIPT_NAME
2271                            | ttf_parser::name::name_id::TYPOGRAPHIC_FAMILY
2272                            | ttf_parser::name::name_id::WWS_FAMILY
2273                            | ttf_parser::name::name_id::COMPATIBLE_FULL
2274                    )
2275                })
2276                .filter_map(|name| name.to_string())
2277                .map(|value| normalize_font_name_for_match(value.trim_start_matches('@')))
2278                .collect()
2279        })
2280        .collect()
2281}
2282
2283fn matching_face_in_names(face_names: &[Vec<String>], normalized_name: &str) -> Option<u32> {
2284    if normalized_name.is_empty() {
2285        return Some(0);
2286    }
2287    let mut partial_match = None;
2288    for (face_index, names) in face_names.iter().enumerate() {
2289        let face_index = face_index as u32;
2290        for key in names {
2291            if key == normalized_name {
2292                return Some(face_index);
2293            }
2294            if !key.is_empty()
2295                && partial_match.is_none()
2296                && (key.contains(normalized_name) || normalized_name.contains(key.as_str()))
2297            {
2298                partial_match = Some(face_index);
2299            }
2300        }
2301    }
2302    partial_match
2303}
2304
2305fn is_supported_font_path(path: &Path) -> bool {
2306    matches!(
2307        path.extension()
2308            .and_then(|s| s.to_str())
2309            .map(|s| s.to_ascii_lowercase())
2310            .as_deref(),
2311        Some("ttf" | "otf" | "ttc")
2312    )
2313}
2314
2315fn font_path_priority(path: &Path) -> (u8, u8, String) {
2316    let name_original = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
2317    let name = name_original.to_ascii_lowercase();
2318    let ext_score = match path
2319        .extension()
2320        .and_then(|s| s.to_str())
2321        .map(|s| s.to_ascii_lowercase())
2322        .as_deref()
2323    {
2324        Some("ttf") | Some("otf") => 0,
2325        Some("ttc") => 1,
2326        _ => 2,
2327    };
2328    let family_score = if name.contains("ms pgothic")
2329        || name.contains("mspgothic")
2330        || name.contains("ms-pgothic")
2331        || name.contains("msgothic")
2332        || name_original.contains("MS Pゴシック")
2333        || name_original.contains("MS PGothic")
2334    {
2335        0
2336    } else if name.contains("pgothic") || name_original.contains("Pゴシック") {
2337        1
2338    } else if name.contains("gothic") || name_original.contains("ゴシック") {
2339        2
2340    } else {
2341        3
2342    };
2343    (family_score, ext_score, name)
2344}
2345
2346fn project_font_dirs(project_dir: &Path) -> Vec<PathBuf> {
2347    let mut dirs = vec![project_dir.join("font"), project_dir.join("fonts")];
2348    if let Ok(exe) = std::env::current_exe()
2349        && let Some(exe_dir) = exe.parent()
2350    {
2351        dirs.push(exe_dir.join("font"));
2352        dirs.push(exe_dir.join("fonts"));
2353    }
2354    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
2355    dirs.push(manifest_dir.join("assets").join("font"));
2356    dirs.push(manifest_dir.join("assets").join("fonts"));
2357    dirs
2358}
2359
2360fn platform_font_candidates_for_name(name: &str) -> Vec<PathBuf> {
2361    let key = normalize_font_name_for_match(name.trim_start_matches('@'));
2362    let mut out = Vec::new();
2363
2364    #[cfg(target_os = "windows")]
2365    {
2366        let windir = std::env::var_os("WINDIR")
2367            .map(PathBuf::from)
2368            .unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
2369        let fonts = windir.join("Fonts");
2370        let names: &[&str] = if key.contains("msgothic") || key.contains("msゴシック") {
2371            &["msgothic.ttc", "msgothic.ttf"]
2372        } else if key.contains("msmincho") || key.contains("ms明朝") {
2373            &["msmincho.ttc", "msmincho.ttf"]
2374        } else if key.contains("meiryo") || key.contains("メイリオ") {
2375            &["meiryo.ttc", "meiryob.ttc"]
2376        } else if key.contains("yugoth") || key.contains("游ゴシック") {
2377            &["YuGothM.ttc", "YuGothR.ttc"]
2378        } else if key.contains("yumin") || key.contains("游明朝") {
2379            &["YuMincho.ttc"]
2380        } else {
2381            &[]
2382        };
2383        out.extend(names.iter().map(|file| fonts.join(file)));
2384    }
2385
2386    #[cfg(target_os = "macos")]
2387    {
2388        let dirs = [
2389            PathBuf::from("/System/Library/Fonts"),
2390            PathBuf::from("/Library/Fonts"),
2391        ];
2392        let names: &[&str] = if key.contains("gothic")
2393            || key.contains("ゴシック")
2394            || key.contains("meiryo")
2395            || key.contains("メイリオ")
2396        {
2397            &[
2398                "ヒラギノ角ゴシック W3.ttc",
2399                "ヒラギノ角ゴシック W6.ttc",
2400                "Hiragino Sans GB.ttc",
2401            ]
2402        } else if key.contains("mincho") || key.contains("明朝") {
2403            &["ヒラギノ明朝 ProN.ttc", "Hiragino Mincho ProN.ttc"]
2404        } else {
2405            &[]
2406        };
2407        for dir in dirs {
2408            out.extend(names.iter().map(|file| dir.join(file)));
2409        }
2410    }
2411
2412    #[cfg(target_os = "linux")]
2413    {
2414        let names: &[&str] = if key.contains("mincho") || key.contains("明朝") {
2415            &[
2416                "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
2417                "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
2418            ]
2419        } else {
2420            &[
2421                "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
2422                "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
2423            ]
2424        };
2425        out.extend(names.iter().map(|name| PathBuf::from(*name)));
2426    }
2427
2428    out
2429}
2430
2431fn platform_font_candidates() -> Vec<PathBuf> {
2432    let mut out = Vec::new();
2433
2434    #[cfg(target_os = "windows")]
2435    {
2436        let windir = std::env::var_os("WINDIR")
2437            .map(PathBuf::from)
2438            .unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
2439        let fonts = windir.join("Fonts");
2440        out.push(fonts.join("msgothic.ttc"));
2441        out.push(fonts.join("msgothic.ttf"));
2442        out.push(fonts.join("YuGothM.ttc"));
2443        out.push(fonts.join("YuGothR.ttc"));
2444    }
2445
2446    #[cfg(target_os = "macos")]
2447    {
2448        out.push(PathBuf::from(
2449            "/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
2450        ));
2451        out.push(PathBuf::from(
2452            "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc",
2453        ));
2454        out.push(PathBuf::from(
2455            "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
2456        ));
2457        out.push(PathBuf::from(
2458            "/System/Library/Fonts/Supplemental/Osaka.ttf",
2459        ));
2460    }
2461
2462    #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "android"))]
2463    {
2464        out.push(PathBuf::from(
2465            "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
2466        ));
2467        out.push(PathBuf::from(
2468            "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
2469        ));
2470        out.push(PathBuf::from(
2471            "/usr/share/fonts/opentype/noto/NotoSansCJKjp-Regular.otf",
2472        ));
2473        out.push(PathBuf::from(
2474            "/usr/share/fonts/truetype/fonts-japanese-gothic.ttf",
2475        ));
2476    }
2477
2478    out
2479}
2480
2481fn draw_glyph_5x7(rgba: &mut [u8], w: u32, h: u32, x: u32, y: u32, ch: char, scale: u32) {
2482    let glyph = glyph_5x7(ch);
2483    for (row, bits) in glyph.iter().enumerate() {
2484        for col in 0..5 {
2485            if (bits >> (4 - col)) & 1 == 0 {
2486                continue;
2487            }
2488            let px = x + col as u32 * scale;
2489            let py = y + row as u32 * scale;
2490            for sy in 0..scale {
2491                let yy = py + sy;
2492                if yy >= h {
2493                    continue;
2494                }
2495                for sx in 0..scale {
2496                    let xx = px + sx;
2497                    if xx >= w {
2498                        continue;
2499                    }
2500                    let idx = ((yy * w + xx) * 4) as usize;
2501                    rgba[idx] = 255;
2502                    rgba[idx + 1] = 255;
2503                    rgba[idx + 2] = 255;
2504                    rgba[idx + 3] = 255;
2505                }
2506            }
2507        }
2508    }
2509}
2510
2511fn glyph_5x7(ch: char) -> [u8; 7] {
2512    match ch.to_ascii_uppercase() {
2513        'A' => [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
2514        'B' => [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E],
2515        'C' => [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E],
2516        'D' => [0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E],
2517        'E' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F],
2518        'F' => [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10],
2519        'G' => [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E],
2520        'H' => [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11],
2521        'I' => [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E],
2522        'J' => [0x01, 0x01, 0x01, 0x01, 0x11, 0x11, 0x0E],
2523        'K' => [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11],
2524        'L' => [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F],
2525        'M' => [0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11],
2526        'N' => [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11],
2527        'O' => [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
2528        'P' => [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10],
2529        'Q' => [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D],
2530        'R' => [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11],
2531        'S' => [0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E],
2532        'T' => [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
2533        'U' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E],
2534        'V' => [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04],
2535        'W' => [0x11, 0x11, 0x11, 0x15, 0x15, 0x15, 0x0A],
2536        'X' => [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11],
2537        'Y' => [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04],
2538        'Z' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F],
2539        '0' => [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E],
2540        '1' => [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E],
2541        '2' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F],
2542        '3' => [0x1E, 0x01, 0x01, 0x06, 0x01, 0x01, 0x1E],
2543        '4' => [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02],
2544        '5' => [0x1F, 0x10, 0x10, 0x1E, 0x01, 0x01, 0x1E],
2545        '6' => [0x0E, 0x10, 0x10, 0x1E, 0x11, 0x11, 0x0E],
2546        '7' => [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08],
2547        '8' => [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E],
2548        '9' => [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x01, 0x0E],
2549        ':' => [0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00],
2550        '.' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06],
2551        ',' => [0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x04],
2552        '-' => [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
2553        '_' => [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F],
2554        '/' => [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
2555        '\\' => [0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00],
2556        '[' => [0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E],
2557        ']' => [0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E],
2558        '(' => [0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02],
2559        ')' => [0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08],
2560        '#' => [0x0A, 0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x0A],
2561        '+' => [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
2562        '=' => [0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00, 0x00],
2563        '*' => [0x00, 0x11, 0x0A, 0x1F, 0x0A, 0x11, 0x00],
2564        '?' => [0x0E, 0x11, 0x01, 0x02, 0x04, 0x00, 0x04],
2565        '!' => [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04],
2566        '>' => [0x10, 0x08, 0x04, 0x02, 0x04, 0x08, 0x10],
2567        '<' => [0x01, 0x02, 0x04, 0x08, 0x04, 0x02, 0x01],
2568        '|' => [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04],
2569        ' ' => [0x00; 7],
2570        _ => [0x1F, 0x11, 0x15, 0x15, 0x15, 0x11, 0x1F],
2571    }
2572}
2573
2574#[cfg(test)]
2575mod font_shadow_mode_tests {
2576    use super::*;
2577
2578    #[test]
2579    fn original_four_state_shadow_mode_mapping() {
2580        assert_eq!(font_shadow_mode_flags(0), (false, false));
2581        assert_eq!(font_shadow_mode_flags(1), (true, false));
2582        assert_eq!(font_shadow_mode_flags(2), (false, true));
2583        assert_eq!(font_shadow_mode_flags(3), (true, true));
2584    }
2585
2586    #[test]
2587    fn invalid_shadow_modes_are_clamped_like_runtime_config() {
2588        assert_eq!(font_shadow_mode_flags(-1), (false, false));
2589        assert_eq!(font_shadow_mode_flags(99), (true, true));
2590    }
2591
2592    #[test]
2593    fn gdi_gray8_uses_the_original_65_level_alpha_table() {
2594        assert_eq!(gdi_gray8_alpha_from_coverage(0.0), 0);
2595        assert_eq!(gdi_gray8_alpha_from_coverage(1.0), 251);
2596        assert_eq!(gdi_gray8_alpha_from_coverage(32.0 / 64.0), 125);
2597    }
2598
2599    #[test]
2600    fn normal_fuchi_is_centered_around_the_body_pixel() {
2601        let mut rgba = vec![0u8; 5 * 5 * 4];
2602        draw_glyph_bitmap_face(
2603            &mut rgba,
2604            5,
2605            5,
2606            2 + fuchi_texture_offset(),
2607            2 + fuchi_texture_offset(),
2608            1,
2609            1,
2610            &[255],
2611            2,
2612            (0, 0, 0, 255),
2613        );
2614
2615        for y in 0..5usize {
2616            for x in 0..5usize {
2617                let alpha = rgba[(y * 5 + x) * 4 + 3];
2618                let expected = (1..=3).contains(&x) && (1..=3).contains(&y);
2619                assert_eq!(alpha != 0, expected, "unexpected fuchi pixel at ({x},{y})");
2620            }
2621        }
2622    }
2623
2624    #[test]
2625    fn repeated_face_alpha_uses_tona3_integer_truncation() {
2626        let mut rgba = vec![0u8; 4];
2627        blend_rgba_pixel(&mut rgba, 1, 0, 0, 0, 0, 0, 128);
2628        blend_rgba_pixel(&mut rgba, 1, 0, 0, 0, 0, 0, 128);
2629        assert_eq!(rgba[3], 192);
2630    }
2631
2632    #[test]
2633    fn blend_over_opaque_destination_does_not_overflow() {
2634        // Semi-transparent glyph blended over an already-opaque pixel:
2635        // dst * da * inv_sa = 255 * 255 * 127 ~= 8.3M, which overflowed the
2636        // u16 intermediate (`attempt to multiply with overflow` in debug).
2637        let mut rgba = vec![255u8; 4]; // opaque destination
2638        blend_rgba_pixel(&mut rgba, 1, 0, 0, 255, 255, 255, 128);
2639        // out_a = 128 + 255 - 128*255/255 = 255; blend -> 255
2640        assert_eq!(rgba[0], 255);
2641        assert_eq!(rgba[3], 255);
2642    }
2643
2644    #[test]
2645    fn coloured_glyph_blend_uses_wide_original_integer_arithmetic() {
2646        let mut rgba = [255, 220, 192, 255];
2647        blend_rgba_pixel(&mut rgba, 1, 0, 0, 80, 160, 240, 128);
2648        assert_eq!(rgba, [167, 190, 216, 255]);
2649    }
2650}