1use std::collections::HashMap;
17
18use eframe::egui::{
19 Align, ColorImage, Context, Pos2, Rect, Sense, Stroke, StrokeKind, TextFormat, TextureHandle,
20 TextureOptions, Ui, pos2, text::LayoutJob, text_selection::LabelSelectionState, vec2,
21};
22use odox_core::{
23 Border, Document, Element, Family, Node, Ns, Properties, TextAlign, TextProperties,
24};
25
26use crate::format::{self, DEFAULT_SIZE};
27
28#[derive(Default)]
33pub struct Pictures {
34 textures: HashMap<String, Option<TextureHandle>>,
35}
36
37impl Pictures {
38 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 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
67pub struct Flow<'a> {
69 pub document: &'a Document,
71 pub pictures: &'a mut Pictures,
73 pub zoom: f32,
75 pub palette: format::Palette,
77 pub scroll_to_heading: Option<usize>,
83 headings_seen: usize,
85}
86
87impl<'a> Flow<'a> {
88 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
101const LIST_STEP: f32 = 18.0;
103const LIST_GUTTER: f32 = 20.0;
105const LABEL_GAP: f32 = 5.0;
107
108struct Spacing {
110 left: f32,
112 right: f32,
114 indent: f32,
116 before: f32,
118 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#[derive(Clone, Copy)]
142struct Run<'a> {
143 inherited: &'a TextProperties,
145 size: f32,
148 format: &'a TextFormat,
150 palette: format::Palette,
152}
153
154#[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 if self.0[level] == 0 {
166 self.0[level] = start;
167 } else {
168 self.0[level] += 1;
169 }
170 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 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 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 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 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 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 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, response) = ui.allocate_exact_size(vec2(width, height), Sense::click_and_drag());
296
297 if element.is(&Ns::Text, "h") {
298 if self.scroll_to_heading == Some(self.headings_seen) {
299 ui.scroll_to_rect(rect, Some(Align::TOP));
300 }
301 self.headings_seen += 1;
302 }
303
304 if let Some(fill) = properties.paragraph.background {
305 ui.painter().rect_filled(rect, 0.0, format::color32(fill));
306 }
307 paint_borders(ui, rect, &properties.paragraph.border, zoom);
308
309 let anchor = match properties.paragraph.align.unwrap_or(TextAlign::Start) {
313 TextAlign::Center => rect.left() + left + indent.max(0.0) + wrap / 2.0,
314 TextAlign::End => rect.left() + left + indent.max(0.0) + wrap,
315 _ => rect.left() + left + indent.max(0.0),
316 };
317 LabelSelectionState::label_text_selection(
325 ui,
326 &response,
327 pos2(anchor, rect.top()),
328 galley,
329 base.color,
330 Stroke::NONE,
331 );
332
333 if let Some(label) = label {
334 let mut label_job = LayoutJob::default();
335 label_job.append(label, 0.0, base.clone());
336 let label_galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(label_job));
337 let x = rect.left() + left - label_galley.size().x - LABEL_GAP * zoom;
341 ui.painter()
342 .galley(pos2(x, rect.top()), label_galley, base.color);
343 }
344
345 for frame in frames {
346 self.frame(ui, &frame, body_width);
347 }
348 if after > 0.0 {
349 ui.add_space(after);
350 }
351 }
352
353 fn runs(
360 &self,
361 parent: &Element,
362 run: &Run<'_>,
363 job: &mut LayoutJob,
364 frames: &mut Vec<Element>,
365 ) {
366 let Run {
367 inherited,
368 size,
369 format,
370 palette,
371 } = *run;
372 for child in &parent.children {
373 match child {
374 Node::Text(text) | Node::CData(text) => job.append(text, 0.0, format.clone()),
375 Node::Comment(_) | Node::ProcessingInstruction(_) => {}
376 Node::Element(element) => {
377 if element.is(&Ns::Text, "s") {
378 let count = element.attr_usize(&Ns::Text, "c").unwrap_or(1).min(256);
379 job.append(&" ".repeat(count), 0.0, format.clone());
380 } else if element.is(&Ns::Text, "tab") {
381 job.append(" ", 0.0, format.clone());
386 } else if element.is(&Ns::Text, "line-break") {
387 job.append("\n", 0.0, format.clone());
388 } else if element.is(&Ns::Text, "span") {
389 let style = self.style_of(element, &Family::Text);
390 let merged = merge(inherited, &style.text);
391 let inner_size = format::size_of(&merged, size);
392 let inner = format::text_format(&merged, size, self.zoom, palette);
393 let run = Run {
394 inherited: &merged,
395 size: inner_size,
396 format: &inner,
397 palette,
398 };
399 self.runs(element, &run, job, frames);
400 } else if element.is(&Ns::Text, "a") {
401 let style = self.style_of(element, &Family::Text);
402 let merged = merge(inherited, &style.text);
403 let inner_size = format::size_of(&merged, size);
404 let inner = format::link_format(&merged, size, self.zoom, palette);
405 let run = Run {
406 inherited: &merged,
407 size: inner_size,
408 format: &inner,
409 palette,
410 };
411 self.runs(element, &run, job, frames);
412 } else if element.is(&Ns::Draw, "frame") {
413 frames.push(element.clone());
414 } else if element.is(&Ns::Text, "note") {
415 if let Some(citation) = element.child(&Ns::Text, "note-citation") {
419 let mut raised = format.clone();
420 raised.font_id.size *= 0.7;
421 raised.valign = Align::TOP;
422 job.append(&citation.plain_text(), 0.0, raised);
423 }
424 } else if is_inline_passthrough(element) {
425 self.runs(element, run, job, frames);
426 }
427 }
428 }
429 }
430 }
431
432 fn list(
434 &mut self,
435 ui: &mut Ui,
436 element: &Element,
437 width: f32,
438 level: usize,
439 inherited_style: Option<&str>,
440 counters: &mut Counters,
441 ) {
442 let style_name = element
443 .attr(&Ns::Text, "style-name")
444 .or(inherited_style)
445 .unwrap_or_default()
446 .to_owned();
447 #[allow(clippy::cast_precision_loss)]
451 let indent = LIST_STEP * self.zoom * (level as f32 + 1.0);
452 let gutter = LIST_GUTTER * self.zoom;
453
454 for item in element.elements() {
455 let numbered = item.is(&Ns::Text, "list-item");
456 if !numbered && !item.is(&Ns::Text, "list-header") {
457 continue;
458 }
459 let label = if numbered {
460 let level_style = self.level_style(&style_name, level);
461 let start = level_style
462 .as_ref()
463 .and_then(|s| s.attr_usize(&Ns::Text, "start-value"))
464 .unwrap_or(1);
465 let number = counters.bump(level, start);
466 Self::label(level_style.as_ref(), level, number, counters)
467 } else {
468 String::new()
471 };
472
473 let mut first = true;
474 for block in item.elements() {
475 if block.is(&Ns::Text, "list") {
476 self.list(ui, block, width, level + 1, Some(&style_name), counters);
477 continue;
478 }
479 let on_this_block = if first && !label.is_empty() {
480 Some(label.as_str())
481 } else {
482 None
483 };
484 if block.is(&Ns::Text, "p") || block.is(&Ns::Text, "h") {
485 first = false;
486 ui.horizontal_top(|ui| {
490 ui.add_space(indent + gutter);
491 self.paragraph(ui, block, width - indent - gutter, on_this_block);
492 });
493 } else if block.is(&Ns::Table, "table") {
494 first = false;
495 ui.horizontal_top(|ui| {
496 ui.add_space(indent + gutter);
497 self.table(ui, block, width - indent - gutter);
498 });
499 } else if block.is(&Ns::Draw, "frame") {
500 first = false;
501 ui.horizontal_top(|ui| {
502 ui.add_space(indent + gutter);
503 self.frame(ui, block, width - indent - gutter);
504 });
505 }
506 }
507 }
508 }
509
510 fn level_style(&self, list_style: &str, level: usize) -> Option<Element> {
513 let style = self.document.styles.list_style(list_style)?;
514 style
515 .elements()
516 .find(|e| e.attr_usize(&Ns::Text, "level") == Some(level + 1))
517 .cloned()
518 }
519
520 fn label(
522 level_style: Option<&Element>,
523 level: usize,
524 number: usize,
525 counters: &Counters,
526 ) -> String {
527 let Some(style) = level_style else {
528 return "\u{2022}".to_owned();
531 };
532 if style.is(&Ns::Text, "list-level-style-bullet") {
533 return style
534 .attr(&Ns::Text, "bullet-char")
535 .unwrap_or("\u{2022}")
536 .to_owned();
537 }
538 if style.is(&Ns::Text, "list-level-style-number") {
539 let format = style.attr(&Ns::Style, "num-format").unwrap_or("1");
540 let prefix = style.attr(&Ns::Text, "num-prefix").unwrap_or_default();
541 let suffix = style.attr(&Ns::Text, "num-suffix").unwrap_or_default();
542 let display = style
545 .attr_usize(&Ns::Text, "display-levels")
546 .unwrap_or(1)
547 .max(1);
548 let mut numbers = Vec::new();
549 let first = (level + 1).saturating_sub(display);
550 for ancestor in first..level {
551 numbers.push(number_text(counters.at(ancestor), format));
552 }
553 numbers.push(number_text(number, format));
554 return format!("{prefix}{}{suffix}", numbers.join("."));
555 }
556 "\u{2022}".to_owned()
558 }
559
560 fn table(&mut self, ui: &mut Ui, table: &Element, width: f32) {
562 let columns = column_widths(self.document, table, width, self.zoom);
563 if columns.is_empty() {
564 return;
565 }
566 ui.add_space(4.0 * self.zoom);
567 self.rows(ui, table, &columns);
568 ui.add_space(4.0 * self.zoom);
569 }
570
571 fn rows(&mut self, ui: &mut Ui, parent: &Element, columns: &[f32]) {
572 for element in parent.elements() {
573 if element.is(&Ns::Table, "table-row") {
574 self.row(ui, element, columns);
575 } else if element.is(&Ns::Table, "table-header-rows")
576 || element.is(&Ns::Table, "table-rows")
577 || element.is(&Ns::Table, "table-row-group")
578 {
579 self.rows(ui, element, columns);
580 }
581 }
582 }
583
584 fn row(&mut self, ui: &mut Ui, row: &Element, columns: &[f32]) {
585 let reserved = ui.painter().add(eframe::egui::Shape::Noop);
589 let mut painted = Vec::new();
590
591 let response = ui.horizontal_top(|ui| {
592 ui.spacing_mut().item_spacing.x = 0.0;
593 let mut column = 0usize;
594 for cell in row.elements() {
595 let covered = cell.is(&Ns::Table, "covered-table-cell");
596 if !covered && !cell.is(&Ns::Table, "table-cell") {
597 continue;
598 }
599 let repeat = cell
600 .attr_usize(&Ns::Table, "number-columns-repeated")
601 .unwrap_or(1)
602 .max(1);
603 let spanned = cell
604 .attr_usize(&Ns::Table, "number-columns-spanned")
605 .unwrap_or(1)
606 .max(1);
607 for _ in 0..repeat {
608 let width: f32 = columns
614 .iter()
615 .skip(column)
616 .take(spanned)
617 .sum::<f32>()
618 .max(8.0);
619 if !covered {
620 let properties = self.style_of(cell, &Family::TableCell);
621 let padding = properties
622 .cell
623 .padding
624 .left
625 .map_or(2.0, odox_core::Length::points)
626 * self.zoom;
627 let inner = ui
628 .allocate_ui_with_layout(
629 vec2(width, 0.0),
630 eframe::egui::Layout::top_down(Align::Min),
631 |ui| {
632 ui.add_space(padding);
633 ui.set_min_width(width);
634 ui.set_max_width(width);
635 let content = (width - padding * 2.0).max(8.0);
636 self.blocks(ui, cell, content);
637 ui.add_space(padding);
638 },
639 )
640 .response
641 .rect;
642 painted.push((inner, properties));
643 }
644 column += 1;
645 }
646 }
647 });
648
649 let row_rect = response.response.rect;
650 let mut shapes = Vec::new();
651 for (rect, properties) in painted {
652 let cell_rect = Rect::from_min_max(
653 pos2(rect.left(), row_rect.top()),
654 pos2(rect.right(), row_rect.bottom()),
655 );
656 if let Some(fill) = properties.cell.background {
657 shapes.push(eframe::egui::Shape::rect_filled(
658 cell_rect,
659 0.0,
660 format::color32(fill),
661 ));
662 }
663 for (edge, from, to) in edges(cell_rect) {
664 if let Some(border) = edge_of(&properties.cell.border, edge) {
665 shapes.push(eframe::egui::Shape::line_segment(
666 [from, to],
667 Stroke::new(
668 (border.width.points() * self.zoom).max(1.0),
669 format::color32(border.color),
670 ),
671 ));
672 }
673 }
674 }
675 ui.painter().set(reserved, eframe::egui::Shape::Vec(shapes));
676 }
677
678 pub fn frame(&mut self, ui: &mut Ui, frame: &Element, width: f32) {
685 let zoom = self.zoom;
686 let declared = |local: &str| {
687 frame
688 .attr(&Ns::Svg, local)
689 .and_then(odox_core::Length::parse)
690 .map(|l| l.points() * zoom)
691 };
692
693 if let Some(image) = frame.child(&Ns::Draw, "image") {
694 let href = image
695 .attr(&Ns::Xlink, "href")
696 .unwrap_or_default()
697 .to_owned();
698 let found = self
699 .pictures
700 .get(ui.ctx(), self.document, &href)
701 .map(|texture| (texture.id(), texture.size()));
702 if let Some((id, [pixels_wide, pixels_high])) = found {
703 #[allow(clippy::cast_precision_loss)]
704 let aspect = if pixels_wide == 0 {
705 1.0
706 } else {
707 pixels_high as f32 / pixels_wide as f32
708 };
709 let w = declared("width").unwrap_or(width).min(width);
710 let h = declared("height").unwrap_or(w * aspect);
711 let size = vec2(w, h);
712 ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
713 return;
714 }
715 }
716
717 if let Some(box_) = frame.child(&Ns::Draw, "text-box") {
721 let w = declared("width").unwrap_or(width).min(width);
722 ui.allocate_ui_with_layout(
723 vec2(w, 0.0),
724 eframe::egui::Layout::top_down(Align::Min),
725 |ui| {
726 ui.set_max_width(w);
727 eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
728 self.blocks(ui, box_, w - 16.0 * zoom);
729 });
730 },
731 );
732 return;
733 }
734
735 let w = declared("width").unwrap_or(width).min(width);
736 let h = declared("height").unwrap_or(48.0 * zoom);
737 let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
738 ui.painter().rect_stroke(
739 rect,
740 2.0,
741 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
742 StrokeKind::Inside,
743 );
744 }
745
746 fn page_break(&mut self, ui: &mut Ui, width: f32) {
751 ui.add_space(8.0 * self.zoom);
752 let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
753 ui.painter().hline(
754 rect.x_range(),
755 rect.center().y,
756 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
757 );
758 ui.add_space(8.0 * self.zoom);
759 }
760}
761
762fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
765 TextProperties {
766 font_family: child
767 .font_family
768 .clone()
769 .or_else(|| parent.font_family.clone()),
770 size: child.size.or(parent.size),
771 bold: child.bold.or(parent.bold),
772 italic: child.italic.or(parent.italic),
773 underline: child.underline.or(parent.underline),
774 strike: child.strike.or(parent.strike),
775 color: child.color.or(parent.color),
776 background: child.background.or(parent.background),
777 position: child.position.or(parent.position),
778 uppercase: child.uppercase.or(parent.uppercase),
779 }
780}
781
782#[derive(Clone, Copy)]
784enum Edge {
785 Left,
786 Right,
787 Top,
788 Bottom,
789}
790
791fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
792 [
793 (Edge::Left, rect.left_top(), rect.left_bottom()),
794 (Edge::Right, rect.right_top(), rect.right_bottom()),
795 (Edge::Top, rect.left_top(), rect.right_top()),
796 (Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
797 ]
798}
799
800fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
801 match edge {
802 Edge::Left => borders.left,
803 Edge::Right => borders.right,
804 Edge::Top => borders.top,
805 Edge::Bottom => borders.bottom,
806 }
807}
808
809fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
810 for (edge, from, to) in edges(rect) {
811 if let Some(border) = edge_of(borders, edge) {
812 ui.painter().line_segment(
813 [from, to],
814 Stroke::new(
815 (border.width.points() * zoom).max(1.0),
816 format::color32(border.color),
817 ),
818 );
819 }
820 }
821}
822
823fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
828 let mut declared = Vec::new();
829 collect_columns(document, table, &mut declared);
830 if declared.is_empty() {
831 return Vec::new();
832 }
833 let total: f32 = declared.iter().filter_map(|w| *w).sum();
834 let unstated = declared.iter().filter(|w| w.is_none()).count();
835
836 let scaled = total * zoom;
839 let factor = if scaled > width && scaled > 0.0 {
840 width / scaled
841 } else {
842 1.0
843 };
844 #[allow(clippy::cast_precision_loss)]
845 let share = if unstated == 0 {
846 0.0
847 } else {
848 ((width - scaled * factor) / unstated as f32).max(16.0)
849 };
850 declared
851 .into_iter()
852 .map(|w| w.map_or(share, |points| points * zoom * factor))
853 .collect()
854}
855
856fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
857 for element in parent.elements() {
858 if element.is(&Ns::Table, "table-column") {
859 let repeat = element
860 .attr_usize(&Ns::Table, "number-columns-repeated")
861 .unwrap_or(1)
862 .clamp(1, 1024);
863 let width = element
864 .attr(&Ns::Table, "style-name")
865 .map(|name| document.styles.resolve(&Family::TableColumn, name))
866 .and_then(|p| p.column_width)
867 .map(odox_core::Length::points);
868 for _ in 0..repeat {
869 into.push(width);
870 }
871 } else if element.is(&Ns::Table, "table-columns")
872 || element.is(&Ns::Table, "table-header-columns")
873 || element.is(&Ns::Table, "table-column-group")
874 {
875 collect_columns(document, element, into);
876 }
877 }
878}
879
880fn number_text(number: usize, format: &str) -> String {
882 match format.chars().next() {
883 Some('a') => alphabetic(number, b'a'),
884 Some('A') => alphabetic(number, b'A'),
885 Some('i') => roman(number).to_lowercase(),
886 Some('I') => roman(number),
887 None => String::new(),
890 _ => number.to_string(),
891 }
892}
893
894fn alphabetic(number: usize, first: u8) -> String {
896 let mut n = number;
897 let mut out = Vec::new();
898 while n > 0 {
899 let remainder = (n - 1) % 26;
900 out.push(first + u8::try_from(remainder).unwrap_or(0));
901 n = (n - 1) / 26;
902 }
903 out.reverse();
904 String::from_utf8(out).unwrap_or_default()
905}
906
907fn roman(number: usize) -> String {
908 const VALUES: [(usize, &str); 13] = [
909 (1000, "M"),
910 (900, "CM"),
911 (500, "D"),
912 (400, "CD"),
913 (100, "C"),
914 (90, "XC"),
915 (50, "L"),
916 (40, "XL"),
917 (10, "X"),
918 (9, "IX"),
919 (5, "V"),
920 (4, "IV"),
921 (1, "I"),
922 ];
923 if number == 0 || number > 3999 {
926 return number.to_string();
927 }
928 let mut left = number;
929 let mut out = String::new();
930 for (value, numeral) in VALUES {
931 while left >= value {
932 out.push_str(numeral);
933 left -= value;
934 }
935 }
936 out
937}
938
939fn is_block_container(element: &Element) -> bool {
941 element.name.ns == Ns::Text
942 && matches!(
943 &*element.name.local,
944 "section"
945 | "index-body"
946 | "index-title"
947 | "table-of-content"
948 | "illustration-index"
949 | "table-index"
950 | "object-index"
951 | "user-index"
952 | "alphabetical-index"
953 | "bibliography"
954 | "tracked-changes"
955 | "deletion"
956 )
957}
958
959fn is_inline_passthrough(element: &Element) -> bool {
965 element.name.ns == Ns::Text
966 && matches!(
967 &*element.name.local,
968 "bookmark"
969 | "bookmark-start"
970 | "bookmark-end"
971 | "reference-mark"
972 | "reference-mark-start"
973 | "reference-mark-end"
974 | "span"
975 | "bibliography-mark"
976 | "ruby"
977 | "ruby-base"
978 | "meta"
979 | "meta-field"
980 | "change-start"
981 | "change-end"
982 | "page-number"
983 | "page-count"
984 | "title"
985 | "subject"
986 | "author-name"
987 | "author-initials"
988 | "chapter"
989 | "file-name"
990 | "sheet-name"
991 | "date"
992 | "time"
993 | "creator"
994 | "description"
995 | "keywords"
996 | "sequence"
997 | "bookmark-ref"
998 | "sequence-ref"
999 | "reference-ref"
1000 | "variable-get"
1001 | "variable-set"
1002 | "user-field-get"
1003 | "placeholder"
1004 | "conditional-text"
1005 | "hidden-text"
1006 | "text-input"
1007 )
1008}