Skip to main content

odox_ui/
flow.rs

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