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