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        let mut max_w = 0u32;
1131        let mut curr_w = 0u32;
1132        // flatten が emit する行数を数える: '\n' と折返しは無条件に 1 行、
1133        // 最終セグメントは文字が残っている場合のみ 1 行になる
1134        let mut emitted_lines = 0u32;
1135        let mut seg_has_content = false;
1136        let line_height = size_px + 4;
1137
1138        let mut prev_char: Option<char> = None;
1139        let mut prev_advance = 0u32;
1140
1141        for c in s.chars() {
1142            if c == '\n' {
1143                if white_space_pre_wrap {
1144                    // pre-wrap の場合は改行をそのまま改行として処理
1145                    if curr_w > max_w {
1146                        max_w = curr_w;
1147                    }
1148                    emitted_lines += 1;
1149                    curr_w = 0;
1150                    seg_has_content = false;
1151                    prev_char = None;
1152                    prev_advance = 0;
1153                    continue;
1154                } else {
1155                    // 通常時(collapse)でも flatten で '\n' が送られてきた場合は改行する
1156                    // (現在の flatten 実装に合わせる)
1157                    if curr_w > max_w {
1158                        max_w = curr_w;
1159                    }
1160                    emitted_lines += 1;
1161                    curr_w = 0;
1162                    seg_has_content = false;
1163                    prev_char = None;
1164                    prev_advance = 0;
1165                    continue;
1166                }
1167            }
1168
1169            // letter-spacing + word-spacing(半角スペースのみ)を加算した実 advance(0 でクランプ)。
1170            let extra = ls + if c == ' ' { ws } else { 0 };
1171            let advance = (self.get_glyph_advance(c, size_px) as i32 + extra).max(0) as u32;
1172
1173            if curr_w > 0 && curr_w + advance > max_width {
1174                // 行頭禁則: 新しい行の先頭に来るべき文字cが行頭禁則文字の場合
1175                // この文字を現在の行の末尾に引き込んで改行を遅らせる
1176                if !word_break_all && is_line_start_forbidden(c) {
1177                    curr_w += advance;
1178                    seg_has_content = true;
1179                    prev_char = Some(c);
1180                    prev_advance = advance;
1181                    continue;
1182                }
1183
1184                // 【2026-09-04】単語の途中では折り返さない。
1185                //
1186                // 従来は 1 文字単位で切っており、英単語が語中で割れていた。
1187                // CSS の既定(`overflow-wrap: normal`)では単語は分割せず、
1188                // 収まらなければはみ出す。実害は見た目だけではない:
1189                // min-content をこの関数で測っているため、語中で切れると
1190                // min-content が 1 文字ぶんになり、flex の縮小が項目を
1191                // 1 文字幅まで潰す(ナビ項目 "Papers" が縦一列に潰れた)。
1192                let can_break_here = prev_char
1193                    .map(|pc| {
1194                        crate::os_lib::layout::line_break::can_break_between(
1195                            pc,
1196                            c,
1197                            word_break_all,
1198                        )
1199                    })
1200                    .unwrap_or(true);
1201                if !can_break_here {
1202                    // 単語の内部なので切らずに続ける(行からはみ出す)。
1203                    //
1204                    // 【既知の差】厳密には、行の途中で始まった単語が収まらない
1205                    // 場合は**その単語ごと次の行へ送る**のが正しい。ここでは
1206                    // 送らずにはみ出させる。描画側(`web_engine::layout`)と
1207                    // 規則を一致させることを優先した——計測と描画がずれると、
1208                    // 箱の大きさと実際の文字の位置が食い違う。
1209                    curr_w += advance;
1210                    seg_has_content = true;
1211                    prev_char = Some(c);
1212                    prev_advance = advance;
1213                    continue;
1214                }
1215
1216                // 行末禁則: 現在の行の末尾文字が行末禁則文字の場合
1217                // その文字を次の行の先頭に送る
1218                let send_to_next = !word_break_all && prev_char.map(is_line_end_forbidden).unwrap_or(false);
1219                let line_w = if send_to_next {
1220                    curr_w.saturating_sub(prev_advance)
1221                } else {
1222                    curr_w
1223                };
1224                if line_w > max_w {
1225                    max_w = line_w;
1226                }
1227
1228                emitted_lines += 1;
1229
1230                // flatten と同じく、改行をまたいだ文字 c は新しい行で即座に消費する
1231                curr_w = if send_to_next { prev_advance } else { 0 } + advance;
1232                seg_has_content = true;
1233                prev_char = Some(c);
1234                prev_advance = advance;
1235                continue;
1236            }
1237
1238            curr_w += advance;
1239            seg_has_content = true;
1240            prev_char = Some(c);
1241            prev_advance = advance;
1242        }
1243
1244        if curr_w > max_w {
1245            max_w = curr_w;
1246        }
1247        if seg_has_content {
1248            emitted_lines += 1;
1249        }
1250
1251        (max_w, emitted_lines.max(1) * line_height)
1252    }
1253}
1254
1255// 静的グローバルフォントマネージャ
1256pub static GLOBAL_VECTOR_FONT: Mutex<Option<VectorFont>> = Mutex::new(None);
1257pub static GLOBAL_TERMINAL_FONT: Mutex<Option<VectorFont>> = Mutex::new(None);
1258
1259// ============ 実行時カスタムフォント登録(`@font-face`用) ============
1260//
1261// 従来はフォントが`include_bytes!`によるコンパイル時埋め込みのみに限られており
1262// (上記コメント参照)、`@font-face`でページが指定するWebフォントを実行時に
1263// 読み込む手段が丸ごと存在しなかった。TTF/OTF形式(WOFF2は非対応。`glyf`/`loca`
1264// テーブル変換を伴うため別途デコーダが必要で本対応の範囲外)に限定した実行時
1265// フォント登録レジストリを新設する(2026-07-21、www.sugi-lab.net互換性調査より。
1266// 実測ではGoogle FontsはAtmOSのUser-Agent文字列に対しTTF形式で応答するため
1267// (WOFF2形式でのみ配信されるFontAwesomeとは異なる)、この経路だけでも本文用
1268// Webフォントの読込が可能になる)。
1269//
1270// `VectorFont::new`は`&'static [u8]`を要求するが、実行時に取得したバイト列は
1271// 本来 'static ではない。ここでは`Box::leak`で意図的にリークして 'static
1272// 参照へ変換する(この処理系にはページ遷移でフォントを解放する仕組みが元々無く、
1273// 埋め込みフォントも実質的にプロセス終了まで生き続ける想定と同じ性質のため、
1274// 新規リークを許容する)。
1275pub static CUSTOM_FONTS: Mutex<BTreeMap<String, VectorFont>> = Mutex::new(BTreeMap::new());
1276
1277/// バイト列の先頭シグネチャからTTF/OTFかどうかを判定する(WOFF2等は`false`)。
1278fn looks_like_ttf_or_otf(bytes: &[u8]) -> bool {
1279    if bytes.len() < 4 {
1280        return false;
1281    }
1282    matches!(
1283        &bytes[0..4],
1284        [0x00, 0x01, 0x00, 0x00] | [b'O', b'T', b'T', b'O'] | [b't', b'r', b'u', b'e']
1285    )
1286}
1287
1288/// `@font-face`で参照されたフォントを実行時に登録する。TTF/OTF以外(WOFF2等)は
1289/// 安全に`false`を返して拒否する(`ttf_parser`側でも不正な形式は`Err`で弾かれ
1290/// クラッシュしないが、シグネチャの時点で早期に判定した方が無駄なパースを避けられる)。
1291/// 同じ`family`名で複数回呼ばれた場合は後勝ちで上書きする。
1292///
1293/// **`GLOBAL_VECTOR_FONT`のプライマリを新フォントへ切り替える**(`set_primary`。
1294/// 旧プライマリはフォールバックへ退避するため既存のグリフカバレッジは失わない)。
1295/// この処理系はまだ要素/ノード単位の`font-family`対応フォント選択
1296/// (`kernel/draw.rs`の描画箇所・幅計測箇所すべてに手を入れる大規模な
1297/// レンダラ統合が必要)を実装しておらず、それを安全に完成させるにはページ全体の
1298/// テキスト描画・幅計測の整合性(描画に使うフォントと幅計測に使うフォントが
1299/// 常に一致していないと文字が重なる既知の脆さがある)を壊さないよう慎重な
1300/// 段階的実装が必要と判断し見送っている。代わりに「取得できたWebフォントを
1301/// ページ全体の主要フォントとして採用する」という単純だが安全な戦略を採る
1302/// (`draw.rs`/`render.rs`側の変更が一切不要で、既存のグローバルフォント経由
1303/// の描画・幅計測パイプラインをそのまま使い回せるため、描画と幅計測の
1304/// 不整合が原理的に発生しない)。将来的に複数の`@font-face`ファミリーを
1305/// 要素ごとに使い分ける必要が生じた場合は、別途`font-family`対応の
1306/// レンダラ統合に着手すること。2026-07-22実装。
1307/// バイト列の先頭シグネチャからWOFF2かどうかを判定する。
1308fn looks_like_woff2(bytes: &[u8]) -> bool {
1309    bytes.len() >= 4 && &bytes[0..4] == b"wOF2"
1310}
1311
1312/// `@font-face`で参照されたフォントを実行時に登録する。
1313/// TTF/OTF に加え、WOFF2 (Brotli圧縮) フォントの自動展開・登録に対応。
1314/// 同じ`family`名で複数回呼ばれた場合は後勝ちで上書きする。
1315///
1316/// **`GLOBAL_VECTOR_FONT`のプライマリを新フォントへ切り替える**(`set_primary`。
1317/// 旧プライマリはフォールバックへ退避するため既存のグリフカバレッジは失わない)。
1318/// `family` と `weight` から `CUSTOM_FONTS` のキーを作る。
1319///
1320/// 【2026-08-05】以前は family 名だけをキーにしていたため、
1321/// 同じ family でウェイト違いを定義する CSS(Font Awesome など)で
1322/// **後勝ちで上書き**され、solid の字形が失われていた。
1323pub fn font_key(family: &str, weight: u16) -> alloc::string::String {
1324    alloc::format!("{}|{}", family.trim().to_lowercase(), weight)
1325}
1326
1327/// ウェイトを指定して登録する。
1328pub fn register_custom_font_weighted(family: &str, weight: u16, bytes: Vec<u8>) -> bool {
1329    register_custom_font_inner(family, weight, bytes)
1330}
1331
1332pub fn register_custom_font(family: &str, bytes: Vec<u8>) -> bool {
1333    register_custom_font_inner(family, 400, bytes)
1334}
1335
1336fn register_custom_font_inner(family: &str, weight: u16, bytes: Vec<u8>) -> bool {
1337    // **安全性ゲート解除(2026-07-24)**: 2026-07-22に発見された「`decompress_brotli`が
1338    // 特定の入力でQEMU起動をハングさせる」問題の根本原因は、2026-07-23に別途
1339    // 特定・修正済みである:`decompress_brotli`内部の3つのプールバッファ(合計
1340    // 約160KB)がスタック上の固定配列として確保されており、64KBしかない起動時
1341    // コアスタックを溢れさせていた(スタックオーバーフロー)。この関数自体を
1342    // ヒープ確保(`Vec`)へ移行したことで根本解決済み(詳細は`decompress_brotli`
1343    // 冒頭のコメント参照)。あわせて`main.rs`にソフトウェアスタック番兵による
1344    // 検知機構も追加済みのため、万一同種の問題が再発してもハングではなく明確な
1345    // 検知・報告に変わる。以上を踏まえ、WOFF2の自動展開を再度有効化する。
1346    // 【2026-08-03】この関数は失敗を**4 箇所すべて無言**で返していた
1347    // (WOFF2 展開失敗・未知の形式・パース失敗・`set_primary` の失敗)。
1348    // そのため「フォントを取得できているのに適用されない」ときに
1349    // どこで落ちたのか分からなかった。規約は `spec/logging_policy.md`(L-1/L-2)。
1350    // 【2026-08-03】キャッシュ命中後、成功も失敗も一切ログに出ないまま
1351    // 停止する事象を追う。300/420/540 秒すべて同じ地点で止まったので
1352    // 時間不足ではない。入口・各段の通過を記録する。
1353    crate::warn!(
1354        "[FONT] 登録開始 family={} bytes={}",
1355        family,
1356        bytes.len()
1357    );
1358    // 内蔵フォントチェーン(字形が無い文字の代替)。
1359    // ローカル変数 `font_bytes` が関数 `font_bytes()` を隠すため、
1360    // シャドウイングされる前にここで用意しておく。
1361    let fallback_chain: Vec<&'static [u8]> = DEFAULT_FONT_CHAIN
1362        .iter()
1363        .filter_map(|n| font_bytes(n))
1364        .collect();
1365    let font_bytes = if looks_like_woff2(&bytes) {
1366        match decompress_woff2(&bytes) {
1367            Some(sfnt) => sfnt,
1368            None => {
1369                crate::warn!(
1370                    "[FONT] WOFF2 の展開に失敗 family={} bytes={}",
1371                    family,
1372                    bytes.len()
1373                );
1374                return false;
1375            }
1376        }
1377    } else if looks_like_ttf_or_otf(&bytes) {
1378        bytes
1379    } else {
1380        crate::warn!(
1381            "[FONT] 未知のフォント形式 family={} bytes={} head={:02x?}",
1382            family,
1383            bytes.len(),
1384            &bytes[..bytes.len().min(4)]
1385        );
1386        return false;
1387    };
1388
1389    let leaked: &'static [u8] = Vec::leak(font_bytes);
1390    let _pf = crate::os_lib::web_engine::perf::start();
1391    crate::warn!("[FONT] 解析開始 family={} bytes={}", family, leaked.len());
1392    // 【2026-08-04 発見・修正】`VectorFont::new` は `fallbacks` が空。
1393    //
1394    // `@font-face` で登録したフォントで文字を描くようにした結果、
1395    // **そのフォントに字形が無い文字が何も描かれなくなった**
1396    // (Font Awesome に日本語の字形は無い)。
1397    // 実ブラウザは字形が無ければ次のフォントへ落とす。
1398    //
1399    // 内蔵フォントチェーンを代替として与える(`fallback_chain` は関数冒頭で用意)。
1400    // これで「Web フォントで描けるものはそれで、無いものは内蔵で」になる。
1401    let _parsed = VectorFont::new_with_fallbacks(leaked, &fallback_chain);
1402    crate::os_lib::web_engine::perf::add(
1403        crate::os_lib::web_engine::perf::Slot::FontParse,
1404        _pf,
1405    );
1406    match _parsed {
1407        Ok(font) => {
1408            {
1409                let mut map = CUSTOM_FONTS.lock();
1410                // ウェイト付きキーで登録する(同名ファミリの後勝ちを防ぐ)。
1411                map.insert(font_key(family, weight), font);
1412                // family 名だけのキーも用意する(ウェイト指定が無い描画のため)。
1413                // `VectorFont` は `Clone` ではないので同じバイト列から作り直す。
1414                // **既に入っていれば上書きしない**(最初に届いたものを保つ)。
1415                let plain = family.trim().to_lowercase();
1416                if !map.contains_key(&plain) {
1417                    if let Ok(f2) = VectorFont::new_with_fallbacks(leaked, &fallback_chain) {
1418                        map.insert(plain, f2);
1419                    }
1420                }
1421            }
1422            // 【2026-08-03 発見・修正】ここで `set_primary` して
1423            // **グローバル既定フォントを差し替えていた**。
1424            //
1425            // WOFF2 の展開・再構築を直して Font Awesome が実際に登録できるように
1426            // なった途端、**アイコンフォントが全文字を乗っ取る**という
1427            // 明白なリグレッションが出た(OS のタイトルバーやタブまで
1428            // アイコン書体になった。スクリーンショットで確認)。
1429            //
1430            // `@font-face` の登録は「この family でこのフォントが使える」と
1431            // 記録するだけであって、**文書全体の既定フォントを変える操作ではない**。
1432            // 差し替えはしない。
1433            //
1434            // 【残作業】描画側(`draw_string_ja_size_ext_bg_ls_ws`)は
1435            // `GLOBAL_VECTOR_FONT` しか見ておらず `font-family` を受け取らない。
1436            // そのため登録しただけでは Web フォントは字形に反映されない。
1437            // family 指定で `CUSTOM_FONTS` から選ぶ経路が本来の実装
1438            // (`spec/webfont_loading.md`)。
1439            // 節目のログ(1 ファミリにつき事実上 1 回。L-3)。
1440            //
1441            // 【2026-08-05】Font Awesome のアイコンが ☐ になる件の切り分け。
1442            // カスケード側(family・content・エスケープ解除)は単体試験で
1443            // 正常と確定済み。残る疑いは**字形そのものが無い**こと。
1444            // 代表的な符号位置(U+F02D=fa-book, U+F015=fa-home)を引いて記録する。
1445            let probe = |c: char| -> bool {
1446                CUSTOM_FONTS
1447                    .lock()
1448                    .get(&family.trim().to_lowercase())
1449                    .map(|f| f.has_glyph(c))
1450                    .unwrap_or(false)
1451            };
1452            crate::warn!(
1453                "[FONT] 登録完了 family={} bytes={} 字形F02D={} 字形F015={}",
1454                family,
1455                leaked.len(),
1456                probe('\u{f02d}'),
1457                probe('\u{f015}')
1458            );
1459            true
1460        }
1461        Err(e) => {
1462            crate::warn!(
1463                "[FONT] フォントのパースに失敗 family={} bytes={} err={:?}",
1464                family,
1465                leaked.len(),
1466                e
1467            );
1468            false
1469        }
1470    }
1471}
1472
1473/// 指定した`font-family`名で登録済みのカスタムフォントが存在するかを返す
1474/// (大小文字を区別しない)。
1475/// 【2026-08-05】ウェイトまで見て登録済みかを返す。
1476///
1477/// `has_custom_font` は family 名だけを見るため、
1478/// `@font-face` の取り込みループが「同じ family は登録済み」と判断し、
1479/// **別ウェイト(solid 900)を一度も取り込まなかった**。
1480/// 実測: `Font Awesome 7 Free` に regular(400) だけが入り、
1481/// `.fas`(solid)のアイコンが描けなかった。
1482pub fn has_custom_font_weighted(family: &str, weight: u16) -> bool {
1483    CUSTOM_FONTS.lock().contains_key(&font_key(family, weight))
1484}
1485
1486pub fn has_custom_font(family: &str) -> bool {
1487    CUSTOM_FONTS.lock().contains_key(&family.trim().to_lowercase())
1488}
1489
1490// ============ フォントレジストリ ============
1491//
1492// 実行時にフォントフォルダは存在しないため、選択可能なフォントは include_bytes! で
1493// 埋め込んだものに限る。設定アプリのセレクトボックスはこの一覧を提示する。
1494// (フォントを増やすとカーネルバイナリ(.rodata)が肥大するため、代表的なものに絞る)
1495
1496/// 設定アプリで選択できるフォント名の一覧(フォントフォルダ相当)。
1497pub const AVAILABLE_FONTS: &[&str] = &[
1498    "Mplus1Code-Regular",
1499    "HackGenConsoleNF-Regular",
1500    "ipam",
1501    "ipag",
1502    "MPLUS2-Regular",
1503    "MPLUS2-Bold",
1504];
1505
1506/// OS レベルのデフォルトのフォントフォールバック順。
1507pub const DEFAULT_FONT_CHAIN: [&str; 3] =
1508    ["Mplus1Code-Regular", "HackGenConsoleNF-Regular", "ipam"];
1509
1510/// フォント名から埋め込みバイト列を解決する。未知の名前は None。
1511pub fn font_bytes(name: &str) -> Option<&'static [u8]> {
1512    Some(match name {
1513        "Mplus1Code-Regular" => include_bytes!("../../fonts/Mplus1Code/Mplus1Code-Regular.ttf"),
1514        "HackGenConsoleNF-Regular" => {
1515            include_bytes!("../../fonts/HackGen/HackGenConsoleNF-Regular.ttf")
1516        }
1517        "ipam" => include_bytes!("../../fonts/ipa/ipam.ttf"),
1518        "ipag" | "IPAg" => include_bytes!("../../fonts/ipa/ipag.ttf"),
1519        "MPLUS2-Regular" => include_bytes!("../../fonts/MPLUS2/MPLUS2-Regular.ttf"),
1520        "MPLUS2-Bold" => include_bytes!("../../fonts/MPLUS2/MPLUS2-Bold.ttf"),
1521        _ => return None,
1522    })
1523}
1524
1525/// フォント名のフォールバックチェーンからグローバルフォントを初期化する。
1526/// 先頭がプライマリ、以降がフォールバック。未知/解決不能な名前は読み飛ばす。
1527/// 有効なフォントが 1 つも無ければデフォルトチェーンにフォールバックする。
1528pub fn init_global_font_chain(names: &[&str]) {
1529    let mut bytes: Vec<&'static [u8]> = names.iter().filter_map(|n| font_bytes(n)).collect();
1530    if bytes.is_empty() {
1531        bytes = DEFAULT_FONT_CHAIN
1532            .iter()
1533            .filter_map(|n| font_bytes(n))
1534            .collect();
1535    }
1536    if bytes.is_empty() {
1537        crate::info!("[vector_font] No embedded fonts available for chain init!");
1538        return;
1539    }
1540    let primary = bytes[0];
1541    let fallbacks: Vec<&'static [u8]> = bytes[1..].to_vec();
1542    match VectorFont::new_with_fallbacks(primary, &fallbacks) {
1543        Ok(font) => {
1544            crate::info!(
1545                "[vector_font] Global font chain initialized ({} fonts).",
1546                bytes.len()
1547            );
1548            *GLOBAL_VECTOR_FONT.lock() = Some(font);
1549        }
1550        Err(e) => {
1551            crate::info!("[vector_font] Failed to init font chain: {:?}", e);
1552        }
1553    }
1554}
1555
1556/// 起動時にフォントデータをロードしてグローバルインスタンスを初期化します
1557pub fn init_global_font(font_bytes: &'static [u8]) {
1558    match VectorFont::new(font_bytes) {
1559        Ok(mut font) => {
1560            crate::info!("[vector_font] Successfully initialized global vector font.");
1561            crate::info!("[vector_font] Running test get_glyph('o', 16) for global font...");
1562            if font.get_glyph('o', 16).is_some() {
1563                crate::info!("[vector_font] Test get_glyph('o', 16) succeeded for global font!");
1564            } else {
1565                crate::info!("[vector_font] Test get_glyph('o', 16) failed for global font!");
1566            }
1567            *GLOBAL_VECTOR_FONT.lock() = Some(font);
1568        }
1569        Err(e) => {
1570            crate::info!(
1571                "[vector_font] Failed to initialize global vector font: {:?}",
1572                e
1573            );
1574        }
1575    }
1576}
1577
1578/// ターミナル専用の等幅ベクターフォントをロードして初期化します
1579pub fn init_terminal_font() {
1580    let font_bytes = include_bytes!("../../fonts/Mplus1Code/Mplus1Code-Regular.ttf");
1581    match VectorFont::new(font_bytes) {
1582        Ok(mut font) => {
1583            crate::info!("[vector_font] Successfully initialized terminal monospace font.");
1584            crate::info!("[vector_font] Running test get_glyph('o', 16)...");
1585            if font.get_glyph('o', 16).is_some() {
1586                crate::info!("[vector_font] Test get_glyph('o', 16) succeeded!");
1587            } else {
1588                crate::info!("[vector_font] Test get_glyph('o', 16) failed!");
1589            }
1590            *GLOBAL_TERMINAL_FONT.lock() = Some(font);
1591        }
1592        Err(e) => {
1593            crate::info!(
1594                "[vector_font] Failed to initialize terminal monospace font: {:?}",
1595                e
1596            );
1597        }
1598    }
1599}
1600
1601/// グローバルフォントを用いて、指定したサイズでの文字列全体のピクセル幅を計算します
1602pub fn get_vector_string_width(s: &str, size_px: u32) -> u32 {
1603    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1604        font.get_string_width(s, size_px)
1605    } else {
1606        s.len() as u32 * (size_px / 2) // 静的フォールバック値
1607    }
1608}
1609
1610/// letter-spacing 対応版の文字列幅。ls は文字間に加える px(負も可)。
1611pub fn get_vector_string_width_ls(s: &str, size_px: u32, ls: i32) -> u32 {
1612    get_vector_string_width_ls_ws(s, size_px, ls, 0)
1613}
1614
1615/// letter-spacing + word-spacing 対応版の文字列幅。ws は半角スペース1文字ごとに加える px(負も可)。
1616pub fn get_vector_string_width_ls_ws(s: &str, size_px: u32, ls: i32, ws: i32) -> u32 {
1617    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1618        font.get_string_width_ls_ws(s, size_px, ls, ws)
1619    } else {
1620        let space_count = s.chars().filter(|&c| c == ' ').count() as i32;
1621        let base = s.len() as i32 * (size_px / 2) as i32
1622            + s.chars().count() as i32 * ls
1623            + space_count * ws;
1624        base.max(0) as u32
1625    }
1626}
1627
1628/// 1 文字ぶんの advance 幅を返す。
1629/// 折返し計算で 1 文字ずつ幅を加算する用途向け(行全体の再計測を避けて O(n) にする)。
1630pub fn get_vector_char_width(c: char, size_px: u32) -> u32 {
1631    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1632        font.get_glyph_advance(c, size_px)
1633    } else {
1634        size_px / 2
1635    }
1636}
1637
1638pub fn get_vector_string_wrapped_size(s: &str, size_px: u32, max_width: u32) -> (u32, u32) {
1639    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1640        font.get_string_wrapped_size(s, size_px, max_width)
1641    } else {
1642        let char_w = size_px / 2;
1643        let max_chars = (max_width / char_w).max(1);
1644        let chars_count = s.chars().count() as u32;
1645        let lines = chars_count.div_ceil(max_chars);
1646        (
1647            max_width.min(chars_count * char_w),
1648            lines.max(1) * (size_px + 4),
1649        )
1650    }
1651}
1652
1653/// letter-spacing 対応版の折返しサイズ。ls は文字間に加える px(負も可)。
1654pub fn get_vector_string_wrapped_size_ls(
1655    s: &str,
1656    size_px: u32,
1657    max_width: u32,
1658    ls: i32,
1659) -> (u32, u32) {
1660    get_vector_string_wrapped_size_ls_ws(s, size_px, max_width, ls, 0)
1661}
1662
1663/// letter-spacing + word-spacing 対応版の折返しサイズ。ws は半角スペース1文字ごとに加える px(負も可)。
1664pub fn get_vector_string_wrapped_size_ls_ws(
1665    s: &str,
1666    size_px: u32,
1667    max_width: u32,
1668    ls: i32,
1669    ws: i32,
1670) -> (u32, u32) {
1671    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1672        font.get_string_wrapped_size_ls_ws(s, size_px, max_width, ls, ws)
1673    } else {
1674        let char_w = ((size_px / 2) as i32 + ls).max(1) as u32;
1675        let max_chars = (max_width / char_w).max(1);
1676        let chars_count = s.chars().count() as u32;
1677        let lines = chars_count.div_ceil(max_chars);
1678        (
1679            max_width.min(chars_count * char_w),
1680            lines.max(1) * (size_px + 4),
1681        )
1682    }
1683}
1684
1685pub fn get_vector_string_wrapped_size_ext(
1686    s: &str,
1687    size_px: u32,
1688    max_width: u32,
1689    ls: i32,
1690    ws: i32,
1691    word_break_all: bool,
1692    white_space_pre_wrap: bool,
1693) -> (u32, u32) {
1694    if let Some(ref mut font) = *GLOBAL_VECTOR_FONT.lock() {
1695        font.get_string_wrapped_size_ext(s, size_px, max_width, ls, ws, word_break_all, white_space_pre_wrap)
1696    } else {
1697        let char_w = ((size_px / 2) as i32 + ls).max(1) as u32;
1698        let max_chars = (max_width / char_w).max(1);
1699        let chars_count = s.chars().count() as u32;
1700        let lines = chars_count.div_ceil(max_chars);
1701        (
1702            max_width.min(chars_count * char_w),
1703            lines.max(1) * (size_px + 4),
1704        )
1705    }
1706}
1707
1708pub fn is_line_start_forbidden(c: char) -> bool {
1709    matches!(
1710        c,
1711        '、' | '。'
1712            | ','
1713            | '.'
1714            | '・'
1715            | ':'
1716            | ';'
1717            | '?'
1718            | '!'
1719            | ')'
1720            | ']'
1721            | '}'
1722            | '〉'
1723            | '》'
1724            | '』'
1725            | '」'
1726            | '〕'
1727            | 'ー'
1728            | '〜'
1729            | '々'
1730            | '〆'
1731            | 'ぁ'
1732            | 'ぃ'
1733            | 'ぅ'
1734            | 'ぇ'
1735            | 'ぉ'
1736            | 'っ'
1737            | 'ゃ'
1738            | 'ゅ'
1739            | 'ょ'
1740            | 'ゎ'
1741            | 'ァ'
1742            | 'ィ'
1743            | 'ゥ'
1744            | 'ェ'
1745            | 'ォ'
1746            | 'ッ'
1747            | 'ャ'
1748            | 'ュ'
1749            | 'ョ'
1750            | 'ヮ'
1751            | 'ヶ'
1752            | 'ヵ'
1753    )
1754}
1755
1756pub fn is_line_end_forbidden(c: char) -> bool {
1757    matches!(c, '(' | '[' | '{' | '〈' | '《' | '『' | '「' | '〔')
1758}