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