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