Skip to main content

odox_ui/
flow.rs

1//! Drawing an ODF body: the block renderer all three applications share.
2//!
3//! ODF's content model is the same in a text document, a spreadsheet cell and a
4//! slide's text frame — `text:p`, `text:span`, `text:list`, `table:table`,
5//! `draw:frame` — so this is written against that model and not against a format.
6//! A text document hands it the body; a spreadsheet hands it a cell; a
7//! presentation hands it a frame.
8//!
9//! What it does not do is paginate. A page layout says how wide a line may be and
10//! that width is what it is given; page boxes, widows, floats and columns are
11//! typesetting, and a reading view is what this draws.
12//
13// Author: David M. Anderson
14// Built with AI assistance (Claude, Anthropic)
15
16use std::collections::HashMap;
17use std::sync::Arc;
18
19use eframe::egui::{
20    Align, Color32, ColorImage, Context, CursorIcon, Galley, Pos2, Rect, Response, Sense, Stroke,
21    StrokeKind, TextFormat, TextureHandle, TextureOptions, Ui, pos2,
22    text::{CCursor, CCursorRange, LayoutJob},
23    text_selection::{LabelSelectionState, visuals::paint_text_selection},
24    vec2,
25};
26use egui_richedit::{Laid, OffsetMap, ParagraphJob};
27use odox_core::{
28    Border, Document, Element, Family, Node, Ns, Properties, TextAlign, TextProperties, edit,
29};
30
31use crate::find::Highlights;
32use crate::flow_model::PageEditor;
33use crate::format::{self, DEFAULT_SIZE};
34use crate::links;
35
36/// Pictures already decoded, kept for as long as the document is open.
37///
38/// A failed decode is remembered as a failure, so that a picture in a format
39/// nothing here reads is not decoded again on every frame.
40#[derive(Default)]
41pub struct Pictures {
42    textures: HashMap<String, Option<TextureHandle>>,
43}
44
45impl Pictures {
46    /// The texture for a picture the document refers to, decoding it once.
47    pub fn get(
48        &mut self,
49        ctx: &Context,
50        document: &Document,
51        href: &str,
52    ) -> Option<&TextureHandle> {
53        if !self.textures.contains_key(href) {
54            let texture = document
55                .picture(href)
56                .and_then(decode)
57                .map(|image| ctx.load_texture(href, image, TextureOptions::LINEAR));
58            self.textures.insert(href.to_owned(), texture);
59        }
60        self.textures.get(href).and_then(Option::as_ref)
61    }
62
63    /// Forget every picture, for a window that has closed its document.
64    pub fn clear(&mut self) {
65        self.textures.clear();
66    }
67}
68
69fn decode(bytes: &[u8]) -> Option<ColorImage> {
70    let decoded = image::load_from_memory(bytes).ok()?.to_rgba8();
71    let size = [decoded.width() as usize, decoded.height() as usize];
72    Some(ColorImage::from_rgba_unmultiplied(size, decoded.as_raw()))
73}
74
75/// The state a run of blocks is drawn with.
76pub struct Flow<'a> {
77    /// The document, for its styles and its pictures.
78    pub document: &'a Document,
79    /// Pictures decoded so far.
80    pub pictures: &'a mut Pictures,
81    /// Screen points per ODF point.
82    pub zoom: f32,
83    /// The colours to draw in where the document names none.
84    pub palette: format::Palette,
85    /// The heading, counted from zero in document order, to bring into view.
86    ///
87    /// Set by a panel that lists a document's headings. It is answered while the
88    /// body is drawn, because the only moment a heading's position is known is
89    /// the moment it is laid out.
90    pub scroll_to_heading: Option<usize>,
91    /// A place in the document to bring into view: a bookmark's name, or a
92    /// heading's text written `Name|outline`. Answered while the body is drawn,
93    /// for the same reason `scroll_to_heading` is.
94    pub scroll_to_place: Option<String>,
95    /// A place in the document that a link just followed asked for, for the
96    /// view to set `scroll_to_place` to on the next frame.
97    pub followed: Option<String>,
98    /// How many headings have been drawn this pass.
99    headings_seen: usize,
100    /// The page editor, which puts a caret in every paragraph under the root
101    /// and paints them. Outside edit mode there is none.
102    pub page: Option<&'a mut PageEditor>,
103    /// The matches of a search, drawn behind the text they are in.
104    pub find: Option<Highlights<'a>>,
105    /// Whether text in the page can be dragged over to select it. Off on a
106    /// slide in edit mode, where a drag moves the shape instead.
107    pub selectable: bool,
108    /// Where the flow is, as a path of child indices from the root, or from
109    /// wherever [`Self::start_at`] said the root sits.
110    path: Vec<usize>,
111    /// Inside a frame anchored in a paragraph, which is reached by a clone
112    /// and not by a path, or in the second and later copies of a repeated
113    /// cell, whose copies share one path: nothing in either can be edited in
114    /// place.
115    detached: bool,
116}
117
118impl<'a> Flow<'a> {
119    /// A flow over a document, drawing at a zoom.
120    pub fn new(document: &'a Document, pictures: &'a mut Pictures, zoom: f32) -> Self {
121        Self {
122            document,
123            pictures,
124            zoom,
125            palette: format::Palette::default(),
126            scroll_to_heading: None,
127            scroll_to_place: None,
128            followed: None,
129            headings_seen: 0,
130            page: None,
131            find: None,
132            selectable: true,
133            path: Vec::new(),
134            detached: false,
135        }
136    }
137
138    /// Name where the root this flow draws sits under something larger, so
139    /// that every path it reports and matches is from that larger root: a
140    /// slide's label is drawn from the shape and edited from the page.
141    pub fn start_at(&mut self, prefix: Vec<usize>) {
142        self.path = prefix;
143    }
144}
145
146/// How far each list level is indented, in ODF points.
147const LIST_STEP: f32 = 18.0;
148/// The space a list label is drawn in, left of its item's text.
149const LIST_GUTTER: f32 = 20.0;
150/// The gap between a label and the text it belongs to.
151const LABEL_GAP: f32 = 5.0;
152
153/// A paragraph's margins and first-line indent, in screen points.
154struct Spacing {
155    /// Space outside the paragraph's left edge.
156    left: f32,
157    /// Space outside its right edge.
158    right: f32,
159    /// The first line's extra indent, negative for a hanging one.
160    indent: f32,
161    /// Space above the paragraph.
162    before: f32,
163    /// Space below it.
164    after: f32,
165}
166
167impl Spacing {
168    fn of(properties: &odox_core::ParagraphProperties, zoom: f32) -> Self {
169        let points = |length: Option<odox_core::Length>| {
170            length.map_or(0.0, odox_core::Length::points) * zoom
171        };
172        Self {
173            left: points(properties.margin.left),
174            right: points(properties.margin.right),
175            indent: points(properties.text_indent),
176            before: points(properties.margin.top),
177            after: points(properties.margin.bottom),
178        }
179    }
180}
181
182/// What a run of text inherits from the text it sits inside.
183///
184/// One argument rather than four, because a span inside a link inside a
185/// paragraph passes all of them down together and they only ever travel as a set.
186#[derive(Clone, Copy)]
187struct Run<'a> {
188    /// The character properties in force.
189    inherited: &'a TextProperties,
190    /// The size in points those properties resolve to, which is what a relative
191    /// size inside this run is relative to.
192    size: f32,
193    /// What egui draws the run with.
194    format: &'a TextFormat,
195    /// The colours to use where the document names none.
196    palette: format::Palette,
197    /// Whether the run's characters are the paragraph's own text, which the
198    /// caret moves through, or a field's value, which it steps over.
199    counted: bool,
200}
201
202/// Where a list's counters stand, one per level.
203#[derive(Default)]
204struct Counters(Vec<usize>);
205
206impl Counters {
207    fn bump(&mut self, level: usize, start: usize) -> usize {
208        while self.0.len() <= level {
209            self.0.push(0);
210        }
211        // A level that has not been counted at yet begins at the level style's
212        // start value, which is one unless the document says otherwise.
213        if self.0[level] == 0 {
214            self.0[level] = start;
215        } else {
216            self.0[level] += 1;
217        }
218        // Entering a list resets everything below it, which is what makes 2.1
219        // follow 1.3.
220        self.0.truncate(level + 1);
221        self.0[level]
222    }
223
224    fn at(&self, level: usize) -> usize {
225        self.0.get(level).copied().unwrap_or(1)
226    }
227}
228
229impl Flow<'_> {
230    /// Draw every block under an element, in order.
231    ///
232    /// `width` is the space available in screen points, and is what a paragraph
233    /// wraps to.
234    pub fn blocks(&mut self, ui: &mut Ui, parent: &Element, width: f32) {
235        let mut counters = Counters::default();
236        self.blocks_with(ui, parent, width, &mut counters);
237    }
238
239    fn blocks_with(&mut self, ui: &mut Ui, parent: &Element, width: f32, counters: &mut Counters) {
240        for (index, element) in parent.elements_indexed() {
241            self.path.push(index);
242            match () {
243                () if element.is(&Ns::Text, "p") || element.is(&Ns::Text, "h") => {
244                    self.paragraph(ui, element, width, None);
245                }
246                () if element.is(&Ns::Text, "list") => {
247                    // Numbering belongs to a list, not to the body: a second
248                    // list starts at one again unless it says it is continuing
249                    // the one before it, which is what `text:continue-numbering`
250                    // and `text:continue-list` are for.
251                    let continues = element.attr(&Ns::Text, "continue-numbering") == Some("true")
252                        || element.attr(&Ns::Text, "continue-list").is_some();
253                    if !continues {
254                        *counters = Counters::default();
255                    }
256                    self.list(ui, element, width, 0, None, counters);
257                }
258                () if element.is(&Ns::Table, "table") => self.table(ui, element, width),
259                () if element.is(&Ns::Draw, "frame") => self.frame(ui, element, width),
260                () if element.is(&Ns::Text, "soft-page-break") => self.page_break(ui, width),
261                () if is_block_container(element) => {
262                    self.blocks_with(ui, element, width, counters);
263                }
264                () => {}
265            }
266            self.path.pop();
267        }
268    }
269
270    /// The resolved style of a paragraph or heading.
271    fn style_of(&self, element: &Element, family: &Family) -> std::rc::Rc<Properties> {
272        let name = element
273            .attr(&Ns::Text, "style-name")
274            .or_else(|| element.attr(&Ns::Table, "style-name"))
275            .unwrap_or("Standard");
276        self.document.styles.resolve(family, name)
277    }
278
279    /// Colour the matches of a search in a paragraph, and scroll to the current
280    /// one when a search has just moved to it.
281    fn show_matches(&self, ui: &Ui, galley: &mut Arc<Galley>, map: &OffsetMap, origin: Pos2) {
282        let Some(find) = self.find.as_ref().filter(|_| !self.detached) else {
283            return;
284        };
285        let hits = find.within(&self.path);
286        if hits.is_empty() {
287            return;
288        }
289        let at = |offset: usize| CCursor::new(map.to_galley(offset.min(map.model_len())));
290        for (range, current) in hits {
291            let mut visuals = ui.visuals().clone();
292            visuals.selection.bg_fill = format::match_fill(current);
293            visuals.selection.stroke.color = Color32::BLACK;
294            let cursors = CCursorRange::two(at(range.start), at(range.end));
295            paint_text_selection(galley, &visuals, &cursors, None);
296            if current && find.reveal() {
297                let start = galley.pos_from_cursor(at(range.start));
298                let end = galley.pos_from_cursor(at(range.end));
299                ui.scroll_to_rect(
300                    start.union(end).translate(origin.to_vec2()),
301                    Some(Align::Center),
302                );
303            }
304        }
305    }
306
307    /// A link under the pointer shows where it goes, and is followed by a click
308    /// where the text is being read and by Ctrl and a click where it is being
309    /// edited, a plain click there being how the caret is put down.
310    #[allow(clippy::too_many_arguments)]
311    fn follow_link(
312        &mut self,
313        ui: &Ui,
314        response: &Response,
315        links: &[(std::ops::Range<usize>, &str)],
316        galley: &Galley,
317        map: &OffsetMap,
318        origin: Pos2,
319        editing: bool,
320    ) {
321        let Some(pointer) = response
322            .hover_pos()
323            .or_else(|| response.interact_pointer_pos())
324        else {
325            return;
326        };
327        let cursor = galley.cursor_from_pos(pointer - origin);
328        let caret = galley.pos_from_cursor(cursor).center().x + origin.x;
329        let character = if pointer.x < caret {
330            cursor.index.0.saturating_sub(1)
331        } else {
332            cursor.index.0
333        };
334        let offset = map.to_model(character);
335        let Some(href) = links
336            .iter()
337            .find(|(range, _)| range.contains(&offset))
338            .map(|&(_, href)| href)
339        else {
340            return;
341        };
342        let Some(target) = links::target(href) else {
343            return;
344        };
345        let reaching = !editing || ui.input(|input| input.modifiers.command);
346        if reaching {
347            ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
348        }
349        if response.hovered() {
350            response.clone().on_hover_text(href);
351        }
352        if reaching && response.clicked() {
353            match target {
354                links::Target::External(address) => links::open(ui.ctx(), address),
355                links::Target::Within(place) => self.followed = Some(place.to_owned()),
356            }
357        }
358    }
359
360    /// One paragraph or heading, with an optional list label drawn in its margin.
361    fn paragraph(&mut self, ui: &mut Ui, element: &Element, width: f32, label: Option<&str>) {
362        let properties = self.style_of(element, &Family::Paragraph);
363        let zoom = self.zoom;
364        let size = format::size_of(&properties.text, DEFAULT_SIZE);
365
366        let Spacing {
367            left,
368            right,
369            indent,
370            before,
371            after,
372        } = Spacing::of(&properties.paragraph, zoom);
373
374        // The first line's indent may be negative — a hanging indent — and the
375        // text then starts left of the rest of the paragraph, which is where a
376        // list label goes.
377        let body_width = (width - left - right).max(1.0);
378        let wrap = (body_width - indent.max(0.0)).max(1.0);
379
380        if before > 0.0 {
381            ui.add_space(before);
382        }
383
384        let base = format::text_format(&properties.text, DEFAULT_SIZE, zoom, self.palette);
385
386        let (job, frames) = self.layout_job(element, &properties, &base, size, wrap);
387        let (job, map) = job.into_parts();
388
389        let galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(job));
390        let height = galley.size().y;
391        let edited = !self.detached && self.page.is_some();
392        // Click and drag, and not merely hover: the selection plugin begins a
393        // selection only on a response whose sense includes drag, which is what
394        // `Label` adds to its own when it is selectable, and the click half is
395        // what lets a double-click take a word and a triple-click a line.
396        // Without the drag the pointer reaches the scroll area instead and
397        // nothing is selected — measured, not read. The page editor selects
398        // the same way, and on a slide in edit mode, where a drag moves the
399        // shape, it takes clicks alone.
400        let sense = if self.selectable {
401            Sense::click_and_drag()
402        } else {
403            Sense::click()
404        };
405        let (rect, response) = ui.allocate_exact_size(vec2(width, height), sense);
406
407        if element.is(&Ns::Text, "h") {
408            if self.scroll_to_heading == Some(self.headings_seen) {
409                ui.scroll_to_rect(rect, Some(Align::TOP));
410            }
411            self.headings_seen += 1;
412        }
413        if let Some(place) = &self.scroll_to_place
414            && holds_place(element, place)
415        {
416            ui.scroll_to_rect(rect, Some(Align::TOP));
417        }
418
419        if let Some(fill) = properties.paragraph.background {
420            ui.painter().rect_filled(rect, 0.0, format::color32(fill));
421        }
422        paint_borders(ui, rect, &properties.paragraph.border, zoom);
423
424        // The anchor the galley was laid out around: its rows are positioned
425        // relative to this, which is what makes a centred paragraph centre each
426        // of its rows rather than its block.
427        let anchor = match properties.paragraph.align.unwrap_or(TextAlign::Start) {
428            TextAlign::Center => rect.left() + left + indent.max(0.0) + wrap / 2.0,
429            TextAlign::End => rect.left() + left + indent.max(0.0) + wrap,
430            _ => rect.left() + left + indent.max(0.0),
431        };
432        // Through the page editor in edit mode, and through egui's selection
433        // plugin otherwise, rather than the painter: either is what lets a
434        // person drag across the page and press Ctrl+C. Both paint the galley
435        // themselves, at the same anchor the painter would have taken, so
436        // alignment is untouched. Both are called for every paragraph and not
437        // only the visible ones: the plugin drops a selection whose ends it did
438        // not see this frame, and the editor moves Up and Down through where
439        // each paragraph was drawn.
440        let origin = pos2(anchor, rect.top());
441        let mut galley = galley;
442        self.show_matches(ui, &mut galley, &map, origin);
443        let links = edit::links(element);
444        let hit = (!links.is_empty()).then(|| (Arc::clone(&galley), map.clone()));
445        if let Some(page) = self.page.as_deref_mut().filter(|_| edited) {
446            let laid = Laid {
447                galley,
448                map,
449                origin,
450            };
451            page.paragraph(ui, &response, &self.path, laid);
452        } else {
453            LabelSelectionState::label_text_selection(
454                ui,
455                &response,
456                origin,
457                galley,
458                base.color,
459                Stroke::NONE,
460            );
461        }
462
463        if let Some((galley, map)) = hit {
464            self.follow_link(ui, &response, &links, &galley, &map, origin, edited);
465        }
466
467        if let Some(label) = label {
468            let mut label_job = LayoutJob::default();
469            label_job.append(label, 0.0, base.clone());
470            let label_galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(label_job));
471            // Ending just before the text begins, in the gutter the list opened
472            // for it. Painting left of the allocated rectangle is deliberate: the
473            // gutter is space the caller reserved and nothing else draws there.
474            let x = rect.left() + left - label_galley.size().x - LABEL_GAP * zoom;
475            ui.painter()
476                .galley(pos2(x, rect.top()), label_galley, base.color);
477        }
478
479        // Reached by a clone rather than by a path, so nothing inside is
480        // edited in place.
481        let was_detached = self.detached;
482        self.detached = true;
483        for frame in frames {
484            self.frame(ui, &frame, body_width);
485        }
486        self.detached = was_detached;
487        if after > 0.0 {
488            ui.add_space(after);
489        }
490    }
491
492    /// The layout job for a paragraph, and the frames anchored inside it,
493    /// which are drawn after it.
494    fn layout_job(
495        &self,
496        element: &Element,
497        properties: &Properties,
498        base: &TextFormat,
499        size: f32,
500        wrap: f32,
501    ) -> (ParagraphJob, Vec<Element>) {
502        let job = LayoutJob {
503            wrap: eframe::egui::text::TextWrapping {
504                max_width: wrap,
505                ..Default::default()
506            },
507            halign: match properties.paragraph.align.unwrap_or(TextAlign::Start) {
508                TextAlign::Center => Align::Center,
509                TextAlign::End => Align::Max,
510                _ => Align::Min,
511            },
512            justify: properties.paragraph.align == Some(TextAlign::Justify),
513            ..LayoutJob::default()
514        };
515        let mut job = ParagraphJob::new(job);
516        let mut frames = Vec::new();
517        let run = Run {
518            inherited: &properties.text,
519            size,
520            format: base,
521            palette: self.palette,
522            counted: true,
523        };
524        self.runs(element, &run, &mut job, &mut frames);
525
526        // An empty paragraph is a blank line and has to take its height, which an
527        // empty layout job would not.
528        if job.is_empty() {
529            job.atom(" ", 0, base.clone());
530        }
531        if let Some(measure) = properties.paragraph.line_height {
532            // A proportional line height is a proportion of each run's own
533            // size, so a span set larger than its paragraph takes a taller
534            // line; an absolute one is the same for every run.
535            for format in job.formats_mut() {
536                let own = format.font_id.size / self.zoom;
537                format.line_height = Some(measure.resolve(own) * self.zoom);
538            }
539        }
540        (job, frames)
541    }
542
543    /// Append the text of a paragraph's children to a layout job.
544    ///
545    /// Anything that is not text is either resolved into characters — ODF spells
546    /// out runs of spaces, tabs and line breaks rather than writing them
547    /// literally — or collected to be drawn after the paragraph, which is what
548    /// happens to a picture anchored inside one.
549    ///
550    /// Each piece is recorded against the characters [`edit::text`] gives the
551    /// paragraph, so that a caret placed in what is drawn lands in the text an
552    /// edit changes: where the two differ, as a tab drawn as spaces or a field
553    /// drawn as its value, the piece is an atom the caret steps over.
554    fn runs(
555        &self,
556        parent: &Element,
557        run: &Run<'_>,
558        job: &mut ParagraphJob,
559        frames: &mut Vec<Element>,
560    ) {
561        let Run {
562            inherited,
563            size,
564            format,
565            palette,
566            counted,
567        } = *run;
568        let put = |job: &mut ParagraphJob, shown: &str, model_len: usize| {
569            if !counted {
570                job.atom(shown, 0, format.clone());
571            } else if shown.chars().count() == model_len {
572                job.text(shown, format.clone());
573            } else {
574                job.atom(shown, model_len, format.clone());
575            }
576        };
577        for child in &parent.children {
578            match child {
579                Node::Text(text) | Node::CData(text) => put(job, text, text.chars().count()),
580                Node::Comment(_) | Node::ProcessingInstruction(_) => {}
581                Node::Element(element) => {
582                    if element.is(&Ns::Text, "s") {
583                        let count = element.attr_usize(&Ns::Text, "c").unwrap_or(1);
584                        put(job, &" ".repeat(count.min(256)), count);
585                    } else if element.is(&Ns::Text, "tab") {
586                        // egui lays out a tab as a glyph rather than advancing to
587                        // a stop, so the paragraph's tab stops are approximated
588                        // by a fixed advance. A document whose layout depends on
589                        // tab stops is one §6 of DESIGN.md names.
590                        put(job, "    ", 1);
591                    } else if element.is(&Ns::Text, "line-break") {
592                        put(job, "\n", 1);
593                    } else if element.is(&Ns::Text, "span") {
594                        let style = self.style_of(element, &Family::Text);
595                        let merged = merge(inherited, &style.text);
596                        let inner_size = format::size_of(&merged, size);
597                        let inner = format::text_format(&merged, size, self.zoom, palette);
598                        let run = Run {
599                            inherited: &merged,
600                            size: inner_size,
601                            format: &inner,
602                            palette,
603                            counted,
604                        };
605                        self.runs(element, &run, job, frames);
606                    } else if element.is(&Ns::Text, "a") {
607                        let style = self.style_of(element, &Family::Text);
608                        let merged = merge(inherited, &style.text);
609                        let inner_size = format::size_of(&merged, size);
610                        let inner = format::link_format(&merged, size, self.zoom, palette);
611                        let run = Run {
612                            inherited: &merged,
613                            size: inner_size,
614                            format: &inner,
615                            palette,
616                            counted,
617                        };
618                        self.runs(element, &run, job, frames);
619                    } else if element.is(&Ns::Draw, "frame") {
620                        frames.push(element.clone());
621                    } else if element.is(&Ns::Text, "note") {
622                        // The citation is part of the text; the body is a
623                        // footnote and belongs at the foot of a page this view
624                        // does not have.
625                        if let Some(citation) = element.child(&Ns::Text, "note-citation") {
626                            let mut raised = format.clone();
627                            raised.font_id.size *= 0.7;
628                            raised.valign = Align::TOP;
629                            job.atom(&citation.plain_text(), 0, raised);
630                        }
631                    } else if is_inline_passthrough(element) {
632                        // A mark's contents are the paragraph's text; a field's
633                        // are its value, which the paragraph's text does not
634                        // hold.
635                        let run = Run {
636                            counted: counted && edit::holds_text(element),
637                            ..*run
638                        };
639                        self.runs(element, &run, job, frames);
640                    }
641                }
642            }
643        }
644    }
645
646    /// A list, and the lists nested inside it.
647    fn list(
648        &mut self,
649        ui: &mut Ui,
650        element: &Element,
651        width: f32,
652        level: usize,
653        inherited_style: Option<&str>,
654        counters: &mut Counters,
655    ) {
656        let style_name = element
657            .attr(&Ns::Text, "style-name")
658            .or(inherited_style)
659            .unwrap_or_default()
660            .to_owned();
661        // The indent is absolute rather than relative, because a nested list is
662        // drawn by a recursive call and not inside an indented region: a level
663        // knows how deep it is and puts itself there.
664        #[allow(clippy::cast_precision_loss)]
665        let indent = LIST_STEP * self.zoom * (level as f32 + 1.0);
666        let gutter = LIST_GUTTER * self.zoom;
667
668        for (item_index, item) in element.elements_indexed() {
669            let numbered = item.is(&Ns::Text, "list-item");
670            if !numbered && !item.is(&Ns::Text, "list-header") {
671                continue;
672            }
673            self.path.push(item_index);
674            let label = if numbered {
675                let level_style = self.level_style(&style_name, level);
676                let start = level_style
677                    .as_ref()
678                    .and_then(|s| s.attr_usize(&Ns::Text, "start-value"))
679                    .unwrap_or(1);
680                let number = counters.bump(level, start);
681                Self::label(level_style.as_ref(), level, number, counters)
682            } else {
683                // A list header is the paragraph a list may begin with, and it
684                // is not numbered and does not count.
685                String::new()
686            };
687
688            let mut first = true;
689            for (block_index, block) in item.elements_indexed() {
690                self.path.push(block_index);
691                if block.is(&Ns::Text, "list") {
692                    self.list(ui, block, width, level + 1, Some(&style_name), counters);
693                    self.path.pop();
694                    continue;
695                }
696                let on_this_block = if first && !label.is_empty() {
697                    Some(label.as_str())
698                } else {
699                    None
700                };
701                if block.is(&Ns::Text, "p") || block.is(&Ns::Text, "h") {
702                    first = false;
703                    // The label is drawn in the gutter this space opens, left
704                    // of where the text begins, so that a wide label crowds the
705                    // indent rather than the first word.
706                    ui.horizontal_top(|ui| {
707                        ui.add_space(indent + gutter);
708                        self.paragraph(ui, block, width - indent - gutter, on_this_block);
709                    });
710                } else if block.is(&Ns::Table, "table") {
711                    first = false;
712                    ui.horizontal_top(|ui| {
713                        ui.add_space(indent + gutter);
714                        self.table(ui, block, width - indent - gutter);
715                    });
716                } else if block.is(&Ns::Draw, "frame") {
717                    first = false;
718                    ui.horizontal_top(|ui| {
719                        ui.add_space(indent + gutter);
720                        self.frame(ui, block, width - indent - gutter);
721                    });
722                }
723                self.path.pop();
724            }
725            self.path.pop();
726        }
727    }
728
729    /// The level style of a list, which says whether the level is numbered or
730    /// bulleted and how.
731    fn level_style(&self, list_style: &str, level: usize) -> Option<Element> {
732        let style = self.document.styles.list_style(list_style)?;
733        style
734            .elements()
735            .find(|e| e.attr_usize(&Ns::Text, "level") == Some(level + 1))
736            .cloned()
737    }
738
739    /// The text drawn in front of a list item.
740    fn label(
741        level_style: Option<&Element>,
742        level: usize,
743        number: usize,
744        counters: &Counters,
745    ) -> String {
746        let Some(style) = level_style else {
747            // A list whose style the document did not write, which happens in a
748            // document assembled by something that left the style behind.
749            return "\u{2022}".to_owned();
750        };
751        if style.is(&Ns::Text, "list-level-style-bullet") {
752            return style
753                .attr(&Ns::Text, "bullet-char")
754                .unwrap_or("\u{2022}")
755                .to_owned();
756        }
757        if style.is(&Ns::Text, "list-level-style-number") {
758            let format = style.attr(&Ns::Style, "num-format").unwrap_or("1");
759            let prefix = style.attr(&Ns::Style, "num-prefix").unwrap_or_default();
760            let suffix = style.attr(&Ns::Style, "num-suffix").unwrap_or_default();
761            // `text:display-levels` is how 1.2.3 is written: the level's own
762            // number preceded by its ancestors'.
763            let display = style
764                .attr_usize(&Ns::Text, "display-levels")
765                .unwrap_or(1)
766                .max(1);
767            let mut numbers = Vec::new();
768            let first = (level + 1).saturating_sub(display);
769            for ancestor in first..level {
770                numbers.push(number_text(counters.at(ancestor), format));
771            }
772            numbers.push(number_text(number, format));
773            return format!("{prefix}{}{suffix}", numbers.join("."));
774        }
775        // A level drawn with an image, which is a picture this does not fetch.
776        "\u{2022}".to_owned()
777    }
778
779    /// A table, drawn as a grid of cells with the document's own widths.
780    fn table(&mut self, ui: &mut Ui, table: &Element, width: f32) {
781        let columns = column_widths(self.document, table, width, self.zoom);
782        if columns.is_empty() {
783            return;
784        }
785        ui.add_space(4.0 * self.zoom);
786        self.rows(ui, table, &columns);
787        ui.add_space(4.0 * self.zoom);
788    }
789
790    fn rows(&mut self, ui: &mut Ui, parent: &Element, columns: &[f32]) {
791        for (index, element) in parent.elements_indexed() {
792            self.path.push(index);
793            if element.is(&Ns::Table, "table-row") {
794                self.row(ui, element, columns);
795            } else if element.is(&Ns::Table, "table-header-rows")
796                || element.is(&Ns::Table, "table-rows")
797                || element.is(&Ns::Table, "table-row-group")
798            {
799                self.rows(ui, element, columns);
800            }
801            self.path.pop();
802        }
803    }
804
805    fn row(&mut self, ui: &mut Ui, row: &Element, columns: &[f32]) {
806        // The backgrounds and the borders have to be painted behind the text, and
807        // the row's height is only known once the text is laid out, so the shapes
808        // are reserved now and filled in after.
809        let reserved = ui.painter().add(eframe::egui::Shape::Noop);
810        let mut painted = Vec::new();
811
812        let response = ui.horizontal_top(|ui| {
813            ui.spacing_mut().item_spacing.x = 0.0;
814            let mut column = 0usize;
815            for (cell_index, cell) in row.elements_indexed() {
816                let covered = cell.is(&Ns::Table, "covered-table-cell");
817                if !covered && !cell.is(&Ns::Table, "table-cell") {
818                    continue;
819                }
820                let repeat = cell
821                    .attr_usize(&Ns::Table, "number-columns-repeated")
822                    .unwrap_or(1)
823                    .max(1);
824                let spanned = cell
825                    .attr_usize(&Ns::Table, "number-columns-spanned")
826                    .unwrap_or(1)
827                    .max(1);
828                for copy in 0..repeat {
829                    // The width covers every column the cell spans; the position
830                    // advances by one. ODF writes a `table:covered-table-cell`
831                    // for each further column a span reaches, so advancing by the
832                    // span here as well would count those columns twice and push
833                    // the rest of the row off the end of the table.
834                    let width: f32 = columns
835                        .iter()
836                        .skip(column)
837                        .take(spanned)
838                        .sum::<f32>()
839                        .max(8.0);
840                    if !covered {
841                        let properties = self.style_of(cell, &Family::TableCell);
842                        let padding = properties
843                            .cell
844                            .padding
845                            .left
846                            .map_or(2.0, odox_core::Length::points)
847                            * self.zoom;
848                        let inner = ui
849                            .allocate_ui_with_layout(
850                                vec2(width, 0.0),
851                                eframe::egui::Layout::top_down(Align::Min),
852                                |ui| {
853                                    ui.add_space(padding);
854                                    ui.set_min_width(width);
855                                    ui.set_max_width(width);
856                                    let content = (width - padding * 2.0).max(8.0);
857                                    self.path.push(cell_index);
858                                    let was_detached = self.detached;
859                                    self.detached |= copy > 0;
860                                    self.blocks(ui, cell, content);
861                                    self.detached = was_detached;
862                                    self.path.pop();
863                                    ui.add_space(padding);
864                                },
865                            )
866                            .response
867                            .rect;
868                        painted.push((inner, properties));
869                    }
870                    column += 1;
871                }
872            }
873        });
874
875        let row_rect = response.response.rect;
876        let mut shapes = Vec::new();
877        for (rect, properties) in painted {
878            let cell_rect = Rect::from_min_max(
879                pos2(rect.left(), row_rect.top()),
880                pos2(rect.right(), row_rect.bottom()),
881            );
882            if let Some(fill) = properties.cell.background {
883                shapes.push(eframe::egui::Shape::rect_filled(
884                    cell_rect,
885                    0.0,
886                    format::color32(fill),
887                ));
888            }
889            for (edge, from, to) in edges(cell_rect) {
890                if let Some(border) = edge_of(&properties.cell.border, edge) {
891                    shapes.push(eframe::egui::Shape::line_segment(
892                        [from, to],
893                        Stroke::new(
894                            (border.width.points() * self.zoom).max(1.0),
895                            format::color32(border.color),
896                        ),
897                    ));
898                }
899            }
900        }
901        ui.painter().set(reserved, eframe::egui::Shape::Vec(shapes));
902    }
903
904    /// A frame: a box with a picture, a text box or an object in it.
905    ///
906    /// Public because a slide reaches it directly. Everywhere else a frame is
907    /// found among a parent's children by [`Self::blocks`], but a shape on a
908    /// slide *is* the frame, and asking `blocks` to draw it would look inside it
909    /// for blocks and find a `draw:image`, which is not one.
910    pub fn frame(&mut self, ui: &mut Ui, frame: &Element, width: f32) {
911        let zoom = self.zoom;
912        let declared = |local: &str| {
913            frame
914                .attr(&Ns::Svg, local)
915                .and_then(odox_core::Length::parse)
916                .map(|l| l.points() * zoom)
917        };
918
919        // A frame states its picture more than once where the producer had more
920        // than one rendering of it — an SVG and then a PNG of the same drawing,
921        // which is how the presentation templates carry their decorations — in
922        // the producer's order of preference. The first one that decodes is the
923        // answer, so a reader of two formats still draws a template that
924        // prefers a third.
925        let found = frame
926            .elements()
927            .filter(|child| child.is(&Ns::Draw, "image"))
928            .find_map(|image| {
929                let href = image.attr(&Ns::Xlink, "href")?.to_owned();
930                self.pictures
931                    .get(ui.ctx(), self.document, &href)
932                    .map(|texture| (texture.id(), texture.size()))
933            });
934        if let Some((id, [pixels_wide, pixels_high])) = found {
935            #[allow(clippy::cast_precision_loss)]
936            let aspect = if pixels_wide == 0 {
937                1.0
938            } else {
939                pixels_high as f32 / pixels_wide as f32
940            };
941            let w = declared("width").unwrap_or(width).min(width);
942            let h = declared("height").unwrap_or(w * aspect);
943            let size = vec2(w, h);
944            ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
945            return;
946        }
947
948        // A text box draws the blocks inside it; anything else — an embedded
949        // object, a chart, a formula — is a box the size the document asked for,
950        // so that the page does not silently lose the space it occupied.
951        if let Some((box_index, box_)) = frame
952            .elements_indexed()
953            .find(|(_, e)| e.is(&Ns::Draw, "text-box"))
954        {
955            let w = declared("width").unwrap_or(width).min(width);
956            ui.allocate_ui_with_layout(
957                vec2(w, 0.0),
958                eframe::egui::Layout::top_down(Align::Min),
959                |ui| {
960                    ui.set_max_width(w);
961                    eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
962                        self.path.push(box_index);
963                        self.blocks(ui, box_, w - 16.0 * zoom);
964                        self.path.pop();
965                    });
966                },
967            );
968            return;
969        }
970
971        let w = declared("width").unwrap_or(width).min(width);
972        let h = declared("height").unwrap_or(48.0 * zoom);
973        let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
974        ui.painter().rect_stroke(
975            rect,
976            2.0,
977            Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
978            StrokeKind::Inside,
979        );
980    }
981
982    /// Where the document says a page ended.
983    ///
984    /// Not a page: this view does not paginate, and drawing the break the
985    /// producer recorded is how a reader sees that the document has them.
986    fn page_break(&mut self, ui: &mut Ui, width: f32) {
987        ui.add_space(8.0 * self.zoom);
988        let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
989        ui.painter().hline(
990            rect.x_range(),
991            rect.center().y,
992            Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
993        );
994        ui.add_space(8.0 * self.zoom);
995    }
996}
997
998/// A child style over its parent: a property the child does not state is the
999/// parent's.
1000fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
1001    TextProperties {
1002        font_family: child
1003            .font_family
1004            .clone()
1005            .or_else(|| parent.font_family.clone()),
1006        size: child.size.or(parent.size),
1007        bold: child.bold.or(parent.bold),
1008        italic: child.italic.or(parent.italic),
1009        underline: child.underline.or(parent.underline),
1010        strike: child.strike.or(parent.strike),
1011        color: child.color.or(parent.color),
1012        background: child.background.or(parent.background),
1013        position: child.position.or(parent.position),
1014        uppercase: child.uppercase.or(parent.uppercase),
1015    }
1016}
1017
1018/// Which edge of a cell a border belongs to.
1019#[derive(Clone, Copy)]
1020enum Edge {
1021    Left,
1022    Right,
1023    Top,
1024    Bottom,
1025}
1026
1027fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
1028    [
1029        (Edge::Left, rect.left_top(), rect.left_bottom()),
1030        (Edge::Right, rect.right_top(), rect.right_bottom()),
1031        (Edge::Top, rect.left_top(), rect.right_top()),
1032        (Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
1033    ]
1034}
1035
1036fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
1037    match edge {
1038        Edge::Left => borders.left,
1039        Edge::Right => borders.right,
1040        Edge::Top => borders.top,
1041        Edge::Bottom => borders.bottom,
1042    }
1043}
1044
1045fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
1046    for (edge, from, to) in edges(rect) {
1047        if let Some(border) = edge_of(borders, edge) {
1048            ui.painter().line_segment(
1049                [from, to],
1050                Stroke::new(
1051                    (border.width.points() * zoom).max(1.0),
1052                    format::color32(border.color),
1053                ),
1054            );
1055        }
1056    }
1057}
1058
1059/// The width of each of a table's columns in screen points.
1060///
1061/// A column style usually gives one. Where none does, the available width is
1062/// divided evenly, which is what a producer that wrote no widths meant.
1063fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
1064    let mut declared = Vec::new();
1065    collect_columns(document, table, &mut declared);
1066    if declared.is_empty() {
1067        return Vec::new();
1068    }
1069    let total: f32 = declared.iter().filter_map(|w| *w).sum();
1070    let unstated = declared.iter().filter(|w| w.is_none()).count();
1071
1072    // A table wider than the space is scaled down rather than clipped, which is
1073    // what a reading view owes a document written for a wider page.
1074    let scaled = total * zoom;
1075    let factor = if scaled > width && scaled > 0.0 {
1076        width / scaled
1077    } else {
1078        1.0
1079    };
1080    #[allow(clippy::cast_precision_loss)]
1081    let share = if unstated == 0 {
1082        0.0
1083    } else {
1084        ((width - scaled * factor) / unstated as f32).max(16.0)
1085    };
1086    declared
1087        .into_iter()
1088        .map(|w| w.map_or(share, |points| points * zoom * factor))
1089        .collect()
1090}
1091
1092fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
1093    for element in parent.elements() {
1094        if element.is(&Ns::Table, "table-column") {
1095            let repeat = element
1096                .attr_usize(&Ns::Table, "number-columns-repeated")
1097                .unwrap_or(1)
1098                .clamp(1, 1024);
1099            let width = element
1100                .attr(&Ns::Table, "style-name")
1101                .map(|name| document.styles.resolve(&Family::TableColumn, name))
1102                .and_then(|p| p.column_width)
1103                .map(odox_core::Length::points);
1104            for _ in 0..repeat {
1105                into.push(width);
1106            }
1107        } else if element.is(&Ns::Table, "table-columns")
1108            || element.is(&Ns::Table, "table-header-columns")
1109            || element.is(&Ns::Table, "table-column-group")
1110        {
1111            collect_columns(document, element, into);
1112        }
1113    }
1114}
1115
1116/// A number in the format a list level asks for.
1117fn number_text(number: usize, format: &str) -> String {
1118    match format.chars().next() {
1119        Some('a') => alphabetic(number, b'a'),
1120        Some('A') => alphabetic(number, b'A'),
1121        Some('i') => roman(number).to_lowercase(),
1122        Some('I') => roman(number),
1123        // An empty format is a level that shows no number, which ODF uses for a
1124        // list whose label is only its prefix and suffix.
1125        None => String::new(),
1126        _ => number.to_string(),
1127    }
1128}
1129
1130/// `a`, `b`, … `z`, `aa`, which is the spreadsheet column rule and ODF's.
1131fn alphabetic(number: usize, first: u8) -> String {
1132    let mut n = number;
1133    let mut out = Vec::new();
1134    while n > 0 {
1135        let remainder = (n - 1) % 26;
1136        out.push(first + u8::try_from(remainder).unwrap_or(0));
1137        n = (n - 1) / 26;
1138    }
1139    out.reverse();
1140    String::from_utf8(out).unwrap_or_default()
1141}
1142
1143fn roman(number: usize) -> String {
1144    const VALUES: [(usize, &str); 13] = [
1145        (1000, "M"),
1146        (900, "CM"),
1147        (500, "D"),
1148        (400, "CD"),
1149        (100, "C"),
1150        (90, "XC"),
1151        (50, "L"),
1152        (40, "XL"),
1153        (10, "X"),
1154        (9, "IX"),
1155        (5, "V"),
1156        (4, "IV"),
1157        (1, "I"),
1158    ];
1159    // Beyond what Roman numerals reach, the number itself is more use than a
1160    // line of Ms.
1161    if number == 0 || number > 3999 {
1162        return number.to_string();
1163    }
1164    let mut left = number;
1165    let mut out = String::new();
1166    for (value, numeral) in VALUES {
1167        while left >= value {
1168            out.push_str(numeral);
1169            left -= value;
1170        }
1171    }
1172    out
1173}
1174
1175/// Whether a paragraph is what an internal link names: a bookmark in it, by
1176/// name, or a heading by its text, which `LibreOffice` writes `Name|outline`.
1177fn holds_place(element: &Element, place: &str) -> bool {
1178    if let Some(name) = place.strip_suffix("|outline") {
1179        return element.is(&Ns::Text, "h") && element.plain_text().trim() == name.trim();
1180    }
1181    element.elements().any(|e| {
1182        let marks = e.is(&Ns::Text, "bookmark") || e.is(&Ns::Text, "bookmark-start");
1183        (marks && e.attr(&Ns::Text, "name") == Some(place)) || holds_place(e, place)
1184    })
1185}
1186
1187/// Whether an element holds blocks on the body's behalf rather than being one.
1188pub(crate) fn is_block_container(element: &Element) -> bool {
1189    element.name.ns == Ns::Text
1190        && matches!(
1191            &*element.name.local,
1192            "section"
1193                | "index-body"
1194                | "index-title"
1195                | "table-of-content"
1196                | "illustration-index"
1197                | "table-index"
1198                | "object-index"
1199                | "user-index"
1200                | "alphabetical-index"
1201                | "bibliography"
1202                | "tracked-changes"
1203                | "deletion"
1204        )
1205}
1206
1207/// Whether an element is a wrapper around text rather than text of its own.
1208///
1209/// A bookmark, a reference mark and a change mark each sit inside a paragraph,
1210/// carry no characters, and may have text inside them that does belong to the
1211/// paragraph.
1212fn is_inline_passthrough(element: &Element) -> bool {
1213    element.name.ns == Ns::Text
1214        && matches!(
1215            &*element.name.local,
1216            "bookmark"
1217                | "bookmark-start"
1218                | "bookmark-end"
1219                | "reference-mark"
1220                | "reference-mark-start"
1221                | "reference-mark-end"
1222                | "span"
1223                | "bibliography-mark"
1224                | "ruby"
1225                | "ruby-base"
1226                | "meta"
1227                | "meta-field"
1228                | "change-start"
1229                | "change-end"
1230                | "page-number"
1231                | "page-count"
1232                | "title"
1233                | "subject"
1234                | "author-name"
1235                | "author-initials"
1236                | "chapter"
1237                | "file-name"
1238                | "sheet-name"
1239                | "date"
1240                | "time"
1241                | "creator"
1242                | "description"
1243                | "keywords"
1244                | "sequence"
1245                | "bookmark-ref"
1246                | "sequence-ref"
1247                | "reference-ref"
1248                | "variable-get"
1249                | "variable-set"
1250                | "user-field-get"
1251                | "placeholder"
1252                | "conditional-text"
1253                | "hidden-text"
1254                | "text-input"
1255        )
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260    use std::path::Path;
1261
1262    use super::{Flow, Pictures};
1263    use crate::format::{self, DEFAULT_SIZE};
1264    use odox_core::{Document, Element, Family, edit, media_type};
1265
1266    const ANY: &[&str] = &[
1267        media_type::TEXT,
1268        media_type::TEXT_WEB,
1269        media_type::SPREADSHEET,
1270        media_type::PRESENTATION,
1271    ];
1272
1273    fn documents(directory: &Path, into: &mut Vec<Document>) {
1274        let Ok(entries) = std::fs::read_dir(directory) else {
1275            return;
1276        };
1277        for path in entries.filter_map(Result::ok).map(|entry| entry.path()) {
1278            if path.is_dir() {
1279                documents(&path, into);
1280            } else if matches!(
1281                path.extension().and_then(|e| e.to_str()),
1282                Some("odt" | "ods" | "odp")
1283            ) {
1284                let bytes = std::fs::read(&path).expect("a corpus document reads");
1285                into.push(Document::read(&bytes, ANY).expect("a corpus document parses"));
1286            }
1287        }
1288    }
1289
1290    fn paragraphs<'a>(element: &'a Element, into: &mut Vec<&'a Element>) {
1291        for child in element.elements() {
1292            if edit::is_paragraph(child) {
1293                into.push(child);
1294            }
1295            paragraphs(child, into);
1296        }
1297    }
1298
1299    /// The caret is placed in what is drawn and an edit changes what the tree
1300    /// holds, so for every paragraph in the corpus the two have to agree on
1301    /// how many characters there are.
1302    #[test]
1303    fn what_is_drawn_maps_onto_the_text_an_edit_changes() {
1304        let mut corpus = Vec::new();
1305        documents(
1306            &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"),
1307            &mut corpus,
1308        );
1309        assert!(!corpus.is_empty(), "the corpus is where it was");
1310        let mut checked = 0;
1311        for document in &corpus {
1312            let mut pictures = Pictures::default();
1313            let flow = Flow::new(document, &mut pictures, 1.0);
1314            let mut found = Vec::new();
1315            paragraphs(&document.content, &mut found);
1316            for paragraph in found {
1317                let properties = flow.style_of(paragraph, &Family::Paragraph);
1318                let base = format::text_format(&properties.text, DEFAULT_SIZE, 1.0, flow.palette);
1319                let size = format::size_of(&properties.text, DEFAULT_SIZE);
1320                let (job, _) = flow.layout_job(paragraph, &properties, &base, size, 400.0);
1321                let (_, map) = job.into_parts();
1322                let text = edit::text(paragraph);
1323                assert_eq!(map.model_len(), text.chars().count(), "{text:?}");
1324                checked += 1;
1325            }
1326        }
1327        assert!(checked > 0);
1328    }
1329}