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 src="..."> から src を解決する
408            let mut source_url = None;
409            for child in &node.children {
410                if let crate::os_lib::dom::NodeType::Element { tag_name: c_tag, attributes: c_attrs, .. } = &child.node_type {
411                    if c_tag == "source" {
412                        if let Some(src) = c_attrs.get("src") {
413                            source_url = Some(src.clone());
414                            break;
415                        }
416                    }
417                }
418            }
419            if let Some(url) = source_url {
420                attributes.insert(String::from("src"), resolve_relative_path(base_path, &url));
421            }
422            if tag_name == "video" {
423                if let Some(src) = attributes.get_mut("src") {
424                    *src = resolve_relative_path(base_path, src);
425                }
426                if let Some(video_src) = attributes.get_mut("video_src") {
427                    *video_src = resolve_relative_path(base_path, video_src);
428                }
429            }
430        } else if tag_name == "img" {
431            if let Some(src) = attributes.get_mut("src") {
432                *src = resolve_relative_path(base_path, src);
433            }
434        } else if tag_name == "a" || tag_name == "link" {
435            if let Some(href) = attributes.get_mut("href") {
436                *href = resolve_relative_path(base_path, href);
437            }
438        } else if tag_name == "script" {
439            if let Some(src) = attributes.get_mut("src") {
440                *src = resolve_relative_path(base_path, src);
441            }
442        }
443    }
444    for child in &mut node.children {
445        resolve_dom_paths(child, base_path);
446    }
447}
448
449/// CSS transform プロパティを (tx, ty, scale_x, scale_y, rotate_deg, skew_x, skew_y) に簡易パース。
450pub(super) fn parse_transform(s: Option<&str>) -> (i32, i32, f32, f32, f32, f32, f32) {
451    let s = match s {
452        Some(v) if v.trim() != "none" => v.trim(),
453        _ => return (0, 0, 1.0, 1.0, 0.0, 0.0, 0.0),
454    };
455    let mut tx = 0i32;
456    let mut ty = 0i32;
457    let mut scale_x = 1.0f32;
458    let mut scale_y = 1.0f32;
459    let mut rotate = 0.0f32;
460    let mut skew_x = 0.0f32;
461    let mut skew_y = 0.0f32;
462    // 関数列を順番に処理
463    let mut rest = s;
464    while !rest.is_empty() {
465        rest = rest.trim_start();
466        if let Some(after) = rest.strip_prefix("translateX(") {
467            if let Some((inside, after_rest)) = after.split_once(')') {
468                let val = inside.trim();
469                tx += parse_px_val(val);
470                rest = after_rest;
471            } else {
472                break;
473            }
474        } else if let Some(after) = rest.strip_prefix("translateY(") {
475            if let Some((inside, after_rest)) = after.split_once(')') {
476                let val = inside.trim();
477                ty += parse_px_val(val);
478                rest = after_rest;
479            } else {
480                break;
481            }
482        } else if let Some(after) = rest.strip_prefix("translate(") {
483            if let Some((args, after_rest)) = after.split_once(')') {
484                let mut it = args.splitn(2, ',');
485                tx += parse_px_val(it.next().unwrap_or("").trim());
486                ty += parse_px_val(it.next().unwrap_or("0").trim());
487                rest = after_rest;
488            } else {
489                break;
490            }
491        } else if let Some(after) = rest.strip_prefix("translate3d(") {
492            if let Some((args, after_rest)) = after.split_once(')') {
493                let mut it = args.splitn(3, ',');
494                tx += parse_px_val(it.next().unwrap_or("").trim());
495                ty += parse_px_val(it.next().unwrap_or("0").trim());
496                rest = after_rest;
497            } else {
498                break;
499            }
500        } else if let Some(after) = rest.strip_prefix("scale(") {
501            if let Some((inside, after_rest)) = after.split_once(')') {
502                let mut it = inside.split(',');
503                let vx = it.next().unwrap_or("1").trim().parse::<f32>().unwrap_or(1.0);
504                // 第2引数省略時は CSS 仕様どおり scale(x) === scale(x, x)
505                let vy = it
506                    .next()
507                    .map(|v| v.trim().parse::<f32>().unwrap_or(vx))
508                    .unwrap_or(vx);
509                scale_x *= vx;
510                scale_y *= vy;
511                rest = after_rest;
512            } else {
513                break;
514            }
515        } else if let Some(after) = rest.strip_prefix("scaleX(") {
516            if let Some((inside, after_rest)) = after.split_once(')') {
517                scale_x *= inside.trim().parse::<f32>().unwrap_or(1.0);
518                rest = after_rest;
519            } else {
520                break;
521            }
522        } else if let Some(after) = rest.strip_prefix("scaleY(") {
523            if let Some((inside, after_rest)) = after.split_once(')') {
524                scale_y *= inside.trim().parse::<f32>().unwrap_or(1.0);
525                rest = after_rest;
526            } else {
527                break;
528            }
529        } else if let Some(after) = rest.strip_prefix("rotate(") {
530            if let Some((inside, after_rest)) = after.split_once(')') {
531                let val = inside.trim();
532                rotate += if let Some(deg) = val.strip_suffix("deg") {
533                    deg.trim().parse::<f32>().unwrap_or(0.0)
534                } else if let Some(rad) = val.strip_suffix("rad") {
535                    rad.trim().parse::<f32>().unwrap_or(0.0) * 57.295_78
536                } else {
537                    val.parse::<f32>().unwrap_or(0.0)
538                };
539                rest = after_rest;
540            } else {
541                break;
542            }
543        } else if let Some(after) = rest.strip_prefix("matrix(") {
544            if let Some((args_str, after_rest)) = after.split_once(')') {
545                // matrix(a,b,c,d,e,f) → e=tx, f=ty
546                let args: Vec<&str> = args_str.split(',').collect();
547                tx += args
548                    .get(4)
549                    .and_then(|v| v.trim().parse::<f32>().ok())
550                    .map(|v| v as i32)
551                    .unwrap_or(0);
552                ty += args
553                    .get(5)
554                    .and_then(|v| v.trim().parse::<f32>().ok())
555                    .map(|v| v as i32)
556                    .unwrap_or(0);
557                rest = after_rest;
558            } else {
559                break;
560            }
561        } else if let Some(after) = rest.strip_prefix("skewX(") {
562            if let Some((inside, after_rest)) = after.split_once(')') {
563                let val = inside.trim();
564                skew_x += if let Some(deg) = val.strip_suffix("deg") {
565                    deg.trim().parse::<f32>().unwrap_or(0.0)
566                } else if let Some(rad) = val.strip_suffix("rad") {
567                    rad.trim().parse::<f32>().unwrap_or(0.0) * 57.295_78
568                } else {
569                    val.parse::<f32>().unwrap_or(0.0)
570                };
571                rest = after_rest;
572            } else {
573                break;
574            }
575        } else if let Some(after) = rest.strip_prefix("skewY(") {
576            if let Some((inside, after_rest)) = after.split_once(')') {
577                let val = inside.trim();
578                skew_y += if let Some(deg) = val.strip_suffix("deg") {
579                    deg.trim().parse::<f32>().unwrap_or(0.0)
580                } else if let Some(rad) = val.strip_suffix("rad") {
581                    rad.trim().parse::<f32>().unwrap_or(0.0) * 57.295_78
582                } else {
583                    val.parse::<f32>().unwrap_or(0.0)
584                };
585                rest = after_rest;
586            } else {
587                break;
588            }
589        } else if let Some(after) = rest.strip_prefix("skew(") {
590            if let Some((args, after_rest)) = after.split_once(')') {
591                let mut it = args.splitn(2, ',');
592                let ax_str = it.next().unwrap_or("").trim();
593                skew_x += if let Some(deg) = ax_str.strip_suffix("deg") {
594                    deg.trim().parse::<f32>().unwrap_or(0.0)
595                } else if let Some(rad) = ax_str.strip_suffix("rad") {
596                    rad.trim().parse::<f32>().unwrap_or(0.0) * 57.295_78
597                } else {
598                    ax_str.parse::<f32>().unwrap_or(0.0)
599                };
600                if let Some(ay_str) = it.next() {
601                    let ay_str = ay_str.trim();
602                    skew_y += if let Some(deg) = ay_str.strip_suffix("deg") {
603                        deg.trim().parse::<f32>().unwrap_or(0.0)
604                    } else if let Some(rad) = ay_str.strip_suffix("rad") {
605                        rad.trim().parse::<f32>().unwrap_or(0.0) * 57.295_78
606                    } else {
607                        ay_str.parse::<f32>().unwrap_or(0.0)
608                    };
609                }
610                rest = after_rest;
611            } else {
612                break;
613            }
614        } else {
615            // 未知の関数: 閉じ括弧まで skip
616            if let Some((_, after_rest)) = rest.split_once(')') {
617                rest = after_rest;
618            } else {
619                break;
620            }
621        }
622    }
623    (tx, ty, scale_x, scale_y, rotate, skew_x, skew_y)
624}
625
626pub(super) fn parse_px_val(s: &str) -> i32 {
627    if let Some(n) = s.strip_suffix("px") {
628        n.trim().parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
629    } else if let Some(n) = s.strip_suffix('%') {
630        n.trim().parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
631    } else {
632        s.parse::<f32>().ok().map(|v| v as i32).unwrap_or(0)
633    }
634}
635
636pub(super) fn extract_url_path(s: &str) -> Option<String> {
637    let t = s.trim();
638    if let Some(start_idx) = t.find("url(") {
639        let after_url = t.get(start_idx + 4..).unwrap_or("");
640        if let Some(end_idx) = after_url.find(')') {
641            let path_raw = after_url.get(..end_idx).unwrap_or("");
642            let path_clean = path_raw
643                .trim_matches(|c| c == '\'' || c == '"' || c == '`')
644                .trim();
645            return Some(String::from(path_clean));
646        }
647    }
648    None
649}
650
651/// `background-image: url(a.png), url(b.png)` のような複数レイヤーから
652/// 全ての `url(...)` パスを出現順(CSS の重なり順どおり先頭が最前面)で抽出する。
653pub(crate) fn extract_all_url_paths(s: &str) -> alloc::vec::Vec<alloc::string::String> {
654    let mut out = alloc::vec::Vec::new();
655    let mut rest = s;
656    while let Some(start_idx) = rest.find("url(") {
657        let after_url = rest.get(start_idx + 4..).unwrap_or("");
658        let Some(end_idx) = after_url.find(')') else {
659            break;
660        };
661        let path_raw = after_url.get(..end_idx).unwrap_or("");
662        let path_clean = path_raw
663            .trim_matches(|c| c == '\'' || c == '"' || c == '`')
664            .trim();
665        if !path_clean.is_empty() {
666            out.push(alloc::string::String::from(path_clean));
667        }
668        rest = after_url.get(end_idx + 1..).unwrap_or("");
669    }
670    out
671}