menu-ui 0.1.2

Android egui 内置菜单框架:EGL/GL 渲染、JNI 输入桥接、原生文本编辑框与图片选择
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
//! 渲染线程模块。
//!
//! 负责:
//! 1. 从 [`crate::state::AppState`] 取出 Android `Surface` 对应的原生窗口;
//! 2. 初始化 EGL(透明 RGBA8888 窗口表面 + GLES2 上下文);
//! 3. 创建 `egui_glow` 后端与共享的 `egui::Context`;
//! 4. 每帧执行:收集 UI 线程投递的触摸、构建 egui 输入、`run_ui` 构建 UI
//!    (实际 UI 渲染函数由宿主 crate 注册,原生编辑框结果由各
//!    `native_text_edit` 控件按自己的 id 自行回填)、棋盘格化并绘制、
//!    `eglSwapBuffers`。
//! 5. **空闲休眠**:没有排队输入且 egui 未请求重绘时,渲染线程挂起在条件变量
//!    上(不占用 GPU/CPU);被新输入或 egui 的 `request_repaint` 回调唤醒后再
//!    渲染。触摸事件每帧只处理一个,保持 60fps 的输入节奏。
//!
//! 线程模型:`render_loop` 常驻一个线程;Surface 创建/重建时由 `nativeAttach`
//! 停止旧线程并启动新线程(见 `crate::native` 的 JNI 入口)。

use std::ffi::c_void;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::{Duration, Instant};

use egl::{EGLContext, EGLDisplay, EGLSurface};
use ndk::native_window::NativeWindow;

use glow::HasContext;
use egui::{
    Event, PointerButton, RawInput, Rect, TouchDeviceId, TouchId, TouchPhase, Vec2, ViewportInfo,
    ViewportId,
};

use crate::kotlin::maybe_request_native_editor;
use crate::state::{AppState, TouchAction, TEXT_BRIDGE};

/// 渲染线程主循环(空闲休眠模式)。
///
/// 只要 `running == true` 就持续运行:
/// - 没有可用的原生窗口时休眠等待(Surface 尚未创建);
/// - 有窗口时进入 [`run_render_session`];会话退出(Surface 丢失/出错/停止)
///   后回到循环开头,等待新的 Surface;
/// - 会话内部空闲(无输入、无重绘请求)时挂起在条件变量上,不持续渲染。
pub(crate) fn render_loop(state: Arc<AppState>) {
    'outer: loop {
        while state.running.load(Ordering::SeqCst) {
            // 等待可用的原生窗口:没有 Surface 时挂起在条件变量上。
            // 返回 `None` = 已要求停止:跳出 while 走下面的退出确认块。
            let window = loop {
                if !state.running.load(Ordering::SeqCst) {
                    break None;
                }
                if let Some(window) = state.window.lock().unwrap().clone() {
                    break Some(window);
                }
                let guard = state.wake_lock.lock().unwrap();
                if !state.running.load(Ordering::SeqCst) {
                    break None;
                }
                let guard = state.wake.wait(guard).unwrap();
                drop(guard);
            };
            let Some(window) = window else { break };
            match run_render_session(&state, &window) {
                Ok(()) => {}
                Err(e) => {
                    log::error!("render session error: {e}");
                    std::thread::sleep(Duration::from_millis(200));
                }
            }
        }
        // 退出前持 `render_thread` 锁再确认一次:`nativeAttach` 在**同一把锁**
        // 下把 `running` 置回 true 并决定「复用这个线程」——若不同步确认,
        // 可能在它选择复用的瞬间我们正好退出(双方都以为对方在跑)。
        let mut guard = state.render_thread.lock().unwrap();
        if state.running.load(Ordering::SeqCst) {
            drop(guard);
            continue 'outer;
        }
        guard.take(); // 清掉自己的句柄(drop 即 detach)
        break;
    }
}

