Skip to main content

atmos/kernel/
vector_font.rs

1// src/vector_font.rs - ベクトルフォント(TrueType)レンダリングエンジン
2// ttf-parser を使用したアウトライン抽出、4x4スーパーサンプリングEven-Oddラスタライザ、グリフキャッシュ。
3
4#![allow(dead_code)]
5
6extern crate alloc;
7use alloc::collections::BTreeMap;
8use alloc::string::String;
9use alloc::vec::Vec;
10
11/// Brotli 圧縮されたデータを取り、展開されたバイト列を返す。
12/// Brotli 展開用のヒープ確保アロケータ。
13///
14/// 【2026-08-03 発見・修正】従来は `alloc_no_stdlib::StackAllocator` に
15/// **型ごとにスロットを 1 個**しか渡していなかった(`[&mut [u8]; 1]`)。
16/// Brotli は同じ型の領域を複数同時に要求するため、2 個目以降が取れず
17/// `BrotliDecompressStream` から**戻ってこなくなる**。
18///
19/// 実測(`fa-v4compatibility.woff2`, 4132 バイト):
20/// ```text
21/// [FONT][BR] u8 プール確保 完了
22/// [FONT][BR] u32 プール確保 完了
23/// [FONT][BR] HuffmanCode プール確保 完了
24/// [FONT][BR] BrotliState 構築 完了
25/// [FONT][BR] 展開開始 in=4060 out_cap=8750
26/// (以降なし。待機 300/420/540 秒すべて同じ地点で停止)
27/// ```
28///
29/// カーネルには 512MB のヒープがあるので、固定プールをやめて
30/// **必要なだけヒープから確保する**。プールサイズの見積りが不要になり、
31/// 「大きいフォントで枯渇して落ちる/止まる」という失敗モードごと消える。
32struct HeapBuf<T>(Vec<T>);
33
34impl<T> Default for HeapBuf<T> {
35    fn default() -> Self {
36        Self(Vec::new())
37    }
38}
39
40impl<T> alloc_no_stdlib::SliceWrapper<T> for HeapBuf<T> {
41    fn slice(&self) -> &[T] {
42        &self.0
43    }
44}
45
46impl<T> alloc_no_stdlib::SliceWrapperMut<T> for HeapBuf<T> {
47    fn slice_mut(&mut self) -> &mut [T] {
48        &mut self.0
49    }
50}
51
52struct HeapAllocator;
53
54impl<T: Clone + Default> alloc_no_stdlib::Allocator<T> for HeapAllocator {
55    type AllocatedMemory = HeapBuf<T>;
56
57    /// 計算量: **O(len)**(ゼロ初期化)。
58    fn alloc_cell(&mut self, len: usize) -> HeapBuf<T> {
59        HeapBuf(alloc::vec![T::default(); len])
60    }
61
62    /// 解放は `Vec` の Drop に任せる。
63    fn free_cell(&mut self, _data: HeapBuf<T>) {}
64}
65
66/// WOFF2 の Brotli ストリームを展開する。
67///
68/// 計算量: **O(展開後サイズ)**。
69///
70/// 失敗は必ずログに残す(`spec/logging_policy.md` L-1)。
71pub fn decompress_brotli(compressed: &[u8], decompressed_size: usize) -> Option<Vec<u8>> {
72    let mut s = brotli_decompressor::BrotliState::new(
73        HeapAllocator,
74        HeapAllocator,
75        HeapAllocator,
76    );
77
78    // 展開結果が 0 バイトの場合に `available_out` が最初から 0 だと
79    // ストリーミングデコーダが `ResultSuccess` ではなく `NeedsMoreOutput` を
80    // 返すため、最小 16 バイトの余裕を持たせる(2026-07-22 に自己テストで確認)。
81    let mut out_vec = alloc::vec![0u8; decompressed_size.max(16)];
82
83    let mut available_in: usize = compressed.len();
84    let mut input_offset: usize = 0;
85    let mut available_out: usize = out_vec.len();
86    let mut output_offset: usize = 0;
87    let mut total_out: usize = 0;
88
89    let result = brotli_decompressor::BrotliDecompressStream(
90        &mut available_in,
91        &mut input_offset,
92        compressed,
93        &mut available_out,
94        &mut output_offset,
95        &mut out_vec,
96        &mut total_out,
97        &mut s,
98    );
99
100    if matches!(result, brotli_decompressor::BrotliResult::ResultSuccess) {
101        out_vec.truncate(total_out);
102        Some(out_vec)
103    } else {
104        crate::warn!(
105            "[FONT][BR] Brotli 展開に失敗 result={:?} in={} total_out={} in_left={}",
106            result,
107            compressed.len(),
108            total_out,
109            available_in
110        );
111        None
112    }
113}
114
115static WOFF2_KNOWN_TAGS: [&[u8; 4]; 63] = [
116    b"cmap", b"head", b"hhea", b"hmtx", b"maxp", b"name", b"OS/2", b"post",
117    b"cvt ", b"fpgm", b"glyf", b"loca", b"prep", b"CFF ", b"VORG", b"EBDT",
118    b"EBLC", b"gasp", b"hdmx", b"kern", b"LTSH", b"PCLT", b"VDMX", b"vhea",
119    b"vmtx", b"BASE", b"GDEF", b"GPOS", b"GSUB", b"JSTF", b"DSIG", b"MATH",
120    b"CBDT", b"CBLC", b"COLR", b"CPAL", b"SVG ", b"sbix", b"acnt", b"avar",
121    b"bdat", b"bloc", b"bsln", b"cvar", b"fdsc", b"fmtx", b"fvar", b"gvar",
122    b"hsty", b"just", b"lcar", b"mort", b"morx", b"opbd", b"prop", b"trak",
123    b"Zapf", b"Silf", b"Gloc", b"Glat", b"Feat", b"Sill", b"SVG ",
124];
125
126fn read_woff2_uint16(data: &[u8], offset: &mut usize) -> Option<u16> {
127    if *offset + 2 > data.len() {
128        return None;
129    }
130    let val = u16::from_be_bytes([data[*offset], data[*offset + 1]]);
131    *offset += 2;
132    Some(val)
133}
134
135fn read_woff2_uint32(data: &[u8], offset: &mut usize) -> Option<u32> {
136    if *offset + 4 > data.len() {
137        return None;
138    }
139    let val = u32::from_be_bytes([
140        data[*offset],
141        data[*offset + 1],
142        data[*offset + 2],
143        data[*offset + 3],
144    ]);
145    *offset += 4;
146    Some(val)
147}
148
149fn read_woff2_base128(data: &[u8], offset: &mut usize) -> Option<u32> {
150    let mut accum: u32 = 0;
151    for _ in 0..5 {
152        if *offset >= data.len() {
153            return None;
154        }
155        let code = data[*offset];
156        *offset += 1;
157        if accum & 0xFE00_0000 != 0 {
158            return None;
159        }
160        accum = (accum << 7) | ((code & 0x7F) as u32);
161        if (code & 0x80) == 0 {
162            return Some(accum);
163        }
164    }
165    None
166}
167
168struct Woff2Table {
169    tag: [u8; 4],
170    orig_length: u32,
171    transform_length: Option<u32>,
172    flags: u8,
173}
174
175/// WOFF2 形式のフォントデータを受取り、TTF/OTF (sfnt) バイト列に展開する。
176pub fn decompress_woff2(bytes: &[u8]) -> Option<Vec<u8>> {
177    if bytes.len() < 48 || &bytes[0..4] != b"wOF2" {
178        return None;
179    }
180    let mut offset = 4;
181    let flavor = read_woff2_uint32(bytes, &mut offset)?;
182    let _length = read_woff2_uint32(bytes, &mut offset)?;
183    let num_tables = read_woff2_uint16(bytes, &mut offset)? as usize;
184    let _reserved = read_woff2_uint16(bytes, &mut offset)?;
185    let total_sfnt_size = read_woff2_uint32(bytes, &mut offset)? as usize;
186    let total_compressed_size = read_woff2_uint32(bytes, &mut offset)? as usize;
187    let _major_version = read_woff2_uint16(bytes, &mut offset)?;
188    let _minor_version = read_woff2_uint16(bytes, &mut offset)?;
189    let _meta_offset = read_woff2_uint32(bytes, &mut offset)?;
190    let _meta_length = read_woff2_uint32(bytes, &mut offset)?;
191    let _meta_orig_length = read_woff2_uint32(bytes, &mut offset)?;
192    let _priv_offset = read_woff2_uint32(bytes, &mut offset)?;
193    let _priv_length = read_woff2_uint32(bytes, &mut offset)?;
194
195    let mut tables = Vec::with_capacity(num_tables);
196    let mut total_uncompressed_stream_len: usize = 0;
197
198    for _ in 0..num_tables {
199        if offset >= bytes.len() {
200            return None;
201        }
202        let flags = bytes[offset];
203        offset += 1;
204        let tag_idx = flags & 0x3F;
205
206        let tag = if tag_idx == 63 {
207            if offset + 4 > bytes.len() {
208                return None;
209            }
210            let t = [bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]];
211            offset += 4;
212            t
213        } else if (tag_idx as usize) < WOFF2_KNOWN_TAGS.len() {
214            *WOFF2_KNOWN_TAGS[tag_idx as usize]
215        } else {
216            return None;
217        };
218
219        let orig_length = read_woff2_base128(bytes, &mut offset)?;
220        let transform_version = (flags >> 6) & 0x03;
221
222        // 【2026-08-05 発見・修正】変換の有無の判定が仕様と逆だった。
223        //
224        // WOFF2 仕様では **`glyf` と `loca` だけ意味が反転**する:
225        //
226        // | テーブル        | version 0 | version 3 |
227        // |-----------------|-----------|-----------|
228        // | `glyf` / `loca` | **変換あり** | 変換なし |
229        // | その他          | 変換なし  | 変換あり  |
230        //
231        // 以前は「0 = 変換なし」と決め打ちしていたため、変換された `glyf` を
232        // 「変換なし」と誤判定し、`transformLength` を読まずに元長で切り出していた。
233        // **ストリーム上の位置がそこから全部ずれる**うえ、変換済みバイト列を
234        // そのまま SFNT の `glyf` として書いていた。
235        //
236        // 結果、`head`/`cmap` は無事なのでフォントはパースに成功し
237        // `glyph_index()` も引けるが、輪郭データが壊れているため
238        // Font Awesome のアイコンが箱(☐)として描かれていた。
239        let is_glyf_or_loca = &tag == b"glyf" || &tag == b"loca";
240        let transformed = if is_glyf_or_loca {
241            transform_version == 0
242        } else {
243            transform_version != 0
244        };
245
246        let transform_length = if transformed {
247            let tl = read_woff2_base128(bytes, &mut offset)?;
248            total_uncompressed_stream_len += tl as usize;
249            Some(tl)
250        } else {
251            total_uncompressed_stream_len += orig_length as usize;
252            None
253        };
254
255        tables.push(Woff2Table {
256            tag,
257            orig_length,
258            transform_length,
259            flags,
260        });
261    }
262
263    if offset + total_compressed_size > bytes.len() {
264        return None;
265    }
266
267    let compressed_data = &bytes[offset..offset + total_compressed_size];
268    // 【2026-07-31診断】Font Awesome の woff2 展開で OOM した件。
269    // 必要な展開後サイズを記録し、スクラッチバッファが足りているか確認する。
270    crate::warn!(
271        "[WOFF2] decompress compressed={} expected_uncompressed={}",
272        compressed_data.len(),
273        total_uncompressed_stream_len
274    );
275    let decompressed_stream = decompress_brotli(compressed_data, total_uncompressed_stream_len)?;
276
277    // sfnt ファイルの再構築
278    let mut sfnt = Vec::with_capacity(total_sfnt_size.max(12 + num_tables * 16));
279    
280    // sfnt Header (12 bytes)
281    sfnt.extend_from_slice(&flavor.to_be_bytes());
282    sfnt.extend_from_slice(&(num_tables as u16).to_be_bytes());
283
284    let mut max_p2 = 1u16;
285    while max_p2 * 2 <= (num_tables as u16) {
286        max_p2 *= 2;
287    }
288    let search_range = max_p2 * 16;
289    let entry_selector = (16 - max_p2.leading_zeros() - 1) as u16;
290    let range_shift = (num_tables as u16) * 16 - search_range;
291
292    sfnt.extend_from_slice(&search_range.to_be_bytes());
293    sfnt.extend_from_slice(&entry_selector.to_be_bytes());
294    sfnt.extend_from_slice(&range_shift.to_be_bytes());
295
296    // sfnt Table Directory
297    let table_dir_offset = 12;
298    let data_start_offset = table_dir_offset + num_tables * 16;
299
300    let mut sorted_indices: Vec<usize> = (0..num_tables).collect();
301    sorted_indices.sort_by_key(|&idx| tables[idx].tag);
302
303    // 空の Table Directory を確保
304    sfnt.resize(data_start_offset, 0);
305
306    // 【2026-08-03 発見・修正】展開ストリームを**タグ順**に読んでいた。
307    //
308    // WOFF2 仕様では、テーブルデータは**テーブルディレクトリに現れた順**で
309    // 連結されている。タグ順に読むと各テーブルへ別のテーブルのバイト列が
310    // 割り当たり、`head` が壊れる。
311    // 実測: Brotli 展開は成功しているのに `VectorFont::new` が
312    // **`NoHeadTable`** を返していた(Font Awesome 全ファイル)。
313    //
314    // 正しくは「ストリームの切り出しはディレクトリ順」「ディレクトリの
315    // 出力はタグ順」と**分ける**。
316    let mut stream_offset = 0usize;
317    let mut table_ranges: Vec<(usize, usize)> = Vec::with_capacity(num_tables);
318    for table in tables.iter().take(num_tables) {
319        let len = table.orig_length as usize;
320        let read_len = table.transform_length.map(|l| l as usize).unwrap_or(len);
321        if stream_offset + read_len > decompressed_stream.len() {
322            crate::warn!(
323                "[FONT][WOFF2] 展開ストリームが短い tag={:?} 必要={} 残り={}",
324                core::str::from_utf8(&table.tag).unwrap_or("????"),
325                read_len,
326                decompressed_stream.len().saturating_sub(stream_offset)
327            );
328            return None;
329        }
330        table_ranges.push((stream_offset, stream_offset + read_len));
331        stream_offset += read_len;
332    }
333
334    // 【2026-08-05】変換された `glyf` を復元し、`loca` も作り直す。
335    //
336    // WOFF2 は `glyf`/`loca` を変換して格納する(仕様 §5.1)。
337    // 復元しないと輪郭データが壊れたままで、フォントはパースに成功するのに
338    // 字形が箱(☐)になる(実測: Font Awesome のアイコン全部)。
339    //
340    // 復元に失敗したら**フォント全体を諦める**(W-3)。
341    // 壊れたテーブルを書くより、代替フォントで描く方がまし。
342    let mut rebuilt: alloc::collections::BTreeMap<usize, Vec<u8>> =
343        alloc::collections::BTreeMap::new();
344    {
345        let glyf_idx = tables.iter().take(num_tables).position(|t| {
346            &t.tag == b"glyf" && t.transform_length.is_some()
347        });
348        if let Some(gi) = glyf_idx {
349            let (rs, re) = table_ranges[gi];
350            let raw = decompressed_stream.get(rs..re)?;
351            match crate::kernel::woff2_glyf::reconstruct_glyf(raw) {
352                Some((glyf_data, loca_data, _fmt)) => {
353                    crate::warn!(
354                        "[FONT][WOFF2] glyf 変換を復元 {} -> {} バイト(loca {} バイト)",
355                        raw.len(),
356                        glyf_data.len(),
357                        loca_data.len()
358                    );
359                    rebuilt.insert(gi, glyf_data);
360                    if let Some(li) = tables
361                        .iter()
362                        .take(num_tables)
363                        .position(|t| &t.tag == b"loca")
364                    {
365                        rebuilt.insert(li, loca_data);
366                    }
367                }
368                None => {
369                    crate::warn!(
370                        "[FONT][WOFF2] glyf 変換の復元に失敗({} バイト)",
371                        raw.len()
372                    );
373                    return None;
374                }
375            }
376        }
377    }
378
379    let mut current_data_offset = data_start_offset;
380
381    for &idx in &sorted_indices {
382        let table = &tables[idx];
383        let len = table.orig_length as usize;
384        let (rs, re) = table_ranges[idx];
385        // 復元済みテーブル(`glyf`/`loca`)があればそちらを使う。
386        let table_raw: &[u8] = match rebuilt.get(&idx) {
387            Some(v) => v.as_slice(),
388            None => &decompressed_stream[rs..re],
389        };
390        let len = if rebuilt.contains_key(&idx) {
391            table_raw.len()
392        } else {
393            len
394        };
395
396        // チェックサムの計算
397        let mut sum: u32 = 0;
398        let mut i = 0;
399        while i < table_raw.len() {
400            let b0 = table_raw[i] as u32;
401            let b1 = table_raw.get(i + 1).copied().unwrap_or(0) as u32;
402            let b2 = table_raw.get(i + 2).copied().unwrap_or(0) as u32;
403            let b3 = table_raw.get(i + 3).copied().unwrap_or(0) as u32;
404            sum = sum.wrapping_add((b0 << 24) | (b1 << 16) | (b2 << 8) | b3);
405            i += 4;
406        }
407
408        let dir_entry_pos = table_dir_offset + sorted_indices.iter().position(|&x| x == idx)? * 16;
409        sfnt[dir_entry_pos..dir_entry_pos + 4].copy_from_slice(&table.tag);
410        sfnt[dir_entry_pos + 4..dir_entry_pos + 8].copy_from_slice(&sum.to_be_bytes());
411        sfnt[dir_entry_pos + 8..dir_entry_pos + 12].copy_from_slice(&(current_data_offset as u32).to_be_bytes());
412        sfnt[dir_entry_pos + 12..dir_entry_pos + 16].copy_from_slice(&(len as u32).to_be_bytes());
413
414        sfnt.extend_from_slice(table_raw);
415        let padding = (4 - (table_raw.len() % 4)) % 4;
416        sfnt.extend(core::iter::repeat_n(0u8, padding));
417        current_data_offset += table_raw.len() + padding;
418    }
419
420    Some(sfnt)
421}
422
423use spin::Mutex;
424use ttf_parser::{Face, OutlineBuilder};
425
426fn f32_floor(x: f32) -> i32 {
427    if x >= 0.0 {
428        x as i32
429    } else {
430        (x - 1.0) as i32
431    }
432}
433
434fn f32_ceil(x: f32) -> i32 {
435    if x >= 0.0 {
436        (x + 1.0) as i32
437    } else {
438        x as i32
439    }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq)]
443struct Point {
444    x: f32,
445    y: f32,
446}
447
448#[derive(Debug, Clone)]
449enum Segment {
450    Line(Point, Point),
451}
452
453/// グリフのアウトライン(直線線分リスト)を構築するビルダー
454struct GlyphOutlineBuilder {
455    segments: Vec<Segment>,
456    current: Point,
457    start: Point,
458    scale: f32,
459}
460
461impl GlyphOutlineBuilder {
462    fn new(scale: f32) -> Self {
463        Self {
464            segments: Vec::new(),
465            current: Point { x: 0.0, y: 0.0 },
466            start: Point { x: 0.0, y: 0.0 },
467            scale,
468        }
469    }
470
471    /// 2次ベジェ曲線を再帰/ステップ分割して直線セグメントにフラット化します
472    fn flatten_quad(&mut self, p0: Point, p1: Point, p2: Point) {
473        let steps = 6; // 計算量と品質のバランスが最適なステップ数
474        let mut prev = p0;
475        for i in 1..=steps {
476            let t = (i as f32) / (steps as f32);
477            let mt = 1.0 - t;
478            // 2次ベジェ曲線の方程式
479            let x = mt * mt * p0.x + 2.0 * mt * t * p1.x + t * t * p2.x;
480            let y = mt * mt * p0.y + 2.0 * mt * t * p1.y + t * t * p2.y;
481            let current = Point { x, y };
482            self.segments.push(Segment::Line(prev, current));
483            prev = current;
484        }
485    }
486}
487
488impl OutlineBuilder for GlyphOutlineBuilder {
489    fn move_to(&mut self, x: f32, y: f32) {
490        let p = Point {
491            x: x * self.scale,
492            y: y * self.scale,
493        };
494        self.current = p;
495        self.start = p;
496    }
497
498    fn line_to(&mut self, x: f32, y: f32) {
499        let p = Point {
500            x: x * self.scale,
501            y: y * self.scale,
502        };
503        self.segments.push(Segment::Line(self.current, p));
504        self.current = p;
505    }
506
507    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
508        let p1 = Point {
509            x: x1 * self.scale,
510            y: y1 * self.scale,
511        };
512        let p2 = Point {
513            x: x * self.scale,
514            y: y * self.scale,
515        };
516        self.flatten_quad(self.current, p1, p2);
517        self.current = p2;
518    }
519
520    fn curve_to(&mut self, _x1: f32, _y1: f32, _x2: f32, _y2: f32, x: f32, y: f32) {
521        // TrueTypeフォント(2次ベジェ)では基本的に呼ばれないが、
522        // 3次ベジェが含まれる場合は線形補間で対応
523        let p2 = Point {
524            x: x * self.scale,
525            y: y * self.scale,
526        };
527        self.segments.push(Segment::Line(self.current, p2));
528        self.current = p2;
529    }
530
531    fn close(&mut self) {
532        if self.current != self.start {
533            self.segments.push(Segment::Line(self.current, self.start));
534        }
535    }
536}
537
538/// ヒープメモリにキャッシュされるラスタライズ済みグリフデータ
539#[derive(Clone)]
540pub struct CachedGlyph {
541    pub width: u32,
542    pub height: u32,
543    pub x_offset: i32,
544    pub y_offset: i32,
545    pub advance: u32,
546    pub data: Vec<u8>, // A8 フォーマットのアルファマスク (0-255)
547}
548
549/// アルファブレンド済みのカラーグリフデータ
550#[derive(Clone)]
551pub struct CachedColorGlyph {
552    pub width: u32,
553    pub height: u32,
554    pub x_offset: i32,
555    pub y_offset: i32,
556    pub advance: u32,
557    pub data: Vec<u32>, // ARGB8888 形式のブレンド済みピクセルカラー (透明部分は0)
558}
559
560/// 動的フォントキャッシュとフォントロードを管理するマネージャ
561pub struct VectorFont {
562    face: Face<'static>,
563    /// フォールバックフォント(プライマリにグリフが無い文字をこの順で補う)。
564    fallbacks: Vec<Face<'static>>,
565    cache: BTreeMap<(char, u32), CachedGlyph>, // (文字, フォントサイズ) -> キャッシュグリフ
566    color_cache: BTreeMap<(char, u32, u32, u32), CachedColorGlyph>, // (文字, サイズ, 文字色, 背景色) -> キャッシュカラーグリフ
567    advance_cache: BTreeMap<(char, u32), u32>,                      // (文字, サイズ) -> advance幅
568}
569
570/// フォントに存在しない記号類を、視覚的に近い代替文字へマッピングする。
571/// (M PLUS 2 などに含まれない矢印・約物・記号の文字化け/欠落を防ぐ)
572fn fallback_glyph_char(c: char) -> Option<char> {
573    let sub = match c {
574        // 不可視文字可視化記号のフォールバック
575        '␣' => '_', // 半角空白 (Open Box) はアンダーバー
576        '⇥' => '>', // タブは不等号
577        '↵' => '<', // CRLFは不等号
578        '⇠' => '<', // CRは不等号
579        // ダッシュ・ハイフン類
580        '–' | '—' | '―' | '‐' | '‑' | '−' => '-',
581        // 引用符
582        '\u{2018}' | '\u{2019}' | '‚' | '‛' | '`' => '\'',
583        '\u{201C}' | '\u{201D}' | '„' | '‟' => '"',
584        // 中点・箇条書き
585        '•' | '◦' | '‣' | '·' | '∙' => '*',
586        // 乗算・除算
587        '×' => 'x',
588        '÷' => '/',
589        // 省略記号(簡易的に1点へ)
590        '…' => '.',
591        // 全角スペースは呼び出し側で除外済みだが念のため通常空白へ
592        '\u{00A0}' | '\u{202F}' | '\u{2009}' | '\u{200A}' => ' ',
593        // FontAwesome(Private Use Area)アイコンのうち、明確で恣意的でない
594        // テキスト等価物を持つものだけの限定的なフォールバック。
595        //
596        // 【2026-08-05 修正】絵文字への代替(📄 🛡 🎓 💻 ⚙ 🏠 …)を **50 件撤去**した。
597        //
598        // 内蔵の日本語フォントはこれらの字形を持たないが **cmap には登録されていて
599        // 豆腐(□)が返る**。その結果、アイコン位置に ☐ が並んでいた
600        // (実測: `#apps` の一覧、実サイトには無い)。
601        //
602        // アイコンが出せないなら**何も描かない**方が実ブラウザの挙動に近い。
603        // 残すのは内蔵フォントが確実に持つ範囲(U+2600 未満の記号)だけ:
604        // 矢印・山形・チェック・引用符など。
605        //
606        // なお `@font-face` / WOFF2 の読込は**実装済み**になった
607        // (下記コメントは当時のもの)。Font Awesome は CFF ベースで、
608        // 取得・展開・登録まで通っている。
609        // アイコンフォントのグリフは常に欠落するが、矢印・情報記号のように
610        // 一般的なUnicode記号で意味が一意に近似できるものに限り代替する
611        // (それ以外の大半のアイコン、例えば microchip/leaf/home 等は恣意的な
612        // 代替になり「アイコンが無い」より「別の意味の文字が出る」方が誤解を
613        // 招くため、あえてマッピングせず既存の安全な欠落挙動のままにする)。
614        // コードポイントは実サイト www.sugi-lab.net(Font Awesome 7.0.0)の
615        // 実際のDOMから`getComputedStyle(el, '::before').content`で直接
616        // 検証済み(2026-07-21、記憶からの推測ではない)。
617        '\u{f060}' => '\u{2190}', // fa-arrow-left → ←
618        '\u{f061}' => '\u{2192}', // fa-arrow-right → →
619        '\u{f062}' => '\u{2191}', // fa-arrow-up → ↑
620        '\u{f063}' => '\u{2193}', // fa-arrow-down → ↓
621        '\u{f053}' => '\u{2039}', // fa-chevron-left → ‹
622        '\u{f054}' => '\u{203A}', // fa-chevron-right → ›
623        '\u{f077}' => '^',        // fa-chevron-up → ^
624        '\u{f078}' => 'v',        // fa-chevron-down → v(下向き山形に近似)
625        '\u{f104}' => '\u{2039}', // fa-angle-left → ‹
626        '\u{f105}' => '\u{203A}', // fa-angle-right → ›
627        '\u{f106}' => '^',        // fa-angle-up → ^
628        '\u{f107}' => 'v',        // fa-angle-down → v
629        '\u{f00c}' => '\u{2713}', // fa-check → ✓
630        '\u{f00d}' => '\u{2715}', // fa-times / fa-xmark → ✕
631        '\u{f05a}' => 'i',        // fa-info-circle → i(情報記号として一般的)
632        // 2026-07-24: 実サイト www.sugi-lab.net の全ページ(index/profile/
633        // smarthome/timeseries/forms/papers/access)を横断的に走査し、これまで
634        // 未マッピングだった FontAwesome アイコンクラスのコードポイントを
635        // `getComputedStyle(el, '::before').content`で実測・追加。ブランド
636        // アイコン(facebook/github/google/notion等)や、意味が一意に近似
637        // できないもの(filter/mouse-pointer/journal-whills等)は既存方針
638        // どおり対象外のまま。
639        '\u{f35d}' => '↗',        // fa-external-link-alt
640        '\u{f10d}' => '"',        // fa-quote-left
641        '\u{f10e}' => '"',        // fa-quote-right
642        _ => return None,
643    };
644    Some(sub)
645}
646
647impl VectorFont {
648    pub fn new(font_bytes: &'static [u8]) -> Result<Self, ttf_parser::FaceParsingError> {
649        let face = Face::parse(font_bytes, 0)?;
650        Ok(Self {
651            face,
652            fallbacks: Vec::new(),
653            cache: BTreeMap::new(),
654            color_cache: BTreeMap::new(),
655            advance_cache: BTreeMap::new(),
656        })
657    }
658
659    /// プライマリ + フォールバック群でフォントを構築する。
660    /// `fallback_bytes` の各フォントは、プライマリにグリフが無い文字を順に補う。
661    /// パース失敗したフォールバックは無視する(プライマリが失敗したらエラー)。
662    pub fn new_with_fallbacks(
663        font_bytes: &'static [u8],
664        fallback_bytes: &[&'static [u8]],
665    ) -> Result<Self, ttf_parser::FaceParsingError> {
666        let face = Face::parse(font_bytes, 0)?;
667        let mut fallbacks = Vec::new();
668        for fb in fallback_bytes {
669            if let Ok(f) = Face::parse(fb, 0) {
670                fallbacks.push(f);
671            }
672        }
673        Ok(Self {
674            face,
675            fallbacks,
676            cache: BTreeMap::new(),
677            color_cache: BTreeMap::new(),
678            advance_cache: BTreeMap::new(),
679        })
680    }
681
682    /// 新しいプライマリフォントへ切り替える。旧プライマリはフォールバック
683    /// チェーンの先頭へ退避するため、既存のグリフカバレッジは失われない
684    /// (`@font-face`で実行時に取得したフォントを、ページ全体で使う
685    /// 主要フォントとして採用する用途。2026-07-22新設)。キャッシュ済み
686    /// グリフは全てプライマリ変更前のフォントに基づくため破棄する。
687    pub fn set_primary(&mut self, font_bytes: &'static [u8]) -> Result<(), ttf_parser::FaceParsingError> {
688        let new_face = Face::parse(font_bytes, 0)?;
689        let old_primary = core::mem::replace(&mut self.face, new_face);
690        self.fallbacks.insert(0, old_primary);
691        self.cache.clear();
692        self.color_cache.clear();
693        self.advance_cache.clear();
694        Ok(())
695    }
696
697    /// 指定されたサイズでグリフをラスタライズ(またはキャッシュから取得)します
698    /// 【2026-08-05診断】この符号位置の字形を持っているか。
699    /// プライマリと代替の両方を見る(`get_glyph` と同じ探索)。
700    /// 【2026-08-05】**プライマリだけ**が字形を持つか。
701    ///
702    /// Google Fonts は unicode-range でファイルを分割して配信するため、
703    /// 「ウェイト 700 の Noto Sans JP」が Latin だけのサブセットということがある。
704    /// ウェイト指定で選んだフォントがその文字を持たないと、
705    /// 見出しの漢字が欠ける(実測: 「開発アプリケーション」→「□ 発プリケーション」)。
706    /// 選ぶ前にこれで確かめる。
707    pub fn primary_has_glyph(&self, c: char) -> bool {
708        self.face.glyph_index(c).is_some()
709    }
710
711    pub fn has_glyph(&self, c: char) -> bool {
712        // 【2026-08-05】私用領域は**プライマリだけ**を見る。
713        // 代替フォント(内蔵和文)は私用領域に豆腐の字形を持つため、
714        // 含めると「字形がある」と誤って報告してしまう
715        // (実際 `Noto Sans JP` でも `字形F02D=true` になっていた)。
716        if ('\u{e000}'..='\u{f8ff}').contains(&c) {
717            return self.face.glyph_index(c).is_some();
718        }
719        if self.face.glyph_index(c).is_some() {
720            return true;
721        }
722        self.fallbacks.iter().any(|f| f.glyph_index(c).is_some())
723    }
724
725    pub fn get_glyph(&mut self, c: char, size_px: u32) -> Option<&CachedGlyph> {
726        let key = (c, size_px);
727        if self.cache.contains_key(&key) {
728            return self.cache.get(&key);
729        }
730
731        if self.cache.len() >= 8000 {
732            crate::info!(
733                "[vector_font] Cache limit (8000) reached. Clearing glyph cache to free memory."
734            );
735            self.cache.clear();
736        }
737
738        // キャッシュミス:グリフを持つ face を プライマリ→フォールバック の順で選ぶ。
739        // face_idx 0 = プライマリ、1.. = fallbacks[idx-1]。
740        let (face_idx, glyph_id) = {
741            let find = |ch: char| -> Option<(usize, ttf_parser::GlyphId)> {
742                if let Some(id) = self.face.glyph_index(ch) {
743                    return Some((0, id));
744                }
745                // 【2026-08-05 発見・修正】私用領域(U+E000..U+F8FF)は
746                // アイコンフォント専用の領域。ここを内蔵フォントへ
747                // フォールバックさせてはいけない。
748                //
749                // 2026-08-04 に「字形が無い文字が消える」対策として
750                // 内蔵フォントチェーンを代替に加えたが、内蔵フォントは
751                // 私用領域に**箱(tofu)の字形を持っている**。その結果
752                // Font Awesome のアイコンが ☐ として描かれていた
753                // (実測: `字形F02D=true` が Noto Sans JP でも真だった)。
754                //
755                // アイコンが無いときは**何も描かない**のが正しい
756                // (実ブラウザもアイコンフォント未取得時は空白になる)。
757                if ('\u{e000}'..='\u{f8ff}').contains(&ch) {
758                    return None;
759                }
760                for (i, f) in self.fallbacks.iter().enumerate() {
761                    if let Some(id) = f.glyph_index(ch) {
762                        return Some((i + 1, id));
763                    }
764                }
765                None
766            };
767            match find(c) {
768                Some(x) => x,
769                None => {
770                    // どのフォントにも無い文字は、視覚的に近い代替文字で再探索する
771                    fallback_glyph_char(c).and_then(find)?
772                }
773            }
774        };
775        let face: &Face = if face_idx == 0 {
776            &self.face
777        } else {
778            &self.fallbacks[face_idx - 1]
779        };
780
781        let units_per_em = face.units_per_em() as f32;
782        let scale = (size_px as f32) / units_per_em;
783
784        // メトリクスの取得
785        let advance = (face.glyph_hor_advance(glyph_id)? as f32 * scale) as u32;
786
787        // アウトラインを先に抽出する。スペース等の輪郭なし文字は glyph_bounding_box が None を
788        // 返すため、? で early return してしまい advance が捨てられるのを防ぐ。
789        let mut builder = GlyphOutlineBuilder::new(scale);
790        face.outline_glyph(glyph_id, &mut builder);
791
792        // 輪郭が存在しない文字(空白など): advance だけ持つ空グリフとしてキャッシュする。
793        // これを bounding_box の取得より前に行うことで、スペースの advance が正しく適用される。
794        if builder.segments.is_empty() {
795            let empty_glyph = CachedGlyph {
796                width: 0,
797                height: 0,
798                x_offset: 0,
799                y_offset: 0,
800                advance,
801                data: Vec::new(),
802            };
803            self.cache.insert(key, empty_glyph);
804            return self.cache.get(&key);
805        }
806
807        // 輪郭がある文字は必ず bounding_box が存在する。
808        let bounding_box = face.glyph_bounding_box(glyph_id)?;
809
810        // ttf-parserのY軸は上方向が正、画面座標は下方向が正であるため、スケーリングとY軸反転を行います
811        let scale_y = -scale;
812
813        // Y軸方向の座標変換(反転)をセグメントに適用
814        let mut segments = builder.segments;
815        for seg in &mut segments {
816            match seg {
817                Segment::Line(p1, p2) => {
818                    p1.y = -p1.y;
819                    p2.y = -p2.y;
820                }
821            }
822        }
823
824        // Bounding Box のスケーリングと画面座標への変換
825        let bx0 = bounding_box.x_min as f32 * scale;
826        let by0 = bounding_box.y_max as f32 * scale_y; // Y軸反転
827        let bx1 = bounding_box.x_max as f32 * scale;
828        let by1 = bounding_box.y_min as f32 * scale_y; // Y軸反転
829
830        let gx0 = f32_floor(bx0) - 1;
831        let gy0 = f32_floor(by0) - 1;
832        let gx1 = f32_ceil(bx1) + 1;
833        let gy1 = f32_ceil(by1) + 1;
834
835        let width = (gx1 - gx0).max(1) as u32;
836        let height = (gy1 - gy0).max(1) as u32;
837        let x_offset = gx0;
838        let y_offset = gy0;
839
840        if width > 500 || height > 500 || width == 0 || height == 0 {
841            crate::warn!(
842                "[NET][DIAG] glyph c={:?} units_per_em={} scale={} bbox=({},{},{},{}) w={} h={} face_idx={}",
843                c,
844                units_per_em,
845                scale,
846                bounding_box.x_min,
847                bounding_box.y_min,
848                bounding_box.x_max,
849                bounding_box.y_max,
850                width,
851                height,
852                face_idx
853            );
854        }
855
856        // 4x4 スーパーサンプリング Even-Odd ラスタライザ(スキャンライン方式)。
857        // セグメント走査を「ピクセルごと」ではなく「サブスキャンライン(sub_y)ごと」に一度だけ行い、
858        // 交点 x をスパンに変換して各ピクセルのカバレッジを O(交点数) で求める。
859        // これにより従来の 幅×高さ×16×全セグメント数 から、係数 幅 と 全セグメント数 を大幅に削減する。
860        let mut data = alloc::vec![0u8; (width * height) as usize];
861        let mut coverage = alloc::vec![0u8; width as usize];
862        let mut xs: alloc::vec::Vec<f32> = alloc::vec::Vec::new();
863
864        for py in 0..height {
865            let screen_y = (gy0 + py as i32) as f32;
866            for c in coverage.iter_mut() {
867                *c = 0;
868            }
869
870            // 4 本のサブスキャンライン
871            for sy in 0..4 {
872                let sub_y = screen_y + (sy as f32 + 0.5) / 4.0;
873
874                // このスキャンラインと交わる全セグメントの交点 x を一度だけ収集(ピクセル非依存)
875                xs.clear();
876                for seg in &segments {
877                    match seg {
878                        Segment::Line(p1, p2) => {
879                            let y_min = p1.y.min(p2.y);
880                            let y_max = p1.y.max(p2.y);
881                            if sub_y >= y_min && sub_y < y_max && p1.y != p2.y {
882                                let t = (sub_y - p1.y) / (p2.y - p1.y);
883                                xs.push(p1.x + t * (p2.x - p1.x));
884                            }
885                        }
886                    }
887                }
888                if xs.is_empty() {
889                    continue;
890                }
891                // 昇順ソート → even-odd で内部スパンは [xs[0],xs[1]), [xs[2],xs[3]), ...
892                xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
893
894                // 各スパンが覆うサブピクセル列にカバレッジを加算する。
895                // 元実装の判定「交点x > sub_x の個数が奇数なら内側」と等価。
896                let mut i = 0;
897                while i + 1 < xs.len() {
898                    let span_start = xs[i];
899                    let span_end = xs[i + 1];
900                    i += 2;
901                    if span_end <= span_start {
902                        continue;
903                    }
904                    // span_start <= sub_x < span_end を満たすサブピクセル (px, sx) を加算
905                    // sub_x = (gx0 + px) + (sx + 0.5)/4
906                    // px 範囲を概算してクランプ
907                    let px_lo = (f32_floor(span_start) - gx0 - 1).max(0) as u32;
908                    let px_hi = ((f32_ceil(span_end) - gx0 + 1).max(0) as u32).min(width);
909                    for px in px_lo..px_hi {
910                        let screen_x = (gx0 + px as i32) as f32;
911                        for sx in 0..4 {
912                            let sub_x = screen_x + (sx as f32 + 0.5) / 4.0;
913                            if sub_x >= span_start && sub_x < span_end {
914                                coverage[px as usize] += 1;
915                            }
916                        }
917                    }
918                }
919            }
920
921            for px in 0..width {
922                let alpha = ((coverage[px as usize] as u32 * 255) / 16).min(255) as u8;
923                data[(py * width + px) as usize] = alpha;
924            }
925        }
926
927        let cached = CachedGlyph {
928            width,
929            height,
930            x_offset,
931            y_offset,
932            advance,
933            data,
934        };
935
936        self.cache.insert(key, cached);
937        self.cache.get(&key)
938    }
939
940    /// 指定されたサイズ、文字色、背景色でアルファブレンド済みのカラーグリフを取得(またはキャッシュから取得)します
941    pub fn get_color_glyph(
942        &mut self,
943        c: char,
944        size_px: u32,
945        fg_color: u32,
946        bg_color: u32,
947    ) -> Option<&CachedColorGlyph> {
948        let key = (c, size_px, fg_color, bg_color);
949        if self.color_cache.contains_key(&key) {
950            return self.color_cache.get(&key);
951        }
952
953        if self.color_cache.len() >= 16000 {
954            self.color_cache.clear();
955        }
956
957        let glyph = self.get_glyph(c, size_px)?.clone();
958
959        let mut color_data = Vec::with_capacity(glyph.data.len());
960        let fg_r = (fg_color >> 16) & 0xFF;
961        let fg_g = (fg_color >> 8) & 0xFF;
962        let fg_b = fg_color & 0xFF;
963        let bg_r = (bg_color >> 16) & 0xFF;
964        let bg_g = (bg_color >> 8) & 0xFF;
965        let bg_b = bg_color & 0xFF;
966
967        for &alpha in &glyph.data {
968            if alpha == 0 {
969                color_data.push(0x00000000); // 完全透明マーク
970            } else if alpha == 255 {
971                color_data.push(fg_color | 0xFF000000); // 完全不透明
972            } else {
973                let a = alpha as u32;
974                let inv_a = 255 - a;
975                let r = ((fg_r * a + bg_r * inv_a) / 255) & 0xFF;
976                let g = ((fg_g * a + bg_g * inv_a) / 255) & 0xFF;
977                let b = ((fg_b * a + bg_b * inv_a) / 255) & 0xFF;
978                color_data.push(0xFF000000 | (r << 16) | (g << 8) | b);
979            }
980        }
981
982        let cached = CachedColorGlyph {
983            width: glyph.width,
984            height: glyph.height,
985            x_offset: glyph.x_offset,
986            y_offset: glyph.y_offset,
987            advance: glyph.advance,
988            data: color_data,
989        };
990
991        self.color_cache.insert(key, cached);
992        self.color_cache.get(&key)
993    }
994
995    /// 指定されたサイズでグリフのアドバンス(幅)のみを高速に取得(またはキャッシュから取得)します。
996    /// レイアウトの折り返し計算等で重いラスタライズ(Even-Odd法)を回避するために使用します。
997    pub fn get_glyph_advance(&mut self, c: char, size_px: u32) -> u32 {
998        let key = (c, size_px);
999        if let Some(glyph) = self.cache.get(&key) {
1000            return glyph.advance;
1001        }
1002        if let Some(&adv) = self.advance_cache.get(&key) {
1003            return adv;
1004        }
1005
1006        let (face_idx, glyph_id) = {
1007            let find = |ch: char| -> Option<(usize, ttf_parser::GlyphId)> {
1008                if let Some(id) = self.face.glyph_index(ch) {
1009                    return Some((0, id));
1010                }
1011                // 【2026-08-05 発見・修正】私用領域(U+E000..U+F8FF)は
1012                // アイコンフォント専用の領域。ここを内蔵フォントへ
1013                // フォールバックさせてはいけない。
1014                //
1015                // 2026-08-04 に「字形が無い文字が消える」対策として
1016                // 内蔵フォントチェーンを代替に加えたが、内蔵フォントは
1017                // 私用領域に**箱(tofu)の字形を持っている**。その結果
1018                // Font Awesome のアイコンが ☐ として描かれていた
1019                // (実測: `字形F02D=true` が Noto Sans JP でも真だった)。
1020                //
1021                // アイコンが無いときは**何も描かない**のが正しい
1022                // (実ブラウザもアイコンフォント未取得時は空白になる)。
1023                if ('\u{e000}'..='\u{f8ff}').contains(&ch) {
1024                    return None;
1025                }
1026                for (i, f) in self.fallbacks.iter().enumerate() {
1027                    if let Some(id) = f.glyph_index(ch) {
1028                        return Some((i + 1, id));
1029                    }
1030                }
1031                None
1032            };
1033            match find(c) {
1034                Some(x) => x,
1035                None => {
1036                    if let Some(alt) = fallback_glyph_char(c).and_then(find) {
1037                        alt
1038                    } else {
1039                        let default_adv = size_px / 2;
1040                        self.advance_cache.insert(key, default_adv);
1041                        return default_adv;
1042                    }
1043                }
1044            }
1045        };
1046
1047        let face: &Face = if face_idx == 0 {
1048            &self.face
1049        } else {
1050            &self.fallbacks[face_idx - 1]
1051        };
1052        let units_per_em = face.units_per_em() as f32;
1053        let scale = (size_px as f32) / units_per_em;
1054        let advance = if let Some(adv) = face.glyph_hor_advance(glyph_id) {
1055            (adv as f32 * scale) as u32
1056        } else {
1057            size_px / 2
1058        };
1059
1060        if self.advance_cache.len() >= 10000 {
1061            self.advance_cache.clear();
1062        }
1063        self.advance_cache.insert(key, advance);
1064        advance
1065    }
1066
1067    /// 指定したサイズでの文字列全体のピクセル幅(advance合計)を計算します
1068    pub fn get_string_width(&mut self, s: &str, size_px: u32) -> u32 {
1069        self.get_string_width_ls(s, size_px, 0)
1070    }
1071
1072    /// letter-spacing 対応版。各文字の advance に ls(px, 負も可) を加算する。
1073    pub fn get_string_width_ls(&mut self, s: &str, size_px: u32, ls: i32) -> u32 {
1074        self.get_string_width_ls_ws(s, size_px, ls, 0)
1075    }
1076
1077    /// letter-spacing + word-spacing 対応版。半角スペース1文字ごとに ws(px, 負も可) も加算する。
1078    pub fn get_string_width_ls_ws(&mut self, s: &str, size_px: u32, ls: i32, ws: i32) -> u32 {
1079        let mut width = 0i32;
1080        for c in s.chars() {
1081            width += self.get_glyph_advance(c, size_px) as i32 + ls + if c == ' ' { ws } else { 0 };
1082        }
1083        width.max(0) as u32
1084    }
1085
1086    /// 折返し後の (最大行幅, 全体高さ) を返す。
1087    ///
1088    /// 重要: この行分割規則は web_engine.rs flatten_layout のテキスト折返し
1089    /// (`'\n'` は強制改行・行頭/行末禁則・改行時に先頭文字を即消費) と
1090    /// **完全に同一** でなければならない。ここがズレると layout が計算する
1091    /// 段落高さと flatten が生成する行数が食い違い、後続要素 (<hr> 等) が
1092    /// テキストに重なる。どちらかを変更する際は必ず両方を同期すること。
1093    pub fn get_string_wrapped_size(&mut self, s: &str, size_px: u32, max_width: u32) -> (u32, u32) {
1094        self.get_string_wrapped_size_ls_ws(s, size_px, max_width, 0, 0)
1095    }
1096
1097    /// letter-spacing 対応版。各文字 advance に ls を加算して折返し計算する。
1098    pub fn get_string_wrapped_size_ls(
1099        &mut self,
1100        s: &str,
1101        size_px: u32,
1102        max_width: u32,
1103        ls: i32,
1104    ) -> (u32, u32) {
1105        self.get_string_wrapped_size_ls_ws(s, size_px, max_width, ls, 0)
1106    }
1107
1108    pub fn get_string_wrapped_size_ls_ws(
1109        &mut self,
1110        s: &str,
1111        size_px: u32,
1112        max_width: u32,
1113        ls: i32,
1114        ws: i32,
1115    ) -> (u32, u32) {
1116        self.get_string_wrapped_size_ext(s, size_px, max_width, ls, ws, false, false)
1117    }
1118
1119    /// word-break / white-space 対応版
1120    pub fn get_string_wrapped_size_ext(
1121        &mut self,
1122        s: &str,
1123        size_px: u32,
1124        max_width: u32,
1125        ls: i32,
1126        ws: i32,
1127        word_break_all: bool,
1128        _white_space_pre_wrap: bool,
1129    ) -> (u32, u32) {
1130        // 【2026-09-26】折り返し位置は描画側と共通の純粋関数 `line_break::break_positions`
1131        // で決める(単語ごと次の行へ送る・禁則・行末空白のぶら下げ)。従来はここと描画側に
1132        // 「単語を送らずはみ出させる」同趣旨の実装が 2 つあった。
1133        // `\n` は(pre-wrap に限らず、flatten が送ってくる場合も)強制改行として数える。
1134        let line_height = size_px + 4;
1135        let chars: alloc::vec::Vec<char> = s.chars().collect();
1136        let advances: alloc::vec::Vec<i32> = chars
1137            .iter()
1138            .map(|&c| {
1139                if c == '\n' {
1140                    0
1141                } else {
1142                    let extra = ls + if c == ' ' { ws } else { 0 };
1143                    (self.get_glyph_advance(c, size_px) as i32 + extra).max(0)
1144                }
1145            })
1146            .collect();
1147        let breaks = crate::os_lib::layout::line_break::break_positions(
1148            &chars,
1149            &advances,
1150            max_width.min(i32::MAX as u32) as i32,
1151            word_break_all,
1152            |c| !word_break_all && is_line_start_forbidden(c),
1153            |c| !word_break_all && is_line_end_forbidden(c),
1154        );
1155        let mut max_w = 0u32;
1156        let mut emitted_lines = 0u32;
1157        let mut line_w = 0i32;
1158        // 行末の空白(ぶら下げ)を除いた幅。
1159        let mut line_w_no_trailing = 0i32;
1160        let mut seg_has_content = false;
1161        let mut bi = 0usize;
1162        for (i, &c) in chars.iter().enumerate() {
1163            if c == '\n' {
1164                max_w = max_w.max(line_w_no_trailing.max(0) as u32);
1165                emitted_lines += 1;
1166                line_w = 0;
1167                line_w_no_trailing = 0;
1168                seg_has_content = false;
1169                continue;
1170            }
1171            if bi < breaks.len() && breaks[bi] == i {
1172                bi += 1;
1173                max_w = max_w.max(line_w_no_trailing.max(0) as u32);
1174                emitted_lines += 1;
1175                line_w = 0;
1176                line_w_no_trailing = 0;
1177            }
1178            line_w += advances[i];
1179            if !c.is_whitespace() {
1180                line_w_no_trailing = line_w;
1181            }
1182            seg_has_content = true;
1183        }
1184        max_w = max_w.max(line_w_no_trailing.max(0) as u32);
1185        if seg_has_content {
1186            emitted_lines += 1;
1187        }
1188        (max_w, emitted_lines.max(1) * line_height)
1189    }
1190}
1191
1192// 静的グローバルフォントマネージャ
1193pub static GLOBAL_VECTOR_FONT: Mutex<Option<VectorFont>> = Mutex::new(None);
1194pub static GLOBAL_TERMINAL_FONT: Mutex<Option<VectorFont>> = Mutex::new(None);
1195
1196// ============ 実行時カスタムフォント登録(`@font-face`用) ============
1197//
1198// 従来はフォントが`include_bytes!`によるコンパイル時埋め込みのみに限られており
1199// (上記コメント参照)、`@font-face`でページが指定するWebフォントを実行時に
1200// 読み込む手段が丸ごと存在しなかった。TTF/OTF形式(WOFF2は非対応。`glyf`/`loca`
1201// テーブル変換を伴うため別途デコーダが必要で本対応の範囲外)に限定した実行時
1202// フォント登録レジストリを新設する(2026-07-21、www.sugi-lab.net互換性調査より。
1203// 実測ではGoogle FontsはAtmOSのUser-Agent文字列に対しTTF形式で応答するため
1204// (WOFF2形式でのみ配信されるFontAwesomeとは異なる)、この経路だけでも本文用
1205// Webフォントの読込が可能になる)。
1206//
1207// `VectorFont::new`は`&'static [u8]`を要求するが、実行時に取得したバイト列は
1208// 本来 'static ではない。ここでは`Box::leak`で意図的にリークして 'static
1209// 参照へ変換する(この処理系にはページ遷移でフォントを解放する仕組みが元々無く、
1210// 埋め込みフォントも実質的にプロセス終了まで生き続ける想定と同じ性質のため、
1211// 新規リークを許容する)。
1212pub static CUSTOM_FONTS: Mutex<BTreeMap<String, VectorFont>> = Mutex::new(BTreeMap::new());
1213
1214/// バイト列の先頭シグネチャからTTF/OTFかどうかを判定する(WOFF2等は`false`)。
1215fn looks_like_ttf_or_otf(bytes: &[u8]) -> bool {
1216    if bytes.len() < 4 {
1217        return false;
1218    }
1219    matches!(
1220        &bytes[0..4],
1221        [0x00, 0x01, 0x00, 0x00] | [b'O', b'T', b'T', b'O'] | [b't', b'r', b'u', b'e']
1222    )
1223}
1224
1225/// `@font-face`で参照されたフォントを実行時に登録する。TTF/OTF以外(WOFF2等)は
1226/// 安全に`false`を返して拒否する(`ttf_parser`側でも不正な形式は`Err`で弾かれ
1227/// クラッシュしないが、シグネチャの時点で早期に判定した方が無駄なパースを避けられる)。
1228/// 同じ`family`名で複数回呼ばれた場合は後勝ちで上書きする。
1229///
1230/// **`GLOBAL_VECTOR_FONT`のプライマリを新フォントへ切り替える**(`set_primary`。
1231/// 旧プライマリはフォールバックへ退避するため既存のグリフカバレッジは失わない)。
1232/// この処理系はまだ要素/ノード単位の`font-family`対応フォント選択
1233/// (`kernel/draw.rs`の描画箇所・幅計測箇所すべてに手を入れる大規模な
1234/// レンダラ統合が必要)を実装しておらず、それを安全に完成させるにはページ全体の
1235/// テキスト描画・幅計測の整合性(描画に使うフォントと幅計測に使うフォントが
1236/// 常に一致していないと文字が重なる既知の脆さがある)を壊さないよう慎重な
1237/// 段階的実装が必要と判断し見送っている。代わりに「取得できたWebフォントを
1238/// ページ全体の主要フォントとして採用する」という単純だが安全な戦略を採る
1239/// (`draw.rs`/`render.rs`側の変更が一切不要で、既存のグローバルフォント経由
1240/// の描画・幅計測パイプラインをそのまま使い回せるため、描画と幅計測の
1241/// 不整合が原理的に発生しない)。将来的に複数の`@font-face`ファミリーを
1242/// 要素ごとに使い分ける必要が生じた場合は、別途`font-family`対応の
1243/// レンダラ統合に着手すること。2026-07-22実装。
1244/// バイト列の先頭シグネチャからWOFF2かどうかを判定する。
1245fn looks_like_woff2(bytes: &[u8]) -> bool {
1246    bytes.len() >= 4 && &bytes[0..4] == b"wOF2"
1247}
1248
1249/// `@font-face`で参照されたフォントを実行時に登録する。
1250/// TTF/OTF に加え、WOFF2 (Brotli圧縮) フォントの自動展開・登録に対応。
1251/// 同じ`family`名で複数回呼ばれた場合は後勝ちで上書きする。
1252///
1253/// **`GLOBAL_VECTOR_FONT`のプライマリを新フォントへ切り替える**(`set_primary`。
1254/// 旧プライマリはフォールバックへ退避するため既存のグリフカバレッジは失わない)。
1255/// `family` と `weight` から `CUSTOM_FONTS` のキーを作る。
1256///
1257/// 【2026-08-05】以前は family 名だけをキーにしていたため、
1258/// 同じ family でウェイト違いを定義する CSS(Font Awesome など)で
1259/// **後勝ちで上書き**され、solid の字形が失われていた。
1260pub fn font_key(family: &str, weight: u16) -> alloc::string::String {
1261    alloc::format!("{}|{}", family.trim().to_lowercase(), weight)
1262}
1263
1264/// ウェイトを指定して登録する。
1265pub fn register_custom_font_weighted(family: &str, weight: u16, bytes: Vec<u8>) -> bool {
1266    register_custom_font_inner(family, weight, bytes)
1267}
1268
1269pub fn register_custom_font(family: &str, bytes: Vec<u8>) -> bool {
1270    register_custom_font_inner(family, 400, bytes)
1271}
1272
1273fn register_custom_font_inner(family: &str, weight: u16, bytes: Vec<u8>) -> bool {
1274    // **安全性ゲート解除(2026-07-24)**: 2026-07-22に発見された「`decompress_brotli`が
1275    // 特定の入力でQEMU起動をハングさせる」問題の根本原因は、2026-07-23に別途
1276    // 特定・修正済みである:`decompress_brotli`内部の3つのプールバッファ(合計
1277    // 約160KB)がスタック上の固定配列として確保されており、64KBしかない起動時
1278    // コアスタックを溢れさせていた(スタックオーバーフロー)。この関数自体を
1279    // ヒープ確保(`Vec`)へ移行したことで根本解決済み(詳細は`decompress_brotli`
1280    // 冒頭のコメント参照)。あわせて`main.rs`にソフトウェアスタック番兵による
1281    // 検知機構も追加済みのため、万一同種の問題が再発してもハングではなく明確な
1282    // 検知・報告に変わる。以上を踏まえ、WOFF2の自動展開を再度有効化する。
1283    // 【2026-08-03】この関数は失敗を**4 箇所すべて無言**で返していた
1284    // (WOFF2 展開失敗・未知の形式・パース失敗・`set_primary` の失敗)。
1285    // そのため「フォントを取得できているのに適用されない」ときに
1286    // どこで落ちたのか分からなかった。規約は `spec/logging_policy.md`(L-1/L-2)。
1287    // 【2026-08-03】キャッシュ命中後、成功も失敗も一切ログに出ないまま
1288    // 停止する事象を追う。300/420/540 秒すべて同じ地点で止まったので
1289    // 時間不足ではない。入口・各段の通過を記録する。
1290    crate::warn!(
1291        "[FONT] 登録開始 family={} bytes={}",
1292        family,
1293        bytes.len()
1294    );
1295    // 内蔵フォントチェーン(字形が無い文字の代替)。
1296    // ローカル変数 `font_bytes` が関数 `font_bytes()` を隠すため、
1297    // シャドウイングされる前にここで用意しておく。
1298    let fallback_chain: Vec<&'static [u8]> = DEFAULT_FONT_CHAIN
1299        .iter()
1300        .filter_map(|n| font_bytes(n))
1301        .collect();
1302    let font_bytes = if looks_like_woff2(&bytes) {
1303        match decompress_woff2(&bytes) {
1304            Some(sfnt) => sfnt,
1305            None => {
1306                crate::warn!(
1307                    "[FONT] WOFF2 の展開に失敗 family={} bytes={}",
1308                    family,
1309                    bytes.len()
1310                );
1311                return false;
1312            }
1313        }
1314    } else if looks_like_ttf_or_otf(&bytes) {
1315        bytes
1316    } else {
1317        crate::warn!(
1318            "[FONT] 未知のフォント形式 family={} bytes={} head={:02x?}",
1319            family,
1320            bytes.len(),
1321            &bytes[..bytes.len().min(4)]
1322        );
1323        return false;
1324    };
1325
1326    let leaked: &'static [u8] = Vec::leak(font_bytes);
1327    let _pf = crate::os_lib::web_engine::perf::start();
1328    crate::warn!("[FONT] 解析開始 family={} bytes={}", family, leaked.len());
1329    // 【2026-08-04 発見・修正】`VectorFont::new` は `fallbacks` が空。
1330    //
1331    // `@font-face` で登録したフォントで文字を描くようにした結果、
1332    // **そのフォントに字形が無い文字が何も描かれなくなった**
1333    // (Font Awesome に日本語の字形は無い)。
1334    // 実ブラウザは字形が無ければ次のフォントへ落とす。
1335    //
1336    // 内蔵フォントチェーンを代替として与える(`fallback_chain` は関数冒頭で用意)。
1337    // これで「Web フォントで描けるものはそれで、無いものは内蔵で」になる。
1338    let _parsed = VectorFont::new_with_fallbacks(leaked, &fallback_chain);
1339    crate::os_lib::web_engine::perf::add(
1340        crate::os_lib::web_engine::perf::Slot::FontParse,
1341        _pf,
1342    );
1343    match _parsed {
1344        Ok(font) => {
1345            {
1346                let mut map = CUSTOM_FONTS.lock();
1347                // ウェイト付きキーで登録する(同名ファミリの後勝ちを防ぐ)。
1348                map.insert(font_key(family, weight), font);
1349                // family 名だけのキーも用意する(ウェイト指定が無い描画のため)。
1350                // `VectorFont` は `Clone` ではないので同じバイト列から作り直す。
1351                // **既に入っていれば上書きしない**(最初に届いたものを保つ)。
1352                let plain = family.trim().to_lowercase();
1353                if !map.contains_key(&plain) {
1354                    if let Ok(f2) = VectorFont::new_with_fallbacks(leaked, &fallback_chain) {
1355                        map.insert(plain, f2);
1356                    }
1357                }
1358            }
1359            // 【2026-08-03 発見・修正】ここで `set_primary` して
1360            // **グローバル既定フォントを差し替えていた**。
1361            //
1362            // WOFF2 の展開・再構築を直して Font Awesome が実際に登録できるように
1363            // なった途端、**アイコンフォントが全文字を乗っ取る**という
1364            // 明白なリグレッションが出た(OS のタイトルバーやタブまで
1365            // アイコン書体になった。スクリーンショットで確認)。
1366            //
1367            // `@font-face` の登録は「この family でこのフォントが使える」と
1368            // 記録するだけであって、**文書全体の既定フォントを変える操作ではない**。
1369            // 差し替えはしない。
1370            //
1371            // 【残作業】描画側(`draw_string_ja_size_ext_bg_ls_ws`)は
1372            // `GLOBAL_VECTOR_FONT` しか見ておらず `font-family` を受け取らない。
1373            // そのため登録しただけでは Web フォントは字形に反映されない。
1374            // family 指定で `CUSTOM_FONTS` から選ぶ経路が本来の実装
1375            // (`spec/webfont_loading.md`)。
1376            // 節目のログ(1 ファミリにつき事実上 1 回。L-3)。
1377            //
1378            // 【2026-08-05】Font Awesome のアイコンが ☐ になる件の切り分け。
1379            // カスケード側(family・content・エスケープ解除)は単体試験で
1380            // 正常と確定済み。残る疑いは**字形そのものが無い**こと。
1381            // 代表的な符号位置(U+F02D=fa-book, U+F015=fa-home)を引いて記録する。
1382            let probe = |c: char| -> bool {
1383                CUSTOM_FONTS
1384                    .lock()
1385                    .get(&family.trim().to_lowercase())
1386                    .map(|f| f.has_glyph(c))
1387                    .unwrap_or(false)
1388            };
1389            crate::warn!(
1390                "[FONT] 登録完了 family={} bytes={} 字形F02D={} 字形F015={}",
1391                family,
1392                leaked.len(),
1393                probe('\u{f02d}'),
1394                probe('\u{f015}')
1395            );
1396            true
1397        }
1398        Err(e) => {
1399            crate::warn!(
1400                "[FONT] フォントのパースに失敗 family={} bytes={} err={:?}",
1401                family,
1402                leaked.len(),
1403                e
1404            );
1405            false
1406        }
1407    }
1408}
1409
1410/// 指定した`font-family`名で登録済みのカスタムフォントが存在するかを返す
1411/// (大小文字を区別しない)。
1412/// 【2026-08-05】ウェイトまで見て登録済みかを返す。
1413///
1414/// `has_custom_font` は family 名だけを見るため、
1415/// `@font-face` の取り込みループが「同じ family は登録済み」と判断し、
1416/// **別ウェイト(solid 900)を一度も取り込まなかった**。
1417/// 実測: `Font Awesome 7 Free` に regular(400) だけが入り、
1418/// `.fas`(solid)のアイコンが描けなかった。
1419pub fn has_custom_font_weighted(family: &str, weight: u16) -> bool {
1420    CUSTOM_FONTS.lock().contains_key(&font_key(family, weight))
1421}
1422
1423pub fn has_custom_font(family: &str) -> bool {
1424    CUSTOM_FONTS.lock().contains_key(&family.trim().to_lowercase())
1425}
1426
1427// ============ フォントレジストリ ============
1428//
1429// 実行時にフォントフォルダは存在しないため、選択可能なフォントは include_bytes! で
1430// 埋め込んだものに限る。設定アプリのセレクトボックスはこの一覧を提示する。
1431// (フォントを増やすとカーネルバイナリ(.rodata)が肥大するため、代表的なものに絞る)
1432
1433/// 設定アプリで選択できるフォント名の一覧(フォントフォルダ相当)。
1434pub const AVAILABLE_FONTS: &[&str] = &[
1435    "Mplus1Code-Regular",
1436    "HackGenConsoleNF-Regular",
1437    "ipam",
1438    "ipag",
1439    "MPLUS2-Regular",
1440    "MPLUS2-Bold",
1441];
1442
1443/// OS レベルのデフォルトのフォントフォールバック順。
1444pub const DEFAULT_FONT_CHAIN: [&str; 3] =
1445    ["Mplus1Code-Regular", "HackGenConsoleNF-Regular", "ipam"];
1446
1447/// フォント名から埋め込みバイト列を解決する。未知の名前は None。
1448pub fn font_bytes(name: &str) -> Option<&'static [u8]> {
1449    Some(match name {
1450        "Mplus1Code-Regular" => include_bytes!("../../fonts/Mplus1Code/Mplus1Code-Regular.ttf"),
1451        "HackGenConsoleNF-Regular" => {
1452            include_bytes!("../../fonts/HackGen/HackGenConsoleNF-Regular.ttf")
1453        }
1454        "ipam" => include_bytes!("../../fonts/ipa/ipam.ttf"),
1455        "ipag" | "IPAg" => include_bytes!("../../fonts/ipa/ipag.ttf"),
1456        "MPLUS2-Regular" => include_bytes!("../../fonts/MPLUS2/MPLUS2-Regular.ttf"),
1457        "MPLUS2-Bold" => include_bytes!("../../fonts/MPLUS2/MPLUS2-Bold.ttf"),
1458        _ => return None,
1459    })
1460}
1461
1462/// フォント名のフォールバックチェーンからグローバルフォントを初期化する。
1463/// 先頭がプライマリ、以降がフォールバック。未知/解決不能な名前は読み飛ばす。
1464/// 有効なフォントが 1 つも無ければデフォルトチェーンにフォールバックする。
1465pub fn init_global_font_chain(names: &[&str]) {
1466    let mut bytes: Vec<&'static [u8]> = names.iter().filter_map(|n| font_bytes(n)).collect();
1467    if bytes.is_empty() {
1468        bytes = DEFAULT_FONT_CHAIN
1469            .iter()
1470            .filter_map(|n| font_bytes(n))
1471            .collect();
1472    }
1473    if bytes.is_empty() {
1474        crate::info!("[vector_font] No embedded fonts available for chain init!");
1475        return;
1476    }
1477    let primary = bytes[0];
1478    let fallbacks: Vec<&'static [u8]> = bytes[1..].to_vec();
1479    match VectorFont::new_with_fallbacks(primary, &fallbacks) {
1480        Ok(font) => {
1481            crate::info!(
1482                "[vector_font] Global font chain initialized ({} fonts).",
1483                bytes.len()
1484            );
1485            *GLOBAL_VECTOR_FONT.lock() = Some(font);
1486        }
1487        Err(e) => {
1488            crate::info!("[vector_font] Failed to init font chain: {:?}", e);
1489        }
1490    }
1491}
1492
1493/// 起動時にフォントデータをロードしてグローバルインスタンスを初期化します
1494pub fn init_global_font(font_bytes: &'static [u8]) {
1495    match VectorFont::new(font_bytes) {
1496        Ok(mut font) => {
1497            crate::info!("[vector_font] Successfully initialized global vector font.");
1498            crate::info!("[vector_font] Running test get_glyph('o', 16) for global font...");
1499            if font.get_glyph('o', 16).is_some() {
1500                crate::info!("[vector_font] Test get_glyph('o', 16) succeeded for global font!");
1501            } else {
1502                crate::info!("[vector_font] Test get_glyph('o', 16) failed for global font!");
1503            }
1504            *GLOBAL_VECTOR_FONT.lock() = Some(font);
1505        }
1506        Err(e) => {
1507            crate::info!(
1508                "[vector_font] Failed to initialize global vector font: {:?}",
1509                e
1510            );
1511        }
1512    }
1513}
1514
1515/// ターミナル専用の等幅ベクターフォントをロードして初期化します
1516pub fn init_terminal_font() {
1517    let font_bytes = include_bytes!("../../fonts/Mplus1Code/Mplus1Code-Regular.ttf");
1518    match VectorFont::new(font_bytes) {
1519        Ok(mut font) => {
1520            crate::info!("[vector_font] Successfully initialized terminal monospace font.");
1521            crate::info!("[vector_font] Running test get_glyph('o', 16)...");
1522            if font.get_glyph('o', 16).is_some() {
1523                crate::info!("[vector_font] Test get_glyph('o', 16) succeeded!");
1524            } else {
1525                crate::info!("[vector_font] Test get_glyph('o', 16) failed!");
1526            }
1527            *GLOBAL_TERMINAL_FONT.lock() = Some(font);
1528        }
1529        Err(e) => {
1530            crate::info!(
1531                "[vector_font] Failed to initialize terminal monospace font: {:?}",
1532                e
1533            );
1534        }
1535    }
1536}
1537
1538/// グローバルフォントを用いて、指定したサイズでの文字列全体のピクセル幅を計算します
1539/// `family_list` と太字指定から、`draw::draw_string_family` と**同じ規則**で Web フォントを選び、
1540/// `f` に渡す(希望ウェイト → 文字列を描ける最も近いウェイト → family 名だけのキー)。
1541/// 登録済みの family が無ければ `None`(呼び出し側は内蔵フォントへ落とす)。
1542pub fn with_family_font<R>(
1543    s: &str,
1544    family_list: &str,
1545    bold: bool,
1546    f: impl FnOnce(&mut VectorFont) -> R,
1547) -> Option<R> {
1548    if family_list.is_empty() {
1549        return None;
1550    }
1551    let want: &[u16] = if bold { &[900, 700] } else { &[400] };
1552    let mut fonts = CUSTOM_FONTS.lock();
1553    for fam in family_list.split(',') {
1554        let key = fam.trim().trim_matches('"').trim_matches('\'').to_lowercase();
1555        if key.is_empty() {
1556            continue;
1557        }
1558        let prefix = alloc::format!("{}|", key);
1559        let available: Vec<(u16, bool)> = fonts
1560            .range(prefix.clone()..)
1561            .take_while(|(k, _)| k.starts_with(&prefix))
1562            .filter_map(|(k, font)| {
1563                let w = k.get(prefix.len()..)?.parse::<u16>().ok()?;
1564                Some((w, crate::kernel::font_select::covers_all(s, |c| font.primary_has_glyph(c))))
1565            })
1566            .collect();
1567        let hit = crate::kernel::font_select::pick_weight(want, &available)
1568            .map(|w| font_key(&key, w))
1569            .unwrap_or(key.clone());
1570        if fonts.contains_key(&hit) {
1571            if let Some(font) = fonts.get_mut(&hit) {
1572                return Some(f(font));
1573            }
1574        }
1575        // 総称名(sans-serif 等)は、このページで読み込まれた該当系統の Web フォントへ割り当てる
1576        // (`font_select::generic_family_candidates`。内蔵フォントは等幅のため)。
1577        for cand in crate::kernel::font_select::generic_family_candidates(&key) {
1578            let prefix = alloc::format!("{}|", cand);
1579            let available: Vec<(u16, bool)> = fonts
1580                .range(prefix.clone()..)
1581                .take_while(|(k, _)| k.starts_with(&prefix))
1582                .filter_map(|(k, font)| {
1583                    let w = k.get(prefix.len()..)?.parse::<u16>().ok()?;
1584                    Some((w, crate::kernel::font_select::covers_all(s, |c| font.primary_has_glyph(c))))
1585                })
1586                .collect();
1587            if let Some(w) = crate::kernel::font_select::pick_weight(want, &available) {
1588                if let Some(font) = fonts.get_mut(&font_key(cand, w)) {
1589                    return Some(f(font));
1590                }
1591            }
1592        }
1593    }
1594    None
1595}
1596
1597/// `font-family` を考慮した文字列幅(`draw::draw_string_family` と**同じ書体選択**で測る)。
1598///
1599/// 【2026-09-26 バグ修正】行の組み立て(`web_engine::layout` の折り返し)は内蔵フォントの
1600/// 幅で測り、描画は Web フォント(Noto Sans JP 等)で描いていた。書体によって文字幅が違うため
1601/// 行が実際より短く見積もられ、描画時に窓の右端を超えた分が描画関数の中で勝手に改行されて、
1602/// 次の行と重なっていた(sugi-lab.net/profile.htm の研究概要)。
1603/// 登録済みの family が無ければ内蔵フォントの幅(従来どおり)。
1604pub fn get_vector_string_width_family(
1605    s: &str,
1606    size_px: u32,
1607    family_list: &str,
1608    bold: bool,
1609    ls: i32,
1610    ws: i32,
1611) -> u32 {
1612    with_family_font(s, family_list, bold, |f| f.get_string_width_ls_ws(s, size_px, ls, ws))
1613        .unwrap_or_else(|| get_vector_string_width_ls_ws(s, size_px, ls, ws))
1614}
1615
1616/// `get_vector_string_wrapped_size_ext` の `font-family` 考慮版(レイアウトの箱の計測用。
1617/// 描画と同じ書体で折り返し行数を測る)。
1618#[allow(clippy::too_many_arguments)]
1619pub fn get_vector_string_wrapped_size_family(
1620    s: &str,
1621    size_px: u32,
1622    max_width: u32,
1623    ls: i32,
1624    ws: i32,
1625    word_break_all: bool,
1626    white_space_pre_wrap: bool,
1627    family_list: &str,
1628    bold: bool,
1629) -> (u32, u32) {
1630    with_family_font(s, family_list, bold, |f| {
1631        f.get_string_wrapped_size_ext(s, size_px, max_width, ls, ws, word_break_all, white_space_pre_wrap)
1632    })
1633    .unwrap_or_else(|| {
1634        get_vector_string_wrapped_size_ext(s, size_px, max_width, ls, ws, word_break_all, white_space_pre_wrap)
1635    })
1636}
1637
1638pub fn get_vector_string_width(s: &str, size_px: u32) -> u32 {
1639    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1640        font.get_string_width(s, size_px)
1641    } else {
1642        s.len() as u32 * (size_px / 2) // 静的フォールバック値
1643    }
1644}
1645
1646/// letter-spacing 対応版の文字列幅。ls は文字間に加える px(負も可)。
1647pub fn get_vector_string_width_ls(s: &str, size_px: u32, ls: i32) -> u32 {
1648    get_vector_string_width_ls_ws(s, size_px, ls, 0)
1649}
1650
1651/// letter-spacing + word-spacing 対応版の文字列幅。ws は半角スペース1文字ごとに加える px(負も可)。
1652pub fn get_vector_string_width_ls_ws(s: &str, size_px: u32, ls: i32, ws: i32) -> u32 {
1653    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1654        font.get_string_width_ls_ws(s, size_px, ls, ws)
1655    } else {
1656        let space_count = s.chars().filter(|&c| c == ' ').count() as i32;
1657        let base = s.len() as i32 * (size_px / 2) as i32
1658            + s.chars().count() as i32 * ls
1659            + space_count * ws;
1660        base.max(0) as u32
1661    }
1662}
1663
1664/// 1 文字ぶんの advance 幅を返す。
1665/// 折返し計算で 1 文字ずつ幅を加算する用途向け(行全体の再計測を避けて O(n) にする)。
1666pub fn get_vector_char_width(c: char, size_px: u32) -> u32 {
1667    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1668        font.get_glyph_advance(c, size_px)
1669    } else {
1670        size_px / 2
1671    }
1672}
1673
1674pub fn get_vector_string_wrapped_size(s: &str, size_px: u32, max_width: u32) -> (u32, u32) {
1675    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1676        font.get_string_wrapped_size(s, size_px, max_width)
1677    } else {
1678        let char_w = size_px / 2;
1679        let max_chars = (max_width / char_w).max(1);
1680        let chars_count = s.chars().count() as u32;
1681        let lines = chars_count.div_ceil(max_chars);
1682        (
1683            max_width.min(chars_count * char_w),
1684            lines.max(1) * (size_px + 4),
1685        )
1686    }
1687}
1688
1689/// letter-spacing 対応版の折返しサイズ。ls は文字間に加える px(負も可)。
1690pub fn get_vector_string_wrapped_size_ls(
1691    s: &str,
1692    size_px: u32,
1693    max_width: u32,
1694    ls: i32,
1695) -> (u32, u32) {
1696    get_vector_string_wrapped_size_ls_ws(s, size_px, max_width, ls, 0)
1697}
1698
1699/// letter-spacing + word-spacing 対応版の折返しサイズ。ws は半角スペース1文字ごとに加える px(負も可)。
1700pub fn get_vector_string_wrapped_size_ls_ws(
1701    s: &str,
1702    size_px: u32,
1703    max_width: u32,
1704    ls: i32,
1705    ws: i32,
1706) -> (u32, u32) {
1707    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1708        font.get_string_wrapped_size_ls_ws(s, size_px, max_width, ls, ws)
1709    } else {
1710        let char_w = ((size_px / 2) as i32 + ls).max(1) as u32;
1711        let max_chars = (max_width / char_w).max(1);
1712        let chars_count = s.chars().count() as u32;
1713        let lines = chars_count.div_ceil(max_chars);
1714        (
1715            max_width.min(chars_count * char_w),
1716            lines.max(1) * (size_px + 4),
1717        )
1718    }
1719}
1720
1721pub fn get_vector_string_wrapped_size_ext(
1722    s: &str,
1723    size_px: u32,
1724    max_width: u32,
1725    ls: i32,
1726    ws: i32,
1727    word_break_all: bool,
1728    white_space_pre_wrap: bool,
1729) -> (u32, u32) {
1730    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1731        font.get_string_wrapped_size_ext(s, size_px, max_width, ls, ws, word_break_all, white_space_pre_wrap)
1732    } else {
1733        let char_w = ((size_px / 2) as i32 + ls).max(1) as u32;
1734        let max_chars = (max_width / char_w).max(1);
1735        let chars_count = s.chars().count() as u32;
1736        let lines = chars_count.div_ceil(max_chars);
1737        (
1738            max_width.min(chars_count * char_w),
1739            lines.max(1) * (size_px + 4),
1740        )
1741    }
1742}
1743
1744pub fn is_line_start_forbidden(c: char) -> bool {
1745    matches!(
1746        c,
1747        '、' | '。'
1748            | ','
1749            | '.'
1750            | '・'
1751            | ':'
1752            | ';'
1753            | '?'
1754            | '!'
1755            | ')'
1756            | ']'
1757            | '}'
1758            | '〉'
1759            | '》'
1760            | '』'
1761            | '」'
1762            | '〕'
1763            | 'ー'
1764            | '〜'
1765            | '々'
1766            | '〆'
1767            | 'ぁ'
1768            | 'ぃ'
1769            | 'ぅ'
1770            | 'ぇ'
1771            | 'ぉ'
1772            | 'っ'
1773            | 'ゃ'
1774            | 'ゅ'
1775            | 'ょ'
1776            | 'ゎ'
1777            | 'ァ'
1778            | 'ィ'
1779            | 'ゥ'
1780            | 'ェ'
1781            | 'ォ'
1782            | 'ッ'
1783            | 'ャ'
1784            | 'ュ'
1785            | 'ョ'
1786            | 'ヮ'
1787            | 'ヶ'
1788            | 'ヵ'
1789    )
1790}
1791
1792pub fn is_line_end_forbidden(c: char) -> bool {
1793    matches!(c, '(' | '[' | '{' | '〈' | '《' | '『' | '「' | '〔')
1794}