1use std::collections::HashMap;
17use std::sync::Arc;
18
19use eframe::egui::{
20 Align, Color32, ColorImage, Context, CursorIcon, Galley, Pos2, Rect, Response, Sense, Stroke,
21 StrokeKind, TextFormat, TextureHandle, TextureOptions, Ui, pos2,
22 text::{CCursor, CCursorRange, LayoutJob},
23 text_selection::{LabelSelectionState, visuals::paint_text_selection},
24 vec2,
25};
26use egui_richedit::{Laid, OffsetMap, ParagraphJob};
27use odox_core::{
28 Border, Document, Element, Family, Node, Ns, Properties, TextAlign, TextProperties, edit,
29};
30
31use crate::find::Highlights;
32use crate::flow_model::PageEditor;
33use crate::format::{self, DEFAULT_SIZE};
34use crate::links;
35
36#[derive(Default)]
41pub struct Pictures {
42 textures: HashMap<String, Option<TextureHandle>>,
43}
44
45impl Pictures {
46 pub fn get(
48 &mut self,
49 ctx: &Context,
50 document: &Document,
51 href: &str,
52 ) -> Option<&TextureHandle> {
53 if !self.textures.contains_key(href) {
54 let texture = document
55 .picture(href)
56 .and_then(decode)
57 .map(|image| ctx.load_texture(href, image, TextureOptions::LINEAR));
58 self.textures.insert(href.to_owned(), texture);
59 }
60 self.textures.get(href).and_then(Option::as_ref)
61 }
62
63 pub fn clear(&mut self) {
65 self.textures.clear();
66 }
67}
68
69fn decode(bytes: &[u8]) -> Option<ColorImage> {
70 let decoded = image::load_from_memory(bytes).ok()?.to_rgba8();
71 let size = [decoded.width() as usize, decoded.height() as usize];
72 Some(ColorImage::from_rgba_unmultiplied(size, decoded.as_raw()))
73}
74
75pub struct Flow<'a> {
77 pub document: &'a Document,
79 pub pictures: &'a mut Pictures,
81 pub zoom: f32,
83 pub palette: format::Palette,
85 pub scroll_to_heading: Option<usize>,
91 pub scroll_to_place: Option<String>,
95 pub followed: Option<String>,
98 headings_seen: usize,
100 pub page: Option<&'a mut PageEditor>,
103 pub find: Option<Highlights<'a>>,
105 pub selectable: bool,
108 path: Vec<usize>,
111 detached: bool,
116}
117
118impl<'a> Flow<'a> {
119 pub fn new(document: &'a Document, pictures: &'a mut Pictures, zoom: f32) -> Self {
121 Self {
122 document,
123 pictures,
124 zoom,
125 palette: format::Palette::default(),
126 scroll_to_heading: None,
127 scroll_to_place: None,
128 followed: None,
129 headings_seen: 0,
130 page: None,
131 find: None,
132 selectable: true,
133 path: Vec::new(),
134 detached: false,
135 }
136 }
137
138 pub fn start_at(&mut self, prefix: Vec<usize>) {
142 self.path = prefix;
143 }
144}
145
146const LIST_STEP: f32 = 18.0;
148const LIST_GUTTER: f32 = 20.0;
150const LABEL_GAP: f32 = 5.0;
152
153struct Spacing {
155 left: f32,
157 right: f32,
159 indent: f32,
161 before: f32,
163 after: f32,
165}
166
167impl Spacing {
168 fn of(properties: &odox_core::ParagraphProperties, zoom: f32) -> Self {
169 let points = |length: Option<odox_core::Length>| {
170 length.map_or(0.0, odox_core::Length::points) * zoom
171 };
172 Self {
173 left: points(properties.margin.left),
174 right: points(properties.margin.right),
175 indent: points(properties.text_indent),
176 before: points(properties.margin.top),
177 after: points(properties.margin.bottom),
178 }
179 }
180}
181
182#[derive(Clone, Copy)]
187struct Run<'a> {
188 inherited: &'a TextProperties,
190 size: f32,
193 format: &'a TextFormat,
195 palette: format::Palette,
197 counted: bool,
200}
201
202#[derive(Default)]
204struct Counters(Vec<usize>);
205
206impl Counters {
207 fn bump(&mut self, level: usize, start: usize) -> usize {
208 while self.0.len() <= level {
209 self.0.push(0);
210 }
211 if self.0[level] == 0 {
214 self.0[level] = start;
215 } else {
216 self.0[level] += 1;
217 }
218 self.0.truncate(level + 1);
221 self.0[level]
222 }
223
224 fn at(&self, level: usize) -> usize {
225 self.0.get(level).copied().unwrap_or(1)
226 }
227}
228
229impl Flow<'_> {
230 pub fn blocks(&mut self, ui: &mut Ui, parent: &Element, width: f32) {
235 let mut counters = Counters::default();
236 self.blocks_with(ui, parent, width, &mut counters);
237 }
238
239 fn blocks_with(&mut self, ui: &mut Ui, parent: &Element, width: f32, counters: &mut Counters) {
240 for (index, element) in parent.elements_indexed() {
241 self.path.push(index);
242 match () {
243 () if element.is(&Ns::Text, "p") || element.is(&Ns::Text, "h") => {
244 self.paragraph(ui, element, width, None);
245 }
246 () if element.is(&Ns::Text, "list") => {
247 let continues = element.attr(&Ns::Text, "continue-numbering") == Some("true")
252 || element.attr(&Ns::Text, "continue-list").is_some();
253 if !continues {
254 *counters = Counters::default();
255 }
256 self.list(ui, element, width, 0, None, counters);
257 }
258 () if element.is(&Ns::Table, "table") => self.table(ui, element, width),
259 () if element.is(&Ns::Draw, "frame") => self.frame(ui, element, width),
260 () if element.is(&Ns::Text, "soft-page-break") => self.page_break(ui, width),
261 () if is_block_container(element) => {
262 self.blocks_with(ui, element, width, counters);
263 }
264 () => {}
265 }
266 self.path.pop();
267 }
268 }
269
270 fn style_of(&self, element: &Element, family: &Family) -> std::rc::Rc<Properties> {
272 let name = element
273 .attr(&Ns::Text, "style-name")
274 .or_else(|| element.attr(&Ns::Table, "style-name"))
275 .unwrap_or("Standard");
276 self.document.styles.resolve(family, name)
277 }
278
279 fn show_matches(&self, ui: &Ui, galley: &mut Arc<Galley>, map: &OffsetMap, origin: Pos2) {
282 let Some(find) = self.find.as_ref().filter(|_| !self.detached) else {
283 return;
284 };
285 let hits = find.within(&self.path);
286 if hits.is_empty() {
287 return;
288 }
289 let at = |offset: usize| CCursor::new(map.to_galley(offset.min(map.model_len())));
290 for (range, current) in hits {
291 let mut visuals = ui.visuals().clone();
292 visuals.selection.bg_fill = format::match_fill(current);
293 visuals.selection.stroke.color = Color32::BLACK;
294 let cursors = CCursorRange::two(at(range.start), at(range.end));
295 paint_text_selection(galley, &visuals, &cursors, None);
296 if current && find.reveal() {
297 let start = galley.pos_from_cursor(at(range.start));
298 let end = galley.pos_from_cursor(at(range.end));
299 ui.scroll_to_rect(
300 start.union(end).translate(origin.to_vec2()),
301 Some(Align::Center),
302 );
303 }
304 }
305 }
306
307 #[allow(clippy::too_many_arguments)]
311 fn follow_link(
312 &mut self,
313 ui: &Ui,
314 response: &Response,
315 links: &[(std::ops::Range<usize>, &str)],
316 galley: &Galley,
317 map: &OffsetMap,
318 origin: Pos2,
319 editing: bool,
320 ) {
321 let Some(pointer) = response
322 .hover_pos()
323 .or_else(|| response.interact_pointer_pos())
324 else {
325 return;
326 };
327 let cursor = galley.cursor_from_pos(pointer - origin);
328 let caret = galley.pos_from_cursor(cursor).center().x + origin.x;
329 let character = if pointer.x < caret {
330 cursor.index.0.saturating_sub(1)
331 } else {
332 cursor.index.0
333 };
334 let offset = map.to_model(character);
335 let Some(href) = links
336 .iter()
337 .find(|(range, _)| range.contains(&offset))
338 .map(|&(_, href)| href)
339 else {
340 return;
341 };
342 let Some(target) = links::target(href) else {
343 return;
344 };
345 let reaching = !editing || ui.input(|input| input.modifiers.command);
346 if reaching {
347 ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
348 }
349 if response.hovered() {
350 response.clone().on_hover_text(href);
351 }
352 if reaching && response.clicked() {
353 match target {
354 links::Target::External(address) => links::open(ui.ctx(), address),
355 links::Target::Within(place) => self.followed = Some(place.to_owned()),
356 }
357 }
358 }
359
360 fn paragraph(&mut self, ui: &mut Ui, element: &Element, width: f32, label: Option<&str>) {
362 let properties = self.style_of(element, &Family::Paragraph);
363 let zoom = self.zoom;
364 let size = format::size_of(&properties.text, DEFAULT_SIZE);
365
366 let Spacing {
367 left,
368 right,
369 indent,
370 before,
371 after,
372 } = Spacing::of(&properties.paragraph, zoom);
373
374 let body_width = (width - left - right).max(1.0);
378 let wrap = (body_width - indent.max(0.0)).max(1.0);
379
380 if before > 0.0 {
381 ui.add_space(before);
382 }
383
384 let base = format::text_format(&properties.text, DEFAULT_SIZE, zoom, self.palette);
385
386 let (job, frames) = self.layout_job(element, &properties, &base, size, wrap);
387 let (job, map) = job.into_parts();
388
389 let galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(job));
390 let height = galley.size().y;
391 let edited = !self.detached && self.page.is_some();
392 let sense = if self.selectable {
401 Sense::click_and_drag()
402 } else {
403 Sense::click()
404 };
405 let (rect, response) = ui.allocate_exact_size(vec2(width, height), sense);
406
407 if element.is(&Ns::Text, "h") {
408 if self.scroll_to_heading == Some(self.headings_seen) {
409 ui.scroll_to_rect(rect, Some(Align::TOP));
410 }
411 self.headings_seen += 1;
412 }
413 if let Some(place) = &self.scroll_to_place
414 && holds_place(element, place)
415 {
416 ui.scroll_to_rect(rect, Some(Align::TOP));
417 }
418
419 if let Some(fill) = properties.paragraph.background {
420 ui.painter().rect_filled(rect, 0.0, format::color32(fill));
421 }
422 paint_borders(ui, rect, &properties.paragraph.border, zoom);
423
424 let anchor = match properties.paragraph.align.unwrap_or(TextAlign::Start) {
428 TextAlign::Center => rect.left() + left + indent.max(0.0) + wrap / 2.0,
429 TextAlign::End => rect.left() + left + indent.max(0.0) + wrap,
430 _ => rect.left() + left + indent.max(0.0),
431 };
432 let origin = pos2(anchor, rect.top());
441 let mut galley = galley;
442 self.show_matches(ui, &mut galley, &map, origin);
443 let links = edit::links(element);
444 let hit = (!links.is_empty()).then(|| (Arc::clone(&galley), map.clone()));
445 if let Some(page) = self.page.as_deref_mut().filter(|_| edited) {
446 let laid = Laid {
447 galley,
448 map,
449 origin,
450 };
451 page.paragraph(ui, &response, &self.path, laid);
452 } else {
453 LabelSelectionState::label_text_selection(
454 ui,
455 &response,
456 origin,
457 galley,
458 base.color,
459 Stroke::NONE,
460 );
461 }
462
463 if let Some((galley, map)) = hit {
464 self.follow_link(ui, &response, &links, &galley, &map, origin, edited);
465 }
466
467 if let Some(label) = label {
468 let mut label_job = LayoutJob::default();
469 label_job.append(label, 0.0, base.clone());
470 let label_galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(label_job));
471 let x = rect.left() + left - label_galley.size().x - LABEL_GAP * zoom;
475 ui.painter()
476 .galley(pos2(x, rect.top()), label_galley, base.color);
477 }
478
479 let was_detached = self.detached;
482 self.detached = true;
483 for frame in frames {
484 self.frame(ui, &frame, body_width);
485 }
486 self.detached = was_detached;
487 if after > 0.0 {
488 ui.add_space(after);
489 }
490 }
491
492 fn layout_job(
495 &self,
496 element: &Element,
497 properties: &Properties,
498 base: &TextFormat,
499 size: f32,
500 wrap: f32,
501 ) -> (ParagraphJob, Vec<Element>) {
502 let job = LayoutJob {
503 wrap: eframe::egui::text::TextWrapping {
504 max_width: wrap,
505 ..Default::default()
506 },
507 halign: match properties.paragraph.align.unwrap_or(TextAlign::Start) {
508 TextAlign::Center => Align::Center,
509 TextAlign::End => Align::Max,
510 _ => Align::Min,
511 },
512 justify: properties.paragraph.align == Some(TextAlign::Justify),
513 ..LayoutJob::default()
514 };
515 let mut job = ParagraphJob::new(job);
516 let mut frames = Vec::new();
517 let run = Run {
518 inherited: &properties.text,
519 size,
520 format: base,
521 palette: self.palette,
522 counted: true,
523 };
524 self.runs(element, &run, &mut job, &mut frames);
525
526 if job.is_empty() {
529 job.atom(" ", 0, base.clone());
530 }
531 if let Some(measure) = properties.paragraph.line_height {
532 for format in job.formats_mut() {
536 let own = format.font_id.size / self.zoom;
537 format.line_height = Some(measure.resolve(own) * self.zoom);
538 }
539 }
540 (job, frames)
541 }
542
543 fn runs(
555 &self,
556 parent: &Element,
557 run: &Run<'_>,
558 job: &mut ParagraphJob,
559 frames: &mut Vec<Element>,
560 ) {
561 let Run {
562 inherited,
563 size,
564 format,
565 palette,
566 counted,
567 } = *run;
568 let put = |job: &mut ParagraphJob, shown: &str, model_len: usize| {
569 if !counted {
570 job.atom(shown, 0, format.clone());
571 } else if shown.chars().count() == model_len {
572 job.text(shown, format.clone());
573 } else {
574 job.atom(shown, model_len, format.clone());
575 }
576 };
577 for child in &parent.children {
578 match child {
579 Node::Text(text) | Node::CData(text) => put(job, text, text.chars().count()),
580 Node::Comment(_) | Node::ProcessingInstruction(_) => {}
581 Node::Element(element) => {
582 if element.is(&Ns::Text, "s") {
583 let count = element.attr_usize(&Ns::Text, "c").unwrap_or(1);
584 put(job, &" ".repeat(count.min(256)), count);
585 } else if element.is(&Ns::Text, "tab") {
586 put(job, " ", 1);
591 } else if element.is(&Ns::Text, "line-break") {
592 put(job, "\n", 1);
593 } else if element.is(&Ns::Text, "span") {
594 let style = self.style_of(element, &Family::Text);
595 let merged = merge(inherited, &style.text);
596 let inner_size = format::size_of(&merged, size);
597 let inner = format::text_format(&merged, size, self.zoom, palette);
598 let run = Run {
599 inherited: &merged,
600 size: inner_size,
601 format: &inner,
602 palette,
603 counted,
604 };
605 self.runs(element, &run, job, frames);
606 } else if element.is(&Ns::Text, "a") {
607 let style = self.style_of(element, &Family::Text);
608 let merged = merge(inherited, &style.text);
609 let inner_size = format::size_of(&merged, size);
610 let inner = format::link_format(&merged, size, self.zoom, palette);
611 let run = Run {
612 inherited: &merged,
613 size: inner_size,
614 format: &inner,
615 palette,
616 counted,
617 };
618 self.runs(element, &run, job, frames);
619 } else if element.is(&Ns::Draw, "frame") {
620 frames.push(element.clone());
621 } else if element.is(&Ns::Text, "note") {
622 if let Some(citation) = element.child(&Ns::Text, "note-citation") {
626 let mut raised = format.clone();
627 raised.font_id.size *= 0.7;
628 raised.valign = Align::TOP;
629 job.atom(&citation.plain_text(), 0, raised);
630 }
631 } else if is_inline_passthrough(element) {
632 let run = Run {
636 counted: counted && edit::holds_text(element),
637 ..*run
638 };
639 self.runs(element, &run, job, frames);
640 }
641 }
642 }
643 }
644 }
645
646 fn list(
648 &mut self,
649 ui: &mut Ui,
650 element: &Element,
651 width: f32,
652 level: usize,
653 inherited_style: Option<&str>,
654 counters: &mut Counters,
655 ) {
656 let style_name = element
657 .attr(&Ns::Text, "style-name")
658 .or(inherited_style)
659 .unwrap_or_default()
660 .to_owned();
661 #[allow(clippy::cast_precision_loss)]
665 let indent = LIST_STEP * self.zoom * (level as f32 + 1.0);
666 let gutter = LIST_GUTTER * self.zoom;
667
668 for (item_index, item) in element.elements_indexed() {
669 let numbered = item.is(&Ns::Text, "list-item");
670 if !numbered && !item.is(&Ns::Text, "list-header") {
671 continue;
672 }
673 self.path.push(item_index);
674 let label = if numbered {
675 let level_style = self.level_style(&style_name, level);
676 let start = level_style
677 .as_ref()
678 .and_then(|s| s.attr_usize(&Ns::Text, "start-value"))
679 .unwrap_or(1);
680 let number = counters.bump(level, start);
681 Self::label(level_style.as_ref(), level, number, counters)
682 } else {
683 String::new()
686 };
687
688 let mut first = true;
689 for (block_index, block) in item.elements_indexed() {
690 self.path.push(block_index);
691 if block.is(&Ns::Text, "list") {
692 self.list(ui, block, width, level + 1, Some(&style_name), counters);
693 self.path.pop();
694 continue;
695 }
696 let on_this_block = if first && !label.is_empty() {
697 Some(label.as_str())
698 } else {
699 None
700 };
701 if block.is(&Ns::Text, "p") || block.is(&Ns::Text, "h") {
702 first = false;
703 ui.horizontal_top(|ui| {
707 ui.add_space(indent + gutter);
708 self.paragraph(ui, block, width - indent - gutter, on_this_block);
709 });
710 } else if block.is(&Ns::Table, "table") {
711 first = false;
712 ui.horizontal_top(|ui| {
713 ui.add_space(indent + gutter);
714 self.table(ui, block, width - indent - gutter);
715 });
716 } else if block.is(&Ns::Draw, "frame") {
717 first = false;
718 ui.horizontal_top(|ui| {
719 ui.add_space(indent + gutter);
720 self.frame(ui, block, width - indent - gutter);
721 });
722 }
723 self.path.pop();
724 }
725 self.path.pop();
726 }
727 }
728
729 fn level_style(&self, list_style: &str, level: usize) -> Option<Element> {
732 let style = self.document.styles.list_style(list_style)?;
733 style
734 .elements()
735 .find(|e| e.attr_usize(&Ns::Text, "level") == Some(level + 1))
736 .cloned()
737 }
738
739 fn label(
741 level_style: Option<&Element>,
742 level: usize,
743 number: usize,
744 counters: &Counters,
745 ) -> String {
746 let Some(style) = level_style else {
747 return "\u{2022}".to_owned();
750 };
751 if style.is(&Ns::Text, "list-level-style-bullet") {
752 return style
753 .attr(&Ns::Text, "bullet-char")
754 .unwrap_or("\u{2022}")
755 .to_owned();
756 }
757 if style.is(&Ns::Text, "list-level-style-number") {
758 let format = style.attr(&Ns::Style, "num-format").unwrap_or("1");
759 let prefix = style.attr(&Ns::Style, "num-prefix").unwrap_or_default();
760 let suffix = style.attr(&Ns::Style, "num-suffix").unwrap_or_default();
761 let display = style
764 .attr_usize(&Ns::Text, "display-levels")
765 .unwrap_or(1)
766 .max(1);
767 let mut numbers = Vec::new();
768 let first = (level + 1).saturating_sub(display);
769 for ancestor in first..level {
770 numbers.push(number_text(counters.at(ancestor), format));
771 }
772 numbers.push(number_text(number, format));
773 return format!("{prefix}{}{suffix}", numbers.join("."));
774 }
775 "\u{2022}".to_owned()
777 }
778
779 fn table(&mut self, ui: &mut Ui, table: &Element, width: f32) {
781 let columns = column_widths(self.document, table, width, self.zoom);
782 if columns.is_empty() {
783 return;
784 }
785 ui.add_space(4.0 * self.zoom);
786 self.rows(ui, table, &columns);
787 ui.add_space(4.0 * self.zoom);
788 }
789
790 fn rows(&mut self, ui: &mut Ui, parent: &Element, columns: &[f32]) {
791 for (index, element) in parent.elements_indexed() {
792 self.path.push(index);
793 if element.is(&Ns::Table, "table-row") {
794 self.row(ui, element, columns);
795 } else if element.is(&Ns::Table, "table-header-rows")
796 || element.is(&Ns::Table, "table-rows")
797 || element.is(&Ns::Table, "table-row-group")
798 {
799 self.rows(ui, element, columns);
800 }
801 self.path.pop();
802 }
803 }
804
805 fn row(&mut self, ui: &mut Ui, row: &Element, columns: &[f32]) {
806 let reserved = ui.painter().add(eframe::egui::Shape::Noop);
810 let mut painted = Vec::new();
811
812 let response = ui.horizontal_top(|ui| {
813 ui.spacing_mut().item_spacing.x = 0.0;
814 let mut column = 0usize;
815 for (cell_index, cell) in row.elements_indexed() {
816 let covered = cell.is(&Ns::Table, "covered-table-cell");
817 if !covered && !cell.is(&Ns::Table, "table-cell") {
818 continue;
819 }
820 let repeat = cell
821 .attr_usize(&Ns::Table, "number-columns-repeated")
822 .unwrap_or(1)
823 .max(1);
824 let spanned = cell
825 .attr_usize(&Ns::Table, "number-columns-spanned")
826 .unwrap_or(1)
827 .max(1);
828 for copy in 0..repeat {
829 let width: f32 = columns
835 .iter()
836 .skip(column)
837 .take(spanned)
838 .sum::<f32>()
839 .max(8.0);
840 if !covered {
841 let properties = self.style_of(cell, &Family::TableCell);
842 let padding = properties
843 .cell
844 .padding
845 .left
846 .map_or(2.0, odox_core::Length::points)
847 * self.zoom;
848 let inner = ui
849 .allocate_ui_with_layout(
850 vec2(width, 0.0),
851 eframe::egui::Layout::top_down(Align::Min),
852 |ui| {
853 ui.add_space(padding);
854 ui.set_min_width(width);
855 ui.set_max_width(width);
856 let content = (width - padding * 2.0).max(8.0);
857 self.path.push(cell_index);
858 let was_detached = self.detached;
859 self.detached |= copy > 0;
860 self.blocks(ui, cell, content);
861 self.detached = was_detached;
862 self.path.pop();
863 ui.add_space(padding);
864 },
865 )
866 .response
867 .rect;
868 painted.push((inner, properties));
869 }
870 column += 1;
871 }
872 }
873 });
874
875 let row_rect = response.response.rect;
876 let mut shapes = Vec::new();
877 for (rect, properties) in painted {
878 let cell_rect = Rect::from_min_max(
879 pos2(rect.left(), row_rect.top()),
880 pos2(rect.right(), row_rect.bottom()),
881 );
882 if let Some(fill) = properties.cell.background {
883 shapes.push(eframe::egui::Shape::rect_filled(
884 cell_rect,
885 0.0,
886 format::color32(fill),
887 ));
888 }
889 for (edge, from, to) in edges(cell_rect) {
890 if let Some(border) = edge_of(&properties.cell.border, edge) {
891 shapes.push(eframe::egui::Shape::line_segment(
892 [from, to],
893 Stroke::new(
894 (border.width.points() * self.zoom).max(1.0),
895 format::color32(border.color),
896 ),
897 ));
898 }
899 }
900 }
901 ui.painter().set(reserved, eframe::egui::Shape::Vec(shapes));
902 }
903
904 pub fn frame(&mut self, ui: &mut Ui, frame: &Element, width: f32) {
911 let zoom = self.zoom;
912 let declared = |local: &str| {
913 frame
914 .attr(&Ns::Svg, local)
915 .and_then(odox_core::Length::parse)
916 .map(|l| l.points() * zoom)
917 };
918
919 let found = frame
926 .elements()
927 .filter(|child| child.is(&Ns::Draw, "image"))
928 .find_map(|image| {
929 let href = image.attr(&Ns::Xlink, "href")?.to_owned();
930 self.pictures
931 .get(ui.ctx(), self.document, &href)
932 .map(|texture| (texture.id(), texture.size()))
933 });
934 if let Some((id, [pixels_wide, pixels_high])) = found {
935 #[allow(clippy::cast_precision_loss)]
936 let aspect = if pixels_wide == 0 {
937 1.0
938 } else {
939 pixels_high as f32 / pixels_wide as f32
940 };
941 let w = declared("width").unwrap_or(width).min(width);
942 let h = declared("height").unwrap_or(w * aspect);
943 let size = vec2(w, h);
944 ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
945 return;
946 }
947
948 if let Some((box_index, box_)) = frame
952 .elements_indexed()
953 .find(|(_, e)| e.is(&Ns::Draw, "text-box"))
954 {
955 let w = declared("width").unwrap_or(width).min(width);
956 ui.allocate_ui_with_layout(
957 vec2(w, 0.0),
958 eframe::egui::Layout::top_down(Align::Min),
959 |ui| {
960 ui.set_max_width(w);
961 eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
962 self.path.push(box_index);
963 self.blocks(ui, box_, w - 16.0 * zoom);
964 self.path.pop();
965 });
966 },
967 );
968 return;
969 }
970
971 let w = declared("width").unwrap_or(width).min(width);
972 let h = declared("height").unwrap_or(48.0 * zoom);
973 let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
974 ui.painter().rect_stroke(
975 rect,
976 2.0,
977 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
978 StrokeKind::Inside,
979 );
980 }
981
982 fn page_break(&mut self, ui: &mut Ui, width: f32) {
987 ui.add_space(8.0 * self.zoom);
988 let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
989 ui.painter().hline(
990 rect.x_range(),
991 rect.center().y,
992 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
993 );
994 ui.add_space(8.0 * self.zoom);
995 }
996}
997
998fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
1001 TextProperties {
1002 font_family: child
1003 .font_family
1004 .clone()
1005 .or_else(|| parent.font_family.clone()),
1006 size: child.size.or(parent.size),
1007 bold: child.bold.or(parent.bold),
1008 italic: child.italic.or(parent.italic),
1009 underline: child.underline.or(parent.underline),
1010 strike: child.strike.or(parent.strike),
1011 color: child.color.or(parent.color),
1012 background: child.background.or(parent.background),
1013 position: child.position.or(parent.position),
1014 uppercase: child.uppercase.or(parent.uppercase),
1015 }
1016}
1017
1018#[derive(Clone, Copy)]
1020enum Edge {
1021 Left,
1022 Right,
1023 Top,
1024 Bottom,
1025}
1026
1027fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
1028 [
1029 (Edge::Left, rect.left_top(), rect.left_bottom()),
1030 (Edge::Right, rect.right_top(), rect.right_bottom()),
1031 (Edge::Top, rect.left_top(), rect.right_top()),
1032 (Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
1033 ]
1034}
1035
1036fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
1037 match edge {
1038 Edge::Left => borders.left,
1039 Edge::Right => borders.right,
1040 Edge::Top => borders.top,
1041 Edge::Bottom => borders.bottom,
1042 }
1043}
1044
1045fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
1046 for (edge, from, to) in edges(rect) {
1047 if let Some(border) = edge_of(borders, edge) {
1048 ui.painter().line_segment(
1049 [from, to],
1050 Stroke::new(
1051 (border.width.points() * zoom).max(1.0),
1052 format::color32(border.color),
1053 ),
1054 );
1055 }
1056 }
1057}
1058
1059fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
1064 let mut declared = Vec::new();
1065 collect_columns(document, table, &mut declared);
1066 if declared.is_empty() {
1067 return Vec::new();
1068 }
1069 let total: f32 = declared.iter().filter_map(|w| *w).sum();
1070 let unstated = declared.iter().filter(|w| w.is_none()).count();
1071
1072 let scaled = total * zoom;
1075 let factor = if scaled > width && scaled > 0.0 {
1076 width / scaled
1077 } else {
1078 1.0
1079 };
1080 #[allow(clippy::cast_precision_loss)]
1081 let share = if unstated == 0 {
1082 0.0
1083 } else {
1084 ((width - scaled * factor) / unstated as f32).max(16.0)
1085 };
1086 declared
1087 .into_iter()
1088 .map(|w| w.map_or(share, |points| points * zoom * factor))
1089 .collect()
1090}
1091
1092fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
1093 for element in parent.elements() {
1094 if element.is(&Ns::Table, "table-column") {
1095 let repeat = element
1096 .attr_usize(&Ns::Table, "number-columns-repeated")
1097 .unwrap_or(1)
1098 .clamp(1, 1024);
1099 let width = element
1100 .attr(&Ns::Table, "style-name")
1101 .map(|name| document.styles.resolve(&Family::TableColumn, name))
1102 .and_then(|p| p.column_width)
1103 .map(odox_core::Length::points);
1104 for _ in 0..repeat {
1105 into.push(width);
1106 }
1107 } else if element.is(&Ns::Table, "table-columns")
1108 || element.is(&Ns::Table, "table-header-columns")
1109 || element.is(&Ns::Table, "table-column-group")
1110 {
1111 collect_columns(document, element, into);
1112 }
1113 }
1114}
1115
1116fn number_text(number: usize, format: &str) -> String {
1118 match format.chars().next() {
1119 Some('a') => alphabetic(number, b'a'),
1120 Some('A') => alphabetic(number, b'A'),
1121 Some('i') => roman(number).to_lowercase(),
1122 Some('I') => roman(number),
1123 None => String::new(),
1126 _ => number.to_string(),
1127 }
1128}
1129
1130fn alphabetic(number: usize, first: u8) -> String {
1132 let mut n = number;
1133 let mut out = Vec::new();
1134 while n > 0 {
1135 let remainder = (n - 1) % 26;
1136 out.push(first + u8::try_from(remainder).unwrap_or(0));
1137 n = (n - 1) / 26;
1138 }
1139 out.reverse();
1140 String::from_utf8(out).unwrap_or_default()
1141}
1142
1143fn roman(number: usize) -> String {
1144 const VALUES: [(usize, &str); 13] = [
1145 (1000, "M"),
1146 (900, "CM"),
1147 (500, "D"),
1148 (400, "CD"),
1149 (100, "C"),
1150 (90, "XC"),
1151 (50, "L"),
1152 (40, "XL"),
1153 (10, "X"),
1154 (9, "IX"),
1155 (5, "V"),
1156 (4, "IV"),
1157 (1, "I"),
1158 ];
1159 if number == 0 || number > 3999 {
1162 return number.to_string();
1163 }
1164 let mut left = number;
1165 let mut out = String::new();
1166 for (value, numeral) in VALUES {
1167 while left >= value {
1168 out.push_str(numeral);
1169 left -= value;
1170 }
1171 }
1172 out
1173}
1174
1175fn holds_place(element: &Element, place: &str) -> bool {
1178 if let Some(name) = place.strip_suffix("|outline") {
1179 return element.is(&Ns::Text, "h") && element.plain_text().trim() == name.trim();
1180 }
1181 element.elements().any(|e| {
1182 let marks = e.is(&Ns::Text, "bookmark") || e.is(&Ns::Text, "bookmark-start");
1183 (marks && e.attr(&Ns::Text, "name") == Some(place)) || holds_place(e, place)
1184 })
1185}
1186
1187pub(crate) fn is_block_container(element: &Element) -> bool {
1189 element.name.ns == Ns::Text
1190 && matches!(
1191 &*element.name.local,
1192 "section"
1193 | "index-body"
1194 | "index-title"
1195 | "table-of-content"
1196 | "illustration-index"
1197 | "table-index"
1198 | "object-index"
1199 | "user-index"
1200 | "alphabetical-index"
1201 | "bibliography"
1202 | "tracked-changes"
1203 | "deletion"
1204 )
1205}
1206
1207fn is_inline_passthrough(element: &Element) -> bool {
1213 element.name.ns == Ns::Text
1214 && matches!(
1215 &*element.name.local,
1216 "bookmark"
1217 | "bookmark-start"
1218 | "bookmark-end"
1219 | "reference-mark"
1220 | "reference-mark-start"
1221 | "reference-mark-end"
1222 | "span"
1223 | "bibliography-mark"
1224 | "ruby"
1225 | "ruby-base"
1226 | "meta"
1227 | "meta-field"
1228 | "change-start"
1229 | "change-end"
1230 | "page-number"
1231 | "page-count"
1232 | "title"
1233 | "subject"
1234 | "author-name"
1235 | "author-initials"
1236 | "chapter"
1237 | "file-name"
1238 | "sheet-name"
1239 | "date"
1240 | "time"
1241 | "creator"
1242 | "description"
1243 | "keywords"
1244 | "sequence"
1245 | "bookmark-ref"
1246 | "sequence-ref"
1247 | "reference-ref"
1248 | "variable-get"
1249 | "variable-set"
1250 | "user-field-get"
1251 | "placeholder"
1252 | "conditional-text"
1253 | "hidden-text"
1254 | "text-input"
1255 )
1256}
1257
1258#[cfg(test)]
1259mod tests {
1260 use std::path::Path;
1261
1262 use super::{Flow, Pictures};
1263 use crate::format::{self, DEFAULT_SIZE};
1264 use odox_core::{Document, Element, Family, edit, media_type};
1265
1266 const ANY: &[&str] = &[
1267 media_type::TEXT,
1268 media_type::TEXT_WEB,
1269 media_type::SPREADSHEET,
1270 media_type::PRESENTATION,
1271 ];
1272
1273 fn documents(directory: &Path, into: &mut Vec<Document>) {
1274 let Ok(entries) = std::fs::read_dir(directory) else {
1275 return;
1276 };
1277 for path in entries.filter_map(Result::ok).map(|entry| entry.path()) {
1278 if path.is_dir() {
1279 documents(&path, into);
1280 } else if matches!(
1281 path.extension().and_then(|e| e.to_str()),
1282 Some("odt" | "ods" | "odp")
1283 ) {
1284 let bytes = std::fs::read(&path).expect("a corpus document reads");
1285 into.push(Document::read(&bytes, ANY).expect("a corpus document parses"));
1286 }
1287 }
1288 }
1289
1290 fn paragraphs<'a>(element: &'a Element, into: &mut Vec<&'a Element>) {
1291 for child in element.elements() {
1292 if edit::is_paragraph(child) {
1293 into.push(child);
1294 }
1295 paragraphs(child, into);
1296 }
1297 }
1298
1299 #[test]
1303 fn what_is_drawn_maps_onto_the_text_an_edit_changes() {
1304 let mut corpus = Vec::new();
1305 documents(
1306 &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"),
1307 &mut corpus,
1308 );
1309 assert!(!corpus.is_empty(), "the corpus is where it was");
1310 let mut checked = 0;
1311 for document in &corpus {
1312 let mut pictures = Pictures::default();
1313 let flow = Flow::new(document, &mut pictures, 1.0);
1314 let mut found = Vec::new();
1315 paragraphs(&document.content, &mut found);
1316 for paragraph in found {
1317 let properties = flow.style_of(paragraph, &Family::Paragraph);
1318 let base = format::text_format(&properties.text, DEFAULT_SIZE, 1.0, flow.palette);
1319 let size = format::size_of(&properties.text, DEFAULT_SIZE);
1320 let (job, _) = flow.layout_job(paragraph, &properties, &base, size, 400.0);
1321 let (_, map) = job.into_parts();
1322 let text = edit::text(paragraph);
1323 assert_eq!(map.model_len(), text.chars().count(), "{text:?}");
1324 checked += 1;
1325 }
1326 }
1327 assert!(checked > 0);
1328 }
1329}