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