Skip to main content

atmos/os_lib/
dom.rs

1#![allow(dead_code)]
2// &str バイト境界スライス禁止はクレート全体で施錠済み(main.rs の deny(clippy::string_slice))。
3
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::string::ToString;
7use alloc::vec::Vec;
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum NodeType {
11    Element {
12        tag_name: String,
13        attributes: BTreeMap<String, String>,
14        classes: Vec<String>,
15        id: Option<String>,
16    },
17    Text(String),
18}
19
20#[derive(Debug, Clone)]
21pub struct Node {
22    pub node_type: NodeType,
23    pub children: Vec<Node>,
24}
25
26impl Node {
27    pub fn new_text(text: String) -> Self {
28        Node {
29            node_type: NodeType::Text(text),
30            children: Vec::new(),
31        }
32    }
33
34    pub fn new_element(name: String, attrs: BTreeMap<String, String>) -> Self {
35        let id = attrs.get("id").cloned();
36        let classes = match attrs.get("class") {
37            Some(class_str) => class_str.split_whitespace().map(String::from).collect(),
38            None => Vec::new(),
39        };
40
41        Node {
42            node_type: NodeType::Element {
43                tag_name: name.to_lowercase(),
44                attributes: attrs,
45                classes,
46                id,
47            },
48            children: Vec::new(),
49        }
50    }
51
52    pub fn new_element_direct(
53        name: String,
54        attrs: BTreeMap<String, String>,
55        id: Option<String>,
56        classes: Vec<String>,
57    ) -> Self {
58        Node {
59            node_type: NodeType::Element {
60                tag_name: name.to_lowercase(),
61                attributes: attrs,
62                classes,
63                id,
64            },
65            children: Vec::new(),
66        }
67    }
68
69    /// 子を持たない `html` ルート要素を返す。`parse_html` が返すルートと同じ形状で、
70    /// DOM ブリッジを真実源とするインクリメンタル再レイアウト時、HTML パースを
71    /// 省略するためのプレースホルダとして使う(描画ツリーはブリッジから再構築される)。
72    pub fn empty_root() -> Self {
73        Node::new_element(String::from("html"), BTreeMap::new())
74    }
75
76    const MAX_SEARCH_DEPTH: usize = 256;
77
78    /// ID による要素探索(深さ優先・最初に一致した参照を返す)。
79    pub fn get_element_by_id(&self, target_id: &str) -> Option<&Node> {
80        self.get_element_by_id_depth(target_id, 0)
81    }
82
83    fn get_element_by_id_depth(&self, target_id: &str, depth: usize) -> Option<&Node> {
84        if depth >= Self::MAX_SEARCH_DEPTH {
85            return None;
86        }
87        if let NodeType::Element { id: Some(id), .. } = &self.node_type {
88            if id == target_id {
89                return Some(self);
90            }
91        }
92        for child in &self.children {
93            if let Some(found) = child.get_element_by_id_depth(target_id, depth + 1) {
94                return Some(found);
95            }
96        }
97        None
98    }
99
100    /// クラス名による要素収集(深さ優先・ゼロアロケーション走査)。
101    pub fn get_elements_by_class_name<'a>(&'a self, target_class: &str, out: &mut Vec<&'a Node>) {
102        self.get_elements_by_class_name_depth(target_class, out, 0);
103    }
104
105    fn get_elements_by_class_name_depth<'a>(
106        &'a self,
107        target_class: &str,
108        out: &mut Vec<&'a Node>,
109        depth: usize,
110    ) {
111        if depth >= Self::MAX_SEARCH_DEPTH {
112            return;
113        }
114        if let NodeType::Element { classes, .. } = &self.node_type {
115            if classes.iter().any(|c| c == target_class) {
116                out.push(self);
117            }
118        }
119        for child in &self.children {
120            child.get_elements_by_class_name_depth(target_class, out, depth + 1);
121        }
122    }
123
124    /// タグ名による要素収集(大文字小文字無視)。
125    pub fn get_elements_by_tag_name<'a>(&'a self, target_tag: &str, out: &mut Vec<&'a Node>) {
126        self.get_elements_by_tag_name_depth(target_tag, out, 0);
127    }
128
129    fn get_elements_by_tag_name_depth<'a>(
130        &'a self,
131        target_tag: &str,
132        out: &mut Vec<&'a Node>,
133        depth: usize,
134    ) {
135        if depth >= Self::MAX_SEARCH_DEPTH {
136            return;
137        }
138        if let NodeType::Element { tag_name, .. } = &self.node_type {
139            if target_tag == "*" || tag_name.eq_ignore_ascii_case(target_tag) {
140                out.push(self);
141            }
142        }
143        for child in &self.children {
144            child.get_elements_by_tag_name_depth(target_tag, out, depth + 1);
145        }
146    }
147
148    /// ツリー内の全テキストを連結して取得する(単一バッファ追記方式)。
149    pub fn text_content(&self) -> String {
150        let mut out = String::new();
151        self.collect_text(&mut out, 0);
152        out
153    }
154
155    fn collect_text(&self, out: &mut String, depth: usize) {
156        if depth >= Self::MAX_SEARCH_DEPTH {
157            return;
158        }
159        match &self.node_type {
160            NodeType::Text(t) => out.push_str(t),
161            NodeType::Element { .. } => {
162                for child in &self.children {
163                    child.collect_text(out, depth + 1);
164                }
165            }
166        }
167    }
168}
169
170/// タグ名を取得するヘルパー
171fn get_tag_name(node: &Node) -> Option<&str> {
172    match &node.node_type {
173        NodeType::Element { tag_name, .. } => Some(tag_name.as_str()),
174        _ => None,
175    }
176}
177
178/// HTMLエンティティをデコードする
179pub fn decode_entities(s: &str) -> String {
180    if !s.contains('&') {
181        return s.to_string();
182    }
183
184    let mut result = String::with_capacity(s.len());
185    let bytes = s.as_bytes();
186    let len = bytes.len();
187    let mut i = 0;
188
189    let legacy_entities: &[(&str, char)] = &[
190        ("trade", '™'),
191        ("pound", '£'),
192        ("cent", '¢'),
193        ("euro", '€'),
194        ("quot", '"'),
195        ("apos", '\''),
196        ("nbsp", ' '),
197        ("copy", '©'),
198        ("amp", '&'),
199        ("yen", '¥'),
200        ("reg", '®'),
201        ("deg", '°'),
202        ("lt", '<'),
203        ("gt", '>'),
204    ];
205
206    while i < len {
207        if bytes[i] == b'&' {
208            // 1. セミコロン付きの標準的なデコードを試みる
209            let max_end = if i + 13 < len { i + 13 } else { len };
210            let mut semi = i + 1;
211            while semi < max_end && bytes[semi] != b';' {
212                semi += 1;
213            }
214            if semi < max_end && bytes[semi] == b';' {
215                if let Some(entity) = s.get(i + 1..semi) {
216                    if let Some(decoded) = decode_named_or_numeric(entity) {
217                        result.push(decoded);
218                        i = semi + 1;
219                        continue;
220                    }
221                }
222            }
223
224            // 2. セミコロン無しのデコードを試みる (HTML5適合)
225            let mut decoded_char = None;
226            let mut consumed_len = 0;
227
228            let rest_bytes = &bytes[i + 1..];
229            for &(name, ch) in legacy_entities {
230                let nbytes = name.as_bytes();
231                if rest_bytes.starts_with(nbytes) {
232                    let next_idx = nbytes.len();
233                    let next_char_ok = if next_idx < rest_bytes.len() {
234                        let next_b = rest_bytes[next_idx];
235                        next_b != b'=' && !next_b.is_ascii_alphanumeric()
236                    } else {
237                        true
238                    };
239                    if next_char_ok {
240                        decoded_char = Some(ch);
241                        consumed_len = 1 + nbytes.len();
242                        break;
243                    }
244                }
245            }
246
247            if let Some(ch) = decoded_char {
248                result.push(ch);
249                i += consumed_len;
250                continue;
251            }
252
253            // 有効なエンティティでなければ '&' をそのまま出力
254            result.push('&');
255            i += 1;
256        } else if bytes[i] < 0x80 {
257            result.push(bytes[i] as char);
258            i += 1;
259        } else {
260            if let Some(c) = s.get(i..).and_then(|sub| sub.chars().next()) {
261                result.push(c);
262                i += c.len_utf8();
263            } else {
264                i += 1;
265            }
266        }
267    }
268
269    result
270}
271
272/// 名前付きエンティティまたは数値参照をデコード(HTML5 Named Character References 準拠)
273fn decode_named_or_numeric(entity: &str) -> Option<char> {
274    // 名前付きエンティティ — HTML5 Named Character References 上位100個および一般的な記号
275    let ch = match entity {
276        // ----- 必須5個 -----
277        "amp"   => Some('&'),
278        "lt"    => Some('<'),
279        "gt"    => Some('>'),
280        "quot"  => Some('"'),
281        "apos"  => Some('\''),
282        // ----- 空白・ノンブレーク -----
283        "nbsp"    => Some('\u{00A0}'), // no-break space
284        "ensp"    => Some('\u{2002}'),
285        "emsp"    => Some('\u{2003}'),
286        "thinsp"  => Some('\u{2009}'),
287        "zwnj"    => Some('\u{200C}'),
288        "zwj"     => Some('\u{200D}'),
289        "lrm"     => Some('\u{200E}'),
290        "rlm"     => Some('\u{200F}'),
291        "shy"     => Some('\u{00AD}'), // soft hyphen
292        // ----- 貨幣・著作権 -----
293        "cent"    => Some('\u{00A2}'),
294        "pound"   => Some('\u{00A3}'),
295        "curren"  => Some('\u{00A4}'),
296        "yen"     => Some('\u{00A5}'),
297        "euro"    => Some('\u{20AC}'),
298        "copy"    => Some('\u{00A9}'),
299        "reg"     => Some('\u{00AE}'),
300        "trade"   => Some('\u{2122}'),
301        "sect"    => Some('\u{00A7}'),
302        "para"    => Some('\u{00B6}'),
303        // ----- 数学記号 -----
304        "deg"     => Some('\u{00B0}'),
305        "plusmn"  => Some('\u{00B1}'),
306        "times"   => Some('\u{00D7}'),
307        "divide"  => Some('\u{00F7}'),
308        "frac12"  => Some('\u{00BD}'),
309        "frac14"  => Some('\u{00BC}'),
310        "frac34"  => Some('\u{00BE}'),
311        "sup1"    => Some('\u{00B9}'),
312        "sup2"    => Some('\u{00B2}'),
313        "sup3"    => Some('\u{00B3}'),
314        "micro"   => Some('\u{00B5}'),
315        "infin"   => Some('\u{221E}'),
316        "minus"   => Some('\u{2212}'),
317        "sdot"    => Some('\u{22C5}'),
318        "middot"  => Some('\u{00B7}'),
319        "permil"  => Some('\u{2030}'),
320        "prime"   => Some('\u{2032}'),
321        "Prime"   => Some('\u{2033}'),
322        "sum"     => Some('\u{2211}'),
323        "prod"    => Some('\u{220F}'),
324        "int"     => Some('\u{222B}'),
325        "radic"   => Some('\u{221A}'),
326        "prop"    => Some('\u{221D}'),
327        "part"    => Some('\u{2202}'),
328        "nabla"   => Some('\u{2207}'),
329        "forall"  => Some('\u{2200}'),
330        "exist"   => Some('\u{2203}'),
331        "empty"   => Some('\u{2205}'),
332        "isin"    => Some('\u{2208}'),
333        "notin"   => Some('\u{2209}'),
334        "ni"      => Some('\u{220B}'),
335        "and"     => Some('\u{2227}'),
336        "or"      => Some('\u{2228}'),
337        "cap"     => Some('\u{2229}'),
338        "cup"     => Some('\u{222A}'),
339        "cong"    => Some('\u{2245}'),
340        "asymp"   => Some('\u{2248}'),
341        "ne"      => Some('\u{2260}'),
342        "equiv"   => Some('\u{2261}'),
343        "le"      => Some('\u{2264}'),
344        "ge"      => Some('\u{2265}'),
345        "sub"     => Some('\u{2282}'),
346        "sup"     => Some('\u{2283}'),
347        "sube"    => Some('\u{2286}'),
348        "supe"    => Some('\u{2287}'),
349        "oplus"   => Some('\u{2295}'),
350        "otimes"  => Some('\u{2297}'),
351        "perp"    => Some('\u{22A5}'),
352        "sim"     => Some('\u{223C}'),
353        "ang"     => Some('\u{2220}'),
354        "not"     => Some('\u{00AC}'),
355        // ----- 地理・記号 -----
356        "ordm"    => Some('\u{00BA}'),
357        "ordf"    => Some('\u{00AA}'),
358        "macr"    => Some('\u{00AF}'),
359        "acute"   => Some('\u{00B4}'),
360        "cedil"   => Some('\u{00B8}'),
361        "uml"     => Some('\u{00A8}'),
362        "iexcl"   => Some('\u{00A1}'),
363        "iquest"  => Some('\u{00BF}'),
364        // ----- 引用符・ダッシュ -----
365        "lsquo"   => Some('\u{2018}'), // 左単一引用
366        "rsquo"   => Some('\u{2019}'), // 右単一引用
367        "sbquo"   => Some('\u{201A}'),
368        "ldquo"   => Some('\u{201C}'), // 左二重引用
369        "rdquo"   => Some('\u{201D}'), // 右二重引用
370        "bdquo"   => Some('\u{201E}'),
371        "laquo"   => Some('\u{00AB}'), // 《
372        "raquo"   => Some('\u{00BB}'), // 》
373        "lsaquo"  => Some('\u{2039}'),
374        "rsaquo"  => Some('\u{203A}'),
375        "ndash"   => Some('\u{2013}'), // en dash
376        "mdash"   => Some('\u{2014}'), // em dash
377        "horbar"  => Some('\u{2015}'),
378        "hellip"  => Some('\u{2026}'), // …
379        "bull"    => Some('\u{2022}'), // •
380        "dagger"  => Some('\u{2020}'),
381        "Dagger"  => Some('\u{2021}'),
382        // ----- 空白・形状 -----
383        "spades"  => Some('\u{2660}'),
384        "clubs"   => Some('\u{2663}'),
385        "hearts"  => Some('\u{2665}'),
386        "diams"   => Some('\u{2666}'),
387        "star"    => Some('\u{22C6}'),
388        "larr"    => Some('\u{2190}'),
389        "uarr"    => Some('\u{2191}'),
390        "rarr"    => Some('\u{2192}'),
391        "darr"    => Some('\u{2193}'),
392        "harr"    => Some('\u{2194}'),
393        "crarr"   => Some('\u{21B5}'),
394        "lArr"    => Some('\u{21D0}'),
395        "uArr"    => Some('\u{21D1}'),
396        "rArr"    => Some('\u{21D2}'),
397        "dArr"    => Some('\u{21D3}'),
398        "hArr"    => Some('\u{21D4}'),
399        "frasl"   => Some('\u{2044}'),
400        "weierp"  => Some('\u{2118}'),
401        "image"   => Some('\u{2111}'),
402        "real"    => Some('\u{211C}'),
403        "alefsym" => Some('\u{2135}'),
404        "oline"   => Some('\u{203E}'),
405        // ----- ラテン文字拡張 -----
406        "Agrave" => Some('\u{00C0}'), "Aacute" => Some('\u{00C1}'),
407        "Acirc"  => Some('\u{00C2}'), "Atilde" => Some('\u{00C3}'),
408        "Auml"   => Some('\u{00C4}'), "Aring"  => Some('\u{00C5}'),
409        "AElig"  => Some('\u{00C6}'), "Ccedil" => Some('\u{00C7}'),
410        "Egrave" => Some('\u{00C8}'), "Eacute" => Some('\u{00C9}'),
411        "Ecirc"  => Some('\u{00CA}'), "Euml"   => Some('\u{00CB}'),
412        "Igrave" => Some('\u{00CC}'), "Iacute" => Some('\u{00CD}'),
413        "Icirc"  => Some('\u{00CE}'), "Iuml"   => Some('\u{00CF}'),
414        "ETH"    => Some('\u{00D0}'), "Ntilde" => Some('\u{00D1}'),
415        "Ograve" => Some('\u{00D2}'), "Oacute" => Some('\u{00D3}'),
416        "Ocirc"  => Some('\u{00D4}'), "Otilde" => Some('\u{00D5}'),
417        "Ouml"   => Some('\u{00D6}'), "Oslash" => Some('\u{00D8}'),
418        "Ugrave" => Some('\u{00D9}'), "Uacute" => Some('\u{00DA}'),
419        "Ucirc"  => Some('\u{00DB}'), "Uuml"   => Some('\u{00DC}'),
420        "Yacute" => Some('\u{00DD}'), "THORN"  => Some('\u{00DE}'),
421        "szlig"  => Some('\u{00DF}'),
422        "agrave" => Some('\u{00E0}'), "aacute" => Some('\u{00E1}'),
423        "acirc"  => Some('\u{00E2}'), "atilde" => Some('\u{00E3}'),
424        "auml"   => Some('\u{00E4}'), "aring"  => Some('\u{00E5}'),
425        "aelig"  => Some('\u{00E6}'), "ccedil" => Some('\u{00E7}'),
426        "egrave" => Some('\u{00E8}'), "eacute" => Some('\u{00E9}'),
427        "ecirc"  => Some('\u{00EA}'), "euml"   => Some('\u{00EB}'),
428        "igrave" => Some('\u{00EC}'), "iacute" => Some('\u{00ED}'),
429        "icirc"  => Some('\u{00EE}'), "iuml"   => Some('\u{00EF}'),
430        "eth"    => Some('\u{00F0}'), "ntilde" => Some('\u{00F1}'),
431        "ograve" => Some('\u{00F2}'), "oacute" => Some('\u{00F3}'),
432        "ocirc"  => Some('\u{00F4}'), "otilde" => Some('\u{00F5}'),
433        "ouml"   => Some('\u{00F6}'), "oslash" => Some('\u{00F8}'),
434        "ugrave" => Some('\u{00F9}'), "uacute" => Some('\u{00FA}'),
435        "ucirc"  => Some('\u{00FB}'), "uuml"   => Some('\u{00FC}'),
436        "yacute" => Some('\u{00FD}'), "thorn"  => Some('\u{00FE}'),
437        "yuml"   => Some('\u{00FF}'),
438        // ----- ギリシャ文字 -----
439        "Alpha"   => Some('\u{0391}'), "Beta"    => Some('\u{0392}'),
440        "Gamma"   => Some('\u{0393}'), "Delta"   => Some('\u{0394}'),
441        "Epsilon" => Some('\u{0395}'), "Zeta"    => Some('\u{0396}'),
442        "Eta"     => Some('\u{0397}'), "Theta"   => Some('\u{0398}'),
443        "Iota"    => Some('\u{0399}'), "Kappa"   => Some('\u{039A}'),
444        "Lambda"  => Some('\u{039B}'), "Mu"      => Some('\u{039C}'),
445        "Nu"      => Some('\u{039D}'), "Xi"      => Some('\u{039E}'),
446        "Omicron" => Some('\u{039F}'), "Pi"      => Some('\u{03A0}'),
447        "Rho"     => Some('\u{03A1}'), "Sigma"   => Some('\u{03A3}'),
448        "Tau"     => Some('\u{03A4}'), "Upsilon" => Some('\u{03A5}'),
449        "Phi"     => Some('\u{03A6}'), "Chi"     => Some('\u{03A7}'),
450        "Psi"     => Some('\u{03A8}'), "Omega"   => Some('\u{03A9}'),
451        "alpha"   => Some('\u{03B1}'), "beta"    => Some('\u{03B2}'),
452        "gamma"   => Some('\u{03B3}'), "delta"   => Some('\u{03B4}'),
453        "epsilon" => Some('\u{03B5}'), "zeta"    => Some('\u{03B6}'),
454        "eta"     => Some('\u{03B7}'), "theta"   => Some('\u{03B8}'),
455        "iota"    => Some('\u{03B9}'), "kappa"   => Some('\u{03BA}'),
456        "lambda"  => Some('\u{03BB}'), "mu"      => Some('\u{03BC}'),
457        "nu"      => Some('\u{03BD}'), "xi"      => Some('\u{03BE}'),
458        "omicron" => Some('\u{03BF}'), "pi"      => Some('\u{03C0}'),
459        "rho"     => Some('\u{03C1}'), "sigmaf"  => Some('\u{03C2}'),
460        "sigma"   => Some('\u{03C3}'), "tau"     => Some('\u{03C4}'),
461        "upsilon" => Some('\u{03C5}'), "phi"     => Some('\u{03C6}'),
462        "chi"     => Some('\u{03C7}'), "psi"     => Some('\u{03C8}'),
463        "omega"   => Some('\u{03C9}'), "thetasym"=> Some('\u{03D1}'),
464        "upsih"   => Some('\u{03D2}'), "piv"     => Some('\u{03D6}'),
465        _ => None,
466    };
467    if ch.is_some() {
468        return ch;
469    }
470
471    // 数値参照
472    if let Some(num_str) = entity.strip_prefix('#') {
473        let code = if num_str.starts_with('x') || num_str.starts_with('X') {
474            // 16進数 &#xHHH;
475            u32::from_str_radix(num_str.get(1..).unwrap_or(""), 16).ok()
476        } else {
477            // 10進数 &#NNN;
478            parse_u32(num_str)
479        };
480        if let Some(c) = code {
481            return char::from_u32(c);
482        }
483    }
484
485    None
486}
487
488/// no_std環境用のu32パーサ
489fn parse_u32(s: &str) -> Option<u32> {
490    let mut result: u32 = 0;
491    if s.is_empty() {
492        return None;
493    }
494    for b in s.bytes() {
495        if !b.is_ascii_digit() {
496            return None;
497        }
498        result = result.checked_mul(10)?.checked_add((b - b'0') as u32)?;
499    }
500    Some(result)
501}
502
503/// `<` の位置から `>` を探す。引用符内の `>` を無視する。
504/// 戻り値: `>` のバイトインデックス(見つからなければ None)
505fn find_tag_end(bytes: &[u8], start: usize) -> Option<usize> {
506    let mut i = start;
507    let mut in_quote: Option<u8> = None;
508
509    while i < bytes.len() {
510        let b = bytes[i];
511        match in_quote {
512            Some(q) => {
513                if b == q {
514                    in_quote = None;
515                }
516            }
517            None => {
518                if b == b'"' || b == b'\'' {
519                    in_quote = Some(b);
520                } else if b == b'>' {
521                    return Some(i);
522                }
523            }
524        }
525        i += 1;
526    }
527    None
528}
529
530/// 開始タグをパースする。タグ名・属性・自己閉じフラグを返す。
531fn parse_opening_tag_full(tag_trim: &str) -> (Node, bool) {
532    let bytes = tag_trim.as_bytes();
533    let mut i = 0usize;
534
535    // 先頭の空白をスキップ
536    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
537        i += 1;
538    }
539
540    // タグ名を読み取り
541    let name_start = i;
542    while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'/' && bytes[i] != b'>'
543    {
544        i += 1;
545    }
546    let name = if name_start < i {
547        String::from(tag_trim.get(name_start..i).unwrap_or(""))
548    } else {
549        String::from("div")
550    };
551
552    let mut attrs = BTreeMap::new();
553    let mut is_self_closing = false;
554
555    while i < bytes.len() {
556        // 空白をスキップ
557        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
558            i += 1;
559        }
560        if i >= bytes.len() {
561            break;
562        }
563
564        // '/' が来たら自己閉じタグの可能性
565        if bytes[i] == b'/' {
566            // 残りが '/' のみ、または '/' の後に空白しかない場合は自己閉じ
567            let rest = tag_trim.get(i + 1..).unwrap_or("").trim();
568            if rest.is_empty() {
569                is_self_closing = true;
570                break;
571            }
572            // そうでなければスキップして次の属性へ
573            i += 1;
574            continue;
575        }
576
577        // 属性キーを読み取り
578        let key_start = i;
579        while i < bytes.len()
580            && !bytes[i].is_ascii_whitespace()
581            && bytes[i] != b'='
582            && bytes[i] != b'/'
583            && bytes[i] != b'>'
584        {
585            i += 1;
586        }
587        if key_start == i {
588            break;
589        }
590        let key = String::from(tag_trim.get(key_start..i).unwrap_or(""));
591
592        // 空白をスキップ
593        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
594            i += 1;
595        }
596
597        let mut value = String::new();
598        if i < bytes.len() && bytes[i] == b'=' {
599            i += 1;
600            // 空白をスキップ
601            while i < bytes.len() && bytes[i].is_ascii_whitespace() {
602                i += 1;
603            }
604
605            if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
606                let quote = bytes[i];
607                i += 1;
608                let val_start = i;
609                while i < bytes.len() && bytes[i] != quote {
610                    i += 1;
611                }
612                value = decode_entities(tag_trim.get(val_start..i).unwrap_or(""));
613                if i < bytes.len() && bytes[i] == quote {
614                    i += 1;
615                }
616            } else {
617                // 引用符なしの値
618                let val_start = i;
619                while i < bytes.len()
620                    && !bytes[i].is_ascii_whitespace()
621                    && bytes[i] != b'/'
622                    && bytes[i] != b'>'
623                {
624                    i += 1;
625                }
626                value = decode_entities(tag_trim.get(val_start..i).unwrap_or(""));
627            }
628        }
629
630        if !key.is_empty() {
631            attrs.insert(key, value);
632        }
633    }
634
635    (Node::new_element(name, attrs), is_self_closing)
636}
637
638/// script/style の終了タグを探してスキップする
639/// `</script>` or `</style>` を大文字小文字無視で探す
640/// 戻り値: 終了タグの `>` の次のバイトインデックス
641fn skip_raw_content(html: &str, start: usize, tag_name: &str) -> usize {
642    let bytes = html.as_bytes();
643    let name_bytes = tag_name.as_bytes();
644    let tlen = 3 + name_bytes.len(); // "</" + name + ">"
645
646    let mut i = start;
647    while i + tlen <= bytes.len() {
648        if bytes[i] == b'<' && bytes[i + 1] == b'/' {
649            let mut matched = true;
650            for (k, &nb) in name_bytes.iter().enumerate() {
651                if bytes[i + 2 + k].to_ascii_lowercase() != nb.to_ascii_lowercase() {
652                    matched = false;
653                    break;
654                }
655            }
656            if matched && bytes[i + 2 + name_bytes.len()] == b'>' {
657                return i + tlen;
658            }
659        }
660        i += 1;
661    }
662    bytes.len()
663}
664
665// HTMLパーサ(スタックベース、改良版・ゼロアロケーション走査)
666pub fn parse_html(html: &str) -> Node {
667    let mut root = Node::new_element(String::from("html"), BTreeMap::new());
668    let mut stack: Vec<Node> = Vec::new();
669
670    let bytes = html.as_bytes();
671    let mut i = 0;
672
673    while i < bytes.len() {
674        if bytes[i] == b'<' {
675            // --- HTMLコメント `<!-- ... -->` の処理 ---
676            if i + 3 < bytes.len()
677                && bytes[i + 1] == b'!'
678                && bytes[i + 2] == b'-'
679                && bytes[i + 3] == b'-'
680            {
681                // `-->` を探す
682                let mut j = i + 4;
683                loop {
684                    if j + 2 >= bytes.len() {
685                        j = bytes.len();
686                        break;
687                    }
688                    if bytes[j] == b'-' && bytes[j + 1] == b'-' && bytes[j + 2] == b'>' {
689                        j += 3;
690                        break;
691                    }
692                    j += 1;
693                }
694                i = j;
695                continue;
696            }
697
698            // --- DOCTYPE や `<!...>` の処理 ---
699            if i + 1 < bytes.len() && bytes[i + 1] == b'!' {
700                if let Some(j) = find_tag_end(bytes, i + 1) {
701                    i = j + 1;
702                } else {
703                    i += 1;
704                }
705                continue;
706            }
707
708            // --- 通常のタグ: 引用符を考慮して `>` を探す ---
709            if let Some(j) = find_tag_end(bytes, i + 1) {
710                let tag_content = html.get(i + 1..j).unwrap_or("");
711                let tag_trim = tag_content.trim();
712
713                if tag_trim.is_empty() {
714                    i = j + 1;
715                    continue;
716                }
717
718                if tag_trim.starts_with('/') {
719                    // --- 終了タグ: タグ名の検証 ---
720                    let close_name = tag_trim
721                        .get(1..)
722                        .unwrap_or("")
723                        .split_whitespace()
724                        .next()
725                        .unwrap_or("")
726                        .to_lowercase();
727
728                    if is_void_tag_name(&close_name) {
729                        i = j + 1;
730                        continue;
731                    }
732
733                    // スタックを巻き戻して一致するタグを探す
734                    let mut found_idx: Option<usize> = None;
735                    for idx in (0..stack.len()).rev() {
736                        if let Some(sname) = get_tag_name(&stack[idx]) {
737                            if sname == close_name {
738                                found_idx = Some(idx);
739                                break;
740                            }
741                        }
742                    }
743
744                    if let Some(target_idx) = found_idx {
745                        // target_idx より上のノードを順に閉じる
746                        while stack.len() > target_idx + 1 {
747                            if let Some(child) = stack.pop() {
748                                if let Some(parent) = stack.last_mut() {
749                                    parent.children.push(child);
750                                } else {
751                                    root.children.push(child);
752                                }
753                            }
754                        }
755                        // 一致したタグ自身を閉じる
756                        if let Some(child) = stack.pop() {
757                            if let Some(parent) = stack.last_mut() {
758                                parent.children.push(child);
759                            } else {
760                                root.children.push(child);
761                            }
762                        }
763                    }
764                    // 一致しなければ終了タグを無視
765                } else {
766                    // --- 開始タグまたは自己閉じタグ ---
767                    let (node, is_self_closing) = parse_opening_tag_full(tag_trim);
768
769                    let tag_name_lower = match &node.node_type {
770                        NodeType::Element { tag_name, .. } => tag_name.clone(),
771                        _ => String::new(),
772                    };
773
774                    let is_void = is_void_tag_name(&tag_name_lower);
775
776                    if is_self_closing || is_void {
777                        // 自己閉じタグ / Void要素
778                        if let Some(parent) = stack.last_mut() {
779                            parent.children.push(node);
780                        } else {
781                            root.children.push(node);
782                        }
783                    } else if matches!(tag_name_lower.as_str(), "script" | "style" | "textarea" | "noscript" | "title") {
784                        // script/style: タグとして再帰解釈はしないが、中身(生テキスト)は
785                        // Text 子ノードとして保持する。以前は完全に読み捨てており、
786                        // .textContent での参照や、DOM ブリッジ経由で再構築された
787                        // <style> のスタイルシート抽出(動的に追加された <style> 等)が
788                        // 一切効かないバグがあった。
789                        let skip_to = skip_raw_content(html, j + 1, &tag_name_lower);
790                        let close_tag_len = 3 + tag_name_lower.len(); // "</" + name + ">"
791                        let content_end = skip_to.saturating_sub(close_tag_len).max(j + 1);
792                        let mut node = node;
793                        if content_end > j + 1 {
794                            let raw_text = html.get(j + 1..content_end).unwrap_or("");
795                            node.children.push(Node::new_text(String::from(raw_text)));
796                        }
797                        if let Some(parent) = stack.last_mut() {
798                            parent.children.push(node);
799                        } else {
800                            root.children.push(node);
801                        }
802                        i = skip_to;
803                        continue;
804                    } else {
805                        // 暗黙のクローズ処理 (自動タグポップ)
806                        // Returns Some(idx) if tag `target` is found in stack before any of `stop_at` tags
807                        fn find_implicitly_closeable(
808                            stack: &[Node],
809                            target: &str,
810                            stop_at: &[&str],
811                        ) -> Option<usize> {
812                            for idx in (0..stack.len()).rev() {
813                                if let Some(sname) = get_tag_name(&stack[idx]) {
814                                    if sname == target {
815                                        return Some(idx);
816                                    }
817                                    if stop_at.contains(&sname) {
818                                        return None;
819                                    }
820                                }
821                            }
822                            None
823                        }
824
825                        fn pop_to(stack: &mut Vec<Node>, root: &mut Node, target_idx: usize) {
826                            while stack.len() > target_idx {
827                                if let Some(child) = stack.pop() {
828                                    if let Some(parent) = stack.last_mut() {
829                                        parent.children.push(child);
830                                    } else {
831                                        root.children.push(child);
832                                    }
833                                }
834                            }
835                        }
836
837                        if tag_name_lower == "li" {
838                            if let Some(idx) =
839                                find_implicitly_closeable(&stack, "li", &["ul", "ol"])
840                            {
841                                pop_to(&mut stack, &mut root, idx);
842                            }
843                        } else if tag_name_lower == "tr" {
844                            if let Some(idx) = find_implicitly_closeable(
845                                &stack,
846                                "tr",
847                                &["table", "thead", "tbody", "tfoot"],
848                            ) {
849                                pop_to(&mut stack, &mut root, idx);
850                            }
851                        } else if matches!(tag_name_lower.as_str(), "tbody" | "thead" | "tfoot" | "caption" | "colgroup") {
852                            // 同じ table 内の直前のセクションを閉じる
853                            if let Some(idx) = find_implicitly_closeable(&stack, "tbody", &["table"])
854                                .or_else(|| find_implicitly_closeable(&stack, "thead", &["table"]))
855                                .or_else(|| find_implicitly_closeable(&stack, "tfoot", &["table"]))
856                                .or_else(|| find_implicitly_closeable(&stack, "caption", &["table"]))
857                                .or_else(|| find_implicitly_closeable(&stack, "colgroup", &["table"]))
858                            {
859                                pop_to(&mut stack, &mut root, idx);
860                            }
861                        } else if matches!(tag_name_lower.as_str(), "rt" | "rp") {
862                            if let Some(idx) = find_implicitly_closeable(&stack, "rt", &["ruby"])
863                                .or_else(|| find_implicitly_closeable(&stack, "rp", &["ruby"]))
864                            {
865                                pop_to(&mut stack, &mut root, idx);
866                            }
867                        } else if matches!(tag_name_lower.as_str(), "td" | "th") {
868                            if let Some(idx) =
869                                find_implicitly_closeable(&stack, &tag_name_lower, &["tr", "table"])
870                            {
871                                pop_to(&mut stack, &mut root, idx);
872                            }
873                        } else if tag_name_lower == "option" {
874                            if let Some(idx) =
875                                find_implicitly_closeable(&stack, "option", &["select", "datalist"])
876                            {
877                                pop_to(&mut stack, &mut root, idx);
878                            }
879                        } else if tag_name_lower == "dt" || tag_name_lower == "dd" {
880                            if let Some(idx) = find_implicitly_closeable(&stack, "dt", &["dl"])
881                                .or_else(|| find_implicitly_closeable(&stack, "dd", &["dl"]))
882                            {
883                                pop_to(&mut stack, &mut root, idx);
884                            }
885                        } else if matches!(
886                            tag_name_lower.as_str(),
887                            "p" | "div"
888                                | "ul"
889                                | "ol"
890                                | "h1"
891                                | "h2"
892                                | "h3"
893                                | "h4"
894                                | "h5"
895                                | "h6"
896                                | "pre"
897                                | "blockquote"
898                                | "section"
899                                | "article"
900                                | "nav"
901                                | "aside"
902                                | "header"
903                                | "footer"
904                                | "main"
905                        ) {
906                            // <p> は inline 要素だけを越えて暗黙クローズ
907                            let mut p_idx = None;
908                            for idx in (0..stack.len()).rev() {
909                                if let Some(sname) = get_tag_name(&stack[idx]) {
910                                    if sname == "p" {
911                                        p_idx = Some(idx);
912                                        break;
913                                    }
914                                    if !matches!(
915                                        sname,
916                                        "a" | "span"
917                                            | "b"
918                                            | "strong"
919                                            | "i"
920                                            | "em"
921                                            | "code"
922                                            | "small"
923                                            | "sub"
924                                            | "sup"
925                                            | "abbr"
926                                            | "cite"
927                                            | "q"
928                                            | "s"
929                                            | "u"
930                                    ) {
931                                        break;
932                                    }
933                                }
934                            }
935                            if let Some(target_idx) = p_idx {
936                                pop_to(&mut stack, &mut root, target_idx);
937                            }
938                        }
939
940                        // テーブル要素の自動補完 (HTML5)
941                        if tag_name_lower == "tr" {
942                            if let Some(parent) = stack.last() {
943                                if let Some(pname) = get_tag_name(parent) {
944                                    if pname == "table" {
945                                        stack.push(Node::new_element(String::from("tbody"), alloc::collections::BTreeMap::new()));
946                                    }
947                                }
948                            }
949                        } else if matches!(tag_name_lower.as_str(), "td" | "th") {
950                            if let Some(parent) = stack.last() {
951                                if let Some(pname) = get_tag_name(parent) {
952                                    if matches!(pname, "table" | "thead" | "tbody" | "tfoot") {
953                                        if pname == "table" {
954                                            stack.push(Node::new_element(String::from("tbody"), alloc::collections::BTreeMap::new()));
955                                        }
956                                        stack.push(Node::new_element(String::from("tr"), alloc::collections::BTreeMap::new()));
957                                    }
958                                }
959                            }
960                        } else if tag_name_lower == "col" {
961                            if let Some(parent) = stack.last() {
962                                if let Some(pname) = get_tag_name(parent) {
963                                    if pname == "table" {
964                                        stack.push(Node::new_element(String::from("colgroup"), alloc::collections::BTreeMap::new()));
965                                    }
966                                }
967                            }
968                        }
969
970                        // 通常の開始タグ
971                        stack.push(node);
972                    }
973                }
974                i = j + 1;
975                continue;
976            } else {
977                // `>` が見つからない場合は `<` をスキップ
978                i += 1;
979                continue;
980            }
981        }
982
983        // --- テキストノード ---
984        let mut j = i;
985        while j < bytes.len() && bytes[j] != b'<' {
986            j += 1;
987        }
988        let text_raw = html.get(i..j).unwrap_or("");
989        let text_decoded = decode_entities(text_raw);
990
991        // スタック内に pre タグがあるかチェック
992        let mut is_inside_pre = false;
993        for idx in (0..stack.len()).rev() {
994            if let Some(sname) = get_tag_name(&stack[idx]) {
995                if sname == "pre" {
996                    is_inside_pre = true;
997                    break;
998                }
999            }
1000        }
1001
1002        if is_inside_pre {
1003            let node = Node::new_text(text_decoded);
1004            if let Some(parent) = stack.last_mut() {
1005                parent.children.push(node);
1006            } else {
1007                root.children.push(node);
1008            }
1009        } else {
1010            let text_trim = text_decoded
1011                .trim_matches(|c: char| c == '\r' || c == '\n')
1012                .trim()
1013                .to_string();
1014            if !text_trim.is_empty() {
1015                let node = Node::new_text(text_trim);
1016                if let Some(parent) = stack.last_mut() {
1017                    parent.children.push(node);
1018                } else {
1019                    root.children.push(node);
1020                }
1021            }
1022        }
1023        i = j;
1024    }
1025
1026    // 残りのスタックを回収
1027    while let Some(node) = stack.pop() {
1028        if let Some(parent) = stack.last_mut() {
1029            parent.children.push(node);
1030        } else {
1031            root.children.push(node);
1032        }
1033    }
1034
1035    root
1036}
1037
1038fn is_void_tag_name(name: &str) -> bool {
1039    matches!(
1040        name,
1041        "br" | "hr"
1042            | "img"
1043            | "input"
1044            | "meta"
1045            | "link"
1046            | "source"
1047            | "area"
1048            | "base"
1049            | "col"
1050            | "embed"
1051            | "param"
1052            | "track"
1053            | "wbr"
1054    )
1055}