Skip to main content

mdi_core/
warichu.rs

1//! Presentation-only warichu layout. Never write the generated splits into IR.
2use 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    /// Widths in half-em units at the note's font size.
30    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
120// Segment the complete adjacent text run, so formatting cannot split a cluster.
121fn 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
168// Conservative Japanese line-head / line-end prohibition, including small kana.
169fn 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        // Move an illegal fragment boundary backwards, or forwards if there is
202        // no legal boundary within capacity. Text always wins over clipping.
203        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
266/// Split already parsed inline children; capacity is half-em units at 50% type.
267/// Ruby, tcy, no-break, and unknown inline containers remain indivisible.
268/// An unavailable browser measurement can use 40 (20 note em) as a fallback.
269pub 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    // Nested notes already live at note size. Normalize only the renderer's
319    // generated outer span; escaped author text cannot match this markup.
320    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('&', "&amp;")
338            .replace('"', "&quot;")
339            .replace('<', "&lt;")
340            .replace('>', "&gt;"),
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}