1use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use unicode_segmentation::UnicodeSegmentation;
5
6#[derive(Debug, Clone, Deserialize, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub struct WarichuOptions {
9 pub first_capacity: usize,
10 #[serde(alias = "capacity")]
11 pub continuation_capacity: usize,
12}
13
14#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct WarichuSource {
17 pub path: Vec<usize>,
18 pub start_utf8: usize,
19 pub end_utf8: usize,
20 pub group: usize,
21}
22
23#[derive(Debug, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct WarichuFragment {
26 pub lines: [Vec<Value>; 2],
27 pub sources: [Vec<WarichuSource>; 2],
28 pub html: [String; 2],
29 pub widths: [usize; 2],
31 pub overflow: bool,
32 pub hard_break_after: bool,
33}
34
35struct Unit {
36 nodes: Vec<Value>,
37 sources: Vec<WarichuSource>,
38 is_text: bool,
39 text: String,
40 width: usize,
41 hard_break: bool,
42}
43
44fn visible(node: &Value) -> String {
45 match node["type"].as_str().unwrap_or_default() {
46 "ruby" => node["base"].as_str().unwrap_or_default().to_owned(),
47 "image" => node["alt"].as_str().unwrap_or_default().to_owned(),
48 _ => node["value"]
49 .as_str()
50 .map(str::to_owned)
51 .unwrap_or_else(|| crate::children(node).iter().map(visible).collect()),
52 }
53}
54
55fn weight(text: &str) -> usize {
56 text.graphemes(true)
57 .map(|g| {
58 if g.chars()
59 .all(|c| c.is_ascii() || ('\u{ff61}'..='\u{ff9f}').contains(&c))
60 {
61 1
62 } else {
63 2
64 }
65 })
66 .sum()
67}
68
69fn units(nodes: &[Value], wrappers: &[Value], path: &[usize], out: &mut Vec<Unit>) {
70 for (index, node) in nodes.iter().enumerate() {
71 let mut path = path.to_vec();
72 path.push(index);
73 let kind = node["type"].as_str().unwrap_or_default();
74 if matches!(
75 kind,
76 "strong" | "emphasis" | "delete" | "em" | "kern" | "link"
77 ) {
78 let mut parents = wrappers.to_vec();
79 parents.push(node.clone());
80 units(crate::children(node), &parents, &path, out);
81 continue;
82 }
83 let parts = if kind == "text" {
84 node["value"]
85 .as_str()
86 .unwrap_or_default()
87 .chars()
88 .map(|g| json!({"type":"text", "value":g.to_string()}))
89 .collect::<Vec<_>>()
90 } else {
91 vec![node.clone()]
92 };
93 let mut offset = 0;
94 for mut part in parts {
95 let text = visible(&part);
96 let width = weight(&text);
97 for wrapper in wrappers.iter().rev() {
98 let mut parent = wrapper.clone();
99 parent["children"] = json!([part]);
100 part = parent;
101 }
102 out.push(Unit {
103 nodes: vec![part],
104 sources: vec![WarichuSource {
105 path: path.clone(),
106 start_utf8: offset,
107 end_utf8: offset + text.len(),
108 group: 0,
109 }],
110 is_text: kind == "text",
111 text: text.clone(),
112 width,
113 hard_break: kind == "break",
114 });
115 offset += text.len();
116 }
117 }
118}
119
120fn join_graphemes(input: Vec<Unit>) -> Vec<Unit> {
122 let mut result: Vec<Unit> = Vec::new();
123 let mut input = input.into_iter().peekable();
124 while let Some(unit) = input.next() {
125 if !unit.is_text {
126 result.push(unit);
127 continue;
128 }
129 let mut run = vec![unit];
130 while input.peek().is_some_and(|unit| unit.is_text) {
131 run.push(input.next().unwrap());
132 }
133 let text: String = run.iter().map(|unit| unit.text.as_str()).collect();
134 let mut boundaries = text
135 .grapheme_indices(true)
136 .map(|(offset, _)| offset)
137 .peekable();
138 let mut offset = 0;
139 for unit in run {
140 if boundaries.peek() == Some(&offset) {
141 boundaries.next();
142 result.push(unit);
143 } else {
144 let previous = result.last_mut().unwrap();
145 previous.text.push_str(&unit.text);
146 previous.nodes.extend(unit.nodes);
147 previous.sources.extend(unit.sources);
148 }
149 offset += result
150 .last()
151 .unwrap()
152 .text
153 .chars()
154 .last()
155 .unwrap()
156 .len_utf8();
157 }
158 }
159 for (group, unit) in result.iter_mut().enumerate() {
160 unit.width = weight(&unit.text);
161 for source in &mut unit.sources {
162 source.group = group;
163 }
164 }
165 result
166}
167
168fn legal(left: &Unit, right: &Unit) -> bool {
170 let opening = "(〔[{〈《「『【([{‘“";
171 let closing = "、。,.・:;?!ー々ゝゞヽヾ)〕]}〉》」』】)]},.!?:;’”ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ";
172 !left
173 .text
174 .chars()
175 .last()
176 .is_some_and(|c| opening.contains(c))
177 && !right
178 .text
179 .chars()
180 .next()
181 .is_some_and(|c| closing.contains(c))
182}
183
184fn emit_run(run: &[Unit], options: &WarichuOptions, out: &mut Vec<WarichuFragment>) {
185 let mut start = 0;
186 while start < run.len() {
187 let capacity = if out.is_empty() {
188 options.first_capacity
189 } else {
190 options.continuation_capacity
191 }
192 .max(1);
193 let mut end = start;
194 let mut total = 0;
195 while end < run.len()
196 && (total + run[end].width <= capacity.saturating_mul(2) || end == start)
197 {
198 total += run[end].width;
199 end += 1;
200 }
201 let target = end;
204 while end > start && end < run.len() && !legal(&run[end - 1], &run[end]) {
205 end -= 1;
206 }
207 if end == start {
208 end = target;
209 while end < run.len() && !legal(&run[end - 1], &run[end]) {
210 end += 1;
211 }
212 }
213 total = run[start..end].iter().map(|u| u.width).sum();
214 let mut first = 0;
215 let mut best = None;
216 for split in start + 1..end {
217 first += run[split - 1].width;
218 if !legal(&run[split - 1], &run[split]) {
219 continue;
220 }
221 let second = total - first;
222 let score = (
223 first.max(second) > capacity,
224 first.abs_diff(second),
225 first < second,
226 );
227 if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
228 best = Some((score, split, first));
229 }
230 }
231 let (_, split, first) = best.unwrap_or(((true, total, false), end, total));
232 let second = total - first;
233 out.push(WarichuFragment {
234 lines: [
235 run[start..split]
236 .iter()
237 .flat_map(|u| u.nodes.clone())
238 .collect(),
239 run[split..end]
240 .iter()
241 .flat_map(|u| u.nodes.clone())
242 .collect(),
243 ],
244 sources: [
245 run[start..split]
246 .iter()
247 .flat_map(|u| u.sources.clone())
248 .collect(),
249 run[split..end]
250 .iter()
251 .flat_map(|u| u.sources.clone())
252 .collect(),
253 ],
254 html: [
255 render_units(&run[start..split]),
256 render_units(&run[split..end]),
257 ],
258 widths: [first, second],
259 overflow: first.max(second) > capacity,
260 hard_break_after: false,
261 });
262 start = end;
263 }
264}
265
266pub fn layout_warichu(nodes: &[Value], capacity: usize) -> Vec<WarichuFragment> {
270 layout_warichu_with_options(
271 nodes,
272 &WarichuOptions {
273 first_capacity: capacity,
274 continuation_capacity: capacity,
275 },
276 )
277}
278
279pub fn layout_warichu_with_options(
280 nodes: &[Value],
281 options: &WarichuOptions,
282) -> Vec<WarichuFragment> {
283 let mut input = Vec::new();
284 units(nodes, &[], &[], &mut input);
285 let input = join_graphemes(input);
286 let mut out = Vec::new();
287 let mut start = 0;
288 for (index, unit) in input.iter().enumerate() {
289 if unit.hard_break {
290 let before = out.len();
291 emit_run(&input[start..index], options, &mut out);
292 if out.len() == before {
293 out.push(WarichuFragment {
294 lines: [vec![], vec![]],
295 sources: [vec![], vec![]],
296 html: [String::new(), String::new()],
297 widths: [0, 0],
298 overflow: false,
299 hard_break_after: true,
300 });
301 } else {
302 out.last_mut().unwrap().hard_break_after = true;
303 }
304 start = index + 1;
305 }
306 }
307 emit_run(&input[start..], options, &mut out);
308 out
309}
310
311fn render_units(units: &[Unit]) -> String {
312 let mut out = String::new();
313 for unit in units {
314 for node in &unit.nodes {
315 crate::render_html_node(node, &mut out);
316 }
317 }
318 out.replace(
321 "<span class=\"mdi-warichu\" style=\"font-size:.5em;line-height:1\"",
322 "<span class=\"mdi-warichu\" style=\"font-size:1em;line-height:1\"",
323 )
324}
325
326pub fn layout_warichu_options_json(nodes: &str, options: &str) -> Result<String, String> {
327 let nodes: Vec<Value> = serde_json::from_str(nodes).map_err(|e| e.to_string())?;
328 let options: WarichuOptions = serde_json::from_str(options).map_err(|e| e.to_string())?;
329 serde_json::to_string(&layout_warichu_with_options(&nodes, &options)).map_err(|e| e.to_string())
330}
331
332pub(crate) fn render(nodes: &[Value], out: &mut String) {
333 out.push_str("<span class=\"mdi-warichu\" style=\"font-size:.5em;line-height:1\" data-mdi-warichu-source=\"");
334 let source = serde_json::to_string(nodes).unwrap();
335 out.push_str(
336 &source
337 .replace('&', "&")
338 .replace('"', """)
339 .replace('<', "<")
340 .replace('>', ">"),
341 );
342 out.push_str("\">");
343 for fragment in layout_warichu(nodes, 40) {
344 out.push_str("<span class=\"mdi-warichu-fragment\" style=\"display:inline-flex;flex-direction:column;vertical-align:middle;text-align:start\"");
345 if fragment.overflow {
346 out.push_str(" data-mdi-overflow=\"indivisible\"");
347 }
348 out.push('>');
349 for line in fragment.html {
350 out.push_str("<span class=\"mdi-warichu-line\" style=\"display:block;white-space:nowrap;min-block-size:1em\">");
351 out.push_str(&line);
352 out.push_str("</span>");
353 }
354 out.push_str("</span>");
355 if fragment.hard_break_after {
356 out.push_str("<br>");
357 }
358 }
359 out.push_str("</span>");
360}