Skip to main content

odox_core/edit/
figure.rs

1//! What a picture says to someone who cannot see it.
2//!
3//! A frame holding a picture is a figure, and a figure either has alternative
4//! text, the `svg:title` inside the frame that `LibreOffice` calls its text
5//! alternative, or the longer `svg:desc`, or is decoration that says nothing.
6//! ODF has no decorative flag of its own; `LibreOffice` writes
7//! `loext:decorative` into the frame's graphic style, and that is read and
8//! written here so that a document marked in one application reads the same in
9//! the other.
10//
11// Author: David M. Anderson
12// Built with AI assistance (Claude, Anthropic)
13
14use super::Refused;
15use super::block::{declares, name_for};
16use super::format::{automatic_styles, element};
17use crate::style::{Family, Styles};
18use crate::xml::{Element, Node, Ns};
19
20/// What a figure says in place of its picture.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Description {
23    /// Its alternative text.
24    Text(String),
25    /// Nothing: it is decoration.
26    Decorative,
27    /// Nobody has said.
28    Missing,
29}
30
31/// Whether a frame is a figure: one that holds a picture.
32pub fn is_figure(frame: &Element) -> bool {
33    frame.is(&Ns::Draw, "frame") && frame.child(&Ns::Draw, "image").is_some()
34}
35
36/// The figures under an element, in reading order, as paths from it.
37///
38/// A note's body is not read: it is not drawn, so a picture in it is not shown
39/// to anyone.
40pub fn figures(root: &Element) -> Vec<Vec<usize>> {
41    let mut found = Vec::new();
42    collect(root, &mut Vec::new(), &mut found);
43    found
44}
45
46fn collect(parent: &Element, path: &mut Vec<usize>, into: &mut Vec<Vec<usize>>) {
47    for (index, child) in parent.elements_indexed() {
48        if child.is(&Ns::Text, "note") {
49            continue;
50        }
51        path.push(index);
52        if is_figure(child) {
53            into.push(path.clone());
54        } else {
55            collect(child, path, into);
56        }
57        path.pop();
58    }
59}
60
61/// What a figure says in place of its picture.
62///
63/// Decoration wins over a title, because a person who marked a picture
64/// decorative has said it is to be skipped whatever text it still carries.
65pub fn description(frame: &Element, styles: &Styles) -> Description {
66    let decorative = frame
67        .attr(&Ns::Draw, "style-name")
68        .map(|name| styles.resolve(&Family::Graphic, name))
69        .and_then(|properties| properties.graphic.decorative)
70        .unwrap_or(false);
71    if decorative {
72        return Description::Decorative;
73    }
74    ["title", "desc"]
75        .into_iter()
76        .filter_map(|local| frame.child(&Ns::Svg, local))
77        .map(|text| text.plain_text().trim().to_owned())
78        .find(|text| !text.is_empty())
79        .map_or(Description::Missing, Description::Text)
80}
81
82/// Give the figure at a path under the content root its alternative text,
83/// replacing what its `svg:title` said.
84///
85/// The SVG namespace is declared on the content root where it is not.
86///
87/// # Errors
88///
89/// The path does not lead to a figure, or the SVG namespace's prefix is taken
90/// by another. Each is refused before anything changes.
91pub fn set_alternative_text(
92    content: &mut Element,
93    path: &[usize],
94    text: &str,
95) -> Result<(), Refused> {
96    content
97        .at(path)
98        .filter(|e| is_figure(e))
99        .ok_or(Refused::NotFound)?;
100    if !content.declare(&Ns::Svg) {
101        return Err(Refused::Namespace);
102    }
103    let mut title = element(content.name_for(&Ns::Svg, "title"));
104    title.children.push(Node::Text(text.to_owned()));
105    title.self_closing = false;
106
107    let frame = content.at_mut(path).ok_or(Refused::NotFound)?;
108    let existing = frame
109        .children
110        .iter()
111        .position(|n| matches!(n, Node::Element(e) if e.is(&Ns::Svg, "title")));
112    if let Some(at) = existing {
113        frame.children[at] = Node::Element(title);
114    } else {
115        // After the picture and before a description or a contour, which is
116        // the order the schema gives a frame's children.
117        let at = frame
118            .children
119            .iter()
120            .position(|n| {
121                matches!(n, Node::Element(e)
122                    if e.is(&Ns::Svg, "desc")
123                        || e.is(&Ns::Draw, "contour-polygon")
124                        || e.is(&Ns::Draw, "contour-path"))
125            })
126            .unwrap_or(frame.children.len());
127        frame.children.insert(at, Node::Element(title));
128        frame.self_closing = false;
129    }
130    Ok(())
131}
132
133/// Mark the figure at a path under the content root as decoration.
134///
135/// `LibreOffice`'s namespace is declared on the content root where it is not,
136/// since a document another application wrote has no other way to say it.
137/// The frame is given an automatic graphic style that says so: a copy of the
138/// one it has where that one is automatic, so that nothing else it says is
139/// lost, and otherwise a style that inherits from the one it names. A document
140/// with no `office:automatic-styles` is given one before its body, which moves
141/// the body one place along: a path held from before the edit is to be taken
142/// again.
143///
144/// # Errors
145///
146/// The path does not lead to a figure, the content root does not declare the
147/// style or drawing namespace, or `LibreOffice`'s prefix is another
148/// namespace's there. Each is refused before anything changes.
149pub fn set_decorative(
150    content: &mut Element,
151    path: &[usize],
152    styles: &mut Styles,
153) -> Result<(), Refused> {
154    let frame = content
155        .at(path)
156        .filter(|e| is_figure(e))
157        .ok_or(Refused::NotFound)?;
158    if description(frame, styles) == Description::Decorative {
159        return Ok(());
160    }
161    let current = frame.attr(&Ns::Draw, "style-name").map(ToOwned::to_owned);
162    declares(content, &[Ns::Draw, Ns::Style])?;
163    if !content.declare(&Ns::Loext) {
164        return Err(Refused::Namespace);
165    }
166
167    let automatic = current.as_deref().and_then(|name| {
168        content
169            .child(&Ns::Office, "automatic-styles")?
170            .elements()
171            .find(|s| {
172                s.is(&Ns::Style, "style")
173                    && s.attr(&Ns::Style, "family") == Some("graphic")
174                    && s.attr(&Ns::Style, "name") == Some(name)
175            })
176            .cloned()
177    });
178    let mut style = if let Some(mut copy) = automatic {
179        copy.remove_attr(&Ns::Style, "name");
180        copy
181    } else {
182        let mut style = element(content.name_for(&Ns::Style, "style"));
183        style.set_attr(content.name_for(&Ns::Style, "family"), "graphic");
184        if let Some(parent) = &current {
185            style.set_attr(content.name_for(&Ns::Style, "parent-style-name"), parent);
186        }
187        style
188    };
189    let properties = if let Some(at) = style
190        .children
191        .iter()
192        .position(|n| matches!(n, Node::Element(e) if e.is(&Ns::Style, "graphic-properties")))
193    {
194        at
195    } else {
196        style.children.push(Node::Element(element(
197            content.name_for(&Ns::Style, "graphic-properties"),
198        )));
199        style.self_closing = false;
200        style.children.len() - 1
201    };
202    if let Node::Element(properties) = &mut style.children[properties] {
203        properties.set_attr(content.name_for(&Ns::Loext, "decorative"), "true");
204    }
205
206    let (name, fresh) = name_for(content, styles, style, &Family::Graphic, "gr");
207    let name_attr = content.name_for(&Ns::Draw, "style-name");
208    content
209        .at_mut(path)
210        .ok_or(Refused::NotFound)?
211        .set_attr(name_attr, name);
212    if let Some(fresh) = fresh {
213        let container = automatic_styles(content);
214        container.children.push(Node::Element(fresh));
215        container.self_closing = false;
216    }
217    Ok(())
218}