use std::collections::HashMap;
use std::sync::Arc;
use eframe::egui::accesskit::Role;
use eframe::egui::emath::TSTransform;
use eframe::egui::text_selection::accesskit_text::update_accesskit_for_text_widget;
use eframe::egui::{
Align, Color32, ColorImage, Context, CursorIcon, Galley, Pos2, Rect, Response, Sense, Stroke,
StrokeKind, TextFormat, TextureHandle, TextureOptions, Ui, pos2,
text::{CCursor, CCursorRange, LayoutJob},
text_selection::{LabelSelectionState, visuals::paint_text_selection},
vec2,
};
use egui_richedit::{Laid, OffsetMap, ParagraphJob};
use odox_core::{
Border, Document, Element, Family, Node, Ns, Properties, TextAlign, TextProperties, edit,
};
use crate::find::Highlights;
use crate::flow_model::PageEditor;
use crate::format::{self, DEFAULT_SIZE};
use crate::links;
#[derive(Default)]
pub struct Pictures {
textures: HashMap<String, Option<TextureHandle>>,
}
impl Pictures {
pub fn get(
&mut self,
ctx: &Context,
document: &Document,
href: &str,
) -> Option<&TextureHandle> {
if !self.textures.contains_key(href) {
let texture = document
.picture(href)
.and_then(decode)
.map(|image| ctx.load_texture(href, image, TextureOptions::LINEAR));
self.textures.insert(href.to_owned(), texture);
}
self.textures.get(href).and_then(Option::as_ref)
}
pub fn clear(&mut self) {
self.textures.clear();
}
}
fn decode(bytes: &[u8]) -> Option<ColorImage> {
let decoded = image::load_from_memory(bytes).ok()?.to_rgba8();
let size = [decoded.width() as usize, decoded.height() as usize];
Some(ColorImage::from_rgba_unmultiplied(size, decoded.as_raw()))
}
pub struct Flow<'a> {
pub document: &'a Document,
pub pictures: &'a mut Pictures,
pub zoom: f32,
pub palette: format::Palette,
pub scroll_to_heading: Option<usize>,
pub scroll_to_place: Option<String>,
pub followed: Option<String>,
headings_seen: usize,
pub page: Option<&'a mut PageEditor>,
pub find: Option<Highlights<'a>>,
pub selectable: bool,
path: Vec<usize>,
detached: bool,
}
impl<'a> Flow<'a> {
pub fn new(document: &'a Document, pictures: &'a mut Pictures, zoom: f32) -> Self {
Self {
document,
pictures,
zoom,
palette: format::Palette::default(),
scroll_to_heading: None,
scroll_to_place: None,
followed: None,
headings_seen: 0,
page: None,
find: None,
selectable: true,
path: Vec::new(),
detached: false,
}
}
pub fn start_at(&mut self, prefix: Vec<usize>) {
self.path = prefix;
}
}
const LIST_STEP: f32 = 18.0;
const LIST_GUTTER: f32 = 20.0;
const LABEL_GAP: f32 = 5.0;
struct Spacing {
left: f32,
right: f32,
indent: f32,
before: f32,
after: f32,
}
impl Spacing {
fn of(properties: &odox_core::ParagraphProperties, zoom: f32) -> Self {
let points = |length: Option<odox_core::Length>| {
length.map_or(0.0, odox_core::Length::points) * zoom
};
Self {
left: points(properties.margin.left),
right: points(properties.margin.right),
indent: points(properties.text_indent),
before: points(properties.margin.top),
after: points(properties.margin.bottom),
}
}
}
#[derive(Clone, Copy)]
struct Run<'a> {
inherited: &'a TextProperties,
size: f32,
format: &'a TextFormat,
palette: format::Palette,
counted: bool,
}
#[derive(Default)]
struct Counters(Vec<usize>);
impl Counters {
fn bump(&mut self, level: usize, start: usize) -> usize {
while self.0.len() <= level {
self.0.push(0);
}
if self.0[level] == 0 {
self.0[level] = start;
} else {
self.0[level] += 1;
}
self.0.truncate(level + 1);
self.0[level]
}
fn at(&self, level: usize) -> usize {
self.0.get(level).copied().unwrap_or(1)
}
}
impl Flow<'_> {
pub fn blocks(&mut self, ui: &mut Ui, parent: &Element, width: f32) {
let mut counters = Counters::default();
self.blocks_with(ui, parent, width, &mut counters);
}
fn blocks_with(&mut self, ui: &mut Ui, parent: &Element, width: f32, counters: &mut Counters) {
for (index, element) in parent.elements_indexed() {
self.path.push(index);
match () {
() if element.is(&Ns::Text, "p") || element.is(&Ns::Text, "h") => {
self.paragraph(ui, element, width, None);
}
() if element.is(&Ns::Text, "list") => {
let continues = element.attr(&Ns::Text, "continue-numbering") == Some("true")
|| element.attr(&Ns::Text, "continue-list").is_some();
if !continues {
*counters = Counters::default();
}
self.list(ui, element, width, 0, None, counters);
}
() if element.is(&Ns::Table, "table") => self.table(ui, element, width),
() if element.is(&Ns::Draw, "frame") => self.frame(ui, element, width),
() if element.is(&Ns::Text, "soft-page-break") => self.page_break(ui, width),
() if is_block_container(element) => {
self.blocks_with(ui, element, width, counters);
}
() => {}
}
self.path.pop();
}
}
fn style_of(&self, element: &Element, family: &Family) -> std::rc::Rc<Properties> {
let name = element
.attr(&Ns::Text, "style-name")
.or_else(|| element.attr(&Ns::Table, "style-name"))
.unwrap_or("Standard");
self.document.styles.resolve(family, name)
}
fn show_matches(&self, ui: &Ui, galley: &mut Arc<Galley>, map: &OffsetMap, origin: Pos2) {
let Some(find) = self.find.as_ref().filter(|_| !self.detached) else {
return;
};
let hits = find.within(&self.path);
if hits.is_empty() {
return;
}
let at = |offset: usize| CCursor::new(map.to_galley(offset.min(map.model_len())));
for (range, current) in hits {
let mut visuals = ui.visuals().clone();
visuals.selection.bg_fill = format::match_fill(current);
visuals.selection.stroke.color = Color32::BLACK;
let cursors = CCursorRange::two(at(range.start), at(range.end));
paint_text_selection(galley, &visuals, &cursors, None);
if current && find.reveal() {
let start = galley.pos_from_cursor(at(range.start));
let end = galley.pos_from_cursor(at(range.end));
ui.scroll_to_rect(
start.union(end).translate(origin.to_vec2()),
Some(Align::Center),
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn follow_link(
&mut self,
ui: &Ui,
response: &Response,
links: &[(std::ops::Range<usize>, &str)],
galley: &Galley,
map: &OffsetMap,
origin: Pos2,
editing: bool,
) {
let Some(pointer) = response
.hover_pos()
.or_else(|| response.interact_pointer_pos())
else {
return;
};
let cursor = galley.cursor_from_pos(pointer - origin);
let caret = galley.pos_from_cursor(cursor).center().x + origin.x;
let character = if pointer.x < caret {
cursor.index.0.saturating_sub(1)
} else {
cursor.index.0
};
let offset = map.to_model(character);
let Some(href) = links
.iter()
.find(|(range, _)| range.contains(&offset))
.map(|&(_, href)| href)
else {
return;
};
let Some(target) = links::target(href) else {
return;
};
let reaching = !editing || ui.input(|input| input.modifiers.command);
if reaching {
ui.ctx().set_cursor_icon(CursorIcon::PointingHand);
}
if response.hovered() {
response.clone().on_hover_text(href);
}
if reaching && response.clicked() {
match target {
links::Target::External(address) => links::open(ui.ctx(), address),
links::Target::Within(place) => self.followed = Some(place.to_owned()),
}
}
}
fn paragraph(&mut self, ui: &mut Ui, element: &Element, width: f32, label: Option<&str>) {
let properties = self.style_of(element, &Family::Paragraph);
let zoom = self.zoom;
let size = format::size_of(&properties.text, DEFAULT_SIZE);
let Spacing {
left,
right,
indent,
before,
after,
} = Spacing::of(&properties.paragraph, zoom);
let body_width = (width - left - right).max(1.0);
let wrap = (body_width - indent.max(0.0)).max(1.0);
if before > 0.0 {
ui.add_space(before);
}
let base = format::text_format(&properties.text, DEFAULT_SIZE, zoom, self.palette);
let (job, frames) = self.layout_job(element, &properties, &base, size, wrap);
let (job, map) = job.into_parts();
let galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(job));
let height = galley.size().y;
let edited = !self.detached && self.page.is_some();
let sense = if self.selectable {
Sense::click_and_drag()
} else {
Sense::click()
};
let (rect, response) = ui.allocate_exact_size(vec2(width, height), sense);
if element.is(&Ns::Text, "h") {
if self.scroll_to_heading == Some(self.headings_seen) {
ui.scroll_to_rect(rect, Some(Align::TOP));
}
self.headings_seen += 1;
}
if let Some(place) = &self.scroll_to_place
&& holds_place(element, place)
{
ui.scroll_to_rect(rect, Some(Align::TOP));
}
if let Some(fill) = properties.paragraph.background {
ui.painter().rect_filled(rect, 0.0, format::color32(fill));
}
paint_borders(ui, rect, &properties.paragraph.border, zoom);
let anchor = match properties.paragraph.align.unwrap_or(TextAlign::Start) {
TextAlign::Center => rect.left() + left + indent.max(0.0) + wrap / 2.0,
TextAlign::End => rect.left() + left + indent.max(0.0) + wrap,
_ => rect.left() + left + indent.max(0.0),
};
let origin = pos2(anchor, rect.top());
let mut galley = galley;
self.show_matches(ui, &mut galley, &map, origin);
let readable = Arc::clone(&galley);
let links = edit::links(element);
let hit = (!links.is_empty()).then(|| (Arc::clone(&galley), map.clone()));
if let Some(page) = self.page.as_deref_mut().filter(|_| edited) {
let laid = Laid {
galley,
map,
origin,
};
page.paragraph(ui, &response, &self.path, laid);
} else {
LabelSelectionState::label_text_selection(
ui,
&response,
origin,
galley,
base.color,
Stroke::NONE,
);
}
expose(ui, &response, element, &readable, origin, edited);
if let Some((galley, map)) = hit {
self.follow_link(ui, &response, &links, &galley, &map, origin, edited);
}
if let Some(label) = label {
let mut label_job = LayoutJob::default();
label_job.append(label, 0.0, base.clone());
let label_galley = ui.ctx().fonts_mut(|fonts| fonts.layout_job(label_job));
let x = rect.left() + left - label_galley.size().x - LABEL_GAP * zoom;
ui.painter()
.galley(pos2(x, rect.top()), label_galley, base.color);
}
let was_detached = self.detached;
self.detached = true;
for frame in frames {
self.frame(ui, &frame, body_width);
}
self.detached = was_detached;
if after > 0.0 {
ui.add_space(after);
}
}
fn layout_job(
&self,
element: &Element,
properties: &Properties,
base: &TextFormat,
size: f32,
wrap: f32,
) -> (ParagraphJob, Vec<Element>) {
let job = LayoutJob {
wrap: eframe::egui::text::TextWrapping {
max_width: wrap,
..Default::default()
},
halign: match properties.paragraph.align.unwrap_or(TextAlign::Start) {
TextAlign::Center => Align::Center,
TextAlign::End => Align::Max,
_ => Align::Min,
},
justify: properties.paragraph.align == Some(TextAlign::Justify),
..LayoutJob::default()
};
let mut job = ParagraphJob::new(job);
let mut frames = Vec::new();
let run = Run {
inherited: &properties.text,
size,
format: base,
palette: self.palette,
counted: true,
};
self.runs(element, &run, &mut job, &mut frames);
if job.is_empty() {
job.atom(" ", 0, base.clone());
}
if let Some(measure) = properties.paragraph.line_height {
for format in job.formats_mut() {
let own = format.font_id.size / self.zoom;
format.line_height = Some(measure.resolve(own) * self.zoom);
}
}
(job, frames)
}
fn runs(
&self,
parent: &Element,
run: &Run<'_>,
job: &mut ParagraphJob,
frames: &mut Vec<Element>,
) {
let Run {
inherited,
size,
format,
palette,
counted,
} = *run;
let put = |job: &mut ParagraphJob, shown: &str, model_len: usize| {
if !counted {
job.atom(shown, 0, format.clone());
} else if shown.chars().count() == model_len {
job.text(shown, format.clone());
} else {
job.atom(shown, model_len, format.clone());
}
};
for child in &parent.children {
match child {
Node::Text(text) | Node::CData(text) => put(job, text, text.chars().count()),
Node::Comment(_) | Node::ProcessingInstruction(_) => {}
Node::Element(element) => {
if element.is(&Ns::Text, "s") {
let count = element.attr_usize(&Ns::Text, "c").unwrap_or(1);
put(job, &" ".repeat(count.min(256)), count);
} else if element.is(&Ns::Text, "tab") {
put(job, " ", 1);
} else if element.is(&Ns::Text, "line-break") {
put(job, "\n", 1);
} else if element.is(&Ns::Text, "span") {
let style = self.style_of(element, &Family::Text);
let merged = merge(inherited, &style.text);
let inner_size = format::size_of(&merged, size);
let inner = format::text_format(&merged, size, self.zoom, palette);
let run = Run {
inherited: &merged,
size: inner_size,
format: &inner,
palette,
counted,
};
self.runs(element, &run, job, frames);
} else if element.is(&Ns::Text, "a") {
let style = self.style_of(element, &Family::Text);
let merged = merge(inherited, &style.text);
let inner_size = format::size_of(&merged, size);
let inner = format::link_format(&merged, size, self.zoom, palette);
let run = Run {
inherited: &merged,
size: inner_size,
format: &inner,
palette,
counted,
};
self.runs(element, &run, job, frames);
} else if element.is(&Ns::Draw, "frame") {
frames.push(element.clone());
} else if element.is(&Ns::Text, "note") {
if let Some(citation) = element.child(&Ns::Text, "note-citation") {
let mut raised = format.clone();
raised.font_id.size *= 0.7;
raised.valign = Align::TOP;
job.atom(&citation.plain_text(), 0, raised);
}
} else if is_inline_passthrough(element) {
let run = Run {
counted: counted && edit::holds_text(element),
..*run
};
self.runs(element, &run, job, frames);
}
}
}
}
}
fn list(
&mut self,
ui: &mut Ui,
element: &Element,
width: f32,
level: usize,
inherited_style: Option<&str>,
counters: &mut Counters,
) {
let style_name = element
.attr(&Ns::Text, "style-name")
.or(inherited_style)
.unwrap_or_default()
.to_owned();
#[allow(clippy::cast_precision_loss)]
let indent = LIST_STEP * self.zoom * (level as f32 + 1.0);
let gutter = LIST_GUTTER * self.zoom;
for (item_index, item) in element.elements_indexed() {
let numbered = item.is(&Ns::Text, "list-item");
if !numbered && !item.is(&Ns::Text, "list-header") {
continue;
}
self.path.push(item_index);
let label = if numbered {
let level_style = self.level_style(&style_name, level);
let start = level_style
.as_ref()
.and_then(|s| s.attr_usize(&Ns::Text, "start-value"))
.unwrap_or(1);
let number = counters.bump(level, start);
Self::label(level_style.as_ref(), level, number, counters)
} else {
String::new()
};
let mut first = true;
for (block_index, block) in item.elements_indexed() {
self.path.push(block_index);
if block.is(&Ns::Text, "list") {
self.list(ui, block, width, level + 1, Some(&style_name), counters);
self.path.pop();
continue;
}
let on_this_block = if first && !label.is_empty() {
Some(label.as_str())
} else {
None
};
if block.is(&Ns::Text, "p") || block.is(&Ns::Text, "h") {
first = false;
ui.horizontal_top(|ui| {
ui.add_space(indent + gutter);
self.paragraph(ui, block, width - indent - gutter, on_this_block);
});
} else if block.is(&Ns::Table, "table") {
first = false;
ui.horizontal_top(|ui| {
ui.add_space(indent + gutter);
self.table(ui, block, width - indent - gutter);
});
} else if block.is(&Ns::Draw, "frame") {
first = false;
ui.horizontal_top(|ui| {
ui.add_space(indent + gutter);
self.frame(ui, block, width - indent - gutter);
});
}
self.path.pop();
}
self.path.pop();
}
}
fn level_style(&self, list_style: &str, level: usize) -> Option<Element> {
let style = self.document.styles.list_style(list_style)?;
style
.elements()
.find(|e| e.attr_usize(&Ns::Text, "level") == Some(level + 1))
.cloned()
}
fn label(
level_style: Option<&Element>,
level: usize,
number: usize,
counters: &Counters,
) -> String {
let Some(style) = level_style else {
return "\u{2022}".to_owned();
};
if style.is(&Ns::Text, "list-level-style-bullet") {
return style
.attr(&Ns::Text, "bullet-char")
.unwrap_or("\u{2022}")
.to_owned();
}
if style.is(&Ns::Text, "list-level-style-number") {
let format = style.attr(&Ns::Style, "num-format").unwrap_or("1");
let prefix = style.attr(&Ns::Style, "num-prefix").unwrap_or_default();
let suffix = style.attr(&Ns::Style, "num-suffix").unwrap_or_default();
let display = style
.attr_usize(&Ns::Text, "display-levels")
.unwrap_or(1)
.max(1);
let mut numbers = Vec::new();
let first = (level + 1).saturating_sub(display);
for ancestor in first..level {
numbers.push(number_text(counters.at(ancestor), format));
}
numbers.push(number_text(number, format));
return format!("{prefix}{}{suffix}", numbers.join("."));
}
"\u{2022}".to_owned()
}
fn table(&mut self, ui: &mut Ui, table: &Element, width: f32) {
let columns = column_widths(self.document, table, width, self.zoom);
if columns.is_empty() {
return;
}
ui.add_space(4.0 * self.zoom);
self.rows(ui, table, &columns);
ui.add_space(4.0 * self.zoom);
}
fn rows(&mut self, ui: &mut Ui, parent: &Element, columns: &[f32]) {
for (index, element) in parent.elements_indexed() {
self.path.push(index);
if element.is(&Ns::Table, "table-row") {
self.row(ui, element, columns);
} else if element.is(&Ns::Table, "table-header-rows")
|| element.is(&Ns::Table, "table-rows")
|| element.is(&Ns::Table, "table-row-group")
{
self.rows(ui, element, columns);
}
self.path.pop();
}
}
fn row(&mut self, ui: &mut Ui, row: &Element, columns: &[f32]) {
let reserved = ui.painter().add(eframe::egui::Shape::Noop);
let mut painted = Vec::new();
let response = ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let mut column = 0usize;
for (cell_index, cell) in row.elements_indexed() {
let covered = cell.is(&Ns::Table, "covered-table-cell");
if !covered && !cell.is(&Ns::Table, "table-cell") {
continue;
}
let repeat = cell
.attr_usize(&Ns::Table, "number-columns-repeated")
.unwrap_or(1)
.max(1);
let spanned = cell
.attr_usize(&Ns::Table, "number-columns-spanned")
.unwrap_or(1)
.max(1);
for copy in 0..repeat {
let width: f32 = columns
.iter()
.skip(column)
.take(spanned)
.sum::<f32>()
.max(8.0);
if !covered {
let properties = self.style_of(cell, &Family::TableCell);
let padding = properties
.cell
.padding
.left
.map_or(2.0, odox_core::Length::points)
* self.zoom;
let inner = ui
.allocate_ui_with_layout(
vec2(width, 0.0),
eframe::egui::Layout::top_down(Align::Min),
|ui| {
ui.add_space(padding);
ui.set_min_width(width);
ui.set_max_width(width);
let content = (width - padding * 2.0).max(8.0);
self.path.push(cell_index);
let was_detached = self.detached;
self.detached |= copy > 0;
self.blocks(ui, cell, content);
self.detached = was_detached;
self.path.pop();
ui.add_space(padding);
},
)
.response
.rect;
painted.push((inner, properties));
}
column += 1;
}
}
});
let row_rect = response.response.rect;
let mut shapes = Vec::new();
for (rect, properties) in painted {
let cell_rect = Rect::from_min_max(
pos2(rect.left(), row_rect.top()),
pos2(rect.right(), row_rect.bottom()),
);
if let Some(fill) = properties.cell.background {
shapes.push(eframe::egui::Shape::rect_filled(
cell_rect,
0.0,
format::color32(fill),
));
}
for (edge, from, to) in edges(cell_rect) {
if let Some(border) = edge_of(&properties.cell.border, edge) {
shapes.push(eframe::egui::Shape::line_segment(
[from, to],
Stroke::new(
(border.width.points() * self.zoom).max(1.0),
format::color32(border.color),
),
));
}
}
}
ui.painter().set(reserved, eframe::egui::Shape::Vec(shapes));
}
pub fn frame(&mut self, ui: &mut Ui, frame: &Element, width: f32) {
let zoom = self.zoom;
let declared = |local: &str| {
frame
.attr(&Ns::Svg, local)
.and_then(odox_core::Length::parse)
.map(|l| l.points() * zoom)
};
let found = frame
.elements()
.filter(|child| child.is(&Ns::Draw, "image"))
.find_map(|image| {
let href = image.attr(&Ns::Xlink, "href")?.to_owned();
self.pictures
.get(ui.ctx(), self.document, &href)
.map(|texture| (texture.id(), texture.size()))
});
if let Some((id, [pixels_wide, pixels_high])) = found {
#[allow(clippy::cast_precision_loss)]
let aspect = if pixels_wide == 0 {
1.0
} else {
pixels_high as f32 / pixels_wide as f32
};
let w = declared("width").unwrap_or(width).min(width);
let h = declared("height").unwrap_or(w * aspect);
let size = vec2(w, h);
ui.add(eframe::egui::Image::new((id, size)).fit_to_exact_size(size));
return;
}
if let Some((box_index, box_)) = frame
.elements_indexed()
.find(|(_, e)| e.is(&Ns::Draw, "text-box"))
{
let w = declared("width").unwrap_or(width).min(width);
ui.allocate_ui_with_layout(
vec2(w, 0.0),
eframe::egui::Layout::top_down(Align::Min),
|ui| {
ui.set_max_width(w);
eframe::egui::Frame::group(ui.style()).show(ui, |ui| {
self.path.push(box_index);
self.blocks(ui, box_, w - 16.0 * zoom);
self.path.pop();
});
},
);
return;
}
let w = declared("width").unwrap_or(width).min(width);
let h = declared("height").unwrap_or(48.0 * zoom);
let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
ui.painter().rect_stroke(
rect,
2.0,
Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
StrokeKind::Inside,
);
}
fn page_break(&mut self, ui: &mut Ui, width: f32) {
ui.add_space(8.0 * self.zoom);
let (rect, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover());
ui.painter().hline(
rect.x_range(),
rect.center().y,
Stroke::new(1.0, self.palette.ink.gamma_multiply(0.3)),
);
ui.add_space(8.0 * self.zoom);
}
}
fn merge(parent: &TextProperties, child: &TextProperties) -> TextProperties {
TextProperties {
font_family: child
.font_family
.clone()
.or_else(|| parent.font_family.clone()),
size: child.size.or(parent.size),
bold: child.bold.or(parent.bold),
italic: child.italic.or(parent.italic),
underline: child.underline.or(parent.underline),
strike: child.strike.or(parent.strike),
color: child.color.or(parent.color),
background: child.background.or(parent.background),
position: child.position.or(parent.position),
uppercase: child.uppercase.or(parent.uppercase),
}
}
#[derive(Clone, Copy)]
enum Edge {
Left,
Right,
Top,
Bottom,
}
fn edges(rect: Rect) -> [(Edge, Pos2, Pos2); 4] {
[
(Edge::Left, rect.left_top(), rect.left_bottom()),
(Edge::Right, rect.right_top(), rect.right_bottom()),
(Edge::Top, rect.left_top(), rect.right_top()),
(Edge::Bottom, rect.left_bottom(), rect.right_bottom()),
]
}
fn edge_of(borders: &odox_core::Edges<Border>, edge: Edge) -> Option<Border> {
match edge {
Edge::Left => borders.left,
Edge::Right => borders.right,
Edge::Top => borders.top,
Edge::Bottom => borders.bottom,
}
}
fn paint_borders(ui: &Ui, rect: Rect, borders: &odox_core::Edges<Border>, zoom: f32) {
for (edge, from, to) in edges(rect) {
if let Some(border) = edge_of(borders, edge) {
ui.painter().line_segment(
[from, to],
Stroke::new(
(border.width.points() * zoom).max(1.0),
format::color32(border.color),
),
);
}
}
}
fn column_widths(document: &Document, table: &Element, width: f32, zoom: f32) -> Vec<f32> {
let mut declared = Vec::new();
collect_columns(document, table, &mut declared);
if declared.is_empty() {
return Vec::new();
}
let total: f32 = declared.iter().filter_map(|w| *w).sum();
let unstated = declared.iter().filter(|w| w.is_none()).count();
let scaled = total * zoom;
let factor = if scaled > width && scaled > 0.0 {
width / scaled
} else {
1.0
};
#[allow(clippy::cast_precision_loss)]
let share = if unstated == 0 {
0.0
} else {
((width - scaled * factor) / unstated as f32).max(16.0)
};
declared
.into_iter()
.map(|w| w.map_or(share, |points| points * zoom * factor))
.collect()
}
fn collect_columns(document: &Document, parent: &Element, into: &mut Vec<Option<f32>>) {
for element in parent.elements() {
if element.is(&Ns::Table, "table-column") {
let repeat = element
.attr_usize(&Ns::Table, "number-columns-repeated")
.unwrap_or(1)
.clamp(1, 1024);
let width = element
.attr(&Ns::Table, "style-name")
.map(|name| document.styles.resolve(&Family::TableColumn, name))
.and_then(|p| p.column_width)
.map(odox_core::Length::points);
for _ in 0..repeat {
into.push(width);
}
} else if element.is(&Ns::Table, "table-columns")
|| element.is(&Ns::Table, "table-header-columns")
|| element.is(&Ns::Table, "table-column-group")
{
collect_columns(document, element, into);
}
}
}
fn number_text(number: usize, format: &str) -> String {
match format.chars().next() {
Some('a') => alphabetic(number, b'a'),
Some('A') => alphabetic(number, b'A'),
Some('i') => roman(number).to_lowercase(),
Some('I') => roman(number),
None => String::new(),
_ => number.to_string(),
}
}
fn alphabetic(number: usize, first: u8) -> String {
let mut n = number;
let mut out = Vec::new();
while n > 0 {
let remainder = (n - 1) % 26;
out.push(first + u8::try_from(remainder).unwrap_or(0));
n = (n - 1) / 26;
}
out.reverse();
String::from_utf8(out).unwrap_or_default()
}
fn roman(number: usize) -> String {
const VALUES: [(usize, &str); 13] = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
];
if number == 0 || number > 3999 {
return number.to_string();
}
let mut left = number;
let mut out = String::new();
for (value, numeral) in VALUES {
while left >= value {
out.push_str(numeral);
left -= value;
}
}
out
}
fn expose(
ui: &Ui,
response: &Response,
element: &Element,
galley: &Galley,
origin: Pos2,
edited: bool,
) {
let heading = edit::heading_level(element);
if !edited {
let to_global = ui
.ctx()
.layer_transform_to_global(ui.layer_id())
.unwrap_or_default();
update_accesskit_for_text_widget(
ui.ctx(),
response.id,
None,
if heading.is_some() {
Role::Heading
} else {
Role::Paragraph
},
to_global * TSTransform::from_translation(origin.to_vec2()),
galley,
);
}
if let Some(level) = heading {
ui.ctx().accesskit_node_builder(response.id, |node| {
node.set_role(Role::Heading);
node.set_level(usize::from(level));
});
}
}
fn holds_place(element: &Element, place: &str) -> bool {
if let Some(name) = place.strip_suffix("|outline") {
return element.is(&Ns::Text, "h") && element.plain_text().trim() == name.trim();
}
element.elements().any(|e| {
let marks = e.is(&Ns::Text, "bookmark") || e.is(&Ns::Text, "bookmark-start");
(marks && e.attr(&Ns::Text, "name") == Some(place)) || holds_place(e, place)
})
}
pub(crate) fn is_block_container(element: &Element) -> bool {
element.name.ns == Ns::Text
&& matches!(
&*element.name.local,
"section"
| "index-body"
| "index-title"
| "table-of-content"
| "illustration-index"
| "table-index"
| "object-index"
| "user-index"
| "alphabetical-index"
| "bibliography"
| "tracked-changes"
| "deletion"
)
}
fn is_inline_passthrough(element: &Element) -> bool {
element.name.ns == Ns::Text
&& matches!(
&*element.name.local,
"bookmark"
| "bookmark-start"
| "bookmark-end"
| "reference-mark"
| "reference-mark-start"
| "reference-mark-end"
| "span"
| "bibliography-mark"
| "ruby"
| "ruby-base"
| "meta"
| "meta-field"
| "change-start"
| "change-end"
| "page-number"
| "page-count"
| "title"
| "subject"
| "author-name"
| "author-initials"
| "chapter"
| "file-name"
| "sheet-name"
| "date"
| "time"
| "creator"
| "description"
| "keywords"
| "sequence"
| "bookmark-ref"
| "sequence-ref"
| "reference-ref"
| "variable-get"
| "variable-set"
| "user-field-get"
| "placeholder"
| "conditional-text"
| "hidden-text"
| "text-input"
)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Flow, Pictures};
use crate::format::{self, DEFAULT_SIZE};
use odox_core::{Document, Element, Family, edit, media_type};
const ANY: &[&str] = &[
media_type::TEXT,
media_type::TEXT_WEB,
media_type::SPREADSHEET,
media_type::PRESENTATION,
];
fn documents(directory: &Path, into: &mut Vec<Document>) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for path in entries.filter_map(Result::ok).map(|entry| entry.path()) {
if path.is_dir() {
documents(&path, into);
} else if matches!(
path.extension().and_then(|e| e.to_str()),
Some("odt" | "ods" | "odp")
) {
let bytes = std::fs::read(&path).expect("a corpus document reads");
into.push(Document::read(&bytes, ANY).expect("a corpus document parses"));
}
}
}
fn paragraphs<'a>(element: &'a Element, into: &mut Vec<&'a Element>) {
for child in element.elements() {
if edit::is_paragraph(child) {
into.push(child);
}
paragraphs(child, into);
}
}
#[test]
fn what_is_drawn_maps_onto_the_text_an_edit_changes() {
let mut corpus = Vec::new();
documents(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"),
&mut corpus,
);
assert!(!corpus.is_empty(), "the corpus is where it was");
let mut checked = 0;
for document in &corpus {
let mut pictures = Pictures::default();
let flow = Flow::new(document, &mut pictures, 1.0);
let mut found = Vec::new();
paragraphs(&document.content, &mut found);
for paragraph in found {
let properties = flow.style_of(paragraph, &Family::Paragraph);
let base = format::text_format(&properties.text, DEFAULT_SIZE, 1.0, flow.palette);
let size = format::size_of(&properties.text, DEFAULT_SIZE);
let (job, _) = flow.layout_job(paragraph, &properties, &base, size, 400.0);
let (_, map) = job.into_parts();
let text = edit::text(paragraph);
assert_eq!(map.model_len(), text.chars().count(), "{text:?}");
checked += 1;
}
}
assert!(checked > 0);
}
}