Skip to main content

atmos/os_lib/css/
shorthand.rs

1// 分割: css.rs より機械的に移動(2026-07-16 リファクタ フェーズ3)。
2// ロジック不変。可視性のみ pub(crate) へ昇格し、親が pub(crate) use で再エクスポート。
3use super::*;
4
5pub(crate) fn parse_border_shorthand(s: &str) -> (Option<String>, Option<String>, Option<String>) {
6    let mut width = None;
7    let mut style = None;
8    let mut color = None;
9
10    for token in s.split_whitespace() {
11        let t = token.trim();
12        if t.is_empty() {
13            continue;
14        }
15        let t_lower = t.to_lowercase();
16
17        // 1. スタイルの判定。`BorderStyle` enum は None/Solid/Dashed/Dotted/Double の
18        // 5種のみ対応(groove/ridge/inset/outset の立体的な描画は非対応)なので、
19        // それらのキーワードは意味的に近い値へ縮退させる: `hidden`→`none`(描画上等価)、
20        // `groove`/`ridge`/`inset`/`outset`→`solid`(何もマッチせず境界線ごと消えてしまう
21        // よりは、実線として見える方が実害が小さいという判断)。
22        if matches!(
23            t_lower.as_str(),
24            "none" | "solid" | "dashed" | "dotted" | "double"
25        ) {
26            style = Some(t_lower);
27            continue;
28        }
29        if t_lower == "hidden" {
30            style = Some(String::from("none"));
31            continue;
32        }
33        if matches!(t_lower.as_str(), "groove" | "ridge" | "inset" | "outset") {
34            style = Some(String::from("solid"));
35            continue;
36        }
37
38        // 2. 幅の判定
39        if t_lower.ends_with("px")
40            || t_lower.parse::<f32>().is_ok()
41            || t_lower == "thin"
42            || t_lower == "medium"
43            || t_lower == "thick"
44        {
45            let val = match t_lower.as_str() {
46                "thin" => String::from("1px"),
47                "medium" => String::from("3px"),
48                "thick" => String::from("5px"),
49                _ => t_lower,
50            };
51            width = Some(val);
52            continue;
53        }
54
55        // 3. 色の判定
56        if t_lower.starts_with('#') || t_lower.starts_with("rgb") || parse_color(&t_lower).is_some()
57        {
58            color = Some(t_lower);
59            continue;
60        }
61    }
62
63    (width, style, color)
64}
65
66/// CSS-wide keywords(`initial`/`inherit`/`unset`/`revert`)を持つプロパティを
67/// specified_values から取り除く。取り除いた後の挙動:
68/// - 継承プロパティ(`INHERITED_PROPS`): 呼び出し側の継承伝搬処理(
69///   `if !specified_values.contains_key(k)`)が自然に親の値で埋めるため、
70///   `inherit`/`unset` の意味的にも正しい結果になる。
71/// - 非継承プロパティ: 親の全プロパティを参照する仕組みがこの処理系には無いため、
72///   `inherit` を厳密には解決できず「未指定(=各所のデフォルト値)」へフォールバックする
73///   簡略実装(`initial`/`revert` も同様に扱う)。値を素通りさせて壊れた文字列
74///   (例: `color:inherit` がそのまま色として解釈されようとして失敗する)を残すよりは
75///   安全という判断。
76pub(crate) fn strip_css_wide_keywords(specified_values: &mut BTreeMap<String, String>) {
77    let keys_to_remove: alloc::vec::Vec<String> = specified_values
78        .iter()
79        .filter(|(_, v)| {
80            matches!(
81                v.trim().to_ascii_lowercase().as_str(),
82                "initial" | "inherit" | "unset" | "revert"
83            )
84        })
85        .map(|(k, _)| k.clone())
86        .collect();
87    for k in keys_to_remove {
88        specified_values.remove(&k);
89    }
90}
91
92/// `all: unset/initial/revert`(すべてのプロパティを一括リセットするショートハンド)を解決する。
93/// **簡略化**: 本来はカスケードの中で `all` より後に書かれた個別プロパティだけが生き残るが、
94/// この処理系はプロパティ名ごとに独立してカスケード解決した後の `specified_values` しか
95/// 見えないため、そこまで厳密な順序判定はできない。実用上ほとんどの利用パターン
96/// (`.reset{all:unset}` のように単独で使う)をカバーする近似として、`all` が
97/// CSS-wide keyword なら、カスタムプロパティ(`--` 始まり)と `all` 自身を除く
98/// 全プロパティを削除する。
99pub(crate) fn resolve_all_shorthand(specified_values: &mut BTreeMap<String, String>) {
100    let is_reset = specified_values
101        .get("all")
102        .map(|v| {
103            matches!(
104                v.trim().to_ascii_lowercase().as_str(),
105                "unset" | "initial" | "revert"
106            )
107        })
108        .unwrap_or(false);
109    if !is_reset {
110        return;
111    }
112    let keys_to_remove: alloc::vec::Vec<String> = specified_values
113        .keys()
114        .filter(|k| *k != "all" && !k.starts_with("--"))
115        .cloned()
116        .collect();
117    for k in keys_to_remove {
118        specified_values.remove(&k);
119    }
120    specified_values.remove("all");
121}
122
123/// `margin-block`/`margin-inline`/`padding-block`/`padding-inline`/`inset-block`/
124/// `inset-inline`(1〜2値。1番目が start、2番目(省略時は1番目と同値)が end)を
125/// `-start`/`-end` の longhand へ展開する。既に longhand 側が個別指定されていれば
126/// そちらを優先し、shorthand 側は無視する(`resolve_logical_properties` の
127/// 「物理プロパティが既にあれば論理プロパティを無視する」規則と同じ優先順位の考え方)。
128pub(crate) fn expand_logical_box_shorthands(specified_values: &mut BTreeMap<String, String>) {
129    for prefix in ["margin", "padding", "inset"] {
130        for axis in ["block", "inline"] {
131            let shorthand = alloc::format!("{prefix}-{axis}");
132            let start_prop = alloc::format!("{prefix}-{axis}-start");
133            let end_prop = alloc::format!("{prefix}-{axis}-end");
134            if let Some(v) = specified_values.get(&shorthand).cloned() {
135                let toks: Vec<&str> = v.split_whitespace().collect();
136                if let Some(&start) = toks.first() {
137                    let end = toks.get(1).copied().unwrap_or(start);
138                    specified_values.entry(start_prop).or_insert_with(|| String::from(start));
139                    specified_values.entry(end_prop).or_insert_with(|| String::from(end));
140                }
141            }
142        }
143    }
144}
145
146/// CSS 論理プロパティ(`margin-inline-start` 等)を物理プロパティ(`margin-left` 等)へ
147/// 変換する。この処理系は縦書き(`writing-mode`)に対応していないため、block 軸は常に
148/// 縦方向・inline 軸は常に横方向として扱う簡略実装。`direction:rtl` の場合のみ
149/// inline-start/end を right/left(既定は left/right)へ入れ替える。
150/// 対応する物理プロパティが既に指定されている場合は論理プロパティを無視する
151/// (同じ要素内で両方書かれた場合、物理プロパティを優先する簡易な優先順位)。
152pub(crate) fn resolve_logical_properties(specified_values: &mut BTreeMap<String, String>) {
153    let rtl = specified_values
154        .get("direction")
155        .map(|v| v.trim().eq_ignore_ascii_case("rtl"))
156        .unwrap_or(false);
157    let (inline_start, inline_end) = if rtl {
158        ("right", "left")
159    } else {
160        ("left", "right")
161    };
162    // (プロパティ種別プレフィックス, block-start, block-end, inline-start物理名, inline-end物理名)
163    for prefix in ["margin", "padding", "border"] {
164        let pairs = [
165            (
166                alloc::format!("{prefix}-block-start"),
167                alloc::format!("{prefix}-top"),
168            ),
169            (
170                alloc::format!("{prefix}-block-end"),
171                alloc::format!("{prefix}-bottom"),
172            ),
173            (
174                alloc::format!("{prefix}-inline-start"),
175                alloc::format!("{prefix}-{inline_start}"),
176            ),
177            (
178                alloc::format!("{prefix}-inline-end"),
179                alloc::format!("{prefix}-{inline_end}"),
180            ),
181        ];
182        for (logical, physical) in pairs {
183            if specified_values.contains_key(&physical) {
184                continue;
185            }
186            if let Some(v) = specified_values.get(&logical).cloned() {
187                specified_values.insert(physical, v);
188            }
189        }
190    }
191    // border-{block,inline}-{start,end}-{width,style,color}(ショートハンドではなく
192    // longhand 単体での論理方向指定。上のショートハンド版と同じ direction 解決を使う)。
193    for suffix in ["width", "style", "color"] {
194        let pairs = [
195            (
196                alloc::format!("border-block-start-{suffix}"),
197                alloc::format!("border-top-{suffix}"),
198            ),
199            (
200                alloc::format!("border-block-end-{suffix}"),
201                alloc::format!("border-bottom-{suffix}"),
202            ),
203            (
204                alloc::format!("border-inline-start-{suffix}"),
205                alloc::format!("border-{inline_start}-{suffix}"),
206            ),
207            (
208                alloc::format!("border-inline-end-{suffix}"),
209                alloc::format!("border-{inline_end}-{suffix}"),
210            ),
211        ];
212        for (logical, physical) in pairs {
213            if specified_values.contains_key(&physical) {
214                continue;
215            }
216            if let Some(v) = specified_values.get(&logical).cloned() {
217                specified_values.insert(physical, v);
218            }
219        }
220    }
221    // inset-*(position:absolute/fixed 等のオフセット)。
222    let inset_pairs = [
223        (String::from("inset-block-start"), String::from("top")),
224        (String::from("inset-block-end"), String::from("bottom")),
225        (
226            String::from("inset-inline-start"),
227            inline_start.to_string(),
228        ),
229        (
230            String::from("inset-inline-end"),
231            inline_end.to_string(),
232        ),
233    ];
234    for (logical, physical) in inset_pairs {
235        if specified_values.contains_key(&physical) {
236            continue;
237        }
238        if let Some(v) = specified_values.get(&logical).cloned() {
239            specified_values.insert(physical, v);
240        }
241    }
242}
243
244/// 大文字小文字を無視して `from` を `to` へすべて置換する(`str::replace` は大小文字を
245/// 区別するため使えない)。`currentColor`/`currentcolor`/`CURRENTCOLOR` 等の表記ゆれに対応する。
246pub(crate) fn replace_ignore_case(haystack: &str, from: &str, to: &str) -> String {
247    if from.is_empty() {
248        return String::from(haystack);
249    }
250    let hay_lower = haystack.to_ascii_lowercase();
251    let from_lower = from.to_ascii_lowercase();
252    let mut result = String::new();
253    let mut rest = haystack;
254    let mut rest_lower: &str = &hay_lower;
255    while let Some(pos) = rest_lower.find(from_lower.as_str()) {
256        result.push_str(rest.get(..pos).unwrap_or(""));
257        result.push_str(to);
258        rest = rest.get(pos + from.len()..).unwrap_or("");
259        rest_lower = rest_lower.get(pos + from.len()..).unwrap_or("");
260    }
261    result.push_str(rest);
262    result
263}
264
265/// `currentColor` キーワードを、この要素で確定した `color` の値に置換する。
266/// `color` 自身の継承伝搬が終わった直後(呼び出し元で保証)に実行する必要がある。
267/// `color` プロパティ自体は対象外(`color:currentColor` は無限自己参照になるため触らない)。
268pub(crate) fn resolve_current_color(specified_values: &mut BTreeMap<String, String>) {
269    let Some(color_val) = specified_values.get("color").cloned() else {
270        return;
271    };
272    let keys: alloc::vec::Vec<String> = specified_values
273        .iter()
274        .filter(|(k, v)| k.as_str() != "color" && v.to_ascii_lowercase().contains("currentcolor"))
275        .map(|(k, _)| k.clone())
276        .collect();
277    for k in keys {
278        if let Some(v) = specified_values.get(&k) {
279            let replaced = replace_ignore_case(v, "currentcolor", &color_val);
280            specified_values.insert(k, replaced);
281        }
282    }
283}
284
285/// `place-items`/`place-content`/`place-self`(`align-*`/`justify-*` を1行にまとめる
286/// ショートハンド)を展開する。`place-x: A` は `align-x:A; justify-x:A`、
287/// `place-x: A B` は `align-x:A; justify-x:B` となる。対応する longhand が既に
288/// 指定されていればそちらを優先し、ショートハンドでは上書きしない。
289pub(crate) fn expand_place_shorthands(specified_values: &mut BTreeMap<String, String>) {
290    for prefix in ["items", "content", "self"] {
291        let shorthand_key = alloc::format!("place-{prefix}");
292        let Some(v) = specified_values.get(&shorthand_key).cloned() else {
293            continue;
294        };
295        let toks: alloc::vec::Vec<&str> = v.split_whitespace().collect();
296        let (align_v, justify_v) = match toks.as_slice() {
297            [a] => (*a, *a),
298            [a, j] => (*a, *j),
299            _ => continue,
300        };
301        specified_values
302            .entry(alloc::format!("align-{prefix}"))
303            .or_insert_with(|| String::from(align_v));
304        specified_values
305            .entry(alloc::format!("justify-{prefix}"))
306            .or_insert_with(|| String::from(justify_v));
307    }
308}
309
310/// 文字列リテラル(`"..."`/`'...'`)区間をすべて取り除き、残ったトークンを空白区切りで
311/// 連結して返す。`grid-template` の `"area row" 1fr "area row" 2fr` のような
312/// エリア名と行トラックサイズが交互に並ぶ記法から、トラックサイズだけを取り出すのに使う。
313pub(crate) fn strip_quoted_segments(s: &str) -> String {
314    let mut out = String::new();
315    let mut chars = s.chars().peekable();
316    while let Some(c) = chars.next() {
317        if c == '"' || c == '\'' {
318            let quote = c;
319            for c2 in chars.by_ref() {
320                if c2 == quote {
321                    break;
322                }
323            }
324        } else {
325            out.push(c);
326        }
327    }
328    out.split_whitespace().collect::<alloc::vec::Vec<_>>().join(" ")
329}
330
331/// `grid-template: <rows> / <columns>` および `grid-template-areas` を含む記法
332/// (`"a a" 1fr "b c" 1fr / 1fr 2fr` のようにエリア名の文字列リテラルと行トラック
333/// サイズが交互に並び、任意で `/ <columns>` が続く)を展開する。
334/// エリア名部分はそのまま `grid-template-areas` へ、文字列リテラルを取り除いた
335/// 残りのトークンを `grid-template-rows` へ、`/` 以降を `grid-template-columns` へ
336/// 展開する。対応する longhand が既に指定されていればそちらを優先する。
337pub(crate) fn expand_grid_template_shorthand(specified_values: &mut BTreeMap<String, String>) {
338    let Some(v) = specified_values.get("grid-template").cloned() else {
339        return;
340    };
341    let (rows_part, cols_part) = match v.split_once('/') {
342        Some((r, c)) => (r.trim(), Some(c.trim())),
343        None => (v.trim(), None),
344    };
345    if rows_part.contains('"') || rows_part.contains('\'') {
346        // エリア名の文字列リテラルはそのまま grid-template-areas へ。
347        specified_values
348            .entry(String::from("grid-template-areas"))
349            .or_insert_with(|| String::from(rows_part));
350        // 文字列リテラルを除いた残り(行トラックサイズ)を grid-template-rows へ。
351        let tracks = strip_quoted_segments(rows_part);
352        if !tracks.is_empty() {
353            specified_values
354                .entry(String::from("grid-template-rows"))
355                .or_insert(tracks);
356        }
357    } else if !rows_part.is_empty() {
358        specified_values
359            .entry(String::from("grid-template-rows"))
360            .or_insert_with(|| String::from(rows_part));
361    }
362    if let Some(cols) = cols_part {
363        if !cols.is_empty() {
364            specified_values
365                .entry(String::from("grid-template-columns"))
366                .or_insert_with(|| String::from(cols));
367        }
368    }
369}
370
371/// `text-emphasis: [<style>] [<color>]` ショートハンドを `text-emphasis-style`/
372/// `text-emphasis-color` へ展開する。`filled`/`open` の fill キーワードは非対応で
373/// 読み飛ばす(常に塗りつぶし形状として扱う簡略実装)。
374pub(crate) fn expand_text_emphasis_shorthand(specified_values: &mut BTreeMap<String, String>) {
375    let Some(v) = specified_values.get("text-emphasis").cloned() else {
376        return;
377    };
378    let mut style_tok: Option<&str> = None;
379    let mut color_tok: Option<&str> = None;
380    for tok in v.split_whitespace() {
381        if matches!(tok, "filled" | "open") {
382            continue;
383        }
384        if matches!(tok, "none" | "dot" | "circle" | "double-circle" | "triangle" | "sesame") {
385            style_tok = Some(tok);
386        } else if parse_color(tok).is_some() {
387            color_tok = Some(tok);
388        } else if style_tok.is_none() {
389            // クォート付きの1文字カスタムマーク(例: `"*"`)
390            style_tok = Some(tok);
391        }
392    }
393    if let Some(s) = style_tok {
394        specified_values
395            .entry(String::from("text-emphasis-style"))
396            .or_insert_with(|| String::from(s));
397    }
398    if let Some(c) = color_tok {
399        specified_values
400            .entry(String::from("text-emphasis-color"))
401            .or_insert_with(|| String::from(c));
402    }
403}
404
405/// `font: [style] [weight] size[/line-height] family-list` ショートハンドを
406/// `font-style`/`font-weight`/`font-size`/`line-height`/`font-family` へ展開する。
407/// **簡略化**: `font-variant`(`small-caps` 等)と `font-stretch` は longhand の消費側が
408/// 存在しないため読み飛ばす(値としては無視するだけで、パース自体は失敗させない)。
409/// `caption`/`icon`/`menu`/`message-box`/`small-caption`/`status-bar` のシステムフォント
410/// キーワードは非対応のため素通りする。対応する longhand が既に指定されていればそちらを
411/// 優先する(`.entry().or_insert()` パターン、他のショートハンド展開関数と同じ方針)。
412pub(crate) fn expand_font_shorthand(specified_values: &mut BTreeMap<String, String>) {
413    let Some(font) = specified_values.get("font").cloned() else {
414        return;
415    };
416    let trimmed = font.trim();
417    let lower_whole = trimmed.to_ascii_lowercase();
418    if matches!(
419        lower_whole.as_str(),
420        "caption" | "icon" | "menu" | "message-box" | "small-caption" | "status-bar"
421    ) {
422        return;
423    }
424
425    // `size` トークンの判定: サイズキーワード、または数字始まり+単位(px/em/%等)を持つ値。
426    // これにより `font-weight` の裸の数値(`700` 等、単位を持たない)と区別する。
427    fn is_size_token(t: &str) -> bool {
428        let low = t.to_ascii_lowercase();
429        const SIZE_KEYWORDS: &[&str] = &[
430            "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "larger",
431            "smaller",
432        ];
433        if SIZE_KEYWORDS.contains(&low.as_str()) {
434            return true;
435        }
436        let first_part = low.split('/').next().unwrap_or(&low);
437        let starts_digit = first_part
438            .chars()
439            .next()
440            .map(|c| c.is_ascii_digit())
441            .unwrap_or(false);
442        if !starts_digit {
443            return false;
444        }
445        // 単位は既知のサイズ単位のみを許容する。以前は「数字始まり+任意の英字を含む」
446        // という緩すぎる判定だったため、`oblique <angle>`(CSS Fonts 4、例:
447        // `font: oblique 10deg 12px/1.5 sans-serif`)の `10deg` を size と誤認し、
448        // 本来の size トークン(`12px/1.5`)が見えなくなる/family に混入するバグがあった。
449        const SIZE_UNITS: &[&str] = &[
450            "px", "em", "rem", "pt", "pc", "in", "cm", "mm", "q", "%", "vh", "vw", "vmin", "vmax",
451            "ex", "ch",
452        ];
453        SIZE_UNITS.iter().any(|u| first_part.ends_with(u))
454    }
455
456    let toks: alloc::vec::Vec<&str> = trimmed.split_whitespace().collect();
457    let mut style: Option<alloc::string::String> = None;
458    let mut weight: Option<alloc::string::String> = None;
459    let mut size_idx = None;
460    for (i, t) in toks.iter().enumerate() {
461        if is_size_token(t) {
462            size_idx = Some(i);
463            break;
464        }
465        match t.to_ascii_lowercase().as_str() {
466            "italic" | "oblique" => style = Some(String::from(*t)),
467            "bold" | "bolder" | "lighter" => weight = Some(String::from(*t)),
468            _ => {
469                if t.parse::<u32>().is_ok() {
470                    weight = Some(String::from(*t));
471                }
472                // normal/small-caps 等はいずれかの軸のデフォルトを明示するだけなので読み飛ばす。
473            }
474        }
475    }
476    let Some(size_idx) = size_idx else {
477        // サイズトークンが見つからない = 不正な `font` 値として非対応、何もしない。
478        return;
479    };
480    let size_tok = toks[size_idx];
481    let (size_part, line_height_part) = match size_tok.split_once('/') {
482        Some((s, l)) => (s, Some(l)),
483        None => (size_tok, None),
484    };
485    specified_values
486        .entry(String::from("font-size"))
487        .or_insert_with(|| String::from(size_part));
488    if let Some(lh) = line_height_part {
489        specified_values
490            .entry(String::from("line-height"))
491            .or_insert_with(|| String::from(lh));
492    }
493    if let Some(s) = style {
494        specified_values.entry(String::from("font-style")).or_insert(s);
495    }
496    if let Some(w) = weight {
497        specified_values.entry(String::from("font-weight")).or_insert(w);
498    }
499    let family = toks[size_idx + 1..].join(" ");
500    if !family.is_empty() {
501        specified_values
502            .entry(String::from("font-family"))
503            .or_insert(family);
504    }
505}
506
507pub(crate) fn expand_border_shorthands(specified_values: &mut BTreeMap<String, String>) {
508    // border の展開
509    if let Some(border) = specified_values.get("border").cloned() {
510        let (width, style, color) = parse_border_shorthand(&border);
511        if let Some(w) = width {
512            specified_values
513                .entry(String::from("border-top-width"))
514                .or_insert(w.clone());
515            specified_values
516                .entry(String::from("border-right-width"))
517                .or_insert(w.clone());
518            specified_values
519                .entry(String::from("border-bottom-width"))
520                .or_insert(w.clone());
521            specified_values
522                .entry(String::from("border-left-width"))
523                .or_insert(w.clone());
524        }
525        if let Some(s) = style {
526            specified_values
527                .entry(String::from("border-top-style"))
528                .or_insert(s.clone());
529            specified_values
530                .entry(String::from("border-right-style"))
531                .or_insert(s.clone());
532            specified_values
533                .entry(String::from("border-bottom-style"))
534                .or_insert(s.clone());
535            specified_values
536                .entry(String::from("border-left-style"))
537                .or_insert(s.clone());
538        }
539        if let Some(c) = color {
540            specified_values
541                .entry(String::from("border-top-color"))
542                .or_insert(c.clone());
543            specified_values
544                .entry(String::from("border-right-color"))
545                .or_insert(c.clone());
546            specified_values
547                .entry(String::from("border-bottom-color"))
548                .or_insert(c.clone());
549            specified_values
550                .entry(String::from("border-left-color"))
551                .or_insert(c.clone());
552        }
553    }
554
555    // border-left, border-right, border-top, border-bottom の展開
556    for side in &["top", "right", "bottom", "left"] {
557        let key = alloc::format!("border-{}", side);
558        if let Some(val) = specified_values.get(&key).cloned() {
559            let (width, style, color) = parse_border_shorthand(&val);
560            if let Some(w) = width {
561                specified_values.insert(alloc::format!("border-{}-width", side), w);
562            }
563            if let Some(s) = style {
564                specified_values.insert(alloc::format!("border-{}-style", side), s);
565            }
566            if let Some(c) = color {
567                specified_values.insert(alloc::format!("border-{}-color", side), c);
568            }
569        }
570    }
571}
572
573/// `background` ショートハンド(`background: url(...) center/cover no-repeat` 等)から
574/// `background-position`/`background-size`/`background-repeat` を抜き出し、明示的な
575/// longhand 指定が無い場合のみカスケード時点で補う。
576///
577/// 元々このスラッシュ構文(`<position> / <size>`)の分解ロジックは`web_engine/layout.rs`
578/// 内のローカル関数として存在していたが、`background`ショートハンドを読む同種のコード
579/// ブロックが同ファイル内に10箇所以上重複しており、実際にこの分解ロジックが配線されて
580/// いたのはそのうち1箇所のみだった(残りは`background-size`/`-position`/`-repeat`を
581/// 素通しするだけで、シェアされた `styled.value("background-size")` 経由の longhand
582/// フォールバックしか効かず、ショートハンド内の`/size`が無視されていた)。カスケード
583/// 時点で一度だけ展開して`specified_values`に書き戻すことで、全ての呼び出し箇所が
584/// 自動的に正しい値を受け取るようにする(2026-07-21 発見・修正。詳細は
585/// walkthrough.md 参照)。
586pub(crate) fn expand_background_shorthand(specified_values: &mut BTreeMap<String, String>) {
587    let Some(bg) = specified_values.get("background").cloned() else {
588        return;
589    };
590    // size は `<position> / <size>` のスラッシュの後ろにしか現れない。トークンを
591    // 無条件に2個取ると、`center/cover no-repeat`のように直後に`background-repeat`
592    // 等の別サブ値が続く場合に誤って取り込んでしまう(2026-07-21発見・修正。
593    // `cover`/`contain`/`auto`キーワードまたは長さ/割合に見えるトークンのみを
594    // 最大2個まで採用する)。
595    if let Some((_, after)) = bg.split_once('/') {
596        let toks: Vec<&str> = after
597            .split_whitespace()
598            .take_while(|t| {
599                matches!(*t, "cover" | "contain" | "auto")
600                    || t.ends_with('%')
601                    || t.ends_with("px")
602                    || t.ends_with("em")
603                    || t.ends_with("vw")
604                    || t.ends_with("vh")
605            })
606            .take(2)
607            .collect();
608        if !toks.is_empty() {
609            specified_values
610                .entry(String::from("background-size"))
611                .or_insert_with(|| toks.join(" "));
612        }
613    }
614    let repeat_toks: Vec<&str> = bg
615        .split_whitespace()
616        .filter(|t| {
617            matches!(
618                *t,
619                "no-repeat" | "repeat" | "repeat-x" | "repeat-y" | "space" | "round"
620            )
621        })
622        .take(2)
623        .collect();
624    if !repeat_toks.is_empty() {
625        specified_values
626            .entry(String::from("background-repeat"))
627            .or_insert_with(|| repeat_toks.join(" "));
628    }
629    // position はスラッシュより手前側のみを対象にする(後ろは size)。
630    let position_part = bg.split_once('/').map(|(before, _)| before).unwrap_or(&bg);
631    let position_toks: Vec<&str> = position_part
632        .split_whitespace()
633        .filter(|t| {
634            matches!(*t, "center" | "top" | "bottom" | "left" | "right")
635                || t.ends_with('%')
636                || t.ends_with("px")
637        })
638        .collect();
639    if !position_toks.is_empty() {
640        specified_values
641            .entry(String::from("background-position"))
642            .or_insert_with(|| position_toks.join(" "));
643    }
644}
645
646/// `text-transform` を適用する(uppercase / lowercase / capitalize)。
647pub fn apply_text_transform(text: &str, mode: &str) -> String {
648    match mode.trim() {
649        "uppercase" => text.to_uppercase(),
650        "lowercase" => text.to_lowercase(),
651        "capitalize" => {
652            // 各単語の先頭文字を大文字化(空白区切り)。
653            let mut out = String::with_capacity(text.len());
654            let mut at_word_start = true;
655            for c in text.chars() {
656                if c.is_whitespace() {
657                    at_word_start = true;
658                    out.push(c);
659                } else if at_word_start {
660                    out.extend(c.to_uppercase());
661                    at_word_start = false;
662                } else {
663                    out.push(c);
664                }
665            }
666            out
667        }
668        _ => text.to_string(),
669    }
670}
671
672// ===================================================================
673// CSS Transitions / Animations
674// ===================================================================
675