/// 一次完整的渲染会话:创建 EGL 表面、帧循环、释放资源。
///
/// 会话在以下情况结束:
/// - `running` 被置 false(Surface 销毁/应用退出);
/// - `eglSwapBuffers` 失败(Surface 已失效)。
fn run_render_session(state: &Arc<AppState>, window: &NativeWindow) -> Result<(), String> {
    let session = init_egl(window)?;

    // 用 EGL 的 eglGetProcAddress 加载 OpenGL ES 函数,创建 glow 上下文。
    let gl = unsafe {
        glow::Context::from_loader_function(|name| egl::get_proc_address(name) as *const c_void)
    };
    let gl = Arc::new(gl);

    // egui_glow 后端:GLES2 使用 ES 100 着色器。
    let mut painter = egui_glow::Painter::new(
        gl.clone(),
        "",
        Some(egui_glow::ShaderVersion::Es100),
        false,
    )
    .map_err(|e| format!("egui_glow painter: {e}"))?;

    // 创建共享 egui 上下文并注入字体;UI 线程会通过 `state.egui_ctx`
    // 读取该上下文做输入判定,因此在这里就写入共享状态。
    let egui_ctx = egui::Context::default();
    egui_ctx.set_zoom_factor(1.0);
    // 恢复上一会话保存的 egui 内存(窗口位置/大小等):否则新建的
    // `Context` 内存是空的,悬浮窗会回到 `default_pos`。
    if let Some(saved) = state.egui_memory.lock().unwrap().as_ref() {
        egui_ctx.memory_mut(|m| *m = saved.clone());
    }
    // 顺序很关键:`set_fonts` 必须放在 Memory 恢复**之后**。整体恢复会把
    // `Memory::new_font_definitions` 清成 None——若先 set_fonts 再恢复,
    // 字体定义就被抹掉,回前台后只剩默认字体,中文全部不渲染(窗口变成
    // 空壳、标题/标签消失,自动尺寸还会错误收缩)。
    egui_ctx.set_fonts(font_definitions().clone());
    // 恢复的 Memory 会整体覆盖 options(含 zoom_factor),重新固定缩放。
    egui_ctx.set_zoom_factor(1.0);
    *state.egui_ctx.lock().unwrap() = Some(egui_ctx.clone());

    // 注册 egui 重绘回调:egui 请求重绘(立即或定时)时,记录定时截止时间
    // 并唤醒可能正在空闲休眠的渲染线程。
    {
        let state = state.clone();
        egui_ctx.set_request_repaint_callback(move |info| {
            let deadline = Instant::now() + info.delay;
            {
                let mut slot = state.repaint_deadline.lock().unwrap();
                // 只保留最早的截止时间(egui 的 delay 越小越紧急)。
                if slot.map_or(true, |d| deadline < d) {
                    *slot = Some(deadline);
                }
            }
            state.wake_renderer();
        });
    }

    let mut frame_count: u64 = 0;
    let mut first_frame = true;
    // 只有渲染过“有效帧”(横屏、尺寸正常)的会话才保存 egui 内存:
    // 退后台过渡期 surfaceChanged 可能带着一次错误的竖屏尺寸(实测
    // 1080x1920),那种会话若渲染会把窗口约束到竖屏几何——绝不能把
    // 它的内存存下来,否则回前台窗口位置错误。
    let mut rendered_valid_frame = false;
    // egui 时间戳:必须用进程级单调时钟,不能每会话从 0 重新计时——
    // egui 内存跨会话恢复(含 Area 的 last_became_visible_at),新会话若
    // 时间回绕,fade_in 会算出负 age → 悬浮区域被画成永久全透明。
    static PROCESS_START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
    let process_time = || PROCESS_START.get_or_init(Instant::now).elapsed().as_secs_f64();

    loop {
        if !state.running.load(Ordering::SeqCst) {
            break;
        }
        // Surface 被替换(`nativeAttach`:surfaceChanged / onResume 重连):
        // 本会话作废,正常收尾后由 `render_loop` 用**最新**的 window 重开。
        // 绝不在这里继续用旧 Surface 渲染(那是「选图回来后花屏/不渲染」的成因)。
        if state.take_rebind() {
            break;
        }
        let (w_px, h_px) = *state.size_px.lock().unwrap();
        let density = *state.density.lock().unwrap();
        if w_px == 0 || h_px == 0 || density <= 0.0 {
            // 尺寸尚未就绪:短暂等待而不退出会话(避免重建会话忙循环)。
            std::thread::sleep(Duration::from_millis(50));
            continue;
        }
        // 应用锁定横屏;竖屏尺寸是退后台过渡期的错误表面,跳过渲染。
        if h_px > w_px {
            std::thread::sleep(Duration::from_millis(50));
            continue;
        }

        // 空闲休眠:除首帧外,只有“有排队输入 / egui 请求重绘”才渲染。
        if !first_frame && !wait_for_render_reason(state, &egui_ctx) {
            break;
        }
        first_frame = false;

        // 构建本帧的 egui 原始输入:屏幕尺寸(point)、时间、视口信息。
        let mut raw_input = RawInput {
            screen_rect: Some(Rect::from_min_size(
                egui::Pos2::ZERO,
                Vec2::new(w_px as f32 / density, h_px as f32 / density),
            )),
            time: Some(process_time()),
            viewports: [(
                ViewportId::ROOT,
                ViewportInfo {
                    native_pixels_per_point: Some(density),
                    ..Default::default()
                },
            )]
            .into_iter()
            .collect(),
            events: Vec::new(),
            ..Default::default()
        };

        // 每帧只取一个触摸事件转成 egui 事件。
        //
        // 原因:空闲休眠模式下,快速的 DOWN+UP 可能被合并进同一帧;实测
        // egui 0.35 对“同帧按下+抬起”的控件级 clicked() 判定不可靠(全局
        // primary_clicked 正常但按钮不触发)。逐帧处理一个事件后,DOWN 与 UP
        // 落在不同帧,行为与真实 60fps 输入一致。
        if let Some(touch) = state.touches.lock().unwrap().pop_front() {
            let pos = egui::pos2(touch.x, touch.y);
            // 原生触摸管线:按 egui 官方约定,Touch 事件与指针事件**并行**
            // 上报(egui 0.36 不会从 Touch 合成指针事件;Touch 只喂
            // TouchState——激活 `has_touch_screen()` 与多点手势,ScrollArea
            // 的 `DragScroll::OnTouch` 由此原生生效)。Touch 在前,指针事件
            // 在后,保证同帧内 egui 先登记触摸、再处理指针。
            let phase = match touch.action {
                TouchAction::Down => TouchPhase::Start,
                TouchAction::Move => TouchPhase::Move,
                TouchAction::Up => TouchPhase::End,
            };
            raw_input.events.push(Event::Touch {
                device_id: TouchDeviceId(0),
                id: TouchId(0),
                phase,
                pos,
                force: None,
            });
            match touch.action {
                TouchAction::Down => raw_input.events.push(Event::PointerButton {
                    pos,
                    button: PointerButton::Primary,
                    pressed: true,
                    modifiers: egui::Modifiers::default(),
                }),
                TouchAction::Move => raw_input.events.push(Event::PointerMoved(pos)),
                TouchAction::Up => {
                    // 先发抬起事件,再发 PointerGone 表示指针离开。
                    raw_input.events.push(Event::PointerButton {
                        pos,
                        button: PointerButton::Primary,
                        pressed: false,
                        modifiers: egui::Modifiers::default(),
                    });
                    raw_input.events.push(Event::PointerGone);
                }
            }
        }

        // 合成事件(目前只有:打开原生编辑框后清除 egui 文本框焦点的 Escape)。
        let extra_events = std::mem::take(&mut *state.extra_events.lock().unwrap());
        raw_input.events.extend(extra_events);

        // 原生编辑框返回的文本结果不在这里集中处理:`native_text_edit`
        // 控件会按自己的 id 从 `TEXT_BRIDGE.results` 取回结果并直接回填。
        // 构建 UI。实际 UI 渲染回调未设置(init 未传 build_ui)时,
        // 本帧不运行 egui UI——Surface 保持全透明,不绘制任何窗口/控件。
        // UI 状态由宿主 crate 自持(闭包捕获),框架不感知具体类型;
        // 原生编辑框桥接(`state::TEXT_BRIDGE`)由框架控件内部加锁访问。
        let mut full_output = egui_ctx.run_ui(raw_input, |ui| {
            if let Some(render_fn) = crate::UI_RENDERER.lock().unwrap().as_mut() {
                render_fn(ui);
            }
        });

        // 若本帧有文本控件被点击且没有已打开的对话框,请求 Kotlin 弹原生编辑框。
        maybe_request_native_editor(state);

        // 棋盘格化(生成三角形网格)并用 egui_glow 绘制。
        let pixels_per_point = full_output.pixels_per_point;
        let clipped = egui_ctx.tessellate(full_output.shapes, pixels_per_point);

        // 清屏为全透明(RGBA=0,0,0,0):非 egui 窗口区域会透出下层兄弟控件。
        unsafe {
            gl.clear_color(0.0, 0.0, 0.0, 0.0);
            gl.clear(glow::COLOR_BUFFER_BIT);
        }
        painter.paint_and_update_textures(
            [w_px, h_px],
            pixels_per_point,
            &clipped,
            &mut full_output.textures_delta,
        );

        // 交换缓冲区;失败说明 Surface 已失效,结束本次会话。
        if !egl::swap_buffers(session.display, session.surface) {
            log::warn!("eglSwapBuffers failed - surface lost");
            break;
        }
        rendered_valid_frame = true;

        frame_count += 1;
        if frame_count % 120 == 0 {
        }

    }

    // 会话结束:UI 状态由宿主 crate 自持全局变量,无需回写。
    // 同时保存 egui 内存(窗口位置/大小/折叠状态等),供下一次会话恢复;
    // 但只保存渲染过有效帧的会话——错误尺寸的过渡会话会破坏窗口几何。
    if rendered_valid_frame {
        *state.egui_memory.lock().unwrap() = Some(egui_ctx.memory(|m| m.clone()));
    }

    // 释放顺序很关键:必须先销毁 painter——此时 EGL 上下文仍处于 current,
    // `glDelete*` 等调用才会真正执行。旧顺序先 `make_current(EGL_NO_CONTEXT)`
    // 再 `painter.destroy()`,会触发 “call to OpenGL ES API with no current
    // context”,GL 资源实际没被删除,可能导致回前台后渲染异常。
    painter.destroy();
    egl::make_current(
        session.display,
        egl::EGL_NO_SURFACE,
        egl::EGL_NO_SURFACE,
        egl::EGL_NO_CONTEXT,
    );
    *state.egui_ctx.lock().unwrap() = None;
    egl::destroy_surface(session.display, session.surface);
    egl::destroy_context(session.display, session.context);
    egl::terminate(session.display);
    Ok(())
}

