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 let found = frame
700 .elements()
701 .filter(|child| child.is(&Ns::Draw, "image"))
702 .find_map(|image| {
703 let href = image.attr(&Ns::Xlink, "href")?.to_owned();
704 self.pictures
705 .get(ui.ctx(), self.document, &href)
706 .map(|texture| (texture.id(), texture.size()))
707 });
708 if let Some((id, [pixels_wide, pixels_high])) = found {
709 #[allow(clippy::cast_precision_loss)]
710 let aspect = if pixels_wide == 0 {
711 1.0
712 } else {
713 pixels_high as f32 / pixels_wide as f32
714 };
715 let w = declared("width").unwrap_or(width).min(width);
716 let h = declared("height").unwrap_or(w * aspect);
717 let size = vec2(w, h);
718 ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
719 return;
720 }
721
722 if let Some(box_) = frame.child(&Ns::Draw, "text-box") {
726 let w = declared("width").unwrap_or(width).min(width);
727 ui.allocate_ui_with_layout(
728 vec2(w, 0.0),
729 eframe::egui::Layout::top_down(Align::Min),
730 |ui| {
731 ui.set_max_width(w);
732 eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
733 self.blocks(ui, box_, w - 16.0 * zoom);
734 });
735 },
736 );
737 return;
738 }
739
740 let w = declared("width").unwrap_or(width).min(width);
741 let h = declared("height").unwrap_or(48.0 * zoom);
742 let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
743 ui.painter().rect_stroke(
744 rect,
745 2.0,
746 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
747 StrokeKind::Inside,
748 );
749 }
750
751 fn page_break(&mut self, ui: &mut Ui, width: f32) {
756 ui.add_space(8.0 * self.zoom);
757 let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
758 ui.painter().hline(
759 rect.x_range(),
760 rect.center().y,
761 Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
762 );
763 ui.add_space(8.0 * self.zoom);
764 }
765}
766
767fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
770 TextProperties {
771 font_family: child
772 .font_family
773 .clone()
774 .or_else(|| parent.font_family.clone()),
775 size: child.size.or(parent.size),
776 bold: child.bold.or(parent.bold),
777 italic: child.italic.or(parent.italic),
778 underline: child.underline.or(parent.underline),
779 strike: child.strike.or(parent.strike),
780 color: child.color.or(parent.color),
781 background: child.background.or(parent.background),
782 position: child.position.or(parent.position),
783 uppercase: child.uppercase.or(parent.uppercase),
784 }
785}
786
787#[derive(Clone, Copy)]
789enum Edge {
790 Left,
791 Right,
792 Top,
793 Bottom,
794}
795
796fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
797 [
798 (Edge::Left, rect.left_top(), rect.left_bottom()),
799 (Edge::Right, rect.right_top(), rect.right_bottom()),
800 (Edge::Top, rect.left_top(), rect.right_top()),
801 (Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
802 ]
803}
804
805fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
806 match edge {
807 Edge::Left => borders.left,
808 Edge::Right => borders.right,
809 Edge::Top => borders.top,
810 Edge::Bottom => borders.bottom,
811 }
812}
813
814fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
815 for (edge, from, to) in edges(rect) {
816 if let Some(border) = edge_of(borders, edge) {
817 ui.painter().line_segment(
818 [from, to],
819 Stroke::new(
820 (border.width.points() * zoom).max(1.0),
821 format::color32(border.color),
822 ),
823 );
824 }
825 }
826}
827
828fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
833 let mut declared = Vec::new();
834 collect_columns(document, table, &mut declared);
835 if declared.is_empty() {
836 return Vec::new();
837 }
838 let total: f32 = declared.iter().filter_map(|w| *w).sum();
839 let unstated = declared.iter().filter(|w| w.is_none()).count();
840
841 let scaled = total * zoom;
844 let factor = if scaled > width && scaled > 0.0 {
845 width / scaled
846 } else {
847 1.0
848 };
849 #[allow(clippy::cast_precision_loss)]
850 let share = if unstated == 0 {
851 0.0
852 } else {
853 ((width - scaled * factor) / unstated as f32).max(16.0)
854 };
855 declared
856 .into_iter()
857 .map(|w| w.map_or(share, |points| points * zoom * factor))
858 .collect()
859}
860
861fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
862 for element in parent.elements() {
863 if element.is(&Ns::Table, "table-column") {
864 let repeat = element
865 .attr_usize(&Ns::Table, "number-columns-repeated")
866 .unwrap_or(1)
867 .clamp(1, 1024);
868 let width = element
869 .attr(&Ns::Table, "style-name")
870 .map(|name| document.styles.resolve(&Family::TableColumn, name))
871 .and_then(|p| p.column_width)
872 .map(odox_core::Length::points);
873 for _ in 0..repeat {
874 into.push(width);
875 }
876 } else if element.is(&Ns::Table, "table-columns")
877 || element.is(&Ns::Table, "table-header-columns")
878 || element.is(&Ns::Table, "table-column-group")
879 {
880 collect_columns(document, element, into);
881 }
882 }
883}
884
885fn number_text(number: usize, format: &str) -> String {
887 match format.chars().next() {
888 Some('a') => alphabetic(number, b'a'),
889 Some('A') => alphabetic(number, b'A'),
890 Some('i') => roman(number).to_lowercase(),
891 Some('I') => roman(number),
892 None => String::new(),
895 _ => number.to_string(),
896 }
897}
898
899fn alphabetic(number: usize, first: u8) -> String {
901 let mut n = number;
902 let mut out = Vec::new();
903 while n > 0 {
904 let remainder = (n - 1) % 26;
905 out.push(first + u8::try_from(remainder).unwrap_or(0));
906 n = (n - 1) / 26;
907 }
908 out.reverse();
909 String::from_utf8(out).unwrap_or_default()
910}
911
912fn roman(number: usize) -> String {
913 const VALUES: [(usize, &str); 13] = [
914 (1000, "M"),
915 (900, "CM"),
916 (500, "D"),
917 (400, "CD"),
918 (100, "C"),
919 (90, "XC"),
920 (50, "L"),
921 (40, "XL"),
922 (10, "X"),
923 (9, "IX"),
924 (5, "V"),
925 (4, "IV"),
926 (1, "I"),
927 ];
928 if number == 0 || number > 3999 {
931 return number.to_string();
932 }
933 let mut left = number;
934 let mut out = String::new();
935 for (value, numeral) in VALUES {
936 while left >= value {
937 out.push_str(numeral);
938 left -= value;
939 }
940 }
941 out
942}
943
944fn is_block_container(element: &Element) -> bool {
946 element.name.ns == Ns::Text
947 && matches!(
948 &*element.name.local,
949 "section"
950 | "index-body"
951 | "index-title"
952 | "table-of-content"
953 | "illustration-index"
954 | "table-index"
955 | "object-index"
956 | "user-index"
957 | "alphabetical-index"
958 | "bibliography"
959 | "tracked-changes"
960 | "deletion"
961 )
962}
963
964fn is_inline_passthrough(element: &Element) -> bool {
970 element.name.ns == Ns::Text
971 && matches!(
972 &*element.name.local,
973 "bookmark"
974 | "bookmark-start"
975 | "bookmark-end"
976 | "reference-mark"
977 | "reference-mark-start"
978 | "reference-mark-end"
979 | "span"
980 | "bibliography-mark"
981 | "ruby"
982 | "ruby-base"
983 | "meta"
984 | "meta-field"
985 | "change-start"
986 | "change-end"
987 | "page-number"
988 | "page-count"
989 | "title"
990 | "subject"
991 | "author-name"
992 | "author-initials"
993 | "chapter"
994 | "file-name"
995 | "sheet-name"
996 | "date"
997 | "time"
998 | "creator"
999 | "description"
1000 | "keywords"
1001 | "sequence"
1002 | "bookmark-ref"
1003 | "sequence-ref"
1004 | "reference-ref"
1005 | "variable-get"
1006 | "variable-set"
1007 | "user-field-get"
1008 | "placeholder"
1009 | "conditional-text"
1010 | "hidden-text"
1011 | "text-input"
1012 )
1013}