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, 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        let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
290
291        if element.is(&Ns::Text, "h") {
292            if self.scroll_to_heading == Some(self.headings_seen) {
293                ui.scroll_to_rect(rect, Some(Align::TOP));
294            }
295            self.headings_seen += 1;
296        }
297
298        if let Some(fill) = properties.paragraph.background {
299            ui.painter().rect_filled(rect, 0.0, format::color32(fill));
300        }
301        paint_borders(ui, rect, &properties.paragraph.border, zoom);
302
303        // The anchor the galley was laid out around: its rows are positioned
304        // relative to this, which is what makes a centred paragraph centre each
305        // of its rows rather than its block.
306        let anchor = match properties.paragraph.align.unwrap_or(TextAlign::Start) {
307            TextAlign::Center => rect.left() + left + indent.max(0.0) + wrap / 2.0,
308            TextAlign::End => rect.left() + left + indent.max(0.0) + wrap,
309            _ => rect.left() + left + indent.max(0.0),
310        };
311        ui.painter()
312            .galley(pos2(anchor, rect.top()), galley, base.color);
313
314        if let Some(label) = label {
315            let mut label_job = LayoutJob::default();
316            label_job.append(label, 0.0, base.clone());
317            let label_galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(label_job));
318            // Ending just before the text begins, in the gutter the list opened
319            // for it. Painting left of the allocated rectangle is deliberate: the
320            // gutter is space the caller reserved and nothing else draws there.
321            let x = rect.left() + left - label_galley.size().x - LABEL_GAP * zoom;
322            ui.painter()
323                .galley(pos2(x, rect.top()), label_galley, base.color);
324        }
325
326        for frame in frames {
327            self.frame(ui, &frame, body_width);
328        }
329        if after > 0.0 {
330            ui.add_space(after);
331        }
332    }
333
334    /// Append the text of a paragraph's children to a layout job.
335    ///
336    /// Anything that is not text is either resolved into characters — ODF spells
337    /// out runs of spaces, tabs and line breaks rather than writing them
338    /// literally — or collected to be drawn after the paragraph, which is what
339    /// happens to a picture anchored inside one.
340    fn runs(
341        &self,
342        parent: &Element,
343        run: &Run<'_>,
344        job: &mut LayoutJob,
345        frames: &mut Vec<Element>,
346    ) {
347        let Run {
348            inherited,
349            size,
350            format,
351            palette,
352        } = *run;
353        for child in &parent.children {
354            match child {
355                Node::Text(text) | Node::CData(text) => job.append(text, 0.0, format.clone()),
356                Node::Comment(_) | Node::ProcessingInstruction(_) => {}
357                Node::Element(element) => {
358                    if element.is(&Ns::Text, "s") {
359                        let count = element.attr_usize(&Ns::Text, "c").unwrap_or(1).min(256);
360                        job.append(&" ".repeat(count), 0.0, format.clone());
361                    } else if element.is(&Ns::Text, "tab") {
362                        // egui lays out a tab as a glyph rather than advancing to
363                        // a stop, so the paragraph's tab stops are approximated
364                        // by a fixed advance. A document whose layout depends on
365                        // tab stops is one §6 of DESIGN.md names.
366                        job.append("    ", 0.0, format.clone());
367                    } else if element.is(&Ns::Text, "line-break") {
368                        job.append("\n", 0.0, format.clone());
369                    } else if element.is(&Ns::Text, "span") {
370                        let style = self.style_of(element, &Family::Text);
371                        let merged = merge(inherited, &style.text);
372                        let inner_size = format::size_of(&merged, size);
373                        let inner = format::text_format(&merged, size, self.zoom, palette);
374                        let run = Run {
375                            inherited: &merged,
376                            size: inner_size,
377                            format: &inner,
378                            palette,
379                        };
380                        self.runs(element, &run, job, frames);
381                    } else if element.is(&Ns::Text, "a") {
382                        let style = self.style_of(element, &Family::Text);
383                        let merged = merge(inherited, &style.text);
384                        let inner_size = format::size_of(&merged, size);
385                        let inner = format::link_format(&merged, size, self.zoom, palette);
386                        let run = Run {
387                            inherited: &merged,
388                            size: inner_size,
389                            format: &inner,
390                            palette,
391                        };
392                        self.runs(element, &run, job, frames);
393                    } else if element.is(&Ns::Draw, "frame") {
394                        frames.push(element.clone());
395                    } else if element.is(&Ns::Text, "note") {
396                        // The citation is part of the text; the body is a
397                        // footnote and belongs at the foot of a page this view
398                        // does not have.
399                        if let Some(citation) = element.child(&Ns::Text, "note-citation") {
400                            let mut raised = format.clone();
401                            raised.font_id.size *= 0.7;
402                            raised.valign = Align::TOP;
403                            job.append(&citation.plain_text(), 0.0, raised);
404                        }
405                    } else if is_inline_passthrough(element) {
406                        self.runs(element, run, job, frames);
407                    }
408                }
409            }
410        }
411    }
412
413    /// A list, and the lists nested inside it.
414    fn list(
415        &mut self,
416        ui: &mut Ui,
417        element: &Element,
418        width: f32,
419        level: usize,
420        inherited_style: Option<&str>,
421        counters: &mut Counters,
422    ) {
423        let style_name = element
424            .attr(&Ns::Text, "style-name")
425            .or(inherited_style)
426            .unwrap_or_default()
427            .to_owned();
428        // The indent is absolute rather than relative, because a nested list is
429        // drawn by a recursive call and not inside an indented region: a level
430        // knows how deep it is and puts itself there.
431        #[allow(clippy::cast_precision_loss)]
432        let indent = LIST_STEP * self.zoom * (level as f32 + 1.0);
433        let gutter = LIST_GUTTER * self.zoom;
434
435        for item in element.elements() {
436            let numbered = item.is(&Ns::Text, "list-item");
437            if !numbered && !item.is(&Ns::Text, "list-header") {
438                continue;
439            }
440            let label = if numbered {
441                let level_style = self.level_style(&style_name, level);
442                let start = level_style
443                    .as_ref()
444                    .and_then(|s| s.attr_usize(&Ns::Text, "start-value"))
445                    .unwrap_or(1);
446                let number = counters.bump(level, start);
447                Self::label(level_style.as_ref(), level, number, counters)
448            } else {
449                // A list header is the paragraph a list may begin with, and it
450                // is not numbered and does not count.
451                String::new()
452            };
453
454            let mut first = true;
455            for block in item.elements() {
456                if block.is(&Ns::Text, "list") {
457                    self.list(ui, block, width, level + 1, Some(&style_name), counters);
458                    continue;
459                }
460                let on_this_block = if first && !label.is_empty() {
461                    Some(label.as_str())
462                } else {
463                    None
464                };
465                if block.is(&Ns::Text, "p") || block.is(&Ns::Text, "h") {
466                    first = false;
467                    // The label is drawn in the gutter this space opens, left
468                    // of where the text begins, so that a wide label crowds the
469                    // indent rather than the first word.
470                    ui.horizontal_top(|ui| {
471                        ui.add_space(indent + gutter);
472                        self.paragraph(ui, block, width - indent - gutter, on_this_block);
473                    });
474                } else if block.is(&Ns::Table, "table") {
475                    first = false;
476                    ui.horizontal_top(|ui| {
477                        ui.add_space(indent + gutter);
478                        self.table(ui, block, width - indent - gutter);
479                    });
480                } else if block.is(&Ns::Draw, "frame") {
481                    first = false;
482                    ui.horizontal_top(|ui| {
483                        ui.add_space(indent + gutter);
484                        self.frame(ui, block, width - indent - gutter);
485                    });
486                }
487            }
488        }
489    }
490
491    /// The level style of a list, which says whether the level is numbered or
492    /// bulleted and how.
493    fn level_style(&self, list_style: &str, level: usize) -> Option<Element> {
494        let style = self.document.styles.list_style(list_style)?;
495        style
496            .elements()
497            .find(|e| e.attr_usize(&Ns::Text, "level") == Some(level + 1))
498            .cloned()
499    }
500
501    /// The text drawn in front of a list item.
502    fn label(
503        level_style: Option<&Element>,
504        level: usize,
505        number: usize,
506        counters: &Counters,
507    ) -> String {
508        let Some(style) = level_style else {
509            // A list whose style the document did not write, which happens in a
510            // document assembled by something that left the style behind.
511            return "\u{2022}".to_owned();
512        };
513        if style.is(&Ns::Text, "list-level-style-bullet") {
514            return style
515                .attr(&Ns::Text, "bullet-char")
516                .unwrap_or("\u{2022}")
517                .to_owned();
518        }
519        if style.is(&Ns::Text, "list-level-style-number") {
520            let format = style.attr(&Ns::Style, "num-format").unwrap_or("1");
521            let prefix = style.attr(&Ns::Text, "num-prefix").unwrap_or_default();
522            let suffix = style.attr(&Ns::Text, "num-suffix").unwrap_or_default();
523            // `text:display-levels` is how 1.2.3 is written: the level's own
524            // number preceded by its ancestors'.
525            let display = style
526                .attr_usize(&Ns::Text, "display-levels")
527                .unwrap_or(1)
528                .max(1);
529            let mut numbers = Vec::new();
530            let first = (level + 1).saturating_sub(display);
531            for ancestor in first..level {
532                numbers.push(number_text(counters.at(ancestor), format));
533            }
534            numbers.push(number_text(number, format));
535            return format!("{prefix}{}{suffix}", numbers.join("."));
536        }
537        // A level drawn with an image, which is a picture this does not fetch.
538        "\u{2022}".to_owned()
539    }
540
541    /// A table, drawn as a grid of cells with the document's own widths.
542    fn table(&mut self, ui: &mut Ui, table: &Element, width: f32) {
543        let columns = column_widths(self.document, table, width, self.zoom);
544        if columns.is_empty() {
545            return;
546        }
547        ui.add_space(4.0 * self.zoom);
548        self.rows(ui, table, &columns);
549        ui.add_space(4.0 * self.zoom);
550    }
551
552    fn rows(&mut self, ui: &mut Ui, parent: &Element, columns: &[f32]) {
553        for element in parent.elements() {
554            if element.is(&Ns::Table, "table-row") {
555                self.row(ui, element, columns);
556            } else if element.is(&Ns::Table, "table-header-rows")
557                || element.is(&Ns::Table, "table-rows")
558                || element.is(&Ns::Table, "table-row-group")
559            {
560                self.rows(ui, element, columns);
561            }
562        }
563    }
564
565    fn row(&mut self, ui: &mut Ui, row: &Element, columns: &[f32]) {
566        // The backgrounds and the borders have to be painted behind the text, and
567        // the row's height is only known once the text is laid out, so the shapes
568        // are reserved now and filled in after.
569        let reserved = ui.painter().add(eframe::egui::Shape::Noop);
570        let mut painted = Vec::new();
571
572        let response = ui.horizontal_top(|ui| {
573            ui.spacing_mut().item_spacing.x = 0.0;
574            let mut column = 0usize;
575            for cell in row.elements() {
576                let covered = cell.is(&Ns::Table, "covered-table-cell");
577                if !covered && !cell.is(&Ns::Table, "table-cell") {
578                    continue;
579                }
580                let repeat = cell
581                    .attr_usize(&Ns::Table, "number-columns-repeated")
582                    .unwrap_or(1)
583                    .max(1);
584                let spanned = cell
585                    .attr_usize(&Ns::Table, "number-columns-spanned")
586                    .unwrap_or(1)
587                    .max(1);
588                for _ in 0..repeat {
589                    // The width covers every column the cell spans; the position
590                    // advances by one. ODF writes a `table:covered-table-cell`
591                    // for each further column a span reaches, so advancing by the
592                    // span here as well would count those columns twice and push
593                    // the rest of the row off the end of the table.
594                    let width: f32 = columns
595                        .iter()
596                        .skip(column)
597                        .take(spanned)
598                        .sum::<f32>()
599                        .max(8.0);
600                    if !covered {
601                        let properties = self.style_of(cell, &Family::TableCell);
602                        let padding = properties
603                            .cell
604                            .padding
605                            .left
606                            .map_or(2.0, odox_core::Length::points)
607                            * self.zoom;
608                        let inner = ui
609                            .allocate_ui_with_layout(
610                                vec2(width, 0.0),
611                                eframe::egui::Layout::top_down(Align::Min),
612                                |ui| {
613                                    ui.add_space(padding);
614                                    ui.set_min_width(width);
615                                    ui.set_max_width(width);
616                                    let content = (width - padding * 2.0).max(8.0);
617                                    self.blocks(ui, cell, content);
618                                    ui.add_space(padding);
619                                },
620                            )
621                            .response
622                            .rect;
623                        painted.push((inner, properties));
624                    }
625                    column += 1;
626                }
627            }
628        });
629
630        let row_rect = response.response.rect;
631        let mut shapes = Vec::new();
632        for (rect, properties) in painted {
633            let cell_rect = Rect::from_min_max(
634                pos2(rect.left(), row_rect.top()),
635                pos2(rect.right(), row_rect.bottom()),
636            );
637            if let Some(fill) = properties.cell.background {
638                shapes.push(eframe::egui::Shape::rect_filled(
639                    cell_rect,
640                    0.0,
641                    format::color32(fill),
642                ));
643            }
644            for (edge, from, to) in edges(cell_rect) {
645                if let Some(border) = edge_of(&properties.cell.border, edge) {
646                    shapes.push(eframe::egui::Shape::line_segment(
647                        [from, to],
648                        Stroke::new(
649                            (border.width.points() * self.zoom).max(1.0),
650                            format::color32(border.color),
651                        ),
652                    ));
653                }
654            }
655        }
656        ui.painter().set(reserved, eframe::egui::Shape::Vec(shapes));
657    }
658
659    /// A frame: a box with a picture, a text box or an object in it.
660    fn frame(&mut self, ui: &mut Ui, frame: &Element, width: f32) {
661        let zoom = self.zoom;
662        let declared = |local: &str| {
663            frame
664                .attr(&Ns::Svg, local)
665                .and_then(odox_core::Length::parse)
666                .map(|l| l.points() * zoom)
667        };
668
669        if let Some(image) = frame.child(&Ns::Draw, "image") {
670            let href = image
671                .attr(&Ns::Xlink, "href")
672                .unwrap_or_default()
673                .to_owned();
674            let found = self
675                .pictures
676                .get(ui.ctx(), self.document, &href)
677                .map(|texture| (texture.id(), texture.size()));
678            if let Some((id, [pixels_wide, pixels_high])) = found {
679                #[allow(clippy::cast_precision_loss)]
680                let aspect = if pixels_wide == 0 {
681                    1.0
682                } else {
683                    pixels_high as f32 / pixels_wide as f32
684                };
685                let w = declared("width").unwrap_or(width).min(width);
686                let h = declared("height").unwrap_or(w * aspect);
687                let size = vec2(w, h);
688                ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
689                return;
690            }
691        }
692
693        // A text box draws the blocks inside it; anything else — an embedded
694        // object, a chart, a formula — is a box the size the document asked for,
695        // so that the page does not silently lose the space it occupied.
696        if let Some(box_) = frame.child(&Ns::Draw, "text-box") {
697            let w = declared("width").unwrap_or(width).min(width);
698            ui.allocate_ui_with_layout(
699                vec2(w, 0.0),
700                eframe::egui::Layout::top_down(Align::Min),
701                |ui| {
702                    ui.set_max_width(w);
703                    eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
704                        self.blocks(ui, box_, w - 16.0 * zoom);
705                    });
706                },
707            );
708            return;
709        }
710
711        let w = declared("width").unwrap_or(width).min(width);
712        let h = declared("height").unwrap_or(48.0 * zoom);
713        let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
714        ui.painter().rect_stroke(
715            rect,
716            2.0,
717            Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
718            StrokeKind::Inside,
719        );
720    }
721
722    /// Where the document says a page ended.
723    ///
724    /// Not a page: this view does not paginate, and drawing the break the
725    /// producer recorded is how a reader sees that the document has them.
726    fn page_break(&mut self, ui: &mut Ui, width: f32) {
727        ui.add_space(8.0 * self.zoom);
728        let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
729        ui.painter().hline(
730            rect.x_range(),
731            rect.center().y,
732            Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
733        );
734        ui.add_space(8.0 * self.zoom);
735    }
736}
737
738/// A child style over its parent: a property the child does not state is the
739/// parent's.
740fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
741    TextProperties {
742        font_family: child
743            .font_family
744            .clone()
745            .or_else(|| parent.font_family.clone()),
746        size: child.size.or(parent.size),
747        bold: child.bold.or(parent.bold),
748        italic: child.italic.or(parent.italic),
749        underline: child.underline.or(parent.underline),
750        strike: child.strike.or(parent.strike),
751        color: child.color.or(parent.color),
752        background: child.background.or(parent.background),
753        position: child.position.or(parent.position),
754        uppercase: child.uppercase.or(parent.uppercase),
755    }
756}
757
758/// Which edge of a cell a border belongs to.
759#[derive(Clone, Copy)]
760enum Edge {
761    Left,
762    Right,
763    Top,
764    Bottom,
765}
766
767fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
768    [
769        (Edge::Left, rect.left_top(), rect.left_bottom()),
770        (Edge::Right, rect.right_top(), rect.right_bottom()),
771        (Edge::Top, rect.left_top(), rect.right_top()),
772        (Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
773    ]
774}
775
776fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
777    match edge {
778        Edge::Left => borders.left,
779        Edge::Right => borders.right,
780        Edge::Top => borders.top,
781        Edge::Bottom => borders.bottom,
782    }
783}
784
785fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
786    for (edge, from, to) in edges(rect) {
787        if let Some(border) = edge_of(borders, edge) {
788            ui.painter().line_segment(
789                [from, to],
790                Stroke::new(
791                    (border.width.points() * zoom).max(1.0),
792                    format::color32(border.color),
793                ),
794            );
795        }
796    }
797}
798
799/// The width of each of a table's columns in screen points.
800///
801/// A column style usually gives one. Where none does, the available width is
802/// divided evenly, which is what a producer that wrote no widths meant.
803fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
804    let mut declared = Vec::new();
805    collect_columns(document, table, &mut declared);
806    if declared.is_empty() {
807        return Vec::new();
808    }
809    let total: f32 = declared.iter().filter_map(|w| *w).sum();
810    let unstated = declared.iter().filter(|w| w.is_none()).count();
811
812    // A table wider than the space is scaled down rather than clipped, which is
813    // what a reading view owes a document written for a wider page.
814    let scaled = total * zoom;
815    let factor = if scaled > width && scaled > 0.0 {
816        width / scaled
817    } else {
818        1.0
819    };
820    #[allow(clippy::cast_precision_loss)]
821    let share = if unstated == 0 {
822        0.0
823    } else {
824        ((width - scaled * factor) / unstated as f32).max(16.0)
825    };
826    declared
827        .into_iter()
828        .map(|w| w.map_or(share, |points| points * zoom * factor))
829        .collect()
830}
831
832fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
833    for element in parent.elements() {
834        if element.is(&Ns::Table, "table-column") {
835            let repeat = element
836                .attr_usize(&Ns::Table, "number-columns-repeated")
837                .unwrap_or(1)
838                .clamp(1, 1024);
839            let width = element
840                .attr(&Ns::Table, "style-name")
841                .map(|name| document.styles.resolve(&Family::TableColumn, name))
842                .and_then(|p| p.column_width)
843                .map(odox_core::Length::points);
844            for _ in 0..repeat {
845                into.push(width);
846            }
847        } else if element.is(&Ns::Table, "table-columns")
848            || element.is(&Ns::Table, "table-header-columns")
849            || element.is(&Ns::Table, "table-column-group")
850        {
851            collect_columns(document, element, into);
852        }
853    }
854}
855
856/// A number in the format a list level asks for.
857fn number_text(number: usize, format: &str) -> String {
858    match format.chars().next() {
859        Some('a') => alphabetic(number, b'a'),
860        Some('A') => alphabetic(number, b'A'),
861        Some('i') => roman(number).to_lowercase(),
862        Some('I') => roman(number),
863        // An empty format is a level that shows no number, which ODF uses for a
864        // list whose label is only its prefix and suffix.
865        None => String::new(),
866        _ => number.to_string(),
867    }
868}
869
870/// `a`, `b`, … `z`, `aa`, which is the spreadsheet column rule and ODF's.
871fn alphabetic(number: usize, first: u8) -> String {
872    let mut n = number;
873    let mut out = Vec::new();
874    while n > 0 {
875        let remainder = (n - 1) % 26;
876        out.push(first + u8::try_from(remainder).unwrap_or(0));
877        n = (n - 1) / 26;
878    }
879    out.reverse();
880    String::from_utf8(out).unwrap_or_default()
881}
882
883fn roman(number: usize) -> String {
884    const VALUES: [(usize, &str); 13] = [
885        (1000, "M"),
886        (900, "CM"),
887        (500, "D"),
888        (400, "CD"),
889        (100, "C"),
890        (90, "XC"),
891        (50, "L"),
892        (40, "XL"),
893        (10, "X"),
894        (9, "IX"),
895        (5, "V"),
896        (4, "IV"),
897        (1, "I"),
898    ];
899    // Beyond what Roman numerals reach, the number itself is more use than a
900    // line of Ms.
901    if number == 0 || number > 3999 {
902        return number.to_string();
903    }
904    let mut left = number;
905    let mut out = String::new();
906    for (value, numeral) in VALUES {
907        while left >= value {
908            out.push_str(numeral);
909            left -= value;
910        }
911    }
912    out
913}
914
915/// Whether an element holds blocks on the body's behalf rather than being one.
916fn is_block_container(element: &Element) -> bool {
917    element.name.ns == Ns::Text
918        && matches!(
919            &*element.name.local,
920            "section"
921                | "index-body"
922                | "index-title"
923                | "table-of-content"
924                | "illustration-index"
925                | "table-index"
926                | "object-index"
927                | "user-index"
928                | "alphabetical-index"
929                | "bibliography"
930                | "tracked-changes"
931                | "deletion"
932        )
933}
934
935/// Whether an element is a wrapper around text rather than text of its own.
936///
937/// A bookmark, a reference mark and a change mark each sit inside a paragraph,
938/// carry no characters, and may have text inside them that does belong to the
939/// paragraph.
940fn is_inline_passthrough(element: &Element) -> bool {
941    element.name.ns == Ns::Text
942        && matches!(
943            &*element.name.local,
944            "bookmark"
945                | "bookmark-start"
946                | "bookmark-end"
947                | "reference-mark"
948                | "reference-mark-start"
949                | "reference-mark-end"
950                | "span"
951                | "bibliography-mark"
952                | "ruby"
953                | "ruby-base"
954                | "meta"
955                | "meta-field"
956                | "change-start"
957                | "change-end"
958                | "page-number"
959                | "page-count"
960                | "title"
961                | "subject"
962                | "author-name"
963                | "author-initials"
964                | "chapter"
965                | "file-name"
966                | "sheet-name"
967                | "date"
968                | "time"
969                | "creator"
970                | "description"
971                | "keywords"
972                | "sequence"
973                | "bookmark-ref"
974                | "sequence-ref"
975                | "reference-ref"
976                | "variable-get"
977                | "variable-set"
978                | "user-field-get"
979                | "placeholder"
980                | "conditional-text"
981                | "hidden-text"
982                | "text-input"
983        )
984}