Skip to main content

atmos/os_lib/web_engine/
helpers.rs

1// web_engine/helpers.rs - Standalone utility functions
2
3use alloc::string::String;
4use alloc::vec::Vec;
5
6pub(super) fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
7    if a.len() != b.len() {
8        return false;
9    }
10    for i in 0..a.len() {
11        if !a[i].eq_ignore_ascii_case(&b[i]) {
12            return false;
13        }
14    }
15    true
16}
17
18/// font-size の値をパース。実体は自己完結モジュール `css_units` にあり、
19/// 仕様は `tests/src/test_css_units.rs` に異常系込みで固定してある。
20pub(crate) use super::css_units::parse_font_size;
21
22pub fn hash_str(s: &str) -> u32 {
23    let mut h = 5381u32;
24    for c in s.bytes() {
25        h = h.wrapping_shl(5).wrapping_add(h).wrapping_add(c as u32);
26    }
27    h
28}
29
30pub fn url_encode(s: &str) -> String {
31    let mut encoded = String::new();
32    for b in s.as_bytes() {
33        match *b {
34            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
35                encoded.push(*b as char);
36            }
37            b' ' => {
38                encoded.push('+');
39            }
40            _ => {
41                encoded.push_str(&alloc::format!("%{:02X}", b));
42            }
43        }
44    }
45    encoded
46}
47
48pub(super) fn resolve_relative_path(base_path: &str, rel_path: &str) -> String {
49    // 【2026-08-27】組み立ての本体は純粋モジュールへ切り出した。
50    // `data:` URI・フラグメント・プロトコル相対 URL を壊していたため
51    // (詳細と試験は `rel_path.rs` / `test_rel_path.rs`)。
52    super::rel_path::resolve_relative_path(base_path, rel_path)
53}
54
55/// list-style-type を抽出。`list-style-type` 優先、無ければ `list-style` ショートハンドから
56/// 既知のキーワードを拾う。未指定は空(デフォルトのマーカー挙動)。
57pub(super) fn extract_list_style(styled: &crate::os_lib::css::StyledNode) -> super::ListStyleType {
58    if let Some(t) = styled.specified_values.get("list-style-type") {
59        return super::ListStyleType::parse(t);
60    }
61    if let Some(s) = styled.specified_values.get("list-style") {
62        for tok in s.split_whitespace() {
63            // `ListStyleType::parse` に直接委譲することで、longhand と同じキーワード集合
64            // (upper-alpha/lower-roman 等を含む)を自動的にサポートする。`inside`/`outside`/
65            // `url(...)` 等の list-style-type ではないトークンは Unset になるので読み飛ばす。
66            let parsed = super::ListStyleType::parse(tok);
67            if parsed != super::ListStyleType::Unset {
68                return parsed;
69            }
70        }
71    }
72    super::ListStyleType::Unset
73}
74
75/// list-style-position を抽出。`list-style-position` 優先、無ければ `list-style` ショートハンド
76/// から拾う。既定は "outside"(true を返せば inside)。
77pub(super) fn extract_list_style_inside(styled: &crate::os_lib::css::StyledNode) -> bool {
78    if let Some(p) = styled.specified_values.get("list-style-position") {
79        return p.trim().eq_ignore_ascii_case("inside");
80    }
81    if let Some(s) = styled.specified_values.get("list-style") {
82        for tok in s.split_whitespace() {
83            if tok.eq_ignore_ascii_case("inside") {
84                return true;
85            }
86        }
87    }
88    false
89}
90
91/// `list-style-image: url(...)` を抽出。`list-style-image` 優先、無ければ `list-style`
92/// ショートハンド中の `url(...)` トークンを拾う。未指定/`none` は空文字(従来通り
93/// list-style-type のグリフを使う)。
94pub(super) fn extract_list_style_image(styled: &crate::os_lib::css::StyledNode) -> String {
95    if let Some(v) = styled.specified_values.get("list-style-image") {
96        if v.trim().eq_ignore_ascii_case("none") {
97            return String::new();
98        }
99        if let Some(path) = extract_url_path(v) {
100            return path;
101        }
102    }
103    if let Some(s) = styled.specified_values.get("list-style") {
104        if let Some(path) = extract_url_path(s) {
105            return path;
106        }
107    }
108    String::new()
109}
110
111/// `background-size` を解析。未指定/解析不能なら Stretch(従来互換の全面引き伸ばし)。
112pub(super) fn parse_bg_size(v: Option<&str>) -> super::BgSizeMode {
113    use super::{BgSizeMode, BgSizeVal};
114    fn parse_val(tok: &str) -> BgSizeVal {
115        let t = tok.trim();
116        if t == "auto" {
117            BgSizeVal::Auto
118        } else if let Some(pct) = t.strip_suffix('%') {
119            pct.parse::<f32>()
120                .map(|p| BgSizeVal::Percent(p / 100.0))
121                .unwrap_or(BgSizeVal::Auto)
122        } else if let Some(px) = t.strip_suffix("px") {
123            px.parse::<i32>().map(BgSizeVal::Px).unwrap_or(BgSizeVal::Auto)
124        } else {
125            BgSizeVal::Auto
126        }
127    }
128    let Some(v) = v else {
129        return BgSizeMode::Stretch;
130    };
131    let v = v.trim().to_ascii_lowercase();
132    match v.as_str() {
133        "cover" => BgSizeMode::Cover,
134        "contain" => BgSizeMode::Contain,
135        "" | "auto" => BgSizeMode::Stretch,
136        _ => {
137            let toks: Vec<&str> = v.split_whitespace().collect();
138            match toks.len() {
139                1 => BgSizeMode::Explicit(parse_val(toks[0]), BgSizeVal::Auto),
140                2 => BgSizeMode::Explicit(parse_val(toks[0]), parse_val(toks[1])),
141                _ => BgSizeMode::Stretch,
142            }
143        }
144    }
145}
146
147/// `background-repeat` を解析。(repeat_x, repeat_y) を返す。未指定は
148/// (Repeat, Repeat)(CSS既定)。`repeat-x`/`repeat-y` は片軸 no-repeat のショートハンド。
149/// `<x> <y>` の2値構文(例: `repeat space`, `round no-repeat`)にも対応する。
150pub(crate) fn parse_bg_repeat(v: Option<&str>) -> (super::BgRepeatMode, super::BgRepeatMode) {
151    use super::BgRepeatMode;
152    fn parse_one(t: &str) -> BgRepeatMode {
153        match t {
154            "no-repeat" => BgRepeatMode::NoRepeat,
155            "space" => BgRepeatMode::Space,
156            "round" => BgRepeatMode::Round,
157            _ => BgRepeatMode::Repeat,
158        }
159    }
160    let Some(v) = v else {
161        return (BgRepeatMode::Repeat, BgRepeatMode::Repeat);
162    };
163    let v = v.trim().to_ascii_lowercase();
164    match v.as_str() {
165        "no-repeat" => (BgRepeatMode::NoRepeat, BgRepeatMode::NoRepeat),
166        "repeat-x" => (BgRepeatMode::Repeat, BgRepeatMode::NoRepeat),
167        "repeat-y" => (BgRepeatMode::NoRepeat, BgRepeatMode::Repeat),
168        "repeat" => (BgRepeatMode::Repeat, BgRepeatMode::Repeat),
169        _ => {
170            let toks: Vec<&str> = v.split_whitespace().collect();
171            match toks.len() {
172                2 => (parse_one(toks[0]), parse_one(toks[1])),
173                1 => {
174                    let m = parse_one(toks[0]);
175                    (m, m)
176                }
177                _ => (BgRepeatMode::Repeat, BgRepeatMode::Repeat),
178            }
179        }
180    }
181}
182
183/// `background-position` を解析。(x_frac, y_frac) の 0.0-1.0 アンカーを返す。未指定は (0,0)。
184pub(super) fn parse_bg_position(v: Option<&str>) -> (f32, f32) {
185    fn parse_axis(tok: &str, is_x: bool) -> f32 {
186        let t = tok.trim();
187        match t {
188            "left" | "top" => 0.0,
189            "center" => 0.5,
190            "right" | "bottom" => 1.0,
191            _ => {
192                if let Some(pct) = t.strip_suffix('%') {
193                    pct.parse::<f32>().map(|p| p / 100.0).unwrap_or(0.0)
194                } else {
195                    let _ = is_x;
196                    0.0
197                }
198            }
199        }
200    }
201    let Some(v) = v else {
202        return (0.0, 0.0);
203    };
204    let toks: Vec<&str> = v.split_whitespace().collect();
205    match toks.len() {
206        1 => (parse_axis(toks[0], true), 0.5),
207        2 => {
208            // CSS では `top left` のように y軸→x軸の逆順キーワード指定も許される
209            // (`left top` と同じ意味)。以前は常に「1つ目=x, 2つ目=y」の位置決め打ちだったため、
210            // `top right` のような逆順+非対称な組み合わせで x/y が入れ替わって誤った位置になる
211            // バグがあった(`top`/`bottom` と `left`/`right` がどちらも 0.0/1.0 に丸め込まれる
212            // ため `top left`/`left top` のような対称な組み合わせでは症状が隠れていた)。
213            let first_is_y_kw = matches!(toks[0].trim(), "top" | "bottom");
214            let second_is_x_kw = matches!(toks[1].trim(), "left" | "right");
215            if first_is_y_kw || second_is_x_kw {
216                (parse_axis(toks[1], true), parse_axis(toks[0], false))
217            } else {
218                (parse_axis(toks[0], true), parse_axis(toks[1], false))
219            }
220        }
221        _ => (0.0, 0.0),
222    }
223}
224
225/// `text-decoration` ショートハンド(`underline wavy red` のように line/style/color を
226/// 空白区切り任意順で並べられる)を解析する。戻り値: (underline指定, line-through指定,
227/// overline指定, none指定, style, color)。line キーワードが無いトークン列の場合、対応する
228/// bool は false のまま返す(呼び出し側で「未指定」と区別せず既存状態を保つかは呼び出し側の
229/// 判断に委ねる)。`blink` は認識するのみで描画には反映しない(点滅テキストは非対応)。
230pub(crate) fn parse_text_decoration_shorthand(
231    value: &str,
232) -> (bool, bool, bool, bool, Option<String>, Option<u32>) {
233    let v = value.trim().to_lowercase();
234    if v == "none" {
235        return (false, false, false, true, None, None);
236    }
237    let mut underline = false;
238    let mut line_through = false;
239    let mut overline = false;
240    let mut style: Option<String> = None;
241    let mut color: Option<u32> = None;
242    for tok in v.split_whitespace() {
243        match tok {
244            "underline" => underline = true,
245            "line-through" => line_through = true,
246            "overline" => overline = true,
247            "blink" => {} // 点滅テキストは非対応につき認識のみ
248            "solid" | "dotted" | "dashed" | "wavy" | "double" => style = Some(String::from(tok)),
249            _ => {
250                if let Some(c) = crate::os_lib::css::parse_color(tok) {
251                    color = Some(c);
252                }
253            }
254        }
255    }
256    (underline, line_through, overline, false, style, color)
257}
258
259/// `outline`(ショートハンド or 個別プロパティ)から (color, width_px, style) を抽出する。
260/// style は `dashed`/`dotted` のみ区別し、それ以外(solid 含む)は "solid" 扱いにする
261/// 簡略実装。`outline-style:none` や幅0は無効。
262pub(super) fn parse_outline(styled: &crate::os_lib::css::StyledNode) -> Option<(u32, i32, String)> {
263    fn parse_width_tok(t: &str) -> Option<i32> {
264        match t {
265            "thin" => Some(1),
266            "medium" => Some(3),
267            "thick" => Some(5),
268            _ => t
269                .strip_suffix("px")
270                .and_then(|n| n.trim().parse::<i32>().ok()),
271        }
272    }
273    let mut color: Option<u32> = None;
274    let mut width: Option<i32> = None;
275    let mut has_none_style = false;
276    let mut style = String::from("solid");
277
278    if let Some(w) = styled.specified_values.get("outline-width") {
279        width = parse_width_tok(w.trim());
280    }
281    if let Some(c) = styled.specified_values.get("outline-color") {
282        color = crate::os_lib::css::parse_color(c);
283    }
284    if let Some(s) = styled.specified_values.get("outline-style") {
285        let s_l = s.trim().to_lowercase();
286        // `hidden` は `none` と同様に描画しない(CSS3 outline-style の仕様通り。
287        // border-style の `hidden` 縮退と同じ方針)。groove/ridge/inset/outset は
288        // 立体的な描画が非対応なので border-style と同様 solid へ縮退させる。
289        if s_l == "none" || s_l == "hidden" {
290            has_none_style = true;
291        } else if matches!(s_l.as_str(), "dashed" | "dotted") {
292            style = s_l;
293        } else if matches!(s_l.as_str(), "groove" | "ridge" | "inset" | "outset") {
294            style = String::from("solid");
295        }
296    }
297    if let Some(shorthand) = styled.specified_values.get("outline") {
298        let s = shorthand.trim();
299        if s.eq_ignore_ascii_case("none") {
300            has_none_style = true;
301        } else {
302            for tok in s.split_whitespace() {
303                if let Some(w) = parse_width_tok(tok) {
304                    width = width.or(Some(w));
305                } else if let Some(c) = crate::os_lib::css::parse_color(tok) {
306                    color = color.or(Some(c));
307                } else if tok.eq_ignore_ascii_case("hidden") {
308                    has_none_style = true;
309                } else if matches!(tok, "dashed" | "dotted") {
310                    style = String::from(tok);
311                } else if matches!(tok, "groove" | "ridge" | "inset" | "outset") {
312                    style = String::from("solid");
313                }
314            }
315        }
316    }
317
318    if has_none_style {
319        return None;
320    }
321    let w = width.unwrap_or(3); // outline-width 未指定時の UA 既定は "medium"(≈3px)
322    let c = color?; // 色未指定なら描画しない(currentColor 相当の解決は簡易実装のため非対応)
323    if w <= 0 {
324        return None;
325    }
326    Some((c, w, style))
327}
328
329/// `object-fit` を解析。未指定/不明値は Fill(従来互換の全面引き伸ばし)。
330pub(super) fn parse_object_fit(v: Option<&str>) -> super::ObjectFit {
331    use super::ObjectFit;
332    match v.map(|s| s.trim().to_ascii_lowercase()) {
333        Some(ref s) if s == "contain" => ObjectFit::Contain,
334        Some(ref s) if s == "cover" => ObjectFit::Cover,
335        Some(ref s) if s == "none" => ObjectFit::None,
336        Some(ref s) if s == "scale-down" => ObjectFit::ScaleDown,
337        _ => ObjectFit::Fill,
338    }
339}
340
341/// `object-position` を解析。background-position と同じキーワード/割合構文だが、
342/// 未指定時の既定は (0.5, 0.5)(中央)で background-position の (0,0) とは異なる。
343pub(super) fn parse_object_position(v: Option<&str>) -> (f32, f32) {
344    match v {
345        Some(_) => parse_bg_position(v),
346        None => (0.5, 0.5),
347    }
348}
349
350/// `transform-origin` を解析。background-position と同じキーワード/割合構文だが、
351/// 未指定時の既定は (0.5, 0.5)(中央。CSS仕様どおり)。
352pub(super) fn parse_transform_origin(v: Option<&str>) -> (f32, f32) {
353    match v {
354        Some(_) => parse_bg_position(v),
355        None => (0.5, 0.5),
356    }
357}
358
359pub(super) fn get_base_path(path: &str) -> String {
360    if path == "/" {
361        return String::from("/");
362    }
363    if let Some(last_slash_idx) = path.rfind('/') {
364        if last_slash_idx == 0 {
365            String::from("/")
366        } else {
367            String::from(path.get(..=last_slash_idx).unwrap_or("/"))
368        }
369    } else {
370        String::from("/")
371    }
372}
373
374pub(super) fn resolve_dom_paths(node: &mut crate::os_lib::dom::Node, base_path: &str) {
375    if let crate::os_lib::dom::NodeType::Element {
376        tag_name,
377        attributes,
378        ..
379    } = &mut node.node_type
380    {
381        if tag_name == "picture" {
382            // <picture> 直下の最初の <source srcset="..."> から img.src を解決する
383            let mut source_url = None;
384            for child in &node.children {
385                if let crate::os_lib::dom::NodeType::Element { tag_name: c_tag, attributes: c_attrs, .. } = &child.node_type {
386                    if c_tag == "source" {
387                        if let Some(srcset) = c_attrs.get("srcset").or_else(|| c_attrs.get("src")) {
388                            // srcset="url 1x, url 2x" などの簡易パース (最初のURLだけ取る)
389                            let url = srcset.split_whitespace().next().unwrap_or(srcset);
390                            source_url = Some(String::from(url));
391                            break;
392                        }
393                    }
394                }
395            }
396            if let Some(url) = source_url {
397                for child in &mut node.children {
398                    if let crate::os_lib::dom::NodeType::Element { tag_name: c_tag, attributes: c_attrs, .. } = &mut child.node_type {
399                        if c_tag == "img" {
400                            c_attrs.insert(String::from("src"), resolve_relative_path(base_path, &url));
401                            break;
402                        }
403                    }
404                }
405            }
406        } else if tag_name == "video" || tag_name == "audio" {
407            // 直下の <source> 要素群から W3C Resource selection algorithm に基づき最適な src を解決する
408            let parent_src = attributes.get("src").map(|s| s.as_str());
409            let mut candidates = Vec::new();
410            for child in &node.children {
411                if let crate::os_lib::dom::NodeType::Element { tag_name: c_tag, attributes: c_attrs, .. } = &child.node_type {
412                    if c_tag == "source" {
413                        if let Some(src) = c_attrs.get("src") {
414                            candidates.push(super::media_controls::MediaSourceCandidate {
415                                src: src.clone(),
416                                type_attr: c_attrs.get("type").cloned(),
417                                media_attr: c_attrs.get("media").cloned(),
418                            });
419                        }
420                    }
421                }
422            }
423            if let Some(selected) = super::media_controls::select_media_source(
424                tag_name,
425                parent_src,
426                &candidates,
427                1024,
428                768,
429            ) {
430                attributes.insert(String::from("src"), resolve_relative_path(base_path, &selected));
431            }
432            if tag_name == "video" {
433                if let Some(src) = attributes.get_mut("src") {
434                    *src = resolve_relative_path(base_path, src);
435                }
436                if let Some(video_src) = attributes.get_mut("video_src") {
437                    *video_src = resolve_relative_path(base_path, video_src);
438                }
439            }
440        } else if tag_name == "img" {
441            if let Some(src) = attributes.get_mut("src") {
442                *src = resolve_relative_path(base_path, src);
443            }
444        } else if tag_name == "a" || tag_name == "link" {
445            if let Some(href) = attributes.get_mut("href") {
446                *href = resolve_relative_path(base_path, href);
447            }
448        } else if tag_name == "script" {
449            if let Some(src) = attributes.get_mut("src") {
450                *src = resolve_relative_path(base_path, src);
451            }
452        }
453    }
454    for child in &mut node.children {
455        resolve_dom_paths(child, base_path);
456    }
457}
458
459///// CSS transform プロパティを (tx, ty, scale_x, scale_y, rotate_deg, skew_x, skew_y) にパース。
460/// 幅・高さ・フォントサイズを受け取り、パーセンテージ(要素サイズ基準)や em、各種角度単位(deg, grad, rad, turn)を解決する。
461pub(super) fn parse_transform_with_size(
462    s: Option<&str>,
463    elem_w: f32,
464    elem_h: f32,
465    font_px: f32,
466) -> (i32, i32, f32, f32, f32, f32, f32) {
467    super::css_units::parse_transform_with_size(s, elem_w, elem_h, font_px)
468}
469
470/// 後方互換性ラッパー(サイズ未指定時は 0 基準、16px フォントサイズで解決)。
471pub(super) fn parse_transform(s: Option<&str>) -> (i32, i32, f32, f32, f32, f32, f32) {
472    super::css_units::parse_transform_with_size(s, 0.0, 0.0, 16.0)
473}
474
475
476pub(super) fn parse_px_val(s: &str) -> i32 {
477    if let Some(n) = s.strip_suffix("px") {
478        n.trim().parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
479    } else if let Some(n) = s.strip_suffix('%') {
480        n.trim().parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
481    } else {
482        s.parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
483    }
484}
485
486pub(super) fn extract_url_path(s: &str) -> Option<String> {
487    let t = s.trim();
488    if let Some(start_idx) = t.find("url(") {
489        let after_url = t.get(start_idx + 4..).unwrap_or("");
490        if let Some(end_idx) = after_url.find(')') {
491            let path_raw = after_url.get(..end_idx).unwrap_or("");
492            let path_clean = path_raw
493                .trim_matches(|c| c == '\'' || c == '"' || c == '`')
494                .trim();
495            return Some(String::from(path_clean));
496        }
497    }
498    None
499}
500
501/// `background-image: url(a.png), url(b.png)` のような複数レイヤーから
502/// 全ての `url(...)` パスを出現順(CSS の重なり順どおり先頭が最前面)で抽出する。
503pub(crate) fn extract_all_url_paths(s: &str) -> alloc::vec::Vec<alloc::string::String> {
504    let mut out = alloc::vec::Vec::new();
505    let mut rest = s;
506    while let Some(start_idx) = rest.find("url(") {
507        let after_url = rest.get(start_idx + 4..).unwrap_or("");
508        let Some(end_idx) = after_url.find(')') else {
509            break;
510        };
511        let path_raw = after_url.get(..end_idx).unwrap_or("");
512        let path_clean = path_raw
513            .trim_matches(|c| c == '\'' || c == '"' || c == '`')
514            .trim();
515        if !path_clean.is_empty() {
516            out.push(alloc::string::String::from(path_clean));
517        }
518        rest = after_url.get(end_idx + 1..).unwrap_or("");
519    }
520    out
521}