/// 决定是否渲染下一帧;若当前空闲,则挂起渲染线程直到出现渲染理由。
///
/// 渲染理由(任一即可):
/// - 有排队输入:触摸、合成事件(Escape)、原生编辑框结果;
/// - egui 上一帧请求了立即重绘(`requested_repaint_last_pass()`,如控件
///   交互、动画期间会持续返回 true,从而连续渲染);
/// - egui 的定时重绘(`request_repaint_after`)到期;
/// - 渲染线程被要求停止。
///
/// 返回 `false` 表示渲染会话应结束。
fn wait_for_render_reason(state: &Arc<AppState>, egui_ctx: &egui::Context) -> bool {
    loop {
        if !state.running.load(Ordering::SeqCst) {
            return false;
        }
        // Surface 被替换:结束本会话(外层用最新 window 重开)。
        if state.take_rebind() {
            return false;
        }
        if has_pending_input(state) || egui_ctx.requested_repaint_last_pass() {
            return true;
        }

        // 持锁检查并挂起:所有输入写入方都持 `wake_lock`(见 state.rs 的
        // push_* 方法),因此这里“检查 + 等待”是原子的,不会丢失唤醒。
        let guard = state.wake_lock.lock().unwrap();
        if !state.running.load(Ordering::SeqCst) {
            return false;
        }
        // 持锁复查必须包含 rebind:写入方也持这把锁(见 `request_rebind`),
        // 否则可能「检查完才置位」→ 丢失唤醒、会话一直不重建。
        if state.take_rebind() {
            return false;
        }
        if has_pending_input(state) || egui_ctx.requested_repaint_last_pass() {
            return true;
        }

        let deadline = *state.repaint_deadline.lock().unwrap();
        match deadline {
            Some(d) => {
                let now = Instant::now();
                if now >= d {
                    // 定时重绘到期:清掉截止时间并渲染这一帧。
                    *state.repaint_deadline.lock().unwrap() = None;
                    return true;
                }
                // 睡到截止时间;期间有新输入或停止请求会被提前唤醒。
                let (guard, _) = state.wake.wait_timeout(guard, d - now).unwrap();
                drop(guard);
            }
            None => {
                // 无定时重绘:一直睡到被唤醒(新输入 / 停止请求)。
                let guard = state.wake.wait(guard).unwrap();
                drop(guard);
            }
        }
        // 被唤醒后回到循环顶部重新判断(可能是虚假唤醒、新输入、
        // 停止请求或定时重绘到期)。
    }
}

