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        "comment" | "mdiComment" => String::new(),
47        "ruby" => node["base"].as_str().unwrap_or_default().to_owned(),
48        "image" => node["alt"].as_str().unwrap_or_default().to_owned(),
49        _ => node["value"]
50            .as_str()
51            .map(str::to_owned)
52            .unwrap_or_else(|| crate::children(node).iter().map(visible).collect()),
53    }
54}
55
56fn weight(text: &str) -> usize {
57    text.graphemes(true)
58        .map(|g| {
59            if g.chars()
60                .all(|c| c.is_ascii() || ('\u{ff61}'..='\u{ff9f}').contains(&c))
61            {
62                1
63            } else {
64                2
65            }
66        })
67        .sum()
68}
69
70fn without_comments(mut node: Value) -> Value {
71    if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) {
72        *children = std::mem::take(children)
73            .into_iter()
74            .filter(|child| !matches!(child["type"].as_str(), Some("comment" | "mdiComment")))
75            .map(without_comments)
76            .collect();
77    }
78    node
79}
80
81fn units(nodes: &[Value], wrappers: &[Value], path: &[usize], out: &mut Vec<Unit>) {
82    for (index, node) in nodes.iter().enumerate() {
83        let mut path = path.to_vec();
84        path.push(index);
85        let kind = node["type"].as_str().unwrap_or_default();
86        if matches!(kind, "comment" | "mdiComment") {
87            continue;
88        }
89        if matches!(
90            kind,
91            "strong" | "emphasis" | "delete" | "em" | "kern" | "link"
92        ) {
93            let mut parents = wrappers.to_vec();
94            parents.push(node.clone());
95            units(crate::children(node), &parents, &path, out);
96            continue;
97        }
98        let parts = if kind == "text" {
99            node["value"]
100                .as_str()
101                .unwrap_or_default()
102                .chars()
103                .map(|g| json!({"type":"text", "value":g.to_string()}))
104                .collect::<Vec<_>>()
105        } else {
106            vec![without_comments(node.clone())]
107        };
108        let mut offset = 0;
109        for mut part in parts {
110            let text = visible(&part);
111            let width = weight(&text);
112            for wrapper in wrappers.iter().rev() {
113                let mut parent = wrapper.clone();
114                parent["children"] = json!([part]);
115                part = parent;
116            }
117            out.push(Unit {
118                nodes: vec![part],
119                sources: vec![WarichuSource {
120                    path: path.clone(),
121                    start_utf8: offset,
122                    end_utf8: offset + text.len(),
123                    group: 0,
124                }],
125                is_text: kind == "text",
126                text: text.clone(),
127                width,
128                hard_break: kind == "break",
129            });
130            offset += text.len();
131        }
132    }
133}
134
135// Segment the complete adjacent text run, so formatting cannot split a cluster.
136fn join_graphemes(input: Vec<Unit>) -> Vec<Unit> {
137    let mut result: Vec<Unit> = Vec::new();
138    let mut input = input.into_iter().peekable();
139    while let Some(unit) = input.next() {
140        if !unit.is_text {
141            result.push(unit);
142            continue;
143        }
144        let mut run = vec![unit];
145        while input.peek().is_some_and(|unit| unit.is_text) {
146            run.push(input.next().unwrap());
147        }
148        let text: String = run.iter().map(|unit| unit.text.as_str()).collect();
149        let mut boundaries = text
150            .grapheme_indices(true)
151            .map(|(offset, _)| offset)
152            .peekable();
153        let mut offset = 0;
154        for unit in run {
155            if boundaries.peek() == Some(&offset) {
156                boundaries.next();
157                result.push(unit);
158            } else {
159                let previous = result.last_mut().unwrap();
160                previous.text.push_str(&unit.text);
161                previous.nodes.extend(unit.nodes);
162                previous.sources.extend(unit.sources);
163            }
164            offset += result
165                .last()
166                .unwrap()
167                .text
168                .chars()
169                .last()
170                .unwrap()
171                .len_utf8();
172        }
173    }
174    for (group, unit) in result.iter_mut().enumerate() {
175        unit.width = weight(&unit.text);
176        for source in &mut unit.sources {
177            source.group = group;
178        }
179    }
180    result
181}
182
183// Conservative Japanese line-head / line-end prohibition, including small kana.
184fn legal(left: &Unit, right: &Unit) -> bool {
185    let opening = "(〔[{〈《「『【([{‘“";
186    let closing = "、。,.・:;?!ー々ゝゞヽヾ)〕]}〉》」』】)]},.!?:;’”ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ";
187    !left
188        .text
189        .chars()
190        .last()
191        .is_some_and(|c| opening.contains(c))
192        && !right
193            .text
194            .chars()
195            .next()
196            .is_some_and(|c| closing.contains(c))
197}
198
199fn emit_run(run: &[Unit], options: &WarichuOptions, out: &mut Vec<WarichuFragment>) {
200    let mut start = 0;
201    while start < run.len() {
202        let capacity = if out.is_empty() {
203            options.first_capacity
204        } else {
205            options.continuation_capacity
206        }
207        .max(1);
208        let mut end = start;
209        let mut total = 0;
210        while end < run.len()
211            && (total + run[end].width <= capacity.saturating_mul(2) || end == start)
212        {
213            total += run[end].width;
214            end += 1;
215        }
216        // Move an illegal fragment boundary backwards, or forwards if there is
217        // no legal boundary within capacity. Text always wins over clipping.
218        let target = end;
219        while end > start && end < run.len() && !legal(&run[end - 1], &run[end]) {
220            end -= 1;
221        }
222        if end == start {
223            end = target;
224            while end < run.len() && !legal(&run[end - 1], &run[end]) {
225                end += 1;
226            }
227        }
228        total = run[start..end].iter().map(|u| u.width).sum();
229        let mut first = 0;
230        let mut best = None;
231        for split in start + 1..end {
232            first += run[split - 1].width;
233            if !legal(&run[split - 1], &run[split]) {
234                continue;
235            }
236            let second = total - first;
237            let score = (
238                first.max(second) > capacity,
239                first.abs_diff(second),
240                first < second,
241            );
242            if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
243                best = Some((score, split, first));
244            }
245        }
246        let (_, split, first) = best.unwrap_or(((true, total, false), end, total));
247        let second = total - first;
248        out.push(WarichuFragment {
249            lines: [
250                run[start..split]
251                    .iter()
252                    .flat_map(|u| u.nodes.clone())
253                    .collect(),
254                run[split..end]
255                    .iter()
256                    .flat_map(|u| u.nodes.clone())
257                    .collect(),
258            ],
259            sources: [
260                run[start..split]
261                    .iter()
262                    .flat_map(|u| u.sources.clone())
263                    .collect(),
264                run[split..end]
265                    .iter()
266                    .flat_map(|u| u.sources.clone())
267                    .collect(),
268            ],
269            html: [
270                render_units(&run[start..split]),
271                render_units(&run[split..end]),
272            ],
273            widths: [first, second],
274            overflow: first.max(second) > capacity,
275            hard_break_after: false,
276        });
277        start = end;
278    }
279}
280
281/// Split already parsed inline children; capacity is half-em units at 50% type.
282/// Ruby, tcy, no-break, and unknown inline containers remain indivisible.
283/// An unavailable browser measurement can use 40 (20 note em) as a fallback.
284pub fn layout_warichu(nodes: &[Value], capacity: usize) -> Vec<WarichuFragment> {
285    layout_warichu_with_options(
286        nodes,
287        &WarichuOptions {
288            first_capacity: capacity,
289            continuation_capacity: capacity,
290        },
291    )
292}
293
294pub fn layout_warichu_with_options(
295    nodes: &[Value],
296    options: &WarichuOptions,
297) -> Vec<WarichuFragment> {
298    let mut input = Vec::new();
299    units(nodes, &[], &[], &mut input);
300    let input = join_graphemes(input);
301    let mut out = Vec::new();
302    let mut start = 0;
303    for (index, unit) in input.iter().enumerate() {
304        if unit.hard_break {
305            let before = out.len();
306            emit_run(&input[start..index], options, &mut out);
307            if out.len() == before {
308                out.push(WarichuFragment {
309                    lines: [vec![], vec![]],
310                    sources: [vec![], vec![]],
311                    html: [String::new(), String::new()],
312                    widths: [0, 0],
313                    overflow: false,
314                    hard_break_after: true,
315                });
316            } else {
317                out.last_mut().unwrap().hard_break_after = true;
318            }
319            start = index + 1;
320        }
321    }
322    emit_run(&input[start..], options, &mut out);
323    out
324}
325
326fn render_units(units: &[Unit]) -> String {
327    let mut out = String::new();
328    for unit in units {
329        for node in &unit.nodes {
330            crate::render_html_node(node, &mut out);
331        }
332    }
333    // Nested notes already live at note size. Normalize only the renderer's
334    // generated outer span; escaped author text cannot match this markup.
335    out.replace(
336        "<span class=\"mdi-warichu\" style=\"font-size:.5em;line-height:1\"",
337        "<span class=\"mdi-warichu\" style=\"font-size:1em;line-height:1\"",
338    )
339}
340
341pub fn layout_warichu_options_json(nodes: &str, options: &str) -> Result<String, String> {
342    let nodes: Vec<Value> = serde_json::from_str(nodes).map_err(|e| e.to_string())?;
343    let options: WarichuOptions = serde_json::from_str(options).map_err(|e| e.to_string())?;
344    serde_json::to_string(&layout_warichu_with_options(&nodes, &options)).map_err(|e| e.to_string())
345}
346
347pub(crate) fn render(nodes: &[Value], out: &mut String) {
348    out.push_str("<span class=\"mdi-warichu\" style=\"font-size:.5em;line-height:1\" data-mdi-warichu-source=\"");
349    let source = serde_json::to_string(nodes).unwrap();
350    out.push_str(
351        &source
352            .replace('&', "&amp;")
353            .replace('"', "&quot;")
354            .replace('<', "&lt;")
355            .replace('>', "&gt;"),
356    );
357    out.push_str("\">");
358    for fragment in layout_warichu(nodes, 40) {
359        out.push_str("<span class=\"mdi-warichu-fragment\" style=\"display:inline-flex;flex-direction:column;vertical-align:middle;text-align:start\"");
360        if fragment.overflow {
361            out.push_str(" data-mdi-overflow=\"indivisible\"");
362        }
363        out.push('>');
364        for line in fragment.html {
365            out.push_str("<span class=\"mdi-warichu-line\" style=\"display:block;white-space:nowrap;min-block-size:1em\">");
366            out.push_str(&line);
367            out.push_str("</span>");
368        }
369        out.push_str("</span>");
370        if fragment.hard_break_after {
371            out.push_str("<br>");
372        }
373    }
374    out.push_str("</span>");
375}