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 // ハイフン(ASCII '-', U+2010 HYPHEN, U+00AD SOFT HYPHEN)の後ろでは改行できる。
67 // ただし、次が数字(負の数や数値範囲)または別のハイフンの場合は切らない。
68 if matches!(prev, '-' | '\u{2010}' | '\u{00AD}') {
69 if !next.is_ascii_digit() && next != '-' && next != '\u{2010}' {
70 return true;
71 }
72 }
73 // 表意文字が絡む境目では改行できる。
74 if is_ideographic(prev) || is_ideographic(next) {
75 return true;
76 }
77 // 英数字が連なる部分(=単語の内部)では改行しない。
78 false
79}
80
81/// CSS `white-space` に従って空白を畳む(CSS Text 3 §4.1 の簡略版)。
82///
83/// - `normal`/`nowrap`(未指定含む): 空白・タブ・改行の連続を半角空白 1 つに。
84/// - `pre-line`: 空白・タブの連続は 1 つに、改行は残し、改行の前後の空白は捨てる。
85/// - `pre`/`pre-wrap`/`break-spaces`: 何もしない(保持)。
86///
87/// 対象は ASCII の空白(U+0020/U+0009/U+000A/U+000D/U+000C)のみ。全角空白 U+3000 は
88/// 文字として残す。先頭・末尾の空白も 1 つに畳むだけで消さない(隣接要素との区切りに
89/// なりうるため。行頭・行末の除去は折り返し側で行う)。
90///
91/// 【2026-09-26】HTML の字下げ(改行+タブ 6 個)が畳まれずタブ展開までされ、
92/// www.sugi-lab.net の段落で「IoT」が行頭の空白に押されて 1 行に孤立していた。
93pub fn collapse_white_space(s: &str, white_space: &str) -> alloc::string::String {
94 let ws = white_space.trim();
95 if matches!(ws, "pre" | "pre-wrap" | "break-spaces") {
96 return alloc::string::String::from(s);
97 }
98 let keep_newlines = ws == "pre-line";
99 let is_sp = |c: char| matches!(c, ' ' | '\t' | '\r' | '\u{0C}');
100 let mut out = alloc::string::String::with_capacity(s.len());
101 let mut pending_space = false;
102 let mut pending_newlines = 0usize;
103 for c in s.chars() {
104 if c == '\n' {
105 if keep_newlines {
106 pending_newlines += 1;
107 pending_space = false;
108 } else {
109 pending_space = true;
110 }
111 } else if is_sp(c) {
112 if pending_newlines == 0 {
113 pending_space = true;
114 }
115 } else {
116 if pending_newlines > 0 {
117 // pre-line: 改行の前にあった空白は捨てる。
118 while out.ends_with(' ') {
119 out.pop();
120 }
121 for _ in 0..pending_newlines {
122 out.push('\n');
123 }
124 pending_newlines = 0;
125 } else if pending_space {
126 out.push(' ');
127 }
128 pending_space = false;
129 out.push(c);
130 }
131 }
132 if pending_newlines > 0 {
133 while out.ends_with(' ') {
134 out.pop();
135 }
136 for _ in 0..pending_newlines {
137 out.push('\n');
138 }
139 } else if pending_space {
140 out.push(' ');
141 }
142 out
143}
144
145/// 折り返し位置(新しい行が始まる文字の添字)を求める。`\n` は強制改行として行をリセットする
146/// (位置としては返さない。呼び出し側が `\n` を自分で扱う)。
147///
148/// - 折り返し機会: `can_break_between(前, 次)` かつ「次」が行頭禁則でなく「前」が行末禁則でない
149/// (`break_all` なら常に機会あり)。
150/// - 次の文字で行幅を超えるとき、そこが機会ならそこで、機会でなければ**行内で最後の機会まで
151/// 戻って単語ごと次の行へ送る**。行内に機会が無ければ(一語が行より長い)はみ出させる。
152/// - 空白は行幅を超えても折り返しの引き金にしない(行末の空白はぶら下げる。CSS と同じ)。
153///
154/// 【2026-09-26】従来は計測(`vector_font`)と描画(`web_engine::layout`)に同じ趣旨の実装が
155/// 2 つあり、どちらも「単語を送らずにはみ出させる」簡略版だった。英語の文献リスト
156/// (sugi-lab.net/papers.htm)で単語が窓からはみ出し、描画側で勝手に改行されて重なっていた。
157/// 両者がこの関数を使うことで、箱の高さと実際の行分けが常に一致する。
158///
159/// 計算量: **O(N)**(戻りの走査は各文字高々 1 回)。
160pub fn break_positions(
161 chars: &[char],
162 advances: &[i32],
163 max_w: i32,
164 break_all: bool,
165 start_forbidden: impl Fn(char) -> bool,
166 end_forbidden: impl Fn(char) -> bool,
167) -> alloc::vec::Vec<usize> {
168 break_positions_ex(
169 chars,
170 advances,
171 max_w,
172 break_all,
173 false,
174 start_forbidden,
175 end_forbidden,
176 )
177}
178
179/// 折り返し位置(新しい行が始まる文字の添字)を求める(`overflow-wrap` 拡張対応版)。
180///
181/// `overflow_wrap` が true(`overflow-wrap: break-word` または `overflow-wrap: anywhere`)の場合、
182/// 単語が長すぎて単語送りしても行幅に収まらないときに、はみ出させずに文字境界で緊急分割(emergency break)する。
183pub fn break_positions_ex(
184 chars: &[char],
185 advances: &[i32],
186 max_w: i32,
187 break_all: bool,
188 overflow_wrap: bool,
189 start_forbidden: impl Fn(char) -> bool,
190 end_forbidden: impl Fn(char) -> bool,
191) -> alloc::vec::Vec<usize> {
192 let n = chars.len().min(advances.len());
193 let mut out = alloc::vec::Vec::new();
194 let mut line_start = 0usize;
195 let mut line_w = 0i32;
196 // 行内で最後の折り返し機会(添字, その手前までの行幅)。
197 let mut last_opp: Option<(usize, i32)> = None;
198 let opp_at = |i: usize, start: usize| -> bool {
199 if i <= start || i >= n {
200 return false;
201 }
202 let (p, c) = (chars[i - 1], chars[i]);
203 if p == '\n' {
204 return false;
205 }
206 break_all || (can_break_between(p, c, false) && !start_forbidden(c) && !end_forbidden(p))
207 };
208 let mut i = 0usize;
209 while i < n {
210 let c = chars[i];
211 if c == '\n' {
212 line_start = i + 1;
213 line_w = 0;
214 last_opp = None;
215 i += 1;
216 continue;
217 }
218 let here = opp_at(i, line_start);
219 if here {
220 last_opp = Some((i, line_w));
221 }
222 let a = advances[i].max(0);
223 if line_w > 0 && line_w + a > max_w && !c.is_whitespace() {
224 if here {
225 out.push(i);
226 line_start = i;
227 line_w = 0;
228 last_opp = None;
229 } else if let Some((j, w_before)) = last_opp {
230 // 単語ごと送る: j から新しい行。j..i の幅を持ち越し、その間の機会を探し直す。
231 out.push(j);
232 line_start = j;
233 line_w -= w_before;
234 last_opp = None;
235 let mut w = 0i32;
236 for k in j..i {
237 if opp_at(k, line_start) {
238 last_opp = Some((k, w));
239 }
240 w += advances[k].max(0);
241 }
242 // 持ち越した分だけでまだ溢れるなら、もう一度同じ判定をする(長い行の連続送り)。
243 if line_w > 0 && line_w + a > max_w {
244 continue;
245 }
246 } else if overflow_wrap && i > line_start {
247 // 機会が無い(一語が行より長い)が、overflow_wrap 有効時:
248 // 現在の文字の手前で緊急分割して次行へ送る。
249 out.push(i);
250 line_start = i;
251 line_w = 0;
252 last_opp = None;
253 }
254 // 機会が無く overflow_wrap も無効 → はみ出させる。
255 }
256 line_w += a;
257 i += 1;
258 }
259 out
260}