Skip to main content

odox_core/edit/
block.rs

1//! What a paragraph is: body text or a heading of some level, and whether it
2//! is an item of a list. DESIGN.md ยง11.
3//!
4//! A heading is a `text:h` with an outline level and the paragraph style the
5//! document gives headings of that level, and a document that has none is
6//! given an automatic one in `content.xml` that says only size and weight.
7//! Taking the heading off gives back a `text:p` in the style most of the
8//! document's paragraphs have. A list is a `text:list` of `text:list-item`s
9//! each holding a paragraph; a run of paragraphs side by side becomes one
10//! list, so that numbers run on, and taking the list off stands each
11//! paragraph where its item was.
12//!
13//! None of it adds or removes a paragraph, so a paragraph's place in the
14//! order the paragraphs are drawn in is the same after, which is how a caret
15//! finds it again.
16//
17// Author: David M. Anderson
18// Built with AI assistance (Claude, Anthropic)
19
20use std::collections::BTreeMap;
21
22use super::format::{automatic_styles, element, unnamed};
23use super::{Refused, is_paragraph, take_out_of_list};
24use crate::style::{Family, ListKind, Styles};
25use crate::xml::{Element, Node, Ns};
26
27/// A paragraph's heading level, if it is a heading.
28pub fn heading_level(paragraph: &Element) -> Option<u8> {
29    paragraph.is(&Ns::Text, "h").then(|| {
30        paragraph
31            .attr_usize(&Ns::Text, "outline-level")
32            .and_then(|level| u8::try_from(level).ok())
33            .unwrap_or(1)
34    })
35}
36
37/// A paragraph's heading level and the kind of list it is an item of, for a
38/// button to light. A paragraph in a list whose style the document does not
39/// give is taken for a bulleted one, which is how it is drawn.
40pub fn block_state(
41    content: &Element,
42    path: &[usize],
43    styles: &Styles,
44) -> (Option<u8>, Option<ListKind>) {
45    let Some(paragraph) = content.at(path).filter(|e| is_paragraph(e)) else {
46        return (None, None);
47    };
48    (
49        heading_level(paragraph),
50        list_kind_of(content, path, styles),
51    )
52}
53
54/// Whether an element is what holds a paragraph in a list.
55fn is_item(element: &Element) -> bool {
56    element.is(&Ns::Text, "list-item") || element.is(&Ns::Text, "list-header")
57}
58
59/// The kind of list a paragraph is an item of, by the nearest list above it
60/// that names a style.
61fn list_kind_of(content: &Element, path: &[usize], styles: &Styles) -> Option<ListKind> {
62    let (_, item_path) = path.split_last()?;
63    if !is_item(content.at(item_path)?) {
64        return None;
65    }
66    let mut at = item_path;
67    while let Some((_, above)) = at.split_last() {
68        at = above;
69        let Some(list) = content.at(at).filter(|e| e.is(&Ns::Text, "list")) else {
70            continue;
71        };
72        if let Some(name) = list.attr(&Ns::Text, "style-name") {
73            return Some(styles.list_kind(name).unwrap_or(ListKind::Bullet));
74        }
75    }
76    Some(ListKind::Bullet)
77}
78
79/// What the namespaces an edit writes in have to be declared.
80pub(super) fn declares(content: &Element, spaces: &[Ns]) -> Result<(), Refused> {
81    spaces
82        .iter()
83        .all(|space| content.declares(space))
84        .then_some(())
85        .ok_or(Refused::Namespace)
86}
87
88/// Make a paragraph a heading of a level, or body text again.
89///
90/// A paragraph that is already what is asked for is left alone. The
91/// paragraph's own style is replaced, which is what applying a paragraph style
92/// is; its text, its spans and everything in it stay.
93///
94/// A document with no `office:automatic-styles` is given one before its body
95/// when a heading style has to be written, which moves the body one place
96/// along: a path held from before the edit is to be taken again.
97///
98/// # Errors
99///
100/// The path does not lead to a paragraph, or the content root does not
101/// declare a namespace the edit writes in. Each is refused before anything
102/// changes.
103pub fn set_heading(
104    content: &mut Element,
105    path: &[usize],
106    level: Option<u8>,
107    styles: &mut Styles,
108) -> Result<(), Refused> {
109    let paragraph = content
110        .at(path)
111        .filter(|e| is_paragraph(e))
112        .ok_or(Refused::NotFound)?;
113    let level = level.map(|level| level.clamp(1, 10));
114    if heading_level(paragraph) == level {
115        return Ok(());
116    }
117    declares(content, &[Ns::Text, Ns::Style, Ns::Fo])?;
118
119    let (style, minted) = match level {
120        Some(level) => heading_style(content, styles, level),
121        None => (body_style(content), None),
122    };
123    let name = content.name_for(&Ns::Text, if level.is_some() { "h" } else { "p" });
124    let outline = content.name_for(&Ns::Text, "outline-level");
125    let style_name = content.name_for(&Ns::Text, "style-name");
126    let paragraph = content.at_mut(path).ok_or(Refused::NotFound)?;
127    paragraph.name = name;
128    paragraph.remove_attr(&Ns::Text, "outline-level");
129    paragraph.remove_attr(&Ns::Text, "style-name");
130    if let Some(level) = level {
131        paragraph.set_attr(outline, level.to_string());
132    }
133    if let Some(style) = style {
134        paragraph.set_attr(style_name, style);
135    }
136    if let Some(minted) = minted {
137        let container = automatic_styles(content);
138        container.children.push(Node::Element(minted));
139        container.self_closing = false;
140    }
141    Ok(())
142}
143
144/// The style headings of a level take: the document's own, or one written for
145/// the purpose, which is answered too.
146fn heading_style(
147    content: &Element,
148    styles: &mut Styles,
149    level: u8,
150) -> (Option<String>, Option<Element>) {
151    if let Some(own) = styles.heading_style(level) {
152        return (Some(own.to_owned()), None);
153    }
154    let mut style = element(content.name_for(&Ns::Style, "style"));
155    let set = |element: &mut Element, ns: Ns, local: &str, value: &str| {
156        element.set_attr(content.name_for(&ns, local), value);
157    };
158    set(&mut style, Ns::Style, "family", "paragraph");
159    let mut paragraph = element(content.name_for(&Ns::Style, "paragraph-properties"));
160    set(&mut paragraph, Ns::Fo, "margin-top", "0.423cm");
161    set(&mut paragraph, Ns::Fo, "margin-bottom", "0.212cm");
162    set(&mut paragraph, Ns::Fo, "keep-with-next", "always");
163    let mut text = element(content.name_for(&Ns::Style, "text-properties"));
164    let size = match level {
165        1 => "18pt",
166        2 => "14pt",
167        3 => "12pt",
168        _ => "11pt",
169    };
170    set(&mut text, Ns::Fo, "font-size", size);
171    set(&mut text, Ns::Style, "font-size-asian", size);
172    set(&mut text, Ns::Style, "font-size-complex", size);
173    set(&mut text, Ns::Fo, "font-weight", "bold");
174    set(&mut text, Ns::Style, "font-weight-asian", "bold");
175    set(&mut text, Ns::Style, "font-weight-complex", "bold");
176    for properties in [paragraph, text] {
177        style.children.push(Node::Element(properties));
178    }
179    style.self_closing = false;
180    let (name, fresh) = name_for(content, styles, style, &Family::Paragraph, "P");
181    (Some(name), fresh)
182}
183
184/// The name of an automatic style that says what this one does: one the
185/// document already has, or this one written under the first name no style of
186/// the family has, which is answered as the style to write.
187pub(super) fn name_for(
188    content: &Element,
189    styles: &mut Styles,
190    mut style: Element,
191    family: &Family,
192    prefix: &str,
193) -> (String, Option<Element>) {
194    let wanted = unnamed(&style);
195    let family_name = style.attr(&Ns::Style, "family").map(ToOwned::to_owned);
196    if let Some(existing) = content
197        .child(&Ns::Office, "automatic-styles")
198        .into_iter()
199        .flat_map(Element::elements)
200        .filter(|s| {
201            s.is(&Ns::Style, "style") && s.attr(&Ns::Style, "family") == family_name.as_deref()
202        })
203        .find(|s| unnamed(s) == wanted)
204        .and_then(|s| s.attr(&Ns::Style, "name"))
205    {
206        return (existing.to_owned(), None);
207    }
208    let mut number = 1;
209    while styles.has_style(family, &format!("{prefix}{number}")) {
210        number += 1;
211    }
212    let name = format!("{prefix}{number}");
213    style.attrs.insert(
214        0,
215        crate::xml::Attribute {
216            name: content.name_for(&Ns::Style, "name"),
217            value: name.clone(),
218        },
219    );
220    styles.add(&style);
221    (name, Some(style))
222}
223
224/// The paragraph style most of the document's body paragraphs have, where a
225/// heading taken off goes back to: none, where most have none.
226fn body_style(content: &Element) -> Option<String> {
227    let body = content
228        .child(&Ns::Office, "body")
229        .and_then(|body| body.child(&Ns::Office, "text"))?;
230    let mut counts: BTreeMap<Option<&str>, usize> = BTreeMap::new();
231    for paragraph in body.elements().filter(|e| e.is(&Ns::Text, "p")) {
232        *counts
233            .entry(paragraph.attr(&Ns::Text, "style-name"))
234            .or_default() += 1;
235    }
236    counts
237        .into_iter()
238        .rev()
239        .max_by_key(|&(_, count)| count)
240        .and_then(|(style, _)| style.map(ToOwned::to_owned))
241}
242
243/// Make paragraphs items of a list of a kind, or take them out of the list
244/// they are in. The paths are of paragraphs, in the order they are drawn in.
245///
246/// With a kind, a paragraph in no list is put in one, side by side with the
247/// others that are, so that a run of them is one list and its numbers run on;
248/// one already in a list of another kind has its list given this kind's style;
249/// one in a list of this kind is left. Without a kind, each is taken out of
250/// its list, and what else its item held, a list nested under it, stands
251/// beside it.
252///
253/// A document with no list style of the kind is given one in
254/// `office:automatic-styles`, which if the document has none is written
255/// before the body and moves it one place along: a path held from before the
256/// edit is to be taken again.
257///
258/// # Errors
259///
260/// A path does not lead to a paragraph, or the content root does not declare a
261/// namespace the edit writes in. Each is refused before anything changes.
262pub fn set_list(
263    content: &mut Element,
264    paths: &[Vec<usize>],
265    kind: Option<ListKind>,
266    styles: &mut Styles,
267) -> Result<(), Refused> {
268    for path in paths {
269        content
270            .at(path)
271            .filter(|e| is_paragraph(e))
272            .ok_or(Refused::NotFound)?;
273    }
274    declares(content, &[Ns::Text, Ns::Style, Ns::Fo])?;
275    let Some(kind) = kind else {
276        take_out(content, paths);
277        return Ok(());
278    };
279
280    let (style, minted) = list_style(content, styles, kind);
281    let listed: Vec<&Vec<usize>> = paths
282        .iter()
283        .filter(|path| list_kind_of(content, path, styles).is_some())
284        .collect();
285    let style_name = content.name_for(&Ns::Text, "style-name");
286    for path in &listed {
287        if list_kind_of(content, path, styles) != Some(kind) {
288            let item = path.len() - 1;
289            if let Some(list) = content
290                .at_mut(&path[..item - 1])
291                .filter(|e| e.is(&Ns::Text, "list"))
292            {
293                list.set_attr(style_name.clone(), style.clone());
294            }
295        }
296    }
297    let unlisted: Vec<Vec<usize>> = paths
298        .iter()
299        .filter(|path| list_kind_of(content, path, styles).is_none())
300        .cloned()
301        .collect();
302    wrap(content, &unlisted, &style);
303    if let Some(minted) = minted {
304        let container = automatic_styles(content);
305        container.children.push(Node::Element(minted));
306        container.self_closing = false;
307    }
308    Ok(())
309}
310
311/// Take each paragraph's item out of its list, last first so that the paths of
312/// the ones before hold, and each item once.
313fn take_out(content: &mut Element, paths: &[Vec<usize>]) {
314    let mut items: Vec<Vec<usize>> = paths
315        .iter()
316        .filter_map(|path| {
317            let (_, item) = path.split_last()?;
318            is_item(content.at(item)?).then(|| item.to_vec())
319        })
320        .collect();
321    items.sort();
322    items.dedup();
323    for item in items.iter().rev() {
324        let _ = take_out_of_list(content, item);
325    }
326}
327
328/// Put paragraphs that are in no list in lists of a style: each run of them
329/// that stand side by side in one parent in one list, the last run first so
330/// that the paths of the ones before hold.
331fn wrap(content: &mut Element, paths: &[Vec<usize>], style: &str) {
332    let mut runs: Vec<(Vec<usize>, Vec<usize>)> = Vec::new();
333    for path in paths {
334        let Some((&at, parent)) = path.split_last() else {
335            continue;
336        };
337        let joins = runs.last().is_some_and(|(above, indices)| {
338            above == parent
339                && indices.last().is_some_and(|&last| {
340                    content.at(parent).is_some_and(|holder| {
341                        holder.children[last + 1..at]
342                            .iter()
343                            .all(|node| matches!(node, Node::Text(t) if t.trim().is_empty()))
344                    })
345                })
346        });
347        if joins {
348            if let Some((_, indices)) = runs.last_mut() {
349                indices.push(at);
350            }
351        } else {
352            runs.push((parent.to_vec(), vec![at]));
353        }
354    }
355    let list_name = content.name_for(&Ns::Text, "list");
356    let item_name = content.name_for(&Ns::Text, "list-item");
357    let style_name = content.name_for(&Ns::Text, "style-name");
358    for (parent, indices) in runs.iter().rev() {
359        let (Some(&first), Some(&last)) = (indices.first(), indices.last()) else {
360            continue;
361        };
362        let Some(holder) = content.at_mut(parent) else {
363            continue;
364        };
365        let mut wrapped = element(list_name.clone());
366        wrapped.set_attr(style_name.clone(), style);
367        wrapped.self_closing = false;
368        for node in &holder.children[first..=last] {
369            match node {
370                Node::Element(paragraph) if is_paragraph(paragraph) => {
371                    let mut item = element(item_name.clone());
372                    item.children.push(Node::Element(paragraph.clone()));
373                    item.self_closing = false;
374                    wrapped.children.push(Node::Element(item));
375                }
376                other => wrapped.children.push(other.clone()),
377            }
378        }
379        holder
380            .children
381            .splice(first..=last, [Node::Element(wrapped)]);
382    }
383}
384
385/// The list style a kind of list takes: the document's own, or one written
386/// for the purpose, which is answered too.
387fn list_style(content: &Element, styles: &mut Styles, kind: ListKind) -> (String, Option<Element>) {
388    if let Some(own) = styles.list_style_for(kind) {
389        return (own.to_owned(), None);
390    }
391    let mut number = 1;
392    while styles.list_style(&format!("L{number}")).is_some() {
393        number += 1;
394    }
395    let name = format!("L{number}");
396    let set = |element: &mut Element, ns: Ns, local: &str, value: &str| {
397        element.set_attr(content.name_for(&ns, local), value);
398    };
399    let mut style = element(content.name_for(&Ns::Text, "list-style"));
400    set(&mut style, Ns::Style, "name", &name);
401    style.self_closing = false;
402    for level in 1..=6u8 {
403        let tag = match kind {
404            ListKind::Bullet => "list-level-style-bullet",
405            ListKind::Number => "list-level-style-number",
406        };
407        let mut entry = element(content.name_for(&Ns::Text, tag));
408        set(&mut entry, Ns::Text, "level", &level.to_string());
409        match kind {
410            ListKind::Bullet => set(&mut entry, Ns::Text, "bullet-char", "\u{2022}"),
411            ListKind::Number => {
412                set(&mut entry, Ns::Style, "num-suffix", ".");
413                set(&mut entry, Ns::Style, "num-format", "1");
414            }
415        }
416        let indent = format!("{:.3}cm", 0.635 * (f32::from(level) + 1.0));
417        let mut properties = element(content.name_for(&Ns::Style, "list-level-properties"));
418        set(
419            &mut properties,
420            Ns::Text,
421            "list-level-position-and-space-mode",
422            "label-alignment",
423        );
424        let mut alignment = element(content.name_for(&Ns::Style, "list-level-label-alignment"));
425        set(&mut alignment, Ns::Text, "label-followed-by", "listtab");
426        set(&mut alignment, Ns::Text, "list-tab-stop-position", &indent);
427        set(&mut alignment, Ns::Fo, "text-indent", "-0.635cm");
428        set(&mut alignment, Ns::Fo, "margin-left", &indent);
429        properties.children.push(Node::Element(alignment));
430        properties.self_closing = false;
431        entry.children.push(Node::Element(properties));
432        entry.self_closing = false;
433        style.children.push(Node::Element(entry));
434    }
435    styles.add(&style);
436    (name, Some(style))
437}