/// 是否有排队等待喂给 egui 的输入(触摸 / 合成事件 / 文本结果)。
fn has_pending_input(state: &AppState) -> bool {
    !state.touches.lock().unwrap().is_empty()
        || !state.extra_events.lock().unwrap().is_empty()
        || !TEXT_BRIDGE.lock().unwrap().results.is_empty()
}

/// 初始化 EGL:默认显示器、透明 RGBA8888 窗口配置、GLES2 上下文、窗口表面。
///
/// 透明通道(`EGL_ALPHA_SIZE 8`)是关键:配合 SurfaceView 的
/// `PixelFormat.TRANSLUCENT` 与 `setZOrderOnTop(true)`,egui 未绘制区域
/// 会在屏幕上呈透明,露出下层兄弟控件。
fn init_egl(window: &NativeWindow) -> Result<EglSession, String> {
    let display = egl::get_display(egl::EGL_DEFAULT_DISPLAY)
        .ok_or_else(|| "eglGetDisplay failed".to_owned())?;
    let mut major = 0;
    let mut minor = 0;
    if !egl::initialize(display, &mut major, &mut minor) {
        return Err("eglInitialize failed".to_owned());
    }

    let attribs = [
        egl::EGL_SURFACE_TYPE,
        egl::EGL_WINDOW_BIT,
        egl::EGL_RENDERABLE_TYPE,
        egl::EGL_OPENGL_ES2_BIT,
        egl::EGL_RED_SIZE,
        8,
        egl::EGL_GREEN_SIZE,
        8,
        egl::EGL_BLUE_SIZE,
        8,
        egl::EGL_ALPHA_SIZE,
        8,
        egl::EGL_NONE,
    ];
    let config = egl::choose_config(display, &attribs, 1)
        .ok_or_else(|| "eglChooseConfig failed".to_owned())?;
    let context = egl::create_context(
        display,
        config,
        egl::EGL_NO_CONTEXT,
        &[egl::EGL_CONTEXT_CLIENT_VERSION, 2, egl::EGL_NONE],
    )
    .ok_or_else(|| "eglCreateContext failed".to_owned())?;
    let surface = egl::create_window_surface(
        display,
        config,
        window.ptr().as_ptr() as egl::EGLNativeWindowType,
        &[],
    )
    .ok_or_else(|| "eglCreateWindowSurface failed".to_owned())?;
    if !egl::make_current(display, surface, surface, context) {
        return Err("eglMakeCurrent failed".to_owned());
    }
    egl::swap_interval(display, 1);
    Ok(EglSession {
        display,
        surface,
        context,
    })
}

