Skip to main content

atmos/os_lib/web_engine/
doc_title.rs

1//! 文書タイトル(`<title>`)の抽出(純粋ロジック)。
2//!
3//! DOM 木だけに依存し、ホスト側の試験(`tests/src/test_doc_title.rs`)から
4//! 直接検証できる。
5//!
6//! 【2026-10-04 修正】タブ名が常に "Untitled Page" だった。
7//! `parse_and_layout` が既定値を入れるだけで、`<title>` を読む処理が
8//! どこにも無かった(JS の `document.title` は DOM を直接読むので正しく、
9//! タブ名だけが壊れていた)。
10//!
11//! 仕様は HTML Living Standard の `document.title`:
12//! 文書中で木順に最初の `title` 要素の、子テキストの ASCII 空白を
13//! 除去・畳み込みした値。
14
15use crate::os_lib::dom::{Node, NodeType};
16use alloc::string::String;
17use alloc::vec::Vec;
18
19/// 辿る深さの上限。壊れた HTML の異常な入れ子で時間を食わないため。
20const MAX_DEPTH: usize = 512;
21
22/// 文書タイトルを返す。`<title>` が無い、または空白だけなら `None`。
23///
24/// - 木順で最初の `title` 要素を使う(タグ名は大文字小文字を区別しない)
25/// - `<svg>` の子孫は辿らない。インライン SVG の `<title>` は SVG 名前空間の
26///   別物で、文書タイトルではない(この DOM は名前空間を持たないので
27///   `<svg>` の部分木を除外して近似する)
28/// - 再帰せず明示スタックで辿る(カーネルスレッドのスタックは小さい)
29pub fn document_title(root: &Node) -> Option<String> {
30    let mut stack: Vec<(&Node, usize)> = Vec::new();
31    stack.push((root, 0));
32    while let Some((node, depth)) = stack.pop() {
33        let tag = match &node.node_type {
34            NodeType::Element { tag_name, .. } => tag_name,
35            NodeType::Text(_) => continue,
36        };
37        if tag.eq_ignore_ascii_case("title") {
38            let mut raw = String::new();
39            for child in &node.children {
40                if let NodeType::Text(t) = &child.node_type {
41                    raw.push_str(t);
42                }
43            }
44            let title = collapse_ascii_whitespace(&raw);
45            return if title.is_empty() { None } else { Some(title) };
46        }
47        if tag.eq_ignore_ascii_case("svg") || depth >= MAX_DEPTH {
48            continue;
49        }
50        // 木順(先頭の子から)で辿るため、逆順に積む。
51        for child in node.children.iter().rev() {
52            stack.push((child, depth + 1));
53        }
54    }
55    None
56}
57
58/// ASCII 空白(TAB, LF, FF, CR, SPACE)を前後で除去し、内部の連続を 1 つの
59/// SPACE に畳む。U+3000 などの非 ASCII 空白はそのまま残す(仕様どおり)。
60fn collapse_ascii_whitespace(s: &str) -> String {
61    let mut out = String::with_capacity(s.len());
62    let mut pending_space = false;
63    for c in s.chars() {
64        if matches!(c, '\t' | '\n' | '\x0C' | '\r' | ' ') {
65            pending_space = !out.is_empty();
66        } else {
67            if pending_space {
68                out.push(' ');
69                pending_space = false;
70            }
71            out.push(c);
72        }
73    }
74    out
75}