Skip to main content

atmos/os_lib/layout/
line_break.rs

1//! 行の折り返し機会(soft wrap opportunity)の判定。
2//!
3//! # なぜ必要か
4//!
5//! 従来の折り返しは**1 文字単位**だった。次の文字が入らなければ
6//! そこで改行するので、英単語が語中で分割されていた。
7//! CSS の既定(`overflow-wrap: normal` / `word-break: normal`)では
8//! **単語は分割しない**。収まらなければはみ出すのが正しい。
9//!
10//! 実害は見た目だけではない。min-content(最小内容幅)は
11//! 「available=1 で組んだときの幅」で測っているため、語中で切れる実装だと
12//! min-content が 1 文字ぶんになる。flex の縮小はこの値を下限に使うので、
13//! **項目を 1 文字幅まで潰してしまう**(実サイトのナビ項目 "Papers" が
14//! 縦一列に潰れた)。
15//!
16//! # 方針
17//!
18//! UAX #14 の完全実装はしない。実用上効く規則だけを入れる。
19//! - 空白の**後ろ**では改行できる
20//! - 漢字・かな等(表意文字)の前後では改行できる
21//! - それ以外(英数字が連なる部分)では改行できない
22//!
23//! 禁則処理(行頭・行末禁則)は既存の `vector_font` 側が持っており、
24//! ここでは扱わない。
25//!
26//! # 既知の差
27//!
28//! 厳密には、行の途中で始まった単語が収まらない場合は**その単語ごと
29//! 次の行へ送る**のが正しい。現状は送らずにはみ出させる。
30//! 計測(`vector_font`)と描画(`web_engine::layout`)で**同じ規則**を
31//! 使うことを優先した——両者がずれると、箱の大きさと実際の文字の位置が
32//! 食い違い、見た目の崩れとして現れる。
33
34/// その文字が表意文字(漢字・かな・全角記号など)か。
35///
36/// 日本語は単語の区切りが無いので、文字ごとに改行できる。
37/// 範囲は日本語の本文で実際に現れるものに絞る。
38pub fn is_ideographic(c: char) -> bool {
39    matches!(c as u32,
40        0x3040..=0x309F   // ひらがな
41        | 0x30A0..=0x30FF // カタカナ
42        | 0x3400..=0x4DBF // CJK 拡張 A
43        | 0x4E00..=0x9FFF // CJK 統合漢字
44        | 0xF900..=0xFAFF // CJK 互換漢字
45        | 0xFF00..=0xFF60 // 全角英数・記号
46        | 0xFFE0..=0xFFE6
47        | 0x3000..=0x303F // 句読点・括弧など
48    )
49}
50
51/// `prev` と `next` の**間**で改行してよいか。
52///
53/// `break_all` が真(`word-break: break-all` 等)なら常に改行できる。
54pub fn can_break_between(prev: char, next: char, break_all: bool) -> bool {
55    if break_all {
56        return true;
57    }
58    // 空白の後ろでは改行できる。空白そのものは行末に置ける。
59    if prev.is_whitespace() {
60        return true;
61    }
62    // 次が空白なら、そこで行を切る意味は無い(空白は行末で潰れる)。
63    if next.is_whitespace() {
64        return false;
65    }
66    // 表意文字が絡む境目では改行できる。
67    if is_ideographic(prev) || is_ideographic(next) {
68        return true;
69    }
70    // 英数字が連なる部分(=単語の内部)では改行しない。
71    false
72}
73
74/// CSS `white-space` に従って空白を畳む(CSS Text 3 §4.1 の簡略版)。
75///
76/// - `normal`/`nowrap`(未指定含む): 空白・タブ・改行の連続を半角空白 1 つに。
77/// - `pre-line`: 空白・タブの連続は 1 つに、改行は残し、改行の前後の空白は捨てる。
78/// - `pre`/`pre-wrap`/`break-spaces`: 何もしない(保持)。
79///
80/// 対象は ASCII の空白(U+0020/U+0009/U+000A/U+000D/U+000C)のみ。全角空白 U+3000 は
81/// 文字として残す。先頭・末尾の空白も 1 つに畳むだけで消さない(隣接要素との区切りに
82/// なりうるため。行頭・行末の除去は折り返し側で行う)。
83///
84/// 【2026-09-26】HTML の字下げ(改行+タブ 6 個)が畳まれずタブ展開までされ、
85/// www.sugi-lab.net の段落で「IoT」が行頭の空白に押されて 1 行に孤立していた。
86pub fn collapse_white_space(s: &str, white_space: &str) -> alloc::string::String {
87    let ws = white_space.trim();
88    if matches!(ws, "pre" | "pre-wrap" | "break-spaces") {
89        return alloc::string::String::from(s);
90    }
91    let keep_newlines = ws == "pre-line";
92    let is_sp = |c: char| matches!(c, ' ' | '\t' | '\r' | '\u{0C}');
93    let mut out = alloc::string::String::with_capacity(s.len());
94    let mut pending_space = false;
95    let mut pending_newlines = 0usize;
96    for c in s.chars() {
97        if c == '\n' {
98            if keep_newlines {
99                pending_newlines += 1;
100                pending_space = false;
101            } else {
102                pending_space = true;
103            }
104        } else if is_sp(c) {
105            if pending_newlines == 0 {
106                pending_space = true;
107            }
108        } else {
109            if pending_newlines > 0 {
110                // pre-line: 改行の前にあった空白は捨てる。
111                while out.ends_with(' ') {
112                    out.pop();
113                }
114                for _ in 0..pending_newlines {
115                    out.push('\n');
116                }
117                pending_newlines = 0;
118            } else if pending_space {
119                out.push(' ');
120            }
121            pending_space = false;
122            out.push(c);
123        }
124    }
125    if pending_newlines > 0 {
126        while out.ends_with(' ') {
127            out.pop();
128        }
129        for _ in 0..pending_newlines {
130            out.push('\n');
131        }
132    } else if pending_space {
133        out.push(' ');
134    }
135    out
136}
137
138/// 折り返し位置(新しい行が始まる文字の添字)を求める。`\n` は強制改行として行をリセットする
139/// (位置としては返さない。呼び出し側が `\n` を自分で扱う)。
140///
141/// - 折り返し機会: `can_break_between(前, 次)` かつ「次」が行頭禁則でなく「前」が行末禁則でない
142///   (`break_all` なら常に機会あり)。
143/// - 次の文字で行幅を超えるとき、そこが機会ならそこで、機会でなければ**行内で最後の機会まで
144///   戻って単語ごと次の行へ送る**。行内に機会が無ければ(一語が行より長い)はみ出させる。
145/// - 空白は行幅を超えても折り返しの引き金にしない(行末の空白はぶら下げる。CSS と同じ)。
146///
147/// 【2026-09-26】従来は計測(`vector_font`)と描画(`web_engine::layout`)に同じ趣旨の実装が
148/// 2 つあり、どちらも「単語を送らずにはみ出させる」簡略版だった。英語の文献リスト
149/// (sugi-lab.net/papers.htm)で単語が窓からはみ出し、描画側で勝手に改行されて重なっていた。
150/// 両者がこの関数を使うことで、箱の高さと実際の行分けが常に一致する。
151///
152/// 計算量: **O(N)**(戻りの走査は各文字高々 1 回)。
153pub fn break_positions(
154    chars: &[char],
155    advances: &[i32],
156    max_w: i32,
157    break_all: bool,
158    start_forbidden: impl Fn(char) -> bool,
159    end_forbidden: impl Fn(char) -> bool,
160) -> alloc::vec::Vec<usize> {
161    let n = chars.len().min(advances.len());
162    let mut out = alloc::vec::Vec::new();
163    let mut line_start = 0usize;
164    let mut line_w = 0i32;
165    // 行内で最後の折り返し機会(添字, その手前までの行幅)。
166    let mut last_opp: Option<(usize, i32)> = None;
167    let opp_at = |i: usize, start: usize| -> bool {
168        if i <= start || i >= n {
169            return false;
170        }
171        let (p, c) = (chars[i - 1], chars[i]);
172        if p == '\n' {
173            return false;
174        }
175        break_all || (can_break_between(p, c, false) && !start_forbidden(c) && !end_forbidden(p))
176    };
177    let mut i = 0usize;
178    while i < n {
179        let c = chars[i];
180        if c == '\n' {
181            line_start = i + 1;
182            line_w = 0;
183            last_opp = None;
184            i += 1;
185            continue;
186        }
187        let here = opp_at(i, line_start);
188        if here {
189            last_opp = Some((i, line_w));
190        }
191        let a = advances[i].max(0);
192        if line_w > 0 && line_w + a > max_w && !c.is_whitespace() {
193            if here {
194                out.push(i);
195                line_start = i;
196                line_w = 0;
197                last_opp = None;
198            } else if let Some((j, w_before)) = last_opp {
199                // 単語ごと送る: j から新しい行。j..i の幅を持ち越し、その間の機会を探し直す。
200                out.push(j);
201                line_start = j;
202                line_w -= w_before;
203                last_opp = None;
204                let mut w = 0i32;
205                for k in j..i {
206                    if opp_at(k, line_start) {
207                        last_opp = Some((k, w));
208                    }
209                    w += advances[k].max(0);
210                }
211                // 持ち越した分だけでまだ溢れるなら、もう一度同じ判定をする(長い行の連続送り)。
212                if line_w > 0 && line_w + a > max_w {
213                    continue;
214                }
215            }
216            // 機会が無い(一語が行より長い)→ はみ出させる。
217        }
218        line_w += a;
219        i += 1;
220    }
221    out
222}