/// 一次 EGL 会话持有的资源,统一在此释放。
struct EglSession {
    display: EGLDisplay,
    surface: EGLSurface,
    context: EGLContext,
}

/// 运行时从安卓系统加载 CJK 字体(不内嵌,.so 不包含字体数据)。
///
/// 字体同时注册到 Proportional 与 Monospace 族,保证中文与英文都能显示。
/// 找不到系统字体时返回空字体定义(UI 文字不渲染,但不会崩溃)。
///
/// **进程级缓存**:系统字体(NotoSansCJK-Regular.ttc 约 16MB)只在进程内
/// 首次构建时读盘一次,之后每个渲染会话直接复用。每次回前台都会重建
/// 渲染会话,若不缓存就要反复读盘 + 解析 TTC,导致回前台首帧卡顿。
fn font_definitions() -> &'static egui::FontDefinitions {
    static FONTS: std::sync::OnceLock<egui::FontDefinitions> = std::sync::OnceLock::new();
    FONTS.get_or_init(build_font_definitions)
}

/// 构建字体定义(只执行一次,见 [`font_definitions`])。
fn build_font_definitions() -> egui::FontDefinitions {
    let mut fonts = egui::FontDefinitions::default();
    match load_system_cjk_font() {
        Some((bytes, index)) => {
            let mut data = egui::FontData::from_owned(bytes);
            data.index = index;
            fonts
                .font_data
                .insert("system_cjk".to_owned(), std::sync::Arc::new(data));
            for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
                fonts
                    .families
                    .entry(family)
                    .or_default()
                    .insert(0, "system_cjk".to_owned());
            }
        }
        None => log::error!("renderer: 未找到可用的系统 CJK 字体,UI 文字将无法显示"),
    }
    fonts
}

