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