Skip to main content

atmos/os_lib/layout/
multicol.rs

1//! CSS 多段組(`column-count` / `column-width` / `columns` / `column-gap`)の
2//! 列幾何計算(純粋モジュール)。
3//!
4//! ハードウェアにもグローバル状態にも依存しない算術だけなので、
5//! QEMU 抜きでホスト側から検証できる。
6//!
7//! # 仕様の要点(CSS Multi-column Layout Level 1)
8//!
9//! - `column-count` は列数の指定、`column-width` は 1 列の**最小**目安幅。
10//!   両方指定されたときは「`column-width` から求まる列数」と
11//!   `column-count` の**小さい方**を採る(`column-count` は上限として働く)。
12//! - `column-gap` の既定は `normal`=`1em` 相当。ここでは呼び出し側が
13//!   解決したピクセル値を受け取る。
14//! - 列は等幅で、`gap` は列と列の**間だけ**に入る(外側には入らない)。
15//!   よって `列幅 = (使用可能幅 - gap * (列数 - 1)) / 列数`。
16//!
17//! # 割り切り
18//!
19//! 段抜き(`column-span`)・段区切り(`break-inside`)・段罫線
20//! (`column-rule`)は扱わない。本モジュールは「何列で、各列はどの
21//! x 座標にどの幅で並ぶか」だけを決める。
22
23extern crate alloc;
24use alloc::vec::Vec;
25
26/// 多段組の指定。いずれも解決済みのピクセル値/個数。
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct MultiColSpec {
29    /// `column-count`。未指定は `None`。
30    pub count: Option<i32>,
31    /// `column-width`(1 列の最小目安幅、px)。未指定は `None`。
32    pub width: Option<i32>,
33    /// `column-gap`(px)。`normal` は呼び出し側で 1em 相当へ解決しておく。
34    pub gap: i32,
35}
36
37/// 列の位置と幅。
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct Column {
40    /// コンテンツボックス左端からの x オフセット(px)。
41    pub x: i32,
42    /// 列の幅(px)。
43    pub width: i32,
44}
45
46/// 実際に使う列数を決める(純粋関数)。
47///
48/// - どちらも未指定なら 1 列(多段組ではない)。
49/// - `count` だけなら その値(1 未満は 1 に切り上げ)。
50/// - `width` だけなら 使用可能幅から入るだけ詰める。
51/// - 両方あれば **小さい方**(`count` が上限)。
52///
53/// `avail_w` が 0 以下、または `width` が 0 以下のときは 1 列へ倒す
54/// (ゼロ除算と無限列を防ぐ)。
55pub fn resolve_column_count(spec: &MultiColSpec, avail_w: i32) -> i32 {
56    if avail_w <= 0 {
57        return 1;
58    }
59    // `column-width` から入る列数を求める。gap も 1 列分ずつ消費する。
60    let from_width = spec.width.and_then(|w| {
61        if w <= 0 {
62            return None;
63        }
64        // n 列入る条件: n*w + (n-1)*gap <= avail_w
65        //            → n <= (avail_w + gap) / (w + gap)
66        let denom = w.saturating_add(spec.gap.max(0));
67        if denom <= 0 {
68            return None;
69        }
70        let n = (avail_w.saturating_add(spec.gap.max(0))) / denom;
71        Some(n.max(1))
72    });
73    let from_count = spec.count.map(|c| c.max(1));
74
75    match (from_count, from_width) {
76        (None, None) => 1,
77        (Some(c), None) => c,
78        (None, Some(w)) => w,
79        // 仕様上 `column-count` は上限として働く。
80        (Some(c), Some(w)) => c.min(w),
81    }
82}
83
84/// 列の x 座標と幅を計算する(純粋関数)。
85///
86/// `gap` は列間にだけ入る。端数は最後の列へ寄せず、各列を等幅にしたうえで
87/// 余りぶんだけ最終列を広げる(合計が `avail_w` を超えないようにする)。
88/// 列幅が負になる(gap が広すぎる)場合は 0 幅の列を返し、パニックしない。
89pub fn layout_columns(spec: &MultiColSpec, avail_w: i32) -> Vec<Column> {
90    let n = resolve_column_count(spec, avail_w);
91    let gap = spec.gap.max(0);
92    if n <= 1 {
93        return alloc::vec![Column {
94            x: 0,
95            width: avail_w.max(0),
96        }];
97    }
98    let total_gap = gap.saturating_mul(n - 1);
99    let usable = avail_w.saturating_sub(total_gap);
100    if usable <= 0 {
101        // gap だけで埋まってしまう場合。列は作るが幅 0 にする。
102        return (0..n)
103            .map(|i| Column {
104                x: (gap.saturating_add(0)).saturating_mul(i),
105                width: 0,
106            })
107            .collect();
108    }
109    let base = usable / n;
110    let mut cols = Vec::with_capacity(n as usize);
111    let mut x = 0;
112    for i in 0..n {
113        // 端数(usable % n)は最終列で吸収し、合計が avail_w を超えないようにする。
114        let w = if i == n - 1 { usable - base * (n - 1) } else { base };
115        cols.push(Column { x, width: w });
116        x = x.saturating_add(w).saturating_add(gap);
117    }
118    cols
119}
120
121/// `columns` ショートハンドを `column-width` と `column-count` へ分解する。
122///
123/// 仕様上、値は「長さ」と「整数」が任意順で高々 1 つずつ。
124/// `auto` は「その成分は未指定」を意味する。
125/// 解決できない字句は無視する(不正値でページ全体を壊さない)。
126///
127/// 長さの解決(`px` 以外の単位)は呼び出し側の責務。ここでは
128/// **末尾が `px` か単位無しの数値**だけを長さとして受け取り、
129/// 整数だけの値は列数として扱う。
130pub fn parse_columns_shorthand(s: &str) -> (Option<i32>, Option<i32>) {
131    let mut width = None;
132    let mut count = None;
133    for tok in s.split_whitespace() {
134        let t = tok.trim();
135        if t.is_empty() || t.eq_ignore_ascii_case("auto") {
136            continue;
137        }
138        if let Some(px) = t.strip_suffix("px") {
139            if let Ok(v) = px.trim().parse::<f32>() {
140                if v.is_finite() && v > 0.0 {
141                    width = Some(v as i32);
142                }
143            }
144            continue;
145        }
146        // 単位無しの整数は列数。小数や負数は無視する。
147        if let Ok(v) = t.parse::<i32>() {
148            if v > 0 {
149                count = Some(v);
150            }
151            continue;
152        }
153        // それ以外(`2em` 等の未対応単位)は長さとして扱えないので無視する。
154    }
155    (width, count)
156}
157
158/// `column-fill` プロパティ値(W3C CSS Multi-column Layout Level 1 §7)。
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160pub enum ColumnFill {
161    /// 各列の高さを均等にバランスさせ、左列から順に配置する(既定値)。
162    Balance,
163    /// コンテナの高さまで順番に各列を埋める。
164    Auto,
165}
166
167/// `column-fill` 文字列をパースする(純粋関数)。
168pub fn parse_column_fill(s: &str) -> ColumnFill {
169    if s.trim().eq_ignore_ascii_case("auto") {
170        ColumnFill::Auto
171    } else {
172        ColumnFill::Balance
173    }
174}
175
176/// 子ボックスを列へ振り分けた結果。
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct Placement {
179    /// 何列目へ入れるか(0 起点)。
180    pub col: usize,
181    /// その列の中での上端オフセット(px)。
182    pub y: i32,
183}
184
185/// 各アイテムの高さ列、列数、充填モード(`column-fill`)、コンテナ指定高さ(`max_h`)を受け取り、
186/// ドキュメント順序を維持したまま各列へ配置する(純粋関数)。
187pub fn assign_to_columns_balanced(
188    heights: &[i32],
189    n_cols: usize,
190    fill: ColumnFill,
191    max_h: Option<i32>,
192) -> Vec<Placement> {
193    let n = n_cols.max(1);
194    if heights.is_empty() {
195        return Vec::new();
196    }
197    if n == 1 {
198        let mut y = 0i32;
199        let mut out = Vec::with_capacity(heights.len());
200        for &h in heights {
201            out.push(Placement { col: 0, y });
202            y = y.saturating_add(h.max(0));
203        }
204        return out;
205    }
206
207    match fill {
208        ColumnFill::Auto => {
209            let col_limit = max_h.unwrap_or(i32::MAX);
210            let mut out = Vec::with_capacity(heights.len());
211            let mut current_col = 0usize;
212            let mut current_y = 0i32;
213
214            for &h in heights {
215                let item_h = h.max(0);
216                if current_y > 0 && current_y.saturating_add(item_h) > col_limit && current_col + 1 < n {
217                    current_col += 1;
218                    current_y = 0;
219                }
220                out.push(Placement {
221                    col: current_col,
222                    y: current_y,
223                });
224                current_y = current_y.saturating_add(item_h);
225            }
226            out
227        }
228        ColumnFill::Balance => {
229            let mut low = heights.iter().map(|&h| h.max(0)).max().unwrap_or(0);
230            let mut high: i32 = heights
231                .iter()
232                .map(|&h| h.max(0))
233                .fold(0i32, |acc, x| acc.saturating_add(x));
234            if high <= 0 {
235                return (0..heights.len()).map(|_| Placement { col: 0, y: 0 }).collect();
236            }
237
238            let can_partition = |cap: i32| -> bool {
239                let mut cols_used = 1usize;
240                let mut current_sum = 0i32;
241                for &h in heights {
242                    let item_h = h.max(0);
243                    if current_sum > 0 && current_sum.saturating_add(item_h) > cap {
244                        cols_used += 1;
245                        current_sum = item_h;
246                    } else {
247                        current_sum = current_sum.saturating_add(item_h);
248                    }
249                }
250                cols_used <= n
251            };
252
253            let mut best_cap = high;
254            while low <= high {
255                let mid = low + (high - low) / 2;
256                if can_partition(mid) {
257                    best_cap = mid;
258                    high = mid - 1;
259                } else {
260                    low = mid + 1;
261                }
262            }
263
264            let mut out = Vec::with_capacity(heights.len());
265            let mut current_col = 0usize;
266            let mut current_y = 0i32;
267            let total_items = heights.len();
268
269            for (i, &h) in heights.iter().enumerate() {
270                let item_h = h.max(0);
271                let remaining_items = total_items - i;
272                let remaining_cols = n - current_col;
273
274                if current_col + 1 < n {
275                    let exceeds_cap = current_y > 0 && current_y.saturating_add(item_h) > best_cap;
276                    let must_split_for_remaining = remaining_items <= remaining_cols && current_y > 0;
277                    if exceeds_cap || must_split_for_remaining {
278                        current_col += 1;
279                        current_y = 0;
280                    }
281                }
282
283                out.push(Placement {
284                    col: current_col,
285                    y: current_y,
286                });
287                current_y = current_y.saturating_add(item_h);
288            }
289            out
290        }
291    }
292}
293
294/// 子の高さ列を受け取り、各子をどの列のどの高さへ置くかを決める(純粋関数)。
295///
296/// 不変条件 M-6 のとおり「その時点で高さが最小の列」へ順に入れる貪欲法。
297/// 同じ高さの列が複数あるときは**左の列を優先**する(見た目の安定のため。
298/// 右から埋まると 1 件だけのときに不自然に右へ寄る)。
299///
300/// 返り値の長さは `heights` と同じ。列数が 0 のときは全て 0 列目へ倒す。
301///
302/// 計算量: **O(子数 × 列数)**。列数は高々数十なので実用上問題ない。
303pub fn assign_to_columns(heights: &[i32], n_cols: usize) -> Vec<Placement> {
304    let n = n_cols.max(1);
305    let mut bottoms = alloc::vec![0i32; n];
306    let mut out = Vec::with_capacity(heights.len());
307    for &h in heights {
308        // 最小の列を探す。`<` で比較するので同値なら先に見た(左の)列が残る。
309        let mut min_i = 0usize;
310        for i in 1..n {
311            if bottoms[i] < bottoms[min_i] {
312                min_i = i;
313            }
314        }
315        out.push(Placement {
316            col: min_i,
317            y: bottoms[min_i],
318        });
319        bottoms[min_i] = bottoms[min_i].saturating_add(h.max(0));
320    }
321    out
322}
323
324/// 振り分け後の各列の高さを返す(純粋関数)。
325pub fn column_heights_balanced(
326    heights: &[i32],
327    n_cols: usize,
328    fill: ColumnFill,
329    max_h: Option<i32>,
330) -> Vec<i32> {
331    let n = n_cols.max(1);
332    let places = assign_to_columns_balanced(heights, n, fill, max_h);
333    let mut cols_h = alloc::vec![0i32; n];
334    for (idx, p) in places.into_iter().enumerate() {
335        if p.col < n {
336            let item_h = heights.get(idx).copied().unwrap_or(0).max(0);
337            cols_h[p.col] = cols_h[p.col].max(p.y.saturating_add(item_h));
338        }
339    }
340    cols_h
341}
342
343/// 振り分け後の各列の高さを返す(純粋関数)。
344///
345/// 多段組ブロック自身の高さは、この最大値になる。
346pub fn column_heights(heights: &[i32], n_cols: usize) -> Vec<i32> {
347    let n = n_cols.max(1);
348    let mut bottoms = alloc::vec![0i32; n];
349    for &h in heights {
350        let mut min_i = 0usize;
351        for i in 1..n {
352            if bottoms[i] < bottoms[min_i] {
353                min_i = i;
354            }
355        }
356        bottoms[min_i] = bottoms[min_i].saturating_add(h.max(0));
357    }
358    bottoms
359}
360
361/// `column-gap`(または `gap`)の文字列値を解析してピクセル値へ解決する(純粋関数)。
362/// 既定値 `normal` は `1em` 相当(`font_px`)。未指定または不正値は `font_px`(または 16px)。
363pub fn resolve_column_gap(gap_str: Option<&str>, avail_w: i32, font_px: f32) -> i32 {
364    let s = match gap_str {
365        Some(v) => v.trim(),
366        None => return (if font_px > 0.0 { font_px } else { 16.0 }) as i32,
367    };
368    if s.is_empty() || s.eq_ignore_ascii_case("normal") {
369        return (if font_px > 0.0 { font_px } else { 16.0 }) as i32;
370    }
371    let font = if font_px > 0.0 { font_px } else { 16.0 };
372    if let Some(pct) = s.strip_suffix('%') {
373        let v = pct.trim().parse::<f32>().unwrap_or(0.0);
374        (v * avail_w as f32 / 100.0).max(0.0) as i32
375    } else if let Some(rem) = s.strip_suffix("rem") {
376        (rem.trim().parse::<f32>().unwrap_or(0.0) * 16.0).max(0.0) as i32
377    } else if let Some(em) = s.strip_suffix("em") {
378        (em.trim().parse::<f32>().unwrap_or(0.0) * font).max(0.0) as i32
379    } else if let Some(pt) = s.strip_suffix("pt") {
380        (pt.trim().parse::<f32>().unwrap_or(0.0) * 4.0 / 3.0).max(0.0) as i32
381    } else if let Some(px) = s.strip_suffix("px") {
382        px.trim().parse::<f32>().unwrap_or(0.0).max(0.0) as i32
383    } else if let Ok(v) = s.parse::<f32>() {
384        v.max(0.0) as i32
385    } else {
386        font as i32
387    }
388}
389
390/// 子要素の特性(通常フローで列内に入るか、全幅スパンするか)。
391#[derive(Debug, Clone, Copy, PartialEq, Eq)]
392pub enum ChildKind {
393    ColumnItem { height: i32 },
394    Spanner { height: i32 },
395}
396
397/// 子要素の最終配置結果。
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub struct ChildPlacement {
400    /// 何列目か(Spanner の場合は 0)。
401    pub col: usize,
402    /// 全幅スパンかどうか。
403    pub is_span: bool,
404    /// コンテナ内での上端 y 座標(px)。
405    pub y: i32,
406}
407
408/// 子要素群を段抜き(`column-span: all`)および充填モード(`column-fill`)を考慮してセグメント化し配置する(純粋関数)。
409/// W3C CSS Multi-column Layout Level 1 §6 / §7 準拠。
410///
411/// 戻り値: (各子の配置情報, 全セグメントおよび Spanner を合わせた合計高さ)
412pub fn assign_children_with_spans_ex(
413    items: &[ChildKind],
414    n_cols: usize,
415    fill: ColumnFill,
416    max_h: Option<i32>,
417) -> (Vec<ChildPlacement>, i32) {
418    let mut out = Vec::with_capacity(items.len());
419    let mut y_base = 0i32;
420
421    let mut i = 0;
422    while i < items.len() {
423        match items[i] {
424            ChildKind::Spanner { height } => {
425                out.push(ChildPlacement {
426                    col: 0,
427                    is_span: true,
428                    y: y_base,
429                });
430                y_base = y_base.saturating_add(height.max(0));
431                i += 1;
432            }
433            ChildKind::ColumnItem { .. } => {
434                // 次の Spanner までの ColumnItem 群(セグメント)を収集
435                let seg_start = i;
436                while i < items.len() {
437                    if let ChildKind::Spanner { .. } = items[i] {
438                        break;
439                    }
440                    i += 1;
441                }
442                let seg_heights: Vec<i32> = items[seg_start..i]
443                    .iter()
444                    .map(|it| match it {
445                        ChildKind::ColumnItem { height } => *height,
446                        _ => unreachable!(),
447                    })
448                    .collect();
449
450                let seg_places = assign_to_columns_balanced(&seg_heights, n_cols, fill, max_h);
451                let seg_col_heights = column_heights_balanced(&seg_heights, n_cols, fill, max_h);
452                let seg_max_h = seg_col_heights.into_iter().max().unwrap_or(0);
453
454                for p in seg_places {
455                    out.push(ChildPlacement {
456                        col: p.col,
457                        is_span: false,
458                        y: y_base.saturating_add(p.y),
459                    });
460                }
461                y_base = y_base.saturating_add(seg_max_h);
462            }
463        }
464    }
465
466    (out, y_base)
467}
468
469/// 子要素群を段抜き(`column-span: all`)を考慮してセグメント化し配置する(純粋関数)。
470/// W3C CSS Multi-column Layout Level 1 §6 準拠。
471///
472/// 戻り値: (各子の配置情報, 全セグメントおよび Spanner を合わせた合計高さ)
473pub fn assign_children_with_spans(
474    items: &[ChildKind],
475    n_cols: usize,
476) -> (Vec<ChildPlacement>, i32) {
477    let mut out = Vec::with_capacity(items.len());
478    let mut y_base = 0i32;
479
480    let mut i = 0;
481    while i < items.len() {
482        match items[i] {
483            ChildKind::Spanner { height } => {
484                out.push(ChildPlacement {
485                    col: 0,
486                    is_span: true,
487                    y: y_base,
488                });
489                y_base = y_base.saturating_add(height.max(0));
490                i += 1;
491            }
492            ChildKind::ColumnItem { .. } => {
493                // 次の Spanner までの ColumnItem 群(セグメント)を収集
494                let seg_start = i;
495                while i < items.len() {
496                    if let ChildKind::Spanner { .. } = items[i] {
497                        break;
498                    }
499                    i += 1;
500                }
501                let seg_heights: Vec<i32> = items[seg_start..i]
502                    .iter()
503                    .map(|it| match it {
504                        ChildKind::ColumnItem { height } => *height,
505                        _ => unreachable!(),
506                    })
507                    .collect();
508
509                let seg_places = assign_to_columns(&seg_heights, n_cols);
510                let seg_col_heights = column_heights(&seg_heights, n_cols);
511                let seg_max_h = seg_col_heights.into_iter().max().unwrap_or(0);
512
513                for p in seg_places {
514                    out.push(ChildPlacement {
515                        col: p.col,
516                        is_span: false,
517                        y: y_base.saturating_add(p.y),
518                    });
519                }
520                y_base = y_base.saturating_add(seg_max_h);
521            }
522        }
523    }
524
525    (out, y_base)
526}
527
528/// 段罫線のスタイル種別。
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
530pub enum RuleStyle {
531    None,
532    Solid,
533    Dashed,
534    Dotted,
535    Double,
536    Hidden,
537}
538
539impl RuleStyle {
540    pub fn parse(s: &str) -> Self {
541        match s.trim().to_ascii_lowercase().as_str() {
542            "solid" => RuleStyle::Solid,
543            "dashed" => RuleStyle::Dashed,
544            "dotted" => RuleStyle::Dotted,
545            "double" => RuleStyle::Double,
546            "hidden" => RuleStyle::Hidden,
547            _ => RuleStyle::None,
548        }
549    }
550}
551
552/// 段罫線の解決済みスタイル指定。
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub struct ColumnRuleStyle {
555    pub width: i32,
556    pub style: RuleStyle,
557    pub color: u32,
558}
559
560/// 実際に描画される 1 本の段罫線の矩形情報。
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub struct ColumnRuleRect {
563    pub x: i32,
564    pub y: i32,
565    pub width: i32,
566    pub height: i32,
567    pub color: u32,
568}
569
570/// `column-rule` の太さを解析する(純粋関数)。
571fn parse_rule_width(s: &str, font_px: f32) -> Option<i32> {
572    match s.trim().to_ascii_lowercase().as_str() {
573        "thin" => Some(1),
574        "medium" => Some(3),
575        "thick" => Some(5),
576        _ => {
577            let font = if font_px > 0.0 { font_px } else { 16.0 };
578            let s = s.trim();
579            if let Some(px) = s.strip_suffix("px") {
580                px.trim().parse::<f32>().ok().filter(|f| f.is_finite() && *f >= 0.0).map(|f| f as i32)
581            } else if let Some(em) = s.strip_suffix("em") {
582                em.trim().parse::<f32>().ok().filter(|f| f.is_finite() && *f >= 0.0).map(|f| (f * font) as i32)
583            } else if let Some(rem) = s.strip_suffix("rem") {
584                rem.trim().parse::<f32>().ok().filter(|f| f.is_finite() && *f >= 0.0).map(|f| (f * 16.0) as i32)
585            } else if let Some(pt) = s.strip_suffix("pt") {
586                pt.trim().parse::<f32>().ok().filter(|f| f.is_finite() && *f >= 0.0).map(|f| (f * 4.0 / 3.0) as i32)
587            } else if let Ok(v) = s.parse::<f32>() {
588                if v.is_finite() && v >= 0.0 {
589                    Some(v as i32)
590                } else {
591                    None
592                }
593            } else {
594                None
595            }
596        }
597    }
598}
599
600/// `column-rule`(ショートハンド)および個別ロングハンドをパースする(純粋関数)。
601pub fn parse_column_rule(
602    rule_shorthand: Option<&str>,
603    width_val: Option<&str>,
604    style_val: Option<&str>,
605    color_val: Option<&str>,
606    font_px: f32,
607) -> Option<ColumnRuleStyle> {
608    let mut width = None;
609    let mut style = None;
610    let mut color = None;
611
612    // ショートハンドがあれば分解
613    if let Some(sh) = rule_shorthand {
614        for tok in sh.split_whitespace() {
615            let t = tok.trim();
616            if t.is_empty() {
617                continue;
618            }
619            let st = RuleStyle::parse(t);
620            if st != RuleStyle::None {
621                style = Some(st);
622                continue;
623            }
624            if t.eq_ignore_ascii_case("none") {
625                style = Some(RuleStyle::None);
626                continue;
627            }
628            if let Some(w) = parse_rule_width(t, font_px) {
629                width = Some(w);
630                continue;
631            }
632            if let Some(c) = crate::os_lib::css::parse_color(t) {
633                color = Some(c);
634                continue;
635            }
636        }
637    }
638
639    // 個別ロングハンドの上書き・補完
640    if let Some(w_str) = width_val {
641        if let Some(w) = parse_rule_width(w_str.trim(), font_px) {
642            width = Some(w);
643        }
644    }
645    if let Some(s_str) = style_val {
646        style = Some(RuleStyle::parse(s_str.trim()));
647    }
648    if let Some(c_str) = color_val {
649        if let Some(c) = crate::os_lib::css::parse_color(c_str.trim()) {
650            color = Some(c);
651        }
652    }
653
654    let final_style = style.unwrap_or(RuleStyle::None);
655    if final_style == RuleStyle::None || final_style == RuleStyle::Hidden {
656        return None;
657    }
658    let final_width = width.unwrap_or(3); // 既定 medium = 3px
659    if final_width <= 0 {
660        return None;
661    }
662    let final_color = color.unwrap_or(0xFF000000); // 既定黒
663
664    Some(ColumnRuleStyle {
665        width: final_width,
666        style: final_style,
667        color: final_color,
668    })
669}
670
671/// 列一覧とギャップ、高さ、ルール定義から、各列間に引く段罫線の矩形一覧を計算する(純粋関数)。
672pub fn compute_column_rules(
673    cols: &[Column],
674    gap: i32,
675    y: i32,
676    height: i32,
677    rule: &ColumnRuleStyle,
678) -> Vec<ColumnRuleRect> {
679    if cols.len() <= 1 || rule.width <= 0 || height <= 0 {
680        return Vec::new();
681    }
682    if rule.style == RuleStyle::None || rule.style == RuleStyle::Hidden {
683        return Vec::new();
684    }
685    let mut out = Vec::with_capacity(cols.len() - 1);
686    for i in 0..cols.len() - 1 {
687        let left_edge = cols[i].x + cols[i].width;
688        let right_edge = cols[i + 1].x;
689        let center_x = if right_edge > left_edge {
690            (left_edge + right_edge) / 2
691        } else {
692            left_edge + gap / 2
693        };
694        let x = center_x - rule.width / 2;
695        out.push(ColumnRuleRect {
696            x,
697            y,
698            width: rule.width,
699            height,
700            color: rule.color,
701        });
702    }
703    out
704}
705
706