/// 依次尝试常见安卓系统 CJK 字体路径,返回 `(字体字节, TTC 面索引)`。
///
/// 优先 `/system/fonts/NotoSansCJK-Regular.ttc` 的简体(SC)面;
/// TTC 面索引按文件头里的 `numFonts` 做边界保护——egui 解析越界面索引会直接
/// panic,这里提前钳制到安全范围。
fn load_system_cjk_font() -> Option<(Vec<u8>, u32)> {
    // (路径, 期望的面索引):NotoSansCJK-Regular.ttc 中 SC(简体)通常为第 3 个面
    const CANDIDATES: [(&str, u32); 4] = [
        ("/system/fonts/NotoSansCJK-Regular.ttc", 2),
        ("/system/fonts/NotoSansSC-Regular.otf", 0),
        ("/system/fonts/NotoSansCJKsc-Regular.otf", 0),
        ("/system/fonts/DroidSansFallback.ttf", 0),
    ];
    for (path, want) in CANDIDATES {
        match std::fs::read(path) {
            Ok(bytes) => {
                let index = match ttc_face_count(&bytes) {
                    // TTC 集合:期望面在范围内才用,否则退回 face 0
                    Some(count) if want < count => want,
                    Some(_) => 0,
                    // 单字体(OTF/TTF)
                    None => 0,
                };
                return Some((bytes, index));
            }
            Err(_) => {}
        }
    }
    None
}

/// 解析 TTC 文件头(`ttcf` 魔数 + 版本 + numFonts),返回字体面数量;
/// 不是 TTC(单字体 OTF/TTF)返回 `None`。
fn ttc_face_count(bytes: &[u8]) -> Option<u32> {
    if bytes.len() >= 12 && &bytes[0..4] == b"ttcf" {
        Some(u32::from_be_bytes(bytes[8..12].try_into().ok()?))
    } else {
        None
    }
}