From 74da7e098533ada584fe2a7f1dbdf796ab1fb67d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:01:51 +0000 Subject: [PATCH] Gap 19: split the ODT body reader and writer by topic A pure move, no behaviour changed: `odt/body.rs` (1,720 lines) and `odt/writer.rs` (1,716) are split into child modules by topic, the methods of `Body` and `Writer` moved in `impl` blocks of their own. - body.rs (930): paragraphs, text, lists, element dispatch; body/drawing.rs frames, shapes, groups, formula objects; body/notes.rs notes, comments, fields; body/tracked.rs tracked changes; body/tables.rs tables. - writer.rs (618): the writer, paragraphs, inlines, blocks, styles; writer/props.rs text and paragraph style properties; writer/drawing.rs pictures, text boxes, shapes, groups, formulas; writer/tracked.rs tracked changes; writer/notes.rs notes and comments; writer/tables.rs tables. Items the rest of `odt` uses are re-exported where they were. Every harness document (21) exports content.xml, styles.xml and settings.xml byte for byte as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/formats/src/odt/body.rs | 796 +-------------- crates/formats/src/odt/body/drawing.rs | 198 ++++ crates/formats/src/odt/body/notes.rs | 317 ++++++ crates/formats/src/odt/body/tables.rs | 89 ++ crates/formats/src/odt/body/tracked.rs | 207 ++++ crates/formats/src/odt/writer.rs | 1123 +--------------------- crates/formats/src/odt/writer/drawing.rs | 396 ++++++++ crates/formats/src/odt/writer/notes.rs | 81 ++ crates/formats/src/odt/writer/props.rs | 308 ++++++ crates/formats/src/odt/writer/tables.rs | 144 +++ crates/formats/src/odt/writer/tracked.rs | 220 +++++ 11 files changed, 1980 insertions(+), 1899 deletions(-) create mode 100644 crates/formats/src/odt/body/drawing.rs create mode 100644 crates/formats/src/odt/body/notes.rs create mode 100644 crates/formats/src/odt/body/tables.rs create mode 100644 crates/formats/src/odt/body/tracked.rs create mode 100644 crates/formats/src/odt/writer/drawing.rs create mode 100644 crates/formats/src/odt/writer/notes.rs create mode 100644 crates/formats/src/odt/writer/props.rs create mode 100644 crates/formats/src/odt/writer/tables.rs create mode 100644 crates/formats/src/odt/writer/tracked.rs diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index e0a5b47a7e..2b455891cf 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -7,236 +7,15 @@ use super::{ model, }; -/// An embedded ODF formula sub-document's `content.xml`, read for its `math:math` element -/// (its root, ODF 1.3 Part 3 §2.2.9 B; some writers wrap it in an `office:math`, which isn't -/// it): the first MathML element named `math` in document order, with its -/// subtree, passed to [`crate::mathml::read`]. `None` when there's no such element, it holds -/// nothing, or the XML is malformed. Nesting and element count are bounded like other XML here. -fn read_formula(xml: &[u8]) -> Option<(String, bool)> { - use crate::mathml::{El, Item, MAX_NODES, local}; - let mut stack: Vec = Vec::new(); - let mut math: Option = None; - let mut over = 0usize; - let mut count = 0usize; - events(xml, |ev| { - if math.is_some() { - return; - } - count += 1; - if count > MAX_NODES * 4 { - math = stack.drain(..).next(); - return; - } - match ev { - Ev::Start(q, a) => { - if stack.is_empty() && (local(&q) != "math" || q.starts_with("office:")) { - return; - } - if stack.len() < wordcraft_doc::math::MAX_DEPTH { - stack.push(El { name: q, attrs: a, children: Vec::new() }); - } else { - over += 1; - } - } - Ev::End(_) => { - if over > 0 { - over -= 1; - } else if stack.len() > 1 { - if let Some(done) = stack.pop() - && let Some(parent) = stack.last_mut() - { - parent.children.push(Item::El(done)); - } - } else if stack.len() == 1 { - math = stack.pop(); - } - } - Ev::Text(t) => { - if over == 0 - && let Some(top) = stack.last_mut() - { - top.children.push(Item::Text(t)); - } - } - } - }); - crate::mathml::read(&math?) -} +mod drawing; +mod notes; +mod tables; +mod tracked; -/// Make every comment range in `blocks` close: an end before its comment (or of no comment) is -/// dropped, and a comment whose end never comes covers nothing. Replies have no range. -pub(super) fn balance_comments(blocks: &mut [FBlock]) { - fn each_para(blocks: &mut [FBlock], depth: usize, f: &mut dyn FnMut(&mut Para)) { - for b in blocks { - match b { - FBlock::Para(p) => f(p), - FBlock::Table(t) if depth < model::MAX_DEPTH => { - for c in t.rows.iter_mut().flatten() { - each_para(&mut c.blocks, depth + 1, f); - } - } - FBlock::Table(_) => {} - } - } - } - let mut open = std::collections::HashSet::new(); - each_para(blocks, 0, &mut |p| { - p.inlines.retain(|i| match i { - Inline::Comment { id, comment } if comment.parent.is_none() => { - open.insert(*id); - true - } - Inline::CommentEnd(id) => open.remove(id), - _ => true, - }) - }); - if open.is_empty() { - return; - } - each_para(blocks, 0, &mut |p| { - let mut k = 0; - while k < p.inlines.len() { - if let Some(Inline::Comment { id, comment }) = p.inlines.get(k) - && comment.parent.is_none() - && open.remove(id) - { - let id = *id; - p.inlines.insert(k + 1, Inline::CommentEnd(id)); - k += 1; - } - k += 1; - } - }); -} - -/// Give each edge two cells share the line either cell draws: LibreOffice gives a shared edge to -/// one of them only (the left cell's right side is `none`, the right cell's left side draws it), -/// while WordCraft's cells each draw their own four sides. The cell below is the one in the next -/// row starting at the same column. -fn share_edges(rows: &mut [Vec]) { - let visible = |b: Option| b.filter(|b| b.is_visible()); - for r in 0..rows.len() { - let starts: Vec = rows - .get(r) - .map(|row| { - row.iter() - .scan(0usize, |g, c| { - let s = *g; - *g += c.colspan.max(1) as usize; - Some(s) - }) - .collect() - }) - .unwrap_or_default(); - for k in 0..starts.len() { - // The cell to the right. - if let Some(row) = rows.get_mut(r) - && let [Some(a), Some(b)] = [k, k + 1].map(|i| row.get(i).map(|c| c.borders)) - && (a.is_some() || b.is_some()) - { - let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default()); - let line = visible(a.right).or(visible(b.left)); - if let Some(line) = line { - if let Some(c) = row.get_mut(k) { - c.borders = Some(Borders { right: Some(line), ..a }); - } - if let Some(c) = row.get_mut(k + 1) { - c.borders = Some(Borders { left: Some(line), ..b }); - } - } - } - // The cell below. - let start = starts.get(k).copied().unwrap_or(0); - let below = rows.get(r + 1).and_then(|row| { - let mut g = 0; - row.iter().position(|c| { - let s = g; - g += c.colspan.max(1) as usize; - s == start - }) - }); - let (Some(j), Some(a)) = (below, rows.get(r).and_then(|row| row.get(k)).map(|c| c.borders)) else { continue }; - let Some(b) = rows.get(r + 1).and_then(|row| row.get(j)).map(|c| c.borders) else { continue }; - if a.is_none() && b.is_none() { - continue; - } - let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default()); - if let Some(line) = visible(a.bottom).or(visible(b.top)) { - if let Some(c) = rows.get_mut(r).and_then(|row| row.get_mut(k)) { - c.borders = Some(Borders { bottom: Some(line), ..a }); - } - if let Some(c) = rows.get_mut(r + 1).and_then(|row| row.get_mut(j)) { - c.borders = Some(Borders { top: Some(line), ..b }); - } - } - } - } -} - -struct TableB { - rows: Vec>, - row: Vec, - /// Covered cells still owed to the previous cell's column span. - hcover: usize, - /// Widths in points of the `table:table-column`s so far, 0 where the style gives none. - widths: Vec, -} - -impl TableB { - fn finish(self) -> FTable { - // A column without a width leaves the whole grid to the equal split. - let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() }; - let mut rows = self.rows; - share_edges(&mut rows); - // A table whose cells all say "no line" draws none. - let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some()); - let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible())); - FTable { rows, widths, borderless } - } -} - -/// A `text:changed-region` read from `text:tracked-changes`. -#[derive(Default)] -struct Region { - kind: Option, - /// Sequence number: tells regions by the same author at the same time apart. - id: u32, - author: String, - date: String, - /// A deletion's deleted paragraphs. - blocks: Vec, -} - -impl Region { - fn mark(&self) -> RevMark { - RevMark { author: self.author.trim().to_string(), date: self.date.trim().to_string(), id: Some(self.id) } - } -} - -/// Reader state saved while a deletion's content is read into its own container. -struct DelState { - containers: usize, - tables: usize, - cells: usize, - lists_saved: usize, - fmt: usize, - lists: Vec<(Option, bool)>, - item_used: Vec, - /// Insertions open inside the deleted content (text inserted, then deleted). - ins: Vec<(String, RevMark)>, -} - -/// A frame being read: its width and height, picture bytes, alt text and the equation read from -/// an embedded formula object. -/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far. -struct OpenGroup { - anchor: Option, - style: Option, - members: Vec, -} - -/// Largest size or offset read in a group, points (22 inches). -const MAX_LEN: f32 = 1584.0; +use drawing::{OpenGroup, read_formula}; +use notes::{OpenField, OpenNote, balance_comments}; +use tables::TableB; +use tracked::{DelState, Region, change_mark}; type Frame = (Option, Option, Option>, String, Option<(String, bool)>); @@ -325,65 +104,6 @@ pub(super) struct Body<'a> { next_comment: u32, } -/// A comment being read (ODF 1.3 §14.1): what `office:annotation` and its `dc:creator`, -/// `dc:date` and `meta:creator-initials` say. -#[derive(Default)] -struct CommentInfo { - /// Its flow id. - id: u32, - /// Its `office:name`, which its `office:annotation-end` names. - named: bool, - /// The flow id of the comment it answers (`loext:parent-name`). - parent: Option, - resolved: bool, - author: String, - date: String, - initials: String, - /// The child whose text is being read: 0 author, 1 date, 2 initials. - field: Option, -} - -/// A footnote, endnote or comment being read (ODF 1.3 §6.3, §14.1): what it is, and the -/// reader state of the paragraph it sits in, put aside while its content is read into a -/// container of its own. -struct OpenNote { - /// The `text:note` or `office:annotation` element's depth. - depth: usize, - endnote: bool, - /// The citation's `text:label` (a custom mark). - custom: String, - /// Set for a comment. - comment: Option, - /// Set for a text box: the frame it is in, put aside with its placement while its content is - /// read (a frame inside it is a frame of its own). - text_box: Option<(Option, Option, model::BoxLook)>, - para: Option, - fmt: Vec, - xml_space_end: bool, - para_cont: bool, - lists: Vec<(Option, bool)>, - item_used: Vec, - list_id: u32, - /// Stack heights when the note began: what the note opens and leaves open goes with it. - containers: usize, - tables: usize, - cells: usize, - lists_saved: usize, -} - -/// A text field being read ([`super::fields`]). -struct OpenField { - /// Its Word field code. - instr: String, - /// Its shown text so far. - result: String, - /// Its element's name and depth. - name: String, - depth: usize, - /// The formatting it is in. - fmt: Fmt, -} - impl<'a> Body<'a> { pub(super) fn new(styles: &'a Styles, pages: &'a Pages, zip: &'a mut dyn FnMut(&str) -> Option>) -> Self { let page = pages.first.clone().unwrap_or_default(); @@ -1001,158 +721,6 @@ impl Body<'_> { } } - /// An element inside `text:tracked-changes`, outside deleted content. - fn tracked_start(&mut self, q: &str, a: &Attrs) { - match local(q) { - "changed-region" if self.region.is_none() && self.region_count < MAX_REGIONS => { - let id = get(a, "text:id").unwrap_or_default().to_string(); - let r = Region { id: self.region_count as u32, ..Default::default() }; - self.region_count += 1; - self.region = Some((id, r)); - } - "insertion" | "deletion" | "format-change" if self.region.as_ref().is_some_and(|(_, r)| r.kind.is_none()) => { - let kind = match local(q) { - "insertion" => RevisionKind::Insert, - "deletion" => RevisionKind::Delete, - _ => RevisionKind::Format, - }; - if let Some((_, r)) = &mut self.region { - r.kind = Some(kind); - } - if kind == RevisionKind::Delete { - self.containers.push(Vec::new()); - self.del = Some(DelState { - containers: self.containers.len(), - tables: self.tables.len(), - cells: self.cells.len(), - lists_saved: self.lists_saved.len(), - fmt: self.fmt.len(), - lists: std::mem::take(&mut self.lists), - item_used: std::mem::take(&mut self.item_used), - ins: Vec::new(), - }); - } - } - "change-info" if self.region.is_some() => self.info_at = Some(self.depth), - _ => self.skip = 1, - } - } - - /// End a deletion's content: its paragraphs go to the region being read. - fn close_deletion(&mut self) { - if self.del.is_none() { - return; - } - self.end_para(); - let Some(d) = self.del.take() else { return }; - let base = d.containers.saturating_sub(1).min(self.containers.len()); - let blocks: Vec = self.containers.split_off(base).into_iter().flatten().filter(|b| matches!(b, FBlock::Para(_))).collect(); - if self.containers.is_empty() { - self.containers.push(Vec::new()); - } - self.tables.truncate(d.tables); - self.cells.truncate(d.cells); - self.lists_saved.truncate(d.lists_saved); - self.fmt.truncate(d.fmt); - self.lists = d.lists; - self.item_used = d.item_used; - self.frame = None; - self.in_desc = false; - self.para_cont = false; - if let Some((_, r)) = &mut self.region { - r.blocks = blocks; - } - } - - /// Put a deletion's paragraphs back at a `text:change` point, as deleted text. Inside a - /// paragraph, each deleted paragraph mark splits it; between paragraphs, each deleted - /// paragraph stands alone with its mark deleted. - fn place_deletion(&mut self, blocks: Vec, mark: RevMark) { - let mut paras: Vec = Vec::new(); - for b in blocks { - let FBlock::Para(mut p) = b else { continue }; - // Pictures and other objects carry no revision mark: leave them out. - p.inlines.retain(|i| matches!(i, Inline::Text(..) | Inline::Anchor(_))); - for i in &mut p.inlines { - if let Inline::Text(_, f) = i { - f.del = Some(mark.clone()); - } - } - paras.push(p); - } - let append = |dst: &mut Para, src: Vec| { - for i in src { - match i { - Inline::Text(t, f) => dst.push_text(&t, &f), - other => dst.inlines.push(other), - } - } - }; - let Some(mut cur) = self.para.take() else { - self.para_cont = false; - for mut p in paras { - p.mark_del = Some(Box::new(mark.clone())); - self.finish_para(p); - } - return; - }; - let n = paras.len(); - let mut it = paras.into_iter(); - let mut first_mark_ins = None; - if let Some(first) = it.next() { - first_mark_ins = first.mark_ins; - append(&mut cur, first.inlines); - } - if n < 2 { - self.para = Some(cur); - return; - } - let mut rest = Para { kind: cur.kind, list: cur.list, align: cur.align, ..Default::default() }; - // The host paragraph now ends at the first deleted paragraph's mark. - cur.mark_del = Some(Box::new(mark.clone())); - cur.mark_ins = first_mark_ins; - self.finish_para(cur); - for (k, mut p) in it.enumerate() { - if k + 2 == n { - // The last deleted paragraph runs on into the rest of the body paragraph. - rest.kind = p.kind; - rest.align = p.align.or(rest.align); - append(&mut rest, p.inlines); - } else { - p.mark_del = Some(Box::new(mark.clone())); - self.finish_para(p); - } - } - self.para = Some(rest); - } - - fn tracked_context(&mut self, q: &str, a: &Attrs) -> bool { - if !self.in_tc { - return false; - } - if self.del.is_none() { - self.tracked_start(q, a); - return true; - } - // Inside deleted content: only insertions (text inserted, then deleted) nest. - if matches!(local(q), "change-start" | "change-end") { - let regions = &self.regions; - if let Some(d) = &mut self.del { - change_mark(regions, &mut d.ins, local(q) == "change-start", a); - } - return true; - } - if matches!(local(q), "tracked-changes" | "changed-region" | "insertion" | "deletion" | "format-change" | "change") { - self.skip = 1; - return true; - } - if local(q) == "change-info" && self.region.is_some() { - self.info_at = Some(self.depth); - return true; - } - false - } - fn start_inline(&mut self, q: &str, a: &Attrs) -> bool { // Inside a field everything is its shown text. if self.field.is_some() { @@ -1239,336 +807,6 @@ impl Body<'_> { true } - /// A note or its citation starting: true when handled. A note inside a note (allowed by the - /// schema, never written by Word or LibreOffice), or one in deleted text or a frame, is left - /// out with its content. - fn start_note_part(&mut self, q: &str, a: &Attrs) -> bool { - let free = self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame.is_none(); - if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) { - c.field = match q { - "dc:creator" => Some(0), - "dc:date" => Some(1), - "meta:creator-initials" => Some(2), - _ => None, - }; - if c.field.is_some() { - return true; - } - } - match q { - "office:annotation" if free && self.comment_names.len() < MAX_COMMENTS => self.start_comment(a), - // A text box: its content read as a note's, the frame it is in put aside meanwhile. - "draw:text-box" if self.frame.is_some() && self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame_box.is_none() => { - // A least height (ODF 1.3 §20.207 `fo:min-height`): the box grows to its text. - if let Some(min) = get(a, "fo:min-height").and_then(length).filter(|v| v.is_finite() && *v >= 0.0) - && let Some(fr) = &mut self.frame - { - self.frame_look.auto_fit = true; - fr.1 = fr.1.or(Some(min)); - } - let frame = (self.frame.take(), self.frame_float.take(), std::mem::take(&mut self.frame_look)); - self.start_note(a); - if let Some(n) = &mut self.note { - n.text_box = Some(frame); - } - } - "office:annotation-end" => { - if let Some(id) = get(a, "office:name").and_then(|n| self.comment_names.get(n)).copied() { - self.push_inline(Inline::CommentEnd(id)); - } - } - "text:note" if free => self.start_note(a), - // The citation's text is LibreOffice's number: only a custom mark is kept. - "text:note-citation" => { - if let Some(n) = &mut self.note { - n.custom = get(a, "text:label").map(|l| l.chars().filter(|c| !c.is_control()).take(10).collect()).unwrap_or_default(); - } - self.skip = 1; - } - _ => return false, - } - true - } - - /// Begin reading a comment, as a note: its text in a container of its own. - fn start_comment(&mut self, a: &Attrs) { - let id = self.next_comment; - self.next_comment = self.next_comment.saturating_add(1); - let name = get(a, "office:name").filter(|n| !n.is_empty() && n.len() <= MAX_INFO_CHARS); - if let Some(n) = name { - // The first comment of a name keeps it. - self.comment_names.entry(n.to_string()).or_insert(id); - } - let parent = get(a, "loext:parent-name").and_then(|n| self.comment_names.get(n)).copied(); - let resolved = get(a, "loext:resolved").is_some_and(|r| r.trim() == "true"); - let info = CommentInfo { id, named: name.is_some(), parent, resolved, ..Default::default() }; - self.start_note(a); - if let Some(n) = &mut self.note { - n.comment = Some(info); - } - } - - /// `inline` at the end of the paragraph being read (a new one if none is). - fn push_inline(&mut self, inline: Inline) { - if self.para.is_none() { - self.start_para(None, None); - } - if let Some(p) = &mut self.para { - p.inlines.push(inline); - } - } - - /// Begin reading a note: put the paragraph it is in aside and read its content into a - /// container of its own, outside any list. - fn start_note(&mut self, a: &Attrs) { - let note = OpenNote { - depth: self.depth, - endnote: get(a, "text:note-class").is_some_and(|c| c.trim() == "endnote"), - custom: String::new(), - comment: None, - text_box: None, - para: self.para.take(), - fmt: std::mem::take(&mut self.fmt), - xml_space_end: std::mem::take(&mut self.xml_space_end), - para_cont: std::mem::take(&mut self.para_cont), - lists: std::mem::take(&mut self.lists), - item_used: std::mem::take(&mut self.item_used), - list_id: self.list_id, - containers: self.containers.len(), - tables: self.tables.len(), - cells: self.cells.len(), - lists_saved: self.lists_saved.len(), - }; - self.containers.push(Vec::new()); - self.note = Some(note); - } - - /// End the note being read: its content becomes an [`Inline::Note`] in the paragraph put - /// aside, which goes on. Tables and cells the note left open (malformed) are dropped. - fn end_note(&mut self) { - self.end_para(); - let Some(n) = self.note.take() else { return }; - self.tables.truncate(n.tables); - self.cells.truncate(n.cells); - self.lists_saved.truncate(n.lists_saved); - self.containers.truncate(n.containers + 1); - let blocks = if self.containers.len() > n.containers { self.containers.pop().unwrap_or_default() } else { Vec::new() }; - self.para = n.para; - self.fmt = n.fmt; - self.xml_space_end = n.xml_space_end; - self.para_cont = n.para_cont; - self.lists = n.lists; - self.item_used = n.item_used; - self.list_id = n.list_id; - if let Some((frame, float, look)) = n.text_box { - // Back in the frame, which holds the box's content until it ends. - (self.frame, self.frame_float, self.frame_look) = (frame, float, look); - self.frame_box = Some(blocks); - return; - } - match n.comment { - Some(c) => { - // LibreOffice writes an empty date as all zeroes; a date without a zone is - // read as UTC, as the document's own dates are. - let date = c.date.trim(); - let date = if date.is_empty() || date.starts_with("0000") { - String::new() - } else if date.ends_with(['Z', 'z']) || date.rsplit_once('T').is_some_and(|(_, t)| t.contains(['+', '-'])) { - date.to_string() - } else { - format!("{date}Z") - }; - let comment = FComment { - author: c.author.trim().to_string(), - initials: c.initials.trim().to_string(), - date, - resolved: c.resolved, - parent: c.parent, - blocks, - }; - self.push_inline(Inline::Comment { id: c.id, comment: Box::new(comment) }); - // A comment without a name has no end to wait for: it covers nothing. - if !c.named && c.parent.is_none() { - self.push_inline(Inline::CommentEnd(c.id)); - } - } - None => self.push_inline(Inline::Note { endnote: n.endnote, custom: n.custom, blocks }), - } - } - - /// An element ending at the current depth, for an open note or field: true when that was all - /// there was to it. - fn end_note_or_field(&mut self, q: &str) -> bool { - if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) - && c.field.is_some() - { - c.field = None; - return true; - } - if self.note.as_ref().is_some_and(|n| self.depth < n.depth) { - // The note's own end, or (malformed) an outer element ending first: the note ends, - // then that element. - self.end_field(); - self.end_note(); - if q == "text:note" || q == "office:annotation" || q == "draw:text-box" { - return true; - } - if self.shape_kind.is_some() && super::shapes::is_shape(q) { - // A drawing shape's own end: it goes into the paragraph, its text read. - self.end_frame(); - return true; - } - } - // Everything inside a field was its text: only the field's own end does anything. An - // outer element ending first (malformed) ends the field, then itself. - if let Some(f) = &self.field { - if self.depth >= f.depth { - return true; - } - let own = f.name == q; - self.end_field(); - return own; - } - false - } - - /// Put the field read so far into the paragraph. - fn end_field(&mut self) { - let Some(OpenField { instr, result, mut fmt, .. }) = self.field.take() else { return }; - fmt.ins = self.open_insertion(); - if self.para.is_none() { - self.start_para(None, None); - } - if let Some(p) = &mut self.para { - p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt }); - } - self.xml_space_end = false; - } - - /// End a frame or drawing shape: its picture, equation, text box or shape goes into the - /// paragraph. - fn end_frame(&mut self) { - if let Some((w, h, img, alt, eq)) = self.frame.take() { - let boxed = self.frame_box.take(); - let mut float = self.frame_float.take(); - if boxed.is_some() - && self.shape_kind.is_some() - && let Some(f) = &mut float - { - let (h, v) = std::mem::take(&mut self.shape_flip); - f.flip_h |= h; - f.flip_v ^= v; - } - // Kept when it floats, or turns or mirrors the object in line. - let float = float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline || super::frames::turned(f) || f.flip_h || f.flip_v); - let inline = match (boxed, eq) { - (Some(blocks), _) => { - let look = std::mem::take(&mut self.frame_look); - // Without a height it grows from nothing to its text. - let kind = self.shape_kind.take().unwrap_or(wordcraft_doc::para::ShapeKind::TextBox); - // A shape whose paragraphs are all empty (LibreOffice writes `` in - // each) holds no text. - let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty())); - let blocks = if kind != wordcraft_doc::para::ShapeKind::TextBox && empty { Vec::new() } else { blocks }; - let path = self.shape_path.take().filter(|_| kind == wordcraft_doc::para::ShapeKind::Freeform); - Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path }) - } - (None, Some((linear, display))) => Some(Inline::Equation { linear, display }), - (None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| { - img.float = float.map(Box::new); - Inline::Image(img) - }), - }; - if let Some(inline) = inline { - // A group's member goes into the group, at its place in it. - if let Some(((x, y), g)) = self.member_at.take().zip(self.group.as_mut().filter(|_| self.note.is_none())) { - if g.members.len() < wordcraft_doc::para::MAX_GROUP_CHILDREN { - g.members.push(model::Member { x, y, inline }); - } - } else { - self.push_inline(inline); - } - } - } - self.frame = None; - } - - /// A frame or shape `w` × `h` (attributes `a`) begins: in a group (not in a shape's text), where - /// it sits in it, from its `svg:x`/`svg:y` or `draw:transform` (a line's from its ends, `line`). - fn member_start(&mut self, a: &Attrs, w: f32, h: f32, line: Option<(Option, Option)>) { - self.member_rot = None; - if self.group.is_none() || self.note.is_some() { - return; - } - let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(-MAX_LEN, MAX_LEN)); - let at = match (line, get(a, "draw:transform").and_then(|t| super::frames::transform(t, w, h))) { - (Some((x, y)), _) => (x.unwrap_or(0.0), y.unwrap_or(0.0)), - (None, Some((x, y, rot))) => { - self.member_rot = Some(rot); - (x, y) - } - (None, None) => (len("svg:x").unwrap_or(0.0), len("svg:y").unwrap_or(0.0)), - }; - self.member_at = Some(at); - } - - /// The group being read ends: its members, placed from the corner of the box around them, - /// become one object anchored where the group is. - fn end_group(&mut self) { - let Some(g) = self.group.take() else { return }; - let size = |i: &Inline| match i { - Inline::Shape { w, h, .. } => (*w, *h), - Inline::Image(img) => (img.w, img.h), - Inline::Group { w, h, .. } => (*w, *h), - _ => (0.0, 0.0), - }; - let fin = |v: f32| if v.is_finite() { v.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }; - let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); - for m in &g.members { - let (w, h) = size(&m.inline); - (x0, y0) = (x0.min(fin(m.x)), y0.min(fin(m.y))); - (x1, y1) = (x1.max(fin(m.x + w)), y1.max(fin(m.y + h))); - } - if g.members.is_empty() { - return; - } - let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect(); - let graphic = self.styles.shape_graphic(g.style.as_deref()); - let float = super::frames::float_of(g.anchor.as_deref(), Some(&super::writer::pt(x0)), Some(&super::writer::pt(y0)), &graphic); - let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float)); - let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 }; - self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members }); - } - - /// Begin a drawing shape (ODF 1.3 §10.3): its size, placement and look, and its text read - /// as a text box's. A custom shape's kind comes from its geometry, read inside it. - fn start_shape(&mut self, q: &str, a: &Attrs) { - let kind = super::shapes::element_kind(local(q)); - let g = self.styles.shape_graphic(get(a, "draw:style-name")); - let (w, h, x, y, (flip_h, flip_v)) = super::shapes::geometry(kind, a); - let mut float = if kind == wordcraft_doc::para::ShapeKind::Line { - // A line is placed by its ends. - let at = |v: Option| v.map(super::writer::pt); - super::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g) - } else { - super::frames::place(a, w, h, &g) - }; - (float.flip_h, float.flip_v) = (flip_h, flip_v); - self.shape_flip = (false, false); - self.member_start(a, w, h, (kind == wordcraft_doc::para::ShapeKind::Line).then_some((x, y))); - if let Some(rot) = self.member_rot.take() { - float.rot = rot; - } - self.frame_look = g.shape_look(kind, &self.styles.defs); - self.shape_path = super::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new); - self.shape_kind = Some(kind); - let frame = (Some((Some(w), Some(h), None, String::new(), None)), Some(float), std::mem::take(&mut self.frame_look)); - self.start_note(a); - if let Some(n) = &mut self.note { - n.text_box = Some(frame); - } - } - fn end(&mut self, q: &str) { self.depth = self.depth.saturating_sub(1); if self.end_note_or_field(q) { @@ -1700,21 +938,3 @@ impl Body<'_> { } } } - -/// A `text:change-start` (`start`) or `text:change-end` (attributes `a`) among the insertions -/// `open`: unknown ids, ends without a start and starts past [`MAX_OPEN_INSERTIONS`] are ignored. -fn change_mark(regions: &HashMap, open: &mut Vec<(String, RevMark)>, start: bool, a: &Attrs) { - let Some(id) = get(a, "text:change-id") else { return }; - if !start { - if let Some(k) = open.iter().position(|(o, _)| o == id) { - open.remove(k); - } - return; - } - if let Some(r) = regions.get(id).filter(|r| r.kind == Some(RevisionKind::Insert)) - && open.len() < MAX_OPEN_INSERTIONS - && !open.iter().any(|(o, _)| o == id) - { - open.push((id.to_string(), r.mark())); - } -} diff --git a/crates/formats/src/odt/body/drawing.rs b/crates/formats/src/odt/body/drawing.rs new file mode 100644 index 0000000000..e7e0033656 --- /dev/null +++ b/crates/formats/src/odt/body/drawing.rs @@ -0,0 +1,198 @@ +//! Frames, drawing shapes and groups in the text (ODF 1.3 §10.3–10.4): pictures, formula objects, +//! text boxes, shapes and groups, each read into the paragraph (or the group) it is in. + +use super::{Attrs, Body, Ev, FBlock, Inline, events, get, length, local, make_img, model}; + +/// An embedded ODF formula sub-document's `content.xml`, read for its `math:math` element +/// (its root, ODF 1.3 Part 3 §2.2.9 B; some writers wrap it in an `office:math`, which isn't +/// it): the first MathML element named `math` in document order, with its +/// subtree, passed to [`crate::mathml::read`]. `None` when there's no such element, it holds +/// nothing, or the XML is malformed. Nesting and element count are bounded like other XML here. +pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> { + use crate::mathml::{El, Item, MAX_NODES, local}; + let mut stack: Vec = Vec::new(); + let mut math: Option = None; + let mut over = 0usize; + let mut count = 0usize; + events(xml, |ev| { + if math.is_some() { + return; + } + count += 1; + if count > MAX_NODES * 4 { + math = stack.drain(..).next(); + return; + } + match ev { + Ev::Start(q, a) => { + if stack.is_empty() && (local(&q) != "math" || q.starts_with("office:")) { + return; + } + if stack.len() < wordcraft_doc::math::MAX_DEPTH { + stack.push(El { name: q, attrs: a, children: Vec::new() }); + } else { + over += 1; + } + } + Ev::End(_) => { + if over > 0 { + over -= 1; + } else if stack.len() > 1 { + if let Some(done) = stack.pop() + && let Some(parent) = stack.last_mut() + { + parent.children.push(Item::El(done)); + } + } else if stack.len() == 1 { + math = stack.pop(); + } + } + Ev::Text(t) => { + if over == 0 + && let Some(top) = stack.last_mut() + { + top.children.push(Item::Text(t)); + } + } + } + }); + crate::mathml::read(&math?) +} + +/// A frame being read: its width and height, picture bytes, alt text and the equation read from +/// an embedded formula object. +/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far. +pub(in crate::odt) struct OpenGroup { + pub(in crate::odt) anchor: Option, + pub(in crate::odt) style: Option, + pub(in crate::odt) members: Vec, +} + +/// Largest size or offset read in a group, points (22 inches). +pub(in crate::odt) const MAX_LEN: f32 = 1584.0; + +impl Body<'_> { + /// End a frame or drawing shape: its picture, equation, text box or shape goes into the + /// paragraph. + pub(in crate::odt) fn end_frame(&mut self) { + if let Some((w, h, img, alt, eq)) = self.frame.take() { + let boxed = self.frame_box.take(); + let mut float = self.frame_float.take(); + if boxed.is_some() + && self.shape_kind.is_some() + && let Some(f) = &mut float + { + let (h, v) = std::mem::take(&mut self.shape_flip); + f.flip_h |= h; + f.flip_v ^= v; + } + // Kept when it floats, or turns or mirrors the object in line. + let float = float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline || crate::odt::frames::turned(f) || f.flip_h || f.flip_v); + let inline = match (boxed, eq) { + (Some(blocks), _) => { + let look = std::mem::take(&mut self.frame_look); + // Without a height it grows from nothing to its text. + let kind = self.shape_kind.take().unwrap_or(wordcraft_doc::para::ShapeKind::TextBox); + // A shape whose paragraphs are all empty (LibreOffice writes `` in + // each) holds no text. + let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty())); + let blocks = if kind != wordcraft_doc::para::ShapeKind::TextBox && empty { Vec::new() } else { blocks }; + let path = self.shape_path.take().filter(|_| kind == wordcraft_doc::para::ShapeKind::Freeform); + Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path }) + } + (None, Some((linear, display))) => Some(Inline::Equation { linear, display }), + (None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| { + img.float = float.map(Box::new); + Inline::Image(img) + }), + }; + if let Some(inline) = inline { + // A group's member goes into the group, at its place in it. + if let Some(((x, y), g)) = self.member_at.take().zip(self.group.as_mut().filter(|_| self.note.is_none())) { + if g.members.len() < wordcraft_doc::para::MAX_GROUP_CHILDREN { + g.members.push(model::Member { x, y, inline }); + } + } else { + self.push_inline(inline); + } + } + } + self.frame = None; + } + + /// A frame or shape `w` × `h` (attributes `a`) begins: in a group (not in a shape's text), where + /// it sits in it, from its `svg:x`/`svg:y` or `draw:transform` (a line's from its ends, `line`). + pub(in crate::odt) fn member_start(&mut self, a: &Attrs, w: f32, h: f32, line: Option<(Option, Option)>) { + self.member_rot = None; + if self.group.is_none() || self.note.is_some() { + return; + } + let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(-MAX_LEN, MAX_LEN)); + let at = match (line, get(a, "draw:transform").and_then(|t| crate::odt::frames::transform(t, w, h))) { + (Some((x, y)), _) => (x.unwrap_or(0.0), y.unwrap_or(0.0)), + (None, Some((x, y, rot))) => { + self.member_rot = Some(rot); + (x, y) + } + (None, None) => (len("svg:x").unwrap_or(0.0), len("svg:y").unwrap_or(0.0)), + }; + self.member_at = Some(at); + } + + /// The group being read ends: its members, placed from the corner of the box around them, + /// become one object anchored where the group is. + pub(in crate::odt) fn end_group(&mut self) { + let Some(g) = self.group.take() else { return }; + let size = |i: &Inline| match i { + Inline::Shape { w, h, .. } => (*w, *h), + Inline::Image(img) => (img.w, img.h), + Inline::Group { w, h, .. } => (*w, *h), + _ => (0.0, 0.0), + }; + let fin = |v: f32| if v.is_finite() { v.clamp(-MAX_LEN, MAX_LEN) } else { 0.0 }; + let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for m in &g.members { + let (w, h) = size(&m.inline); + (x0, y0) = (x0.min(fin(m.x)), y0.min(fin(m.y))); + (x1, y1) = (x1.max(fin(m.x + w)), y1.max(fin(m.y + h))); + } + if g.members.is_empty() { + return; + } + let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect(); + let graphic = self.styles.shape_graphic(g.style.as_deref()); + let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic); + let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float)); + let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 }; + self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members }); + } + + /// Begin a drawing shape (ODF 1.3 §10.3): its size, placement and look, and its text read + /// as a text box's. A custom shape's kind comes from its geometry, read inside it. + pub(in crate::odt) fn start_shape(&mut self, q: &str, a: &Attrs) { + let kind = crate::odt::shapes::element_kind(local(q)); + let g = self.styles.shape_graphic(get(a, "draw:style-name")); + let (w, h, x, y, (flip_h, flip_v)) = crate::odt::shapes::geometry(kind, a); + let mut float = if kind == wordcraft_doc::para::ShapeKind::Line { + // A line is placed by its ends. + let at = |v: Option| v.map(crate::odt::writer::pt); + crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g) + } else { + crate::odt::frames::place(a, w, h, &g) + }; + (float.flip_h, float.flip_v) = (flip_h, flip_v); + self.shape_flip = (false, false); + self.member_start(a, w, h, (kind == wordcraft_doc::para::ShapeKind::Line).then_some((x, y))); + if let Some(rot) = self.member_rot.take() { + float.rot = rot; + } + self.frame_look = g.shape_look(kind, &self.styles.defs); + self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new); + self.shape_kind = Some(kind); + let frame = (Some((Some(w), Some(h), None, String::new(), None)), Some(float), std::mem::take(&mut self.frame_look)); + self.start_note(a); + if let Some(n) = &mut self.note { + n.text_box = Some(frame); + } + } +} diff --git a/crates/formats/src/odt/body/notes.rs b/crates/formats/src/odt/body/notes.rs new file mode 100644 index 0000000000..5dce58f4fe --- /dev/null +++ b/crates/formats/src/odt/body/notes.rs @@ -0,0 +1,317 @@ +//! Notes, comments and fields (ODF 1.3 §6.3, §14.1, §7): read as containers of their own while the +//! paragraph they are in is put aside. + +use super::{Attrs, Body, FBlock, FComment, Fmt, Frame, Inline, MAX_COMMENTS, MAX_INFO_CHARS, Para, get, length, model}; + +/// Make every comment range in `blocks` close: an end before its comment (or of no comment) is +/// dropped, and a comment whose end never comes covers nothing. Replies have no range. +pub(in crate::odt) fn balance_comments(blocks: &mut [FBlock]) { + fn each_para(blocks: &mut [FBlock], depth: usize, f: &mut dyn FnMut(&mut Para)) { + for b in blocks { + match b { + FBlock::Para(p) => f(p), + FBlock::Table(t) if depth < model::MAX_DEPTH => { + for c in t.rows.iter_mut().flatten() { + each_para(&mut c.blocks, depth + 1, f); + } + } + FBlock::Table(_) => {} + } + } + } + let mut open = std::collections::HashSet::new(); + each_para(blocks, 0, &mut |p| { + p.inlines.retain(|i| match i { + Inline::Comment { id, comment } if comment.parent.is_none() => { + open.insert(*id); + true + } + Inline::CommentEnd(id) => open.remove(id), + _ => true, + }) + }); + if open.is_empty() { + return; + } + each_para(blocks, 0, &mut |p| { + let mut k = 0; + while k < p.inlines.len() { + if let Some(Inline::Comment { id, comment }) = p.inlines.get(k) + && comment.parent.is_none() + && open.remove(id) + { + let id = *id; + p.inlines.insert(k + 1, Inline::CommentEnd(id)); + k += 1; + } + k += 1; + } + }); +} + +/// A comment being read (ODF 1.3 §14.1): what `office:annotation` and its `dc:creator`, +/// `dc:date` and `meta:creator-initials` say. +#[derive(Default)] +pub(in crate::odt) struct CommentInfo { + /// Its flow id. + pub(in crate::odt) id: u32, + /// Its `office:name`, which its `office:annotation-end` names. + pub(in crate::odt) named: bool, + /// The flow id of the comment it answers (`loext:parent-name`). + pub(in crate::odt) parent: Option, + pub(in crate::odt) resolved: bool, + pub(in crate::odt) author: String, + pub(in crate::odt) date: String, + pub(in crate::odt) initials: String, + /// The child whose text is being read: 0 author, 1 date, 2 initials. + pub(in crate::odt) field: Option, +} + +/// A footnote, endnote or comment being read (ODF 1.3 §6.3, §14.1): what it is, and the +/// reader state of the paragraph it sits in, put aside while its content is read into a +/// container of its own. +pub(in crate::odt) struct OpenNote { + /// The `text:note` or `office:annotation` element's depth. + pub(in crate::odt) depth: usize, + pub(in crate::odt) endnote: bool, + /// The citation's `text:label` (a custom mark). + pub(in crate::odt) custom: String, + /// Set for a comment. + pub(in crate::odt) comment: Option, + /// Set for a text box: the frame it is in, put aside with its placement while its content is + /// read (a frame inside it is a frame of its own). + pub(in crate::odt) text_box: Option<(Option, Option, model::BoxLook)>, + pub(in crate::odt) para: Option, + pub(in crate::odt) fmt: Vec, + pub(in crate::odt) xml_space_end: bool, + pub(in crate::odt) para_cont: bool, + pub(in crate::odt) lists: Vec<(Option, bool)>, + pub(in crate::odt) item_used: Vec, + pub(in crate::odt) list_id: u32, + /// Stack heights when the note began: what the note opens and leaves open goes with it. + pub(in crate::odt) containers: usize, + pub(in crate::odt) tables: usize, + pub(in crate::odt) cells: usize, + pub(in crate::odt) lists_saved: usize, +} + +/// A text field being read ([`super::fields`]). +pub(in crate::odt) struct OpenField { + /// Its Word field code. + pub(in crate::odt) instr: String, + /// Its shown text so far. + pub(in crate::odt) result: String, + /// Its element's name and depth. + pub(in crate::odt) name: String, + pub(in crate::odt) depth: usize, + /// The formatting it is in. + pub(in crate::odt) fmt: Fmt, +} + +impl Body<'_> { + /// A note or its citation starting: true when handled. A note inside a note (allowed by the + /// schema, never written by Word or LibreOffice), or one in deleted text or a frame, is left + /// out with its content. + pub(in crate::odt) fn start_note_part(&mut self, q: &str, a: &Attrs) -> bool { + let free = self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame.is_none(); + if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) { + c.field = match q { + "dc:creator" => Some(0), + "dc:date" => Some(1), + "meta:creator-initials" => Some(2), + _ => None, + }; + if c.field.is_some() { + return true; + } + } + match q { + "office:annotation" if free && self.comment_names.len() < MAX_COMMENTS => self.start_comment(a), + // A text box: its content read as a note's, the frame it is in put aside meanwhile. + "draw:text-box" if self.frame.is_some() && self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame_box.is_none() => { + // A least height (ODF 1.3 §20.207 `fo:min-height`): the box grows to its text. + if let Some(min) = get(a, "fo:min-height").and_then(length).filter(|v| v.is_finite() && *v >= 0.0) + && let Some(fr) = &mut self.frame + { + self.frame_look.auto_fit = true; + fr.1 = fr.1.or(Some(min)); + } + let frame = (self.frame.take(), self.frame_float.take(), std::mem::take(&mut self.frame_look)); + self.start_note(a); + if let Some(n) = &mut self.note { + n.text_box = Some(frame); + } + } + "office:annotation-end" => { + if let Some(id) = get(a, "office:name").and_then(|n| self.comment_names.get(n)).copied() { + self.push_inline(Inline::CommentEnd(id)); + } + } + "text:note" if free => self.start_note(a), + // The citation's text is LibreOffice's number: only a custom mark is kept. + "text:note-citation" => { + if let Some(n) = &mut self.note { + n.custom = get(a, "text:label").map(|l| l.chars().filter(|c| !c.is_control()).take(10).collect()).unwrap_or_default(); + } + self.skip = 1; + } + _ => return false, + } + true + } + + /// Begin reading a comment, as a note: its text in a container of its own. + pub(in crate::odt) fn start_comment(&mut self, a: &Attrs) { + let id = self.next_comment; + self.next_comment = self.next_comment.saturating_add(1); + let name = get(a, "office:name").filter(|n| !n.is_empty() && n.len() <= MAX_INFO_CHARS); + if let Some(n) = name { + // The first comment of a name keeps it. + self.comment_names.entry(n.to_string()).or_insert(id); + } + let parent = get(a, "loext:parent-name").and_then(|n| self.comment_names.get(n)).copied(); + let resolved = get(a, "loext:resolved").is_some_and(|r| r.trim() == "true"); + let info = CommentInfo { id, named: name.is_some(), parent, resolved, ..Default::default() }; + self.start_note(a); + if let Some(n) = &mut self.note { + n.comment = Some(info); + } + } + + /// `inline` at the end of the paragraph being read (a new one if none is). + pub(in crate::odt) fn push_inline(&mut self, inline: Inline) { + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(inline); + } + } + + /// Begin reading a note: put the paragraph it is in aside and read its content into a + /// container of its own, outside any list. + pub(in crate::odt) fn start_note(&mut self, a: &Attrs) { + let note = OpenNote { + depth: self.depth, + endnote: get(a, "text:note-class").is_some_and(|c| c.trim() == "endnote"), + custom: String::new(), + comment: None, + text_box: None, + para: self.para.take(), + fmt: std::mem::take(&mut self.fmt), + xml_space_end: std::mem::take(&mut self.xml_space_end), + para_cont: std::mem::take(&mut self.para_cont), + lists: std::mem::take(&mut self.lists), + item_used: std::mem::take(&mut self.item_used), + list_id: self.list_id, + containers: self.containers.len(), + tables: self.tables.len(), + cells: self.cells.len(), + lists_saved: self.lists_saved.len(), + }; + self.containers.push(Vec::new()); + self.note = Some(note); + } + + /// End the note being read: its content becomes an [`Inline::Note`] in the paragraph put + /// aside, which goes on. Tables and cells the note left open (malformed) are dropped. + pub(in crate::odt) fn end_note(&mut self) { + self.end_para(); + let Some(n) = self.note.take() else { return }; + self.tables.truncate(n.tables); + self.cells.truncate(n.cells); + self.lists_saved.truncate(n.lists_saved); + self.containers.truncate(n.containers + 1); + let blocks = if self.containers.len() > n.containers { self.containers.pop().unwrap_or_default() } else { Vec::new() }; + self.para = n.para; + self.fmt = n.fmt; + self.xml_space_end = n.xml_space_end; + self.para_cont = n.para_cont; + self.lists = n.lists; + self.item_used = n.item_used; + self.list_id = n.list_id; + if let Some((frame, float, look)) = n.text_box { + // Back in the frame, which holds the box's content until it ends. + (self.frame, self.frame_float, self.frame_look) = (frame, float, look); + self.frame_box = Some(blocks); + return; + } + match n.comment { + Some(c) => { + // LibreOffice writes an empty date as all zeroes; a date without a zone is + // read as UTC, as the document's own dates are. + let date = c.date.trim(); + let date = if date.is_empty() || date.starts_with("0000") { + String::new() + } else if date.ends_with(['Z', 'z']) || date.rsplit_once('T').is_some_and(|(_, t)| t.contains(['+', '-'])) { + date.to_string() + } else { + format!("{date}Z") + }; + let comment = FComment { + author: c.author.trim().to_string(), + initials: c.initials.trim().to_string(), + date, + resolved: c.resolved, + parent: c.parent, + blocks, + }; + self.push_inline(Inline::Comment { id: c.id, comment: Box::new(comment) }); + // A comment without a name has no end to wait for: it covers nothing. + if !c.named && c.parent.is_none() { + self.push_inline(Inline::CommentEnd(c.id)); + } + } + None => self.push_inline(Inline::Note { endnote: n.endnote, custom: n.custom, blocks }), + } + } + + /// An element ending at the current depth, for an open note or field: true when that was all + /// there was to it. + pub(in crate::odt) fn end_note_or_field(&mut self, q: &str) -> bool { + if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) + && c.field.is_some() + { + c.field = None; + return true; + } + if self.note.as_ref().is_some_and(|n| self.depth < n.depth) { + // The note's own end, or (malformed) an outer element ending first: the note ends, + // then that element. + self.end_field(); + self.end_note(); + if q == "text:note" || q == "office:annotation" || q == "draw:text-box" { + return true; + } + if self.shape_kind.is_some() && crate::odt::shapes::is_shape(q) { + // A drawing shape's own end: it goes into the paragraph, its text read. + self.end_frame(); + return true; + } + } + // Everything inside a field was its text: only the field's own end does anything. An + // outer element ending first (malformed) ends the field, then itself. + if let Some(f) = &self.field { + if self.depth >= f.depth { + return true; + } + let own = f.name == q; + self.end_field(); + return own; + } + false + } + + /// Put the field read so far into the paragraph. + pub(in crate::odt) fn end_field(&mut self) { + let Some(OpenField { instr, result, mut fmt, .. }) = self.field.take() else { return }; + fmt.ins = self.open_insertion(); + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt }); + } + self.xml_space_end = false; + } +} diff --git a/crates/formats/src/odt/body/tables.rs b/crates/formats/src/odt/body/tables.rs new file mode 100644 index 0000000000..e20ab8fada --- /dev/null +++ b/crates/formats/src/odt/body/tables.rs @@ -0,0 +1,89 @@ +//! Tables being read (ODF 1.3 §9.1): rows, cells and their shared edges. + +use super::{Borders, Cell, FTable}; + +/// Give each edge two cells share the line either cell draws: LibreOffice gives a shared edge to +/// one of them only (the left cell's right side is `none`, the right cell's left side draws it), +/// while WordCraft's cells each draw their own four sides. The cell below is the one in the next +/// row starting at the same column. +pub(in crate::odt) fn share_edges(rows: &mut [Vec]) { + let visible = |b: Option| b.filter(|b| b.is_visible()); + for r in 0..rows.len() { + let starts: Vec = rows + .get(r) + .map(|row| { + row.iter() + .scan(0usize, |g, c| { + let s = *g; + *g += c.colspan.max(1) as usize; + Some(s) + }) + .collect() + }) + .unwrap_or_default(); + for k in 0..starts.len() { + // The cell to the right. + if let Some(row) = rows.get_mut(r) + && let [Some(a), Some(b)] = [k, k + 1].map(|i| row.get(i).map(|c| c.borders)) + && (a.is_some() || b.is_some()) + { + let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default()); + let line = visible(a.right).or(visible(b.left)); + if let Some(line) = line { + if let Some(c) = row.get_mut(k) { + c.borders = Some(Borders { right: Some(line), ..a }); + } + if let Some(c) = row.get_mut(k + 1) { + c.borders = Some(Borders { left: Some(line), ..b }); + } + } + } + // The cell below. + let start = starts.get(k).copied().unwrap_or(0); + let below = rows.get(r + 1).and_then(|row| { + let mut g = 0; + row.iter().position(|c| { + let s = g; + g += c.colspan.max(1) as usize; + s == start + }) + }); + let (Some(j), Some(a)) = (below, rows.get(r).and_then(|row| row.get(k)).map(|c| c.borders)) else { continue }; + let Some(b) = rows.get(r + 1).and_then(|row| row.get(j)).map(|c| c.borders) else { continue }; + if a.is_none() && b.is_none() { + continue; + } + let (a, b) = (a.unwrap_or_default(), b.unwrap_or_default()); + if let Some(line) = visible(a.bottom).or(visible(b.top)) { + if let Some(c) = rows.get_mut(r).and_then(|row| row.get_mut(k)) { + c.borders = Some(Borders { bottom: Some(line), ..a }); + } + if let Some(c) = rows.get_mut(r + 1).and_then(|row| row.get_mut(j)) { + c.borders = Some(Borders { top: Some(line), ..b }); + } + } + } + } +} + +pub(in crate::odt) struct TableB { + pub(in crate::odt) rows: Vec>, + pub(in crate::odt) row: Vec, + /// Covered cells still owed to the previous cell's column span. + pub(in crate::odt) hcover: usize, + /// Widths in points of the `table:table-column`s so far, 0 where the style gives none. + pub(in crate::odt) widths: Vec, +} + +impl TableB { + pub(in crate::odt) fn finish(self) -> FTable { + // A column without a width leaves the whole grid to the equal split. + let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() }; + let mut rows = self.rows; + share_edges(&mut rows); + // A table whose cells all say "no line" draws none. + let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some()); + let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible())); + FTable { rows, widths, borderless } + } +} diff --git a/crates/formats/src/odt/body/tracked.rs b/crates/formats/src/odt/body/tracked.rs new file mode 100644 index 0000000000..aab2dcb231 --- /dev/null +++ b/crates/formats/src/odt/body/tracked.rs @@ -0,0 +1,207 @@ +//! Tracked changes (ODF 1.3 §5.5): the changed regions, deleted content and the insertions open +//! around the text. + +use super::{Attrs, Body, FBlock, HashMap, Inline, MAX_OPEN_INSERTIONS, MAX_REGIONS, Para, RevMark, RevisionKind, get, local}; + +/// A `text:changed-region` read from `text:tracked-changes`. +#[derive(Default)] +pub(in crate::odt) struct Region { + pub(in crate::odt) kind: Option, + /// Sequence number: tells regions by the same author at the same time apart. + pub(in crate::odt) id: u32, + pub(in crate::odt) author: String, + pub(in crate::odt) date: String, + /// A deletion's deleted paragraphs. + pub(in crate::odt) blocks: Vec, +} + +impl Region { + pub(in crate::odt) fn mark(&self) -> RevMark { + RevMark { author: self.author.trim().to_string(), date: self.date.trim().to_string(), id: Some(self.id) } + } +} + +/// Reader state saved while a deletion's content is read into its own container. +pub(in crate::odt) struct DelState { + pub(in crate::odt) containers: usize, + pub(in crate::odt) tables: usize, + pub(in crate::odt) cells: usize, + pub(in crate::odt) lists_saved: usize, + pub(in crate::odt) fmt: usize, + pub(in crate::odt) lists: Vec<(Option, bool)>, + pub(in crate::odt) item_used: Vec, + /// Insertions open inside the deleted content (text inserted, then deleted). + pub(in crate::odt) ins: Vec<(String, RevMark)>, +} + +/// A `text:change-start` (`start`) or `text:change-end` (attributes `a`) among the insertions +/// `open`: unknown ids, ends without a start and starts past [`MAX_OPEN_INSERTIONS`] are ignored. +pub(in crate::odt) fn change_mark(regions: &HashMap, open: &mut Vec<(String, RevMark)>, start: bool, a: &Attrs) { + let Some(id) = get(a, "text:change-id") else { return }; + if !start { + if let Some(k) = open.iter().position(|(o, _)| o == id) { + open.remove(k); + } + return; + } + if let Some(r) = regions.get(id).filter(|r| r.kind == Some(RevisionKind::Insert)) + && open.len() < MAX_OPEN_INSERTIONS + && !open.iter().any(|(o, _)| o == id) + { + open.push((id.to_string(), r.mark())); + } +} + +impl Body<'_> { + /// An element inside `text:tracked-changes`, outside deleted content. + pub(in crate::odt) fn tracked_start(&mut self, q: &str, a: &Attrs) { + match local(q) { + "changed-region" if self.region.is_none() && self.region_count < MAX_REGIONS => { + let id = get(a, "text:id").unwrap_or_default().to_string(); + let r = Region { id: self.region_count as u32, ..Default::default() }; + self.region_count += 1; + self.region = Some((id, r)); + } + "insertion" | "deletion" | "format-change" if self.region.as_ref().is_some_and(|(_, r)| r.kind.is_none()) => { + let kind = match local(q) { + "insertion" => RevisionKind::Insert, + "deletion" => RevisionKind::Delete, + _ => RevisionKind::Format, + }; + if let Some((_, r)) = &mut self.region { + r.kind = Some(kind); + } + if kind == RevisionKind::Delete { + self.containers.push(Vec::new()); + self.del = Some(DelState { + containers: self.containers.len(), + tables: self.tables.len(), + cells: self.cells.len(), + lists_saved: self.lists_saved.len(), + fmt: self.fmt.len(), + lists: std::mem::take(&mut self.lists), + item_used: std::mem::take(&mut self.item_used), + ins: Vec::new(), + }); + } + } + "change-info" if self.region.is_some() => self.info_at = Some(self.depth), + _ => self.skip = 1, + } + } + + /// End a deletion's content: its paragraphs go to the region being read. + pub(in crate::odt) fn close_deletion(&mut self) { + if self.del.is_none() { + return; + } + self.end_para(); + let Some(d) = self.del.take() else { return }; + let base = d.containers.saturating_sub(1).min(self.containers.len()); + let blocks: Vec = self.containers.split_off(base).into_iter().flatten().filter(|b| matches!(b, FBlock::Para(_))).collect(); + if self.containers.is_empty() { + self.containers.push(Vec::new()); + } + self.tables.truncate(d.tables); + self.cells.truncate(d.cells); + self.lists_saved.truncate(d.lists_saved); + self.fmt.truncate(d.fmt); + self.lists = d.lists; + self.item_used = d.item_used; + self.frame = None; + self.in_desc = false; + self.para_cont = false; + if let Some((_, r)) = &mut self.region { + r.blocks = blocks; + } + } + + /// Put a deletion's paragraphs back at a `text:change` point, as deleted text. Inside a + /// paragraph, each deleted paragraph mark splits it; between paragraphs, each deleted + /// paragraph stands alone with its mark deleted. + pub(in crate::odt) fn place_deletion(&mut self, blocks: Vec, mark: RevMark) { + let mut paras: Vec = Vec::new(); + for b in blocks { + let FBlock::Para(mut p) = b else { continue }; + // Pictures and other objects carry no revision mark: leave them out. + p.inlines.retain(|i| matches!(i, Inline::Text(..) | Inline::Anchor(_))); + for i in &mut p.inlines { + if let Inline::Text(_, f) = i { + f.del = Some(mark.clone()); + } + } + paras.push(p); + } + let append = |dst: &mut Para, src: Vec| { + for i in src { + match i { + Inline::Text(t, f) => dst.push_text(&t, &f), + other => dst.inlines.push(other), + } + } + }; + let Some(mut cur) = self.para.take() else { + self.para_cont = false; + for mut p in paras { + p.mark_del = Some(Box::new(mark.clone())); + self.finish_para(p); + } + return; + }; + let n = paras.len(); + let mut it = paras.into_iter(); + let mut first_mark_ins = None; + if let Some(first) = it.next() { + first_mark_ins = first.mark_ins; + append(&mut cur, first.inlines); + } + if n < 2 { + self.para = Some(cur); + return; + } + let mut rest = Para { kind: cur.kind, list: cur.list, align: cur.align, ..Default::default() }; + // The host paragraph now ends at the first deleted paragraph's mark. + cur.mark_del = Some(Box::new(mark.clone())); + cur.mark_ins = first_mark_ins; + self.finish_para(cur); + for (k, mut p) in it.enumerate() { + if k + 2 == n { + // The last deleted paragraph runs on into the rest of the body paragraph. + rest.kind = p.kind; + rest.align = p.align.or(rest.align); + append(&mut rest, p.inlines); + } else { + p.mark_del = Some(Box::new(mark.clone())); + self.finish_para(p); + } + } + self.para = Some(rest); + } + + pub(in crate::odt) fn tracked_context(&mut self, q: &str, a: &Attrs) -> bool { + if !self.in_tc { + return false; + } + if self.del.is_none() { + self.tracked_start(q, a); + return true; + } + // Inside deleted content: only insertions (text inserted, then deleted) nest. + if matches!(local(q), "change-start" | "change-end") { + let regions = &self.regions; + if let Some(d) = &mut self.del { + change_mark(regions, &mut d.ins, local(q) == "change-start", a); + } + return true; + } + if matches!(local(q), "tracked-changes" | "changed-region" | "insertion" | "deletion" | "format-change" | "change") { + self.skip = 1; + return true; + } + if local(q) == "change-info" && self.region.is_some() { + self.info_at = Some(self.depth); + return true; + } + false + } +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 8b8da107ab..b1d8c2ccd6 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -1,6 +1,18 @@ use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model}; use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing}; +mod drawing; +mod notes; +mod props; +mod tables; +mod tracked; + +use drawing::Place; +use notes::annotation_name; +pub(super) use props::{align_attr, border_attrs, odf_name, props_text, script_attrs, spacing_props, tab_stops}; +use props::{odf_border, parent_style, text_props}; +use tracked::{Item, join_deleted_marks}; + // --------------------------------------------------------------------------------------------- // Export @@ -127,526 +139,6 @@ pub(super) struct Writer { part: usize, } -/// `style:text-properties` attributes for what only [`Fmt::extra`] holds (underline and strike -/// are written with the rest); `size` is the text's size, which a raised position is a share of. -fn extra_props(x: &wordcraft_doc::props::CharProps, size: Option) -> String { - let mut p = String::new(); - let on = |v: Option| v == Some(true); - if on(x.caps) { - p.push_str(" fo:text-transform=\"uppercase\""); - } - if on(x.small_caps) { - p.push_str(" fo:font-variant=\"small-caps\""); - } - if on(x.hidden) { - p.push_str(" text:display=\"none\""); - } - if let Some(s) = x.spacing.filter(|s| s.is_finite() && *s != 0.0) { - p.push_str(&format!(" fo:letter-spacing=\"{}\"", pt(s))); - } - if let Some(s) = x.scale.filter(|s| s.is_finite() && *s > 0.0 && *s != 100.0) { - p.push_str(&format!(" style:text-scale=\"{}%\"", s.round())); - } - if let Some(r) = x.position.filter(|r| r.is_finite() && *r != 0.0) { - let size = size.filter(|s| s.is_finite() && *s >= 1.0).unwrap_or(12.0); - let pct = r / size * 100.0; - p.push_str(&format!(" style:text-position=\"{}% 100%\"", (pct * 100.0).round() / 100.0)); - } - if on(x.outline) { - p.push_str(" style:text-outline=\"true\""); - } - if on(x.shadow) { - p.push_str(" fo:text-shadow=\"1pt 1pt\""); - } - if on(x.emboss) { - p.push_str(" style:font-relief=\"embossed\""); - } else if on(x.engrave) { - p.push_str(" style:font-relief=\"engraved\""); - } - p.push_str(&script_attrs(x)); - if let Some(l) = x.lang.as_deref() { - let mut parts = l.split(['-', '_']); - let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric()); - if let Some(lang) = parts.next().filter(ok) { - p.push_str(&format!(" fo:language=\"{lang}\"")); - if let Some(country) = parts.next().filter(ok) { - p.push_str(&format!(" fo:country=\"{country}\"")); - } - } - } - p -} - -/// `style:text-properties` attributes for character properties `cp` (a style's look). -pub(super) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String { - use wordcraft_doc::props::{TextColor, Underline, VertAlign}; - let f = Fmt { - bold: cp.bold == Some(true), - italic: cp.italic == Some(true), - underline: cp.underline.is_some_and(|u| u != Underline::None), - strike: cp.strike == Some(true), - sup: cp.vert_align == Some(VertAlign::Superscript), - sub: cp.vert_align == Some(VertAlign::Subscript), - color: match cp.color { - Some(TextColor::Rgb(c)) => Some(c), - _ => None, - }, - size: cp.size, - font: cp.font.as_deref().map(std::sync::Arc::from), - extra: Some(Box::new(cp.clone())), - ..Default::default() - }; - text_props(&f) -} - -/// `fo:text-align` for `a` (ODF 1.3 §20.223). -pub(super) fn align_attr(a: Option) -> &'static str { - match a { - Some(Align::Center) => " fo:text-align=\"center\"", - Some(Align::Right) => " fo:text-align=\"end\"", - Some(Align::Justify) | Some(Align::Distribute) => " fo:text-align=\"justify\"", - Some(Align::Left) => " fo:text-align=\"start\"", - None => "", - } -} - -/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag -/// `tag`. -fn lang_attrs(tag: &str, suffix: &str) -> String { - let mut parts = tag.split(['-', '_']); - let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric()); - let ns = if suffix.is_empty() { "fo" } else { "style" }; - let mut p = String::new(); - if let Some(lang) = parts.next().filter(ok) { - p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\"")); - if let Some(country) = parts.next().filter(ok) { - p.push_str(&format!(" {ns}:country{suffix}=\"{country}\"")); - } - } - p -} - -/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the -/// two languages. -pub(super) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String { - let mut p = String::new(); - if let Some(f) = x.font_ea.as_deref() { - p.push_str(&format!(" style:font-family-asian=\"'{}'\"", super::writer::x(&f.replace('\'', "")))); - } - if let Some(f) = x.font_cs.as_deref() { - p.push_str(&format!(" style:font-family-complex=\"'{}'\"", super::writer::x(&f.replace('\'', "")))); - } - if let Some(s) = x.size_cs.filter(|s| s.is_finite()) { - p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s))); - } - if let Some(b) = x.bold_cs { - p.push_str(if b { " style:font-weight-complex=\"bold\"" } else { " style:font-weight-complex=\"normal\"" }); - } - if let Some(i) = x.italic_cs { - p.push_str(if i { " style:font-style-complex=\"italic\"" } else { " style:font-style-complex=\"normal\"" }); - } - if let Some(l) = x.lang_ea.as_deref() { - p.push_str(&lang_attrs(l, "-asian")); - } - if let Some(l) = x.lang_bidi.as_deref() { - p.push_str(&lang_attrs(l, "-complex")); - } - p -} - -fn text_props(f: &Fmt) -> String { - let mut p = String::new(); - // Complex-script weight and posture of their own are written with the other script extras. - let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option| f.extra.as_deref().and_then(pick).is_some(); - if f.bold { - p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\""); - if !own_cs(|x| x.bold_cs) { - p.push_str(" style:font-weight-complex=\"bold\""); - } - } - if f.italic { - p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\""); - if !own_cs(|x| x.italic_cs) { - p.push_str(" style:font-style-complex=\"italic\""); - } - } - let extra = f.extra.as_deref(); - if f.underline { - use wordcraft_doc::props::Underline as U; - let (style, kind, width, mode) = match extra.and_then(|e| e.underline) { - Some(U::Double) => ("solid", "double", "auto", ""), - Some(U::Thick) => ("solid", "single", "bold", ""), - Some(U::Dotted) => ("dotted", "single", "auto", ""), - Some(U::Dash) => ("dash", "single", "auto", ""), - Some(U::DotDash) => ("dot-dash", "single", "auto", ""), - Some(U::DotDotDash) => ("dot-dot-dash", "single", "auto", ""), - Some(U::Wave) => ("wave", "single", "auto", ""), - Some(U::DoubleWave) => ("wave", "double", "auto", ""), - Some(U::Words) => ("solid", "single", "auto", " style:text-underline-mode=\"skip-white-space\""), - _ => ("solid", "single", "auto", ""), - }; - let color = - extra.and_then(|e| e.underline_color).map(|c| format!("#{}", c.hex().to_ascii_lowercase())).unwrap_or_else(|| "font-color".into()); - p.push_str(&format!( - " style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}" - )); - } - if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) { - p.push_str(" style:text-line-through-style=\"solid\""); - if extra.is_some_and(|e| e.double_strike == Some(true)) { - p.push_str(" style:text-line-through-type=\"double\""); - } - } - if let Some(e) = extra { - p.push_str(&extra_props(e, f.size)); - } - if f.sup { - p.push_str(" style:text-position=\"super 58%\""); - } else if f.sub { - p.push_str(" style:text-position=\"sub 58%\""); - } - if let Some(c) = f.color { - p.push_str(&format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase())); - } - if let Some(c) = f.background { - p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())); - } - if let Some(s) = f.size { - p.push_str(&format!(" fo:font-size=\"{}\"", pt(s))); - } - if f.code { - p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT)); - } else if let Some(fam) = &f.font { - p.push_str(&format!(" fo:font-family=\"'{}'\"", x(&fam.replace('\'', "")))); - } - p -} - -/// `style:paragraph-properties` attributes for the spacing, indents and line spacing `sp` sets -/// (ODF 1.3 §20.204–209, §20.225, §20.317). -pub(super) fn spacing_props(sp: &model::Spacing) -> String { - let mut p = String::new(); - for (k, v) in [ - ("fo:margin-top", sp.before), - ("fo:margin-bottom", sp.after), - ("fo:margin-left", sp.left), - ("fo:margin-right", sp.right), - ("fo:text-indent", sp.first), - ] { - if let Some(v) = v.filter(|v| v.is_finite()) { - p.push_str(&format!(" {k}=\"{}\"", pt(v))); - } - } - match sp.line { - Some(LineSpacing::Multiple(m)) if m.is_finite() => p.push_str(&format!(" fo:line-height=\"{}%\"", (m * 100.0).round().max(1.0))), - Some(LineSpacing::Exactly(h)) if h.is_finite() => p.push_str(&format!(" fo:line-height=\"{}\"", pt(h))), - Some(LineSpacing::AtLeast(h)) if h.is_finite() => p.push_str(&format!(" style:line-height-at-least=\"{}\"", pt(h))), - _ => {} - } - p -} - -/// The `office:name` of comment `id` written by a writer with names prefixed `prefix`. -fn annotation_name(prefix: &str, id: u32) -> String { - format!("__Annotation__{prefix}{id}") -} - -/// `style:tab-stops` (ODF 1.3 §17.7) for `tabs`, measured from the margin (the package's -/// settings say so); bar and clearing stops have no ODF form and are left out. -pub(super) fn tab_stops(tabs: &[wordcraft_doc::props::TabStop]) -> String { - use wordcraft_doc::props::{TabAlign, TabLeader}; - let mut s = String::from(""); - for t in tabs.iter().filter(|t| t.pos.is_finite()) { - let kind = match t.align { - TabAlign::Left => "", - TabAlign::Center => " style:type=\"center\"", - TabAlign::Right => " style:type=\"right\"", - TabAlign::Decimal => " style:type=\"char\" style:char=\".\"", - TabAlign::Bar | TabAlign::Clear => continue, - }; - let leader = match t.leader { - TabLeader::None => "", - TabLeader::Dot => " style:leader-style=\"dotted\" style:leader-text=\".\"", - TabLeader::Hyphen => " style:leader-style=\"solid\" style:leader-text=\"-\"", - TabLeader::Underscore => " style:leader-style=\"solid\" style:leader-text=\"_\"", - TabLeader::MiddleDot => " style:leader-style=\"dotted\" style:leader-text=\"·\"", - }; - s.push_str(&format!("", pt(t.pos))); - } - s.push_str(""); - s -} - -/// An ODF style name (an XML NCName, ODF 1.3 §19.75) for document style id `id`: letters, -/// digits and `-`, `.` kept, anything else as `_xHH_` (its code point in hex), and a leading -/// digit, `-` or `.` escaped too. -pub(super) fn odf_name(id: &str) -> String { - let mut out = String::new(); - for (i, c) in id.chars().enumerate() { - let ok = c.is_alphanumeric() || (i > 0 && (c == '-' || c == '.' || c.is_ascii_digit())); - if ok && !(i == 0 && c.is_ascii_digit()) { - out.push(c); - } else { - out.push_str(&format!("_x{:X}_", c as u32)); - } - } - if out.is_empty() { "Custom".into() } else { out } -} - -/// `fo:border-*` and `fo:padding-*` attributes for paragraph borders `b`, when a side shows. -pub(super) fn border_attrs(b: Option) -> String { - let mut p = String::new(); - let Some(b) = b.filter(|b| b.any_visible()) else { return p }; - for (side, line) in [("top", b.top), ("left", b.left), ("bottom", b.bottom), ("right", b.right)] { - let shown = line.filter(|l| l.is_visible()); - p.push_str(&format!(" fo:border-{side}=\"{}\"", odf_border(shown))); - if let Some(l) = shown { - p.push_str(&format!(" fo:padding-{side}=\"{}\"", pt(l.space.clamp(0.0, 31.0)))); - } - } - p -} - -/// Word's default cell margins (top, left, bottom, right), points. -const WORD_CELL_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4]; - -/// The cell of `row` that starts at grid column `g`. -fn cell_starting_at(row: &[model::Cell], g: usize) -> Option<&model::Cell> { - let mut s = 0usize; - row.iter().find(|x| { - let here = s == g; - s += x.colspan.clamp(1, 63) as usize; - here - }) -} - -/// A text box frame's graphic properties for `look`: its line (§20.183 `fo:border`), padding -/// (§20.210 `fo:padding-*`) and fill, both ways (LibreOffice reads `draw:fill`, and fills a -/// frame without it). -fn box_look_attrs(look: &model::BoxLook, defs: &mut super::strokes::DrawDefs, prefix: &str) -> String { - let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); - let mut a = match look.line { - Some((c, w)) if w.is_finite() && w > 0.0 => format!(" fo:border=\"{} solid {}\"", pt(w.min(12.0)), hex(c)), - _ => " fo:border=\"none\"".to_string(), - }; - let [l, t, r, b] = look.inset.unwrap_or(DEFAULT_BOX_INSET).map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }); - a.push_str(&format!( - " fo:padding-left=\"{}\" fo:padding-top=\"{}\" fo:padding-right=\"{}\" fo:padding-bottom=\"{}\"", - pt(l), - pt(t), - pt(r), - pt(b) - )); - a.push_str(&fill_attrs(look.fill.as_ref(), true, defs, prefix)); - a -} - -/// The graphic properties of fill `fill` (§20.118 `draw:fill`, §20.119 `draw:fill-color`, -/// §20.120 `draw:fill-gradient-name`; a frame's `fo:background-color` too): a gradient by the -/// name of its definition, with its main colour for readers that draw none. -fn fill_attrs(fill: Option<&wordcraft_doc::fill::Fill>, frame: bool, defs: &mut super::strokes::DrawDefs, prefix: &str) -> String { - use wordcraft_doc::fill::Fill; - let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); - let background = |c: &str| if frame { format!(" fo:background-color=\"{c}\"") } else { String::new() }; - match fill { - Some(f @ Fill::Gradient(g)) => { - let main = hex(f.main_color()); - match defs.gradient(g, prefix) { - Some(name) => format!("{} draw:fill=\"gradient\" draw:fill-gradient-name=\"{name}\" draw:fill-color=\"{main}\"", background(&main)), - None => format!("{} draw:fill=\"solid\" draw:fill-color=\"{main}\"", background(&main)), - } - } - Some(f) => { - let c = hex(f.main_color()); - format!("{} draw:fill=\"solid\" draw:fill-color=\"{c}\"", background(&c)) - } - None => format!("{} draw:fill=\"none\"", background("transparent")), - } -} - -/// Where a frame or shape is written. -#[derive(Clone, Copy)] -enum Place<'a> { - /// In the text: anchored and placed as its float says, in line without one. - Text(Option<&'a wordcraft_doc::para::Float>), - /// A member of a group, at (x, y) from where the group is anchored; its float turns or - /// mirrors it. - Member(f32, f32, Option<&'a wordcraft_doc::para::Float>), -} - -impl<'a> Place<'a> { - /// What turns or mirrors the object, and places it in the text. - fn float(self) -> Option<&'a wordcraft_doc::para::Float> { - match self { - Place::Text(f) | Place::Member(_, _, f) => f, - } - } - - /// Its top left corner (unturned) from where it is anchored, points. - fn origin(self) -> (f32, f32) { - let off = |v: f32, aligned: bool| if aligned || !v.is_finite() { 0.0 } else { v.clamp(-1584.0, 1584.0) }; - match self { - Place::Member(x, y, _) => (off(x, false), off(y, false)), - Place::Text(f) => f - .filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline) - .map_or((0.0, 0.0), |f| (off(f.x, f.h_align.is_some()), off(f.y, f.v_align.is_some()))), - } - } -} - -/// How a frame or shape `w` × `h` at `place` is anchored and placed: its `text:anchor-type` -/// attribute, its graphic style's placement and its `svg:x`/`svg:y` (or `draw:transform` when -/// it is turned). A group's member has no anchor of its own: it is placed by its position alone. -fn placement(place: Place<'_>, w: f32, h: f32) -> (String, String, String) { - let anchor = |a: &str| format!(" text:anchor-type=\"{a}\""); - match place { - Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => { - let (a, props, xy) = super::frames::frame_of(f, w, h); - (anchor(a), props, xy) - } - Place::Text(f) => { - let turn = f.filter(|f| super::frames::turned(f)).map(|f| super::frames::transform_attr(0.0, 0.0, w, h, f.rot)); - (anchor("as-char"), " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), turn.unwrap_or_default()) - } - Place::Member(_, _, f) => { - let (x, y) = place.origin(); - let xy = match f.filter(|f| super::frames::turned(f)) { - Some(f) => super::frames::transform_attr(x, y, w, h, f.rot), - None => format!(" svg:x=\"{}\" svg:y=\"{}\"", pt(x), pt(y)), - }; - (String::new(), String::new(), xy) - } - } -} - -/// A drawing shape's graphic properties for `look`: its line (§20.167 `draw:stroke`, -/// `svg:stroke-color`, `svg:stroke-width`), fill (§20.118 `draw:fill`, `draw:fill-color`), -/// whether it grows to its text (§20.105 `draw:auto-grow-height`) and the padding around its -/// text. -fn shape_look_attrs(look: &model::BoxLook, defs: &mut super::strokes::DrawDefs, prefix: &str) -> String { - let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); - let mut a = match look.line { - Some((c, w)) if w.is_finite() && w > 0.0 => { - let w = w.min(12.0); - let mut a = match defs.dash(&look.dash, prefix) { - Some(name) => format!(" draw:stroke=\"dash\" draw:stroke-dash=\"{name}\""), - None => " draw:stroke=\"solid\"".to_string(), - }; - a.push_str(&format!(" svg:stroke-color=\"{}\" svg:stroke-width=\"{}\"", hex(c), pt(w))); - // Arrowheads (§20.164 `draw:marker-start`, §20.162 `draw:marker-end`). - for (on, end) in [(look.arrows.0, "start"), (look.arrows.1, "end")] { - if on { - let marker = defs.arrow(); - a.push_str(&format!(" draw:marker-{end}=\"{marker}\" draw:marker-{end}-width=\"{}\"", super::strokes::arrow_width(w))); - } - } - a - } - _ => " draw:stroke=\"none\"".to_string(), - }; - a.push_str(&fill_attrs(look.fill.as_ref(), false, defs, prefix)); - // Its size is its own unless it resizes to fit its text (LibreOffice otherwise grows and - // shrinks a shape with text to it). - a.push_str(&format!(" draw:auto-grow-height=\"{}\" draw:auto-grow-width=\"false\"", look.auto_fit)); - if let Some(inset) = look.inset { - let [l, t, r, b] = inset.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }); - a.push_str(&format!( - " fo:padding-left=\"{}\" fo:padding-top=\"{}\" fo:padding-right=\"{}\" fo:padding-bottom=\"{}\"", - pt(l), - pt(t), - pt(r), - pt(b) - )); - } - a -} - -/// A text box's default inner margins (left, top, right, bottom), points: WordCraft's and -/// Word's (0.1" and 0.05"). -const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6]; - -/// An ODF border value (ODF 1.3 §20.183) for `b`; `none` for no line. -fn odf_border(b: Option) -> String { - let Some(b) = b.filter(|b| b.is_visible() && b.width.is_finite()) else { return "none".into() }; - let style = match b.style { - BorderStyle::Double | BorderStyle::Triple => "double", - BorderStyle::Dotted => "dotted", - BorderStyle::Dashed => "dashed", - BorderStyle::DotDash => "dot-dash", - _ => "solid", - }; - let color = b.color.unwrap_or(Rgb(0, 0, 0)).hex().to_ascii_lowercase(); - format!("{} {style} #{color}", pt(b.width.clamp(0.05, 12.0))) -} - -/// The `style:table-cell-properties` attributes of cell `c` of row `ri` of `t`, starting at grid -/// column `g0` of `cols`: its padding, shading, vertical alignment and borders. Each edge two -/// cells share is written once, as LibreOffice writes it (every cell its left and bottom, the -/// first row its top, the last column its right), so lines don't double; a side the cell -/// doesn't state gets the table's grid line, or none in a table without lines. -fn cell_props(t: &FTable, ri: usize, g0: usize, c: &model::Cell, cols: usize) -> String { - let grid = (!t.borderless).then_some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0, 0, 0)), space: 0.0 }); - let line = |c: &model::Cell, pick: fn(&Borders) -> Option| match c.borders.as_ref().and_then(pick) { - Some(b) => Some(b).filter(|b| b.is_visible()), - None => grid, - }; - let row = t.rows.get(ri).map(Vec::as_slice).unwrap_or(&[]); - let left_cell = g0.checked_sub(1).and_then(|g| { - let mut s = 0usize; - row.iter().find(|x| { - let end = s + x.colspan.clamp(1, 63) as usize; - let hit = s <= g && g < end; - s = end; - hit - }) - }); - let below = t.rows.get(ri + 1).and_then(|r| cell_starting_at(r, g0)); - let left = line(c, |b| b.left).or_else(|| left_cell.and_then(|l| line(l, |b| b.right))); - let bottom = line(c, |b| b.bottom).or_else(|| below.and_then(|b| line(b, |x| x.top))); - let top = if ri == 0 { line(c, |b| b.top) } else { None }; - let right = if g0 + c.colspan.clamp(1, 63) as usize >= cols { line(c, |b| b.right) } else { None }; - let m = c.margins.filter(|m| m.iter().all(|v| v.is_finite())).unwrap_or(WORD_CELL_MARGINS); - let mut p = format!( - " fo:padding-top=\"{}\" fo:padding-left=\"{}\" fo:padding-bottom=\"{}\" fo:padding-right=\"{}\"", - pt(m[0].max(0.0)), - pt(m[1].max(0.0)), - pt(m[2].max(0.0)), - pt(m[3].max(0.0)) - ); - p.push_str(&format!( - " fo:border-left=\"{}\" fo:border-right=\"{}\" fo:border-top=\"{}\" fo:border-bottom=\"{}\"", - odf_border(left), - odf_border(right), - odf_border(top), - odf_border(bottom) - )); - if let Some(bg) = c.shading { - p.push_str(&format!(" fo:background-color=\"#{}\"", bg.hex().to_ascii_lowercase())); - } - p.push_str(match c.valign { - Some(VAlign::Top) => " style:vertical-align=\"top\"", - Some(VAlign::Center) => " style:vertical-align=\"middle\"", - Some(VAlign::Bottom) => " style:vertical-align=\"bottom\"", - None => "", - }); - p -} - -fn parent_style(k: Kind) -> &'static str { - match k { - Kind::Heading(1) => "Heading_20_1", - Kind::Heading(2) => "Heading_20_2", - Kind::Heading(3) => "Heading_20_3", - Kind::Heading(4) => "Heading_20_4", - Kind::Heading(5) => "Heading_20_5", - Kind::Heading(_) => "Heading_20_6", - Kind::Title => "Title", - Kind::Quote => "Quotations", - Kind::Code => "Preformatted_20_Text", - Kind::Normal | Kind::Rule => "Standard", - } -} - impl Writer { /// A writer whose names all start with `prefix`. pub(super) fn with_prefix(prefix: &'static str) -> Self { @@ -741,125 +233,6 @@ impl Writer { n } - fn picture(&mut self, img: &model::Img) -> String { - let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() }; - let name = format!("Pictures/{}image{}.{}", self.prefix, self.pictures.len() + 1, ext); - self.pictures.push((name.clone(), img.data.clone(), mime_of(&img.ext).to_string())); - name - } - - /// A new `text:changed-region` of `kind` by `m` with `body` after its change info; its id. - fn region(&mut self, kind: &str, m: &RevMark, body: &str) -> String { - let id = format!("ct{}", self.regions.len() + 1); - // `dc:date` is required: a change without a date is dated now. - let date = if m.date.trim().is_empty() { now_iso() } else { m.date.clone() }; - self.regions.push(format!( - "{}{}{body}", - x(&m.author), - x(&date) - )); - id - } - - /// Make `m` the insertion open in `st` (closing another one first). - fn ins_open(&mut self, st: &mut Option<(RevMark, String)>, m: &RevMark, out: &mut String) { - if st.as_ref().is_some_and(|(o, _)| o == m) { - return; - } - ins_close(st, out); - let ct = self.region("insertion", m, ""); - out.push_str(&format!("")); - *st = Some((m.clone(), ct)); - } - - fn open_ins(&mut self, m: &RevMark, out: &mut String) { - let mut st = self.open_ins.take(); - self.ins_open(&mut st, m, out); - self.open_ins = st; - } - - pub(super) fn close_ins(&mut self, out: &mut String) { - ins_close(&mut self.open_ins, out); - } - - /// Inlines with their insertions as change ranges kept in `st`. - fn ins_inlines(&mut self, items: &[Item<'_>], st: &mut Option<(RevMark, String)>, out: &mut String) { - let mut k = 0; - while k < items.len() { - let ins = items.get(k).and_then(|i| i.marks().0); - let mut j = k + 1; - while j < items.len() && items.get(j).and_then(|i| i.marks().0) == ins { - j += 1; - } - match ins { - Some(m) => self.ins_open(st, m, out), - None => ins_close(st, out), - } - let seg: Vec = items.get(k..j).unwrap_or(&[]).iter().filter_map(|i| i.inline().cloned()).collect(); - self.plain_inlines(&seg, out); - k = j; - } - } - - /// Deleted content: a region holding it (one paragraph per deleted paragraph mark plus - /// one; text inserted before it was deleted as change ranges inside) and a `text:change` - /// point in the body. - fn deletion(&mut self, seg: &[Item<'_>], m: &RevMark, out: &mut String) { - let mut body = String::new(); - let mut st: Option<(RevMark, String)> = None; - let mut start = 0; - let mut first = true; - loop { - let end = seg.iter().skip(start).position(|i| matches!(i, Item::Mark(_))).map(|e| start + e); - if !first { - self.part += 1; - } - first = false; - let style = self.chain.get(self.part).cloned().unwrap_or_else(|| self.cur_style.clone()); - body.push_str(&format!("")); - self.ins_inlines(seg.get(start..end.unwrap_or(seg.len())).unwrap_or(&[]), &mut st, &mut body); - // A deleted paragraph mark that had been inserted: the range runs on past it. - match end.and_then(|e| seg.get(e)).and_then(|i| i.marks().0) { - Some(ins) => self.ins_open(&mut st, ins, &mut body), - None => ins_close(&mut st, &mut body), - } - body.push_str(""); - match end { - Some(e) => start = e + 1, - None => break, - } - } - let ct = self.region("deletion", m, &body); - out.push_str(&format!("")); - } - - /// Paragraph content, with tracked insertions as change ranges and deletions as change points. - fn items(&mut self, items: &[Item<'_>], out: &mut String) { - let mut k = 0; - while k < items.len() { - let key = items.get(k).map(Item::segment).unwrap_or_default(); - let mut j = k + 1; - while j < items.len() && items.get(j).map(Item::segment).unwrap_or_default() == key { - j += 1; - } - let seg = items.get(k..j).unwrap_or(&[]); - match key { - (_, Some(del)) => self.deletion(seg, del, out), - (Some(ins), None) => { - self.open_ins(ins, out); - let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); - self.plain_inlines(&inl, out); - } - (None, None) => { - self.close_ins(out); - let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); - self.plain_inlines(&inl, out); - } - } - k = j; - } - } - fn plain_inlines(&mut self, inl: &[Inline], out: &mut String) { let mut prev_space = true; // ODF has no ruby: its base text goes through the text path below. @@ -972,270 +345,6 @@ impl Writer { } } - /// A comment (ODF 1.3 §14.1): named, so its `office:annotation-end` closes its range, or for - /// a reply naming the comment it answers (LibreOffice's `loext:parent-name`); its author, - /// date, initials, resolved state (`loext:resolved`) and text. - fn comment(&mut self, id: u32, c: &model::FComment, out: &mut String) { - let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); - self.close_ins(out); - let mut attrs = match c.parent { - Some(p) => format!(" loext:parent-name=\"{}\"", annotation_name(self.prefix, p)), - None => format!(" office:name=\"{}\"", annotation_name(self.prefix, id)), - }; - if c.resolved { - attrs.push_str(" loext:resolved=\"true\""); - } - let mut head = String::new(); - if !c.author.is_empty() { - head.push_str(&format!("{}", x(&c.author))); - } - if !c.date.is_empty() { - head.push_str(&format!("{}", x(&c.date))); - } - if !c.initials.is_empty() { - head.push_str(&format!("{}", x(&c.initials))); - } - let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); - let mut body = String::new(); - // An annotation holds paragraphs and lists only: a table's text goes in as paragraphs. - self.blocks(&model::flatten_tables(&c.blocks), &mut body, 1); - self.close_ins(&mut body); - (self.cur_style, self.chain, self.part, self.note_parent) = saved; - out.push_str(&format!("{head}{body}")); - if let Some(m) = reopen { - self.open_ins(&m, out); - } - } - - /// A text box or another drawing shape at `place`. - fn drawing_shape( - &mut self, - kind: wordcraft_doc::para::ShapeKind, - size: (f32, f32), - place: Place<'_>, - (look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>), - blocks: &[FBlock], - out: &mut String, - ) { - if kind == wordcraft_doc::para::ShapeKind::TextBox { - self.text_box(size, place, look, blocks, out); - } else { - self.shape(kind, size, place, (look, path), blocks, out); - } - } - - /// A picture (ODF 1.3 §10.4.4 `draw:image` in a `draw:frame`) at `place`: a floating one or a - /// group's in a graphic style that places and wraps it. - fn image(&mut self, img: &model::Img, place: Place<'_>, out: &mut String) { - let href = self.picture(img); - let n = self.pictures.len(); - let (anchor, style) = match place { - Place::Text(None) => (" text:anchor-type=\"as-char\"".to_string(), String::new()), - _ => { - let (anchor, props, xy) = placement(place, img.w, img.h); - (anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props))) - } - }; - out.push_str(&format!( - "", - self.prefix, - pt(img.w), - pt(img.h) - )); - if !img.alt.is_empty() { - out.push_str(&format!("{}", x(&img.alt))); - } - out.push_str(""); - } - - /// A group (ODF 1.3 §10.3.15 `draw:g`) of `size`: anchored and wrapping as its float says, in - /// line without one; its members at their offsets from its corner. - fn group(&mut self, size: (f32, f32), float: Option<&wordcraft_doc::para::Float>, members: &[model::Member], out: &mut String) { - let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); - self.close_ins(out); - // The group isn't turned: its members are placed by their own positions. - let (anchor, props, _) = placement(Place::Text(float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline)), size.0, size.1); - let style = self.graphic_style(props); - self.boxes += 1; - out.push_str(&format!("", self.prefix, self.boxes)); - let at = Place::Text(float).origin(); - self.members(members, at, 0, out); - out.push_str(""); - if let Some(m) = reopen { - self.open_ins(&m, out); - } - } - - /// A group's `members`, its corner at `at` from where it is anchored (a group in it `depth` - /// deep). - fn members(&mut self, members: &[model::Member], at: (f32, f32), depth: usize, out: &mut String) { - for m in members { - let (x, y) = (at.0 + m.x, at.1 + m.y); - match &m.inline { - Inline::Shape { kind, w, h, float, blocks, look, path } => { - self.drawing_shape(*kind, (*w, *h), Place::Member(x, y, float.as_deref()), (look, path.as_deref()), blocks, out) - } - Inline::Image(img) => self.image(img, Place::Member(x, y, img.float.as_deref()), out), - Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => { - out.push_str(""); - self.members(members, (x, y), depth + 1, out); - out.push_str(""); - } - _ => {} - } - } - } - - /// A text box (ODF 1.3 §10.4.3 `draw:text-box` in a `draw:frame`) of `size` (points): in line - /// with the text, or anchored and placed as it floats, with its fill, line and padding. - fn text_box(&mut self, size: (f32, f32), place: Place<'_>, look: &model::BoxLook, blocks: &[FBlock], out: &mut String) { - let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); - self.close_ins(out); - let frame = box_look_attrs(look, &mut self.draw_defs, self.prefix); - let (anchor, props, xy) = placement(place, size.0, size.1); - let style = self.graphic_style(format!("{props}{frame}")); - let mut body = self.story(blocks); - if body.is_empty() { - body.push_str(""); - } - self.boxes += 1; - let fit = |v: f32| if v.is_finite() { v.clamp(1.0, 1584.0) } else { 72.0 }; - // A box growing to its text has a least height (§20.207 `fo:min-height`), not a height. - let (height, min) = if look.auto_fit { - (String::new(), format!(" fo:min-height=\"{}\"", pt(fit(size.1)))) - } else { - (format!(" svg:height=\"{}\"", pt(fit(size.1))), String::new()) - }; - out.push_str(&format!( - "{body}", - self.prefix, - self.boxes, - pt(fit(size.0)), - )); - if let Some(m) = reopen { - self.open_ins(&m, out); - } - } - - /// A drawing shape (ODF 1.3 §10.3) of `size` (points): a rectangle, ellipse or line, or a - /// custom shape of a predefined geometry, in line with the text or anchored and placed as it - /// floats, with its fill, line and text. - fn shape( - &mut self, - kind: wordcraft_doc::para::ShapeKind, - size: (f32, f32), - place: Place<'_>, - (look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>), - blocks: &[FBlock], - out: &mut String, - ) { - let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); - self.close_ins(out); - let float = place.float(); - let (anchor, props, xy) = placement(place, size.0, size.1); - let look_attrs = shape_look_attrs(look, &mut self.draw_defs, self.prefix); - let style = self.graphic_style(format!("{props}{look_attrs}")); - let body = self.story(blocks); - self.boxes += 1; - let (element, geometry) = match super::shapes::element_of(kind) { - // A freeform without paths has nothing to draw but its frame. - ("draw:path", _) if path.is_none() => ("draw:rect", None), - e => e, - }; - let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 }; - let at = if element == "draw:line" { - { - // A line by its ends: from its box's top left to its bottom right, or bottom left - // to top right when it rises. - let (w, h) = (len(size.0, 0.0), len(size.1, 0.0)); - let (x, y) = place.origin(); - // From its start to its end: mirrored, it starts on the right or at the bottom. - let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v)); - let ((x1, x2), (y1, y2)) = (if fh { (x + w, x) } else { (x, x + w) }, if fv { (y + h, y) } else { (y, y + h) }); - // Turned about its centre: its ends turned with it. - let (cx, cy) = (x + w / 2.0, y + h / 2.0); - let (sin, cos) = float.filter(|f| super::frames::turned(f)).map_or(0.0, |f| f.rot.to_radians()).sin_cos(); - let turn = |px: f32, py: f32| (cx + (px - cx) * cos - (py - cy) * sin, cy + (px - cx) * sin + (py - cy) * cos); - let ((x1, y1), (x2, y2)) = (turn(x1, y1), turn(x2, y2)); - format!(" svg:x1=\"{}\" svg:y1=\"{}\" svg:x2=\"{}\" svg:y2=\"{}\"", pt(x1), pt(y1), pt(x2), pt(y2)) - } - } else { - let (w, h) = (len(size.0, 1.0), len(size.1, 1.0)); - let mut at = format!(" svg:width=\"{}\" svg:height=\"{}\"{xy}", pt(w), pt(h)); - if let Some(f) = path.filter(|_| element == "draw:path") { - // Its paths in a view box of hundredths of a point (whole numbers, §19.570). - let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32); - at.push_str(&format!(" svg:viewBox=\"0 0 {vw} {vh}\" svg:d=\"{}\"", super::path::path_data(f, vw, vh))); - } - at - }; - // Mirrored, as a custom shape's geometry says (§20.112, §20.113). - let mirror = |on: bool, k: &str| if on { format!(" draw:mirror-{k}=\"true\"") } else { String::new() }; - let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v)); - let geometry = geometry - .map(|t| { - format!( - "", - mirror(fh, "horizontal"), - mirror(fv, "vertical") - ) - }) - .unwrap_or_default(); - out.push_str(&format!( - "<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at} draw:z-index=\"0\">{body}{geometry}", - self.prefix, self.boxes, - )); - if let Some(m) = reopen { - self.open_ins(&m, out); - } - } - - /// The paragraphs of a text box's or shape's text, written apart from the paragraph being - /// written. - fn story(&mut self, blocks: &[FBlock]) -> String { - let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); - let mut body = String::new(); - self.blocks(blocks, &mut body, 1); - self.close_ins(&mut body); - (self.cur_style, self.chain, self.part, self.note_parent) = saved; - body - } - - /// A footnote or endnote (ODF 1.3 §6.3): its citation (the number LibreOffice shows until it - /// renumbers, or the custom mark) and its content. The paragraph being written is put aside - /// meanwhile, and an insertion open around the note ends before it and goes on after it. - fn note(&mut self, endnote: bool, custom: &str, blocks: &[FBlock], out: &mut String) { - let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); - self.close_ins(out); - self.notes += 1; - let id = format!("{}ftn{}", self.prefix, self.notes); - let (class, parent) = if endnote { ("endnote", "Endnote") } else { ("footnote", "Footnote") }; - let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.replace(parent)); - let mut body = String::new(); - self.blocks(blocks, &mut body, 1); - self.close_ins(&mut body); - (self.cur_style, self.chain, self.part, self.note_parent) = saved; - if body.is_empty() { - body.push_str(&format!("")); - } - // The number LibreOffice shows by default (footnotes 1, 2, 3; endnotes i, ii, iii) until it - // renumbers them itself. - let number = if endnote { - self.endnotes = self.endnotes.saturating_add(1); - wordcraft_doc::section::roman(self.endnotes) - } else { - self.footnotes = self.footnotes.saturating_add(1); - self.footnotes.to_string() - }; - let (label, citation) = if custom.is_empty() { (String::new(), number) } else { (format!(" text:label=\"{}\"", x(custom)), x(custom)) }; - out.push_str(&format!( - "{citation}{body}" - )); - if let Some(m) = reopen { - self.open_ins(&m, out); - } - } - /// Paragraph `p`, or with `chain` the paragraphs joined at deleted paragraph marks (`p` /// is the first). fn para_in(&mut self, p: &Para, chain: Option<&Vec>, out: &mut String) { @@ -1395,112 +504,6 @@ impl Writer { } } - /// An equation as an embedded formula object (ODF 1.3 §10.4.3): a frame in line with the text - /// pointing at a sub-document whose `content.xml` is the equation's MathML, its frame about - /// the size the formula takes (LibreOffice scales the formula to the frame). - fn formula(&mut self, linear: &str, display: bool, out: &mut String) { - let n = self.formulas.len() + 1; - let name = format!("Object {}{n}", self.prefix); - let mathml = crate::mathml::linear_to_mathml(linear, display); - self.formulas.push((name.clone(), format!("\n{mathml}\n"))); - let (w, h) = super::formula_size::size(linear); - // Centred on the line, as LibreOffice sets its own formulas. - let style = self.graphic_style(" style:vertical-pos=\"middle\" style:vertical-rel=\"text\"".into()); - out.push_str(&format!( - "{}", - self.prefix, - pt(w), - pt(h), - x(&name), - x(linear) - )); - } - - /// The automatic graphic style of a frame placed by `props`. - fn graphic_style(&mut self, props: String) -> String { - let k = match self.graphic_styles.iter().position(|s| *s == props) { - Some(k) => k, - None => { - self.graphic_styles.push(props); - self.graphic_styles.len() - 1 - } - }; - format!("{}fr{}", self.prefix, k + 1) - } - - /// The automatic style of a cell with `props` (its `style:table-cell-properties` attributes). - fn cell_style(&mut self, props: String) -> String { - let k = match self.cell_styles.iter().position(|s| *s == props) { - Some(k) => k, - None => { - self.cell_styles.push(props); - self.cell_styles.len() - 1 - } - }; - format!("{}Cell{}", self.prefix, k + 1) - } - - fn table(&mut self, t: &FTable, out: &mut String, depth: usize) { - self.close_ins(out); - let cols = t.cols().max(1); - let widths: Vec = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 0.0) { - t.widths.clone() - } else { - vec![468.0 / cols as f32; cols] - }; - self.tables.push(widths); - self.table_starts.push(self.master.take()); - let tn = format!("{}{}", self.prefix, self.tables.len()); - out.push_str(&format!("")); - for c in 0..cols { - out.push_str(&format!("", c + 1)); - } - let header_rows = t.rows.iter().take_while(|r| !r.is_empty() && r.iter().all(|c| c.header)).count(); - for (ri, row) in t.rows.iter().enumerate() { - if ri == 0 && header_rows > 0 { - out.push_str(""); - } - out.push_str(""); - let mut g = 0usize; - for c in row { - let span = c.colspan.clamp(1, 63) as usize; - g += span; - if c.covered { - for _ in 0..span { - out.push_str(""); - } - continue; - } - let st = self.cell_style(cell_props(t, ri, g - span, c, cols)); - let mut attrs = format!(" table:style-name=\"{st}\" office:value-type=\"string\""); - if span > 1 { - attrs.push_str(&format!(" table:number-columns-spanned=\"{span}\"")); - } - if c.rowspan > 1 { - attrs.push_str(&format!(" table:number-rows-spanned=\"{}\"", c.rowspan)); - } - out.push_str(&format!("")); - if c.blocks.is_empty() { - out.push_str(""); - } - self.blocks(&c.blocks, out, depth + 1); - self.close_ins(out); - out.push_str(""); - for _ in 1..span { - out.push_str(""); - } - } - for _ in g..cols { - out.push_str(""); - } - out.push_str(""); - if ri + 1 == header_rows { - out.push_str(""); - } - } - out.push_str("\n"); - } - pub(super) fn automatic_styles(&self) -> String { format!("{}", self.style_defs()) } @@ -1612,105 +615,3 @@ impl Writer { s } } - -/// One piece of a paragraph being written: an inline, or the deleted mark of a paragraph -/// joined to the next one. -enum Item<'a> { - Inl(&'a Inline), - Mark(&'a Para), -} - -impl Item<'_> { - /// Its tracked insertion and deletion. - fn marks(&self) -> (Option<&RevMark>, Option<&RevMark>) { - match self { - Item::Inl(Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } | Inline::Field { fmt: f, .. }) => (f.ins.as_ref(), f.del.as_ref()), - Item::Inl(_) => (None, None), - Item::Mark(p) => (p.mark_ins.as_deref(), p.mark_del.as_deref()), - } - } - - /// What a run of content is written as: deleted content is one region whatever was - /// inserted inside it, so only its deletion counts. - fn segment(&self) -> (Option<&RevMark>, Option<&RevMark>) { - match self.marks() { - (_, Some(del)) => (None, Some(del)), - k => k, - } - } - - fn inline(&self) -> Option<&Inline> { - match self { - Item::Inl(i) => Some(i), - Item::Mark(_) => None, - } - } -} - -/// Close the insertion open in `st`. -fn ins_close(st: &mut Option<(RevMark, String)>, out: &mut String) { - if let Some((_, ct)) = st.take() { - out.push_str(&format!("")); - } -} - -/// The blocks after joining at deleted marks, and each joined run's paragraphs by the first one's index. -type JoinedMarks = (Vec, HashMap>); - -/// `blocks` with each run of paragraphs joined at tracked deleted paragraph marks (ODF keeps -/// such a deletion inside one body paragraph): the first paragraph stands for the run, and the -/// whole run is returned by its index. `None` when nothing is joined. A deleted mark with no -/// paragraph after it (before a table, at the end) is written as a plain mark. -fn join_deleted_marks(blocks: &[FBlock]) -> Option { - if !blocks.iter().any(|b| matches!(b, FBlock::Para(p) if p.mark_del.is_some())) { - return None; - } - let mut out = Vec::with_capacity(blocks.len()); - let mut chains = HashMap::new(); - let mut i = 0; - while let Some(b) = blocks.get(i) { - i += 1; - let FBlock::Para(p) = b else { - out.push(b.clone()); - continue; - }; - let mut chain = vec![p.clone()]; - while chain.last().is_some_and(|q| q.mark_del.is_some()) - && let Some(FBlock::Para(q)) = blocks.get(i) - { - chain.push(q.clone()); - i += 1; - } - if chain.len() > 1 { - chains.insert(out.len(), chain); - } - out.push(FBlock::Para(p.clone())); - } - Some((out, chains)) -} - -/// Now, as an `xsd:dateTime` (UTC). -fn now_iso() -> String { - let t = now_unix(); - let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); - // Reuse the crate's 400-year-cycle converter (chrono within a cycle, cycles carried in the - // year) so a clock past chrono's range keeps the proleptic Gregorian date the old hand-rolled - // code gave, never the epoch. `{y:04}` rather than `%Y`, which writes a `+` before year 10000. - let (y, m, d) = crate::sheet::civil(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) -} - -/// Seconds since the Unix epoch: the system clock, or the browser's on the web. -fn now_unix() -> i64 { - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()).unwrap_or(0) - } - // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. - #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] - { - let ms = js_sys::Date::now(); - if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as i64 } else { 0 } - } -} diff --git a/crates/formats/src/odt/writer/drawing.rs b/crates/formats/src/odt/writer/drawing.rs new file mode 100644 index 0000000000..e04f942b33 --- /dev/null +++ b/crates/formats/src/odt/writer/drawing.rs @@ -0,0 +1,396 @@ +//! Pictures, text boxes, drawing shapes, groups and formula objects (ODF 1.3 §10.3–10.4): where +//! each is anchored and placed, how it looks, and its content. + +use super::{FBlock, Inline, Rgb, Writer, mime_of, model, pt, x}; + +/// A text box frame's graphic properties for `look`: its line (§20.183 `fo:border`), padding +/// (§20.210 `fo:padding-*`) and fill, both ways (LibreOffice reads `draw:fill`, and fills a +/// frame without it). +pub(in crate::odt) fn box_look_attrs(look: &model::BoxLook, defs: &mut crate::odt::strokes::DrawDefs, prefix: &str) -> String { + let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); + let mut a = match look.line { + Some((c, w)) if w.is_finite() && w > 0.0 => format!(" fo:border=\"{} solid {}\"", pt(w.min(12.0)), hex(c)), + _ => " fo:border=\"none\"".to_string(), + }; + let [l, t, r, b] = look.inset.unwrap_or(DEFAULT_BOX_INSET).map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }); + a.push_str(&format!( + " fo:padding-left=\"{}\" fo:padding-top=\"{}\" fo:padding-right=\"{}\" fo:padding-bottom=\"{}\"", + pt(l), + pt(t), + pt(r), + pt(b) + )); + a.push_str(&fill_attrs(look.fill.as_ref(), true, defs, prefix)); + a +} + +/// The graphic properties of fill `fill` (§20.118 `draw:fill`, §20.119 `draw:fill-color`, +/// §20.120 `draw:fill-gradient-name`; a frame's `fo:background-color` too): a gradient by the +/// name of its definition, with its main colour for readers that draw none. +pub(in crate::odt) fn fill_attrs( + fill: Option<&wordcraft_doc::fill::Fill>, + frame: bool, + defs: &mut crate::odt::strokes::DrawDefs, + prefix: &str, +) -> String { + use wordcraft_doc::fill::Fill; + let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); + let background = |c: &str| if frame { format!(" fo:background-color=\"{c}\"") } else { String::new() }; + match fill { + Some(f @ Fill::Gradient(g)) => { + let main = hex(f.main_color()); + match defs.gradient(g, prefix) { + Some(name) => format!("{} draw:fill=\"gradient\" draw:fill-gradient-name=\"{name}\" draw:fill-color=\"{main}\"", background(&main)), + None => format!("{} draw:fill=\"solid\" draw:fill-color=\"{main}\"", background(&main)), + } + } + Some(f) => { + let c = hex(f.main_color()); + format!("{} draw:fill=\"solid\" draw:fill-color=\"{c}\"", background(&c)) + } + None => format!("{} draw:fill=\"none\"", background("transparent")), + } +} + +/// Where a frame or shape is written. +#[derive(Clone, Copy)] +pub(in crate::odt) enum Place<'a> { + /// In the text: anchored and placed as its float says, in line without one. + Text(Option<&'a wordcraft_doc::para::Float>), + /// A member of a group, at (x, y) from where the group is anchored; its float turns or + /// mirrors it. + Member(f32, f32, Option<&'a wordcraft_doc::para::Float>), +} + +impl<'a> Place<'a> { + /// What turns or mirrors the object, and places it in the text. + pub(in crate::odt) fn float(self) -> Option<&'a wordcraft_doc::para::Float> { + match self { + Place::Text(f) | Place::Member(_, _, f) => f, + } + } + + /// Its top left corner (unturned) from where it is anchored, points. + pub(in crate::odt) fn origin(self) -> (f32, f32) { + let off = |v: f32, aligned: bool| if aligned || !v.is_finite() { 0.0 } else { v.clamp(-1584.0, 1584.0) }; + match self { + Place::Member(x, y, _) => (off(x, false), off(y, false)), + Place::Text(f) => f + .filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline) + .map_or((0.0, 0.0), |f| (off(f.x, f.h_align.is_some()), off(f.y, f.v_align.is_some()))), + } + } +} + +/// How a frame or shape `w` × `h` at `place` is anchored and placed: its `text:anchor-type` +/// attribute, its graphic style's placement and its `svg:x`/`svg:y` (or `draw:transform` when +/// it is turned). A group's member has no anchor of its own: it is placed by its position alone. +pub(in crate::odt) fn placement(place: Place<'_>, w: f32, h: f32) -> (String, String, String) { + let anchor = |a: &str| format!(" text:anchor-type=\"{a}\""); + match place { + Place::Text(Some(f)) if f.wrap != wordcraft_doc::para::Wrap::Inline => { + let (a, props, xy) = crate::odt::frames::frame_of(f, w, h); + (anchor(a), props, xy) + } + Place::Text(f) => { + let turn = f.filter(|f| crate::odt::frames::turned(f)).map(|f| crate::odt::frames::transform_attr(0.0, 0.0, w, h, f.rot)); + (anchor("as-char"), " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), turn.unwrap_or_default()) + } + Place::Member(_, _, f) => { + let (x, y) = place.origin(); + let xy = match f.filter(|f| crate::odt::frames::turned(f)) { + Some(f) => crate::odt::frames::transform_attr(x, y, w, h, f.rot), + None => format!(" svg:x=\"{}\" svg:y=\"{}\"", pt(x), pt(y)), + }; + (String::new(), String::new(), xy) + } + } +} + +/// A drawing shape's graphic properties for `look`: its line (§20.167 `draw:stroke`, +/// `svg:stroke-color`, `svg:stroke-width`), fill (§20.118 `draw:fill`, `draw:fill-color`), +/// whether it grows to its text (§20.105 `draw:auto-grow-height`) and the padding around its +/// text. +pub(in crate::odt) fn shape_look_attrs(look: &model::BoxLook, defs: &mut crate::odt::strokes::DrawDefs, prefix: &str) -> String { + let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); + let mut a = match look.line { + Some((c, w)) if w.is_finite() && w > 0.0 => { + let w = w.min(12.0); + let mut a = match defs.dash(&look.dash, prefix) { + Some(name) => format!(" draw:stroke=\"dash\" draw:stroke-dash=\"{name}\""), + None => " draw:stroke=\"solid\"".to_string(), + }; + a.push_str(&format!(" svg:stroke-color=\"{}\" svg:stroke-width=\"{}\"", hex(c), pt(w))); + // Arrowheads (§20.164 `draw:marker-start`, §20.162 `draw:marker-end`). + for (on, end) in [(look.arrows.0, "start"), (look.arrows.1, "end")] { + if on { + let marker = defs.arrow(); + a.push_str(&format!(" draw:marker-{end}=\"{marker}\" draw:marker-{end}-width=\"{}\"", crate::odt::strokes::arrow_width(w))); + } + } + a + } + _ => " draw:stroke=\"none\"".to_string(), + }; + a.push_str(&fill_attrs(look.fill.as_ref(), false, defs, prefix)); + // Its size is its own unless it resizes to fit its text (LibreOffice otherwise grows and + // shrinks a shape with text to it). + a.push_str(&format!(" draw:auto-grow-height=\"{}\" draw:auto-grow-width=\"false\"", look.auto_fit)); + if let Some(inset) = look.inset { + let [l, t, r, b] = inset.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }); + a.push_str(&format!( + " fo:padding-left=\"{}\" fo:padding-top=\"{}\" fo:padding-right=\"{}\" fo:padding-bottom=\"{}\"", + pt(l), + pt(t), + pt(r), + pt(b) + )); + } + a +} + +/// A text box's default inner margins (left, top, right, bottom), points: WordCraft's and +/// Word's (0.1" and 0.05"). +pub(in crate::odt) const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6]; + +impl Writer { + pub(in crate::odt) fn picture(&mut self, img: &model::Img) -> String { + let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() }; + let name = format!("Pictures/{}image{}.{}", self.prefix, self.pictures.len() + 1, ext); + self.pictures.push((name.clone(), img.data.clone(), mime_of(&img.ext).to_string())); + name + } + + /// A text box or another drawing shape at `place`. + pub(in crate::odt) fn drawing_shape( + &mut self, + kind: wordcraft_doc::para::ShapeKind, + size: (f32, f32), + place: Place<'_>, + (look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>), + blocks: &[FBlock], + out: &mut String, + ) { + if kind == wordcraft_doc::para::ShapeKind::TextBox { + self.text_box(size, place, look, blocks, out); + } else { + self.shape(kind, size, place, (look, path), blocks, out); + } + } + + /// A picture (ODF 1.3 §10.4.4 `draw:image` in a `draw:frame`) at `place`: a floating one or a + /// group's in a graphic style that places and wraps it. + pub(in crate::odt) fn image(&mut self, img: &model::Img, place: Place<'_>, out: &mut String) { + let href = self.picture(img); + let n = self.pictures.len(); + let (anchor, style) = match place { + Place::Text(None) => (" text:anchor-type=\"as-char\"".to_string(), String::new()), + _ => { + let (anchor, props, xy) = placement(place, img.w, img.h); + (anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props))) + } + }; + out.push_str(&format!( + "", + self.prefix, + pt(img.w), + pt(img.h) + )); + if !img.alt.is_empty() { + out.push_str(&format!("{}", x(&img.alt))); + } + out.push_str(""); + } + + /// A group (ODF 1.3 §10.3.15 `draw:g`) of `size`: anchored and wrapping as its float says, in + /// line without one; its members at their offsets from its corner. + pub(in crate::odt) fn group( + &mut self, + size: (f32, f32), + float: Option<&wordcraft_doc::para::Float>, + members: &[model::Member], + out: &mut String, + ) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + // The group isn't turned: its members are placed by their own positions. + let (anchor, props, _) = placement(Place::Text(float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline)), size.0, size.1); + let style = self.graphic_style(props); + self.boxes += 1; + out.push_str(&format!("", self.prefix, self.boxes)); + let at = Place::Text(float).origin(); + self.members(members, at, 0, out); + out.push_str(""); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + + /// A group's `members`, its corner at `at` from where it is anchored (a group in it `depth` + /// deep). + pub(in crate::odt) fn members(&mut self, members: &[model::Member], at: (f32, f32), depth: usize, out: &mut String) { + for m in members { + let (x, y) = (at.0 + m.x, at.1 + m.y); + match &m.inline { + Inline::Shape { kind, w, h, float, blocks, look, path } => { + self.drawing_shape(*kind, (*w, *h), Place::Member(x, y, float.as_deref()), (look, path.as_deref()), blocks, out) + } + Inline::Image(img) => self.image(img, Place::Member(x, y, img.float.as_deref()), out), + Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => { + out.push_str(""); + self.members(members, (x, y), depth + 1, out); + out.push_str(""); + } + _ => {} + } + } + } + + /// A text box (ODF 1.3 §10.4.3 `draw:text-box` in a `draw:frame`) of `size` (points): in line + /// with the text, or anchored and placed as it floats, with its fill, line and padding. + pub(in crate::odt) fn text_box(&mut self, size: (f32, f32), place: Place<'_>, look: &model::BoxLook, blocks: &[FBlock], out: &mut String) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + let frame = box_look_attrs(look, &mut self.draw_defs, self.prefix); + let (anchor, props, xy) = placement(place, size.0, size.1); + let style = self.graphic_style(format!("{props}{frame}")); + let mut body = self.story(blocks); + if body.is_empty() { + body.push_str(""); + } + self.boxes += 1; + let fit = |v: f32| if v.is_finite() { v.clamp(1.0, 1584.0) } else { 72.0 }; + // A box growing to its text has a least height (§20.207 `fo:min-height`), not a height. + let (height, min) = if look.auto_fit { + (String::new(), format!(" fo:min-height=\"{}\"", pt(fit(size.1)))) + } else { + (format!(" svg:height=\"{}\"", pt(fit(size.1))), String::new()) + }; + out.push_str(&format!( + "{body}", + self.prefix, + self.boxes, + pt(fit(size.0)), + )); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + + /// A drawing shape (ODF 1.3 §10.3) of `size` (points): a rectangle, ellipse or line, or a + /// custom shape of a predefined geometry, in line with the text or anchored and placed as it + /// floats, with its fill, line and text. + pub(in crate::odt) fn shape( + &mut self, + kind: wordcraft_doc::para::ShapeKind, + size: (f32, f32), + place: Place<'_>, + (look, path): (&model::BoxLook, Option<&wordcraft_doc::freeform::Freeform>), + blocks: &[FBlock], + out: &mut String, + ) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + let float = place.float(); + let (anchor, props, xy) = placement(place, size.0, size.1); + let look_attrs = shape_look_attrs(look, &mut self.draw_defs, self.prefix); + let style = self.graphic_style(format!("{props}{look_attrs}")); + let body = self.story(blocks); + self.boxes += 1; + let (element, geometry) = match crate::odt::shapes::element_of(kind) { + // A freeform without paths has nothing to draw but its frame. + ("draw:path", _) if path.is_none() => ("draw:rect", None), + e => e, + }; + let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 }; + let at = if element == "draw:line" { + { + // A line by its ends: from its box's top left to its bottom right, or bottom left + // to top right when it rises. + let (w, h) = (len(size.0, 0.0), len(size.1, 0.0)); + let (x, y) = place.origin(); + // From its start to its end: mirrored, it starts on the right or at the bottom. + let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v)); + let ((x1, x2), (y1, y2)) = (if fh { (x + w, x) } else { (x, x + w) }, if fv { (y + h, y) } else { (y, y + h) }); + // Turned about its centre: its ends turned with it. + let (cx, cy) = (x + w / 2.0, y + h / 2.0); + let (sin, cos) = float.filter(|f| crate::odt::frames::turned(f)).map_or(0.0, |f| f.rot.to_radians()).sin_cos(); + let turn = |px: f32, py: f32| (cx + (px - cx) * cos - (py - cy) * sin, cy + (px - cx) * sin + (py - cy) * cos); + let ((x1, y1), (x2, y2)) = (turn(x1, y1), turn(x2, y2)); + format!(" svg:x1=\"{}\" svg:y1=\"{}\" svg:x2=\"{}\" svg:y2=\"{}\"", pt(x1), pt(y1), pt(x2), pt(y2)) + } + } else { + let (w, h) = (len(size.0, 1.0), len(size.1, 1.0)); + let mut at = format!(" svg:width=\"{}\" svg:height=\"{}\"{xy}", pt(w), pt(h)); + if let Some(f) = path.filter(|_| element == "draw:path") { + // Its paths in a view box of hundredths of a point (whole numbers, §19.570). + let (vw, vh) = ((w * 100.0).round().max(1.0) as u32, (h * 100.0).round().max(1.0) as u32); + at.push_str(&format!(" svg:viewBox=\"0 0 {vw} {vh}\" svg:d=\"{}\"", crate::odt::path::path_data(f, vw, vh))); + } + at + }; + // Mirrored, as a custom shape's geometry says (§20.112, §20.113). + let mirror = |on: bool, k: &str| if on { format!(" draw:mirror-{k}=\"true\"") } else { String::new() }; + let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v)); + let geometry = geometry + .map(|t| { + format!( + "", + mirror(fh, "horizontal"), + mirror(fv, "vertical") + ) + }) + .unwrap_or_default(); + out.push_str(&format!( + "<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at} draw:z-index=\"0\">{body}{geometry}", + self.prefix, self.boxes, + )); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + + /// The paragraphs of a text box's or shape's text, written apart from the paragraph being + /// written. + pub(in crate::odt) fn story(&mut self, blocks: &[FBlock]) -> String { + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); + let mut body = String::new(); + self.blocks(blocks, &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + body + } + + /// An equation as an embedded formula object (ODF 1.3 §10.4.3): a frame in line with the text + /// pointing at a sub-document whose `content.xml` is the equation's MathML, its frame about + /// the size the formula takes (LibreOffice scales the formula to the frame). + pub(in crate::odt) fn formula(&mut self, linear: &str, display: bool, out: &mut String) { + let n = self.formulas.len() + 1; + let name = format!("Object {}{n}", self.prefix); + let mathml = crate::mathml::linear_to_mathml(linear, display); + self.formulas.push((name.clone(), format!("\n{mathml}\n"))); + let (w, h) = crate::odt::formula_size::size(linear); + // Centred on the line, as LibreOffice sets its own formulas. + let style = self.graphic_style(" style:vertical-pos=\"middle\" style:vertical-rel=\"text\"".into()); + out.push_str(&format!( + "{}", + self.prefix, + pt(w), + pt(h), + x(&name), + x(linear) + )); + } + + /// The automatic graphic style of a frame placed by `props`. + pub(in crate::odt) fn graphic_style(&mut self, props: String) -> String { + let k = match self.graphic_styles.iter().position(|s| *s == props) { + Some(k) => k, + None => { + self.graphic_styles.push(props); + self.graphic_styles.len() - 1 + } + }; + format!("{}fr{}", self.prefix, k + 1) + } +} diff --git a/crates/formats/src/odt/writer/notes.rs b/crates/formats/src/odt/writer/notes.rs new file mode 100644 index 0000000000..378bacef79 --- /dev/null +++ b/crates/formats/src/odt/writer/notes.rs @@ -0,0 +1,81 @@ +//! Notes and comments written (ODF 1.3 §6.3, §14.1): their citations, authors, dates, replies and +//! content, the paragraph being written put aside meanwhile. + +use super::{FBlock, Writer, model, x}; + +/// The `office:name` of comment `id` written by a writer with names prefixed `prefix`. +pub(in crate::odt) fn annotation_name(prefix: &str, id: u32) -> String { + format!("__Annotation__{prefix}{id}") +} + +impl Writer { + /// A comment (ODF 1.3 §14.1): named, so its `office:annotation-end` closes its range, or for + /// a reply naming the comment it answers (LibreOffice's `loext:parent-name`); its author, + /// date, initials, resolved state (`loext:resolved`) and text. + pub(in crate::odt) fn comment(&mut self, id: u32, c: &model::FComment, out: &mut String) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + let mut attrs = match c.parent { + Some(p) => format!(" loext:parent-name=\"{}\"", annotation_name(self.prefix, p)), + None => format!(" office:name=\"{}\"", annotation_name(self.prefix, id)), + }; + if c.resolved { + attrs.push_str(" loext:resolved=\"true\""); + } + let mut head = String::new(); + if !c.author.is_empty() { + head.push_str(&format!("{}", x(&c.author))); + } + if !c.date.is_empty() { + head.push_str(&format!("{}", x(&c.date))); + } + if !c.initials.is_empty() { + head.push_str(&format!("{}", x(&c.initials))); + } + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); + let mut body = String::new(); + // An annotation holds paragraphs and lists only: a table's text goes in as paragraphs. + self.blocks(&model::flatten_tables(&c.blocks), &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + out.push_str(&format!("{head}{body}")); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + + /// A footnote or endnote (ODF 1.3 §6.3): its citation (the number LibreOffice shows until it + /// renumbers, or the custom mark) and its content. The paragraph being written is put aside + /// meanwhile, and an insertion open around the note ends before it and goes on after it. + pub(in crate::odt) fn note(&mut self, endnote: bool, custom: &str, blocks: &[FBlock], out: &mut String) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + self.notes += 1; + let id = format!("{}ftn{}", self.prefix, self.notes); + let (class, parent) = if endnote { ("endnote", "Endnote") } else { ("footnote", "Footnote") }; + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.replace(parent)); + let mut body = String::new(); + self.blocks(blocks, &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + if body.is_empty() { + body.push_str(&format!("")); + } + // The number LibreOffice shows by default (footnotes 1, 2, 3; endnotes i, ii, iii) until it + // renumbers them itself. + let number = if endnote { + self.endnotes = self.endnotes.saturating_add(1); + wordcraft_doc::section::roman(self.endnotes) + } else { + self.footnotes = self.footnotes.saturating_add(1); + self.footnotes.to_string() + }; + let (label, citation) = if custom.is_empty() { (String::new(), number) } else { (format!(" text:label=\"{}\"", x(custom)), x(custom)) }; + out.push_str(&format!( + "{citation}{body}" + )); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } +} diff --git a/crates/formats/src/odt/writer/props.rs b/crates/formats/src/odt/writer/props.rs new file mode 100644 index 0000000000..a20613ba99 --- /dev/null +++ b/crates/formats/src/odt/writer/props.rs @@ -0,0 +1,308 @@ +//! Style properties written for text and paragraphs (ODF 1.3 §17.6, §17.8): character +//! formatting, scripts and languages, spacing, tab stops, alignment and borders. + +use super::{Align, Border, BorderStyle, Borders, Fmt, Kind, LineSpacing, Rgb, model, pt, x}; + +/// `style:text-properties` attributes for what only [`Fmt::extra`] holds (underline and strike +/// are written with the rest); `size` is the text's size, which a raised position is a share of. +pub(in crate::odt) fn extra_props(x: &wordcraft_doc::props::CharProps, size: Option) -> String { + let mut p = String::new(); + let on = |v: Option| v == Some(true); + if on(x.caps) { + p.push_str(" fo:text-transform=\"uppercase\""); + } + if on(x.small_caps) { + p.push_str(" fo:font-variant=\"small-caps\""); + } + if on(x.hidden) { + p.push_str(" text:display=\"none\""); + } + if let Some(s) = x.spacing.filter(|s| s.is_finite() && *s != 0.0) { + p.push_str(&format!(" fo:letter-spacing=\"{}\"", pt(s))); + } + if let Some(s) = x.scale.filter(|s| s.is_finite() && *s > 0.0 && *s != 100.0) { + p.push_str(&format!(" style:text-scale=\"{}%\"", s.round())); + } + if let Some(r) = x.position.filter(|r| r.is_finite() && *r != 0.0) { + let size = size.filter(|s| s.is_finite() && *s >= 1.0).unwrap_or(12.0); + let pct = r / size * 100.0; + p.push_str(&format!(" style:text-position=\"{}% 100%\"", (pct * 100.0).round() / 100.0)); + } + if on(x.outline) { + p.push_str(" style:text-outline=\"true\""); + } + if on(x.shadow) { + p.push_str(" fo:text-shadow=\"1pt 1pt\""); + } + if on(x.emboss) { + p.push_str(" style:font-relief=\"embossed\""); + } else if on(x.engrave) { + p.push_str(" style:font-relief=\"engraved\""); + } + p.push_str(&script_attrs(x)); + if let Some(l) = x.lang.as_deref() { + let mut parts = l.split(['-', '_']); + let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric()); + if let Some(lang) = parts.next().filter(ok) { + p.push_str(&format!(" fo:language=\"{lang}\"")); + if let Some(country) = parts.next().filter(ok) { + p.push_str(&format!(" fo:country=\"{country}\"")); + } + } + } + p +} + +/// `style:text-properties` attributes for character properties `cp` (a style's look). +pub(in crate::odt) fn props_text(cp: &wordcraft_doc::props::CharProps) -> String { + use wordcraft_doc::props::{TextColor, Underline, VertAlign}; + let f = Fmt { + bold: cp.bold == Some(true), + italic: cp.italic == Some(true), + underline: cp.underline.is_some_and(|u| u != Underline::None), + strike: cp.strike == Some(true), + sup: cp.vert_align == Some(VertAlign::Superscript), + sub: cp.vert_align == Some(VertAlign::Subscript), + color: match cp.color { + Some(TextColor::Rgb(c)) => Some(c), + _ => None, + }, + size: cp.size, + font: cp.font.as_deref().map(std::sync::Arc::from), + extra: Some(Box::new(cp.clone())), + ..Default::default() + }; + text_props(&f) +} + +/// `fo:text-align` for `a` (ODF 1.3 §20.223). +pub(in crate::odt) fn align_attr(a: Option) -> &'static str { + match a { + Some(Align::Center) => " fo:text-align=\"center\"", + Some(Align::Right) => " fo:text-align=\"end\"", + Some(Align::Justify) | Some(Align::Distribute) => " fo:text-align=\"justify\"", + Some(Align::Left) => " fo:text-align=\"start\"", + None => "", + } +} + +/// `fo:language`/`fo:country` (with `suffix`: `-asian`, `-complex` on `style:`) for BCP 47 tag +/// `tag`. +pub(in crate::odt) fn lang_attrs(tag: &str, suffix: &str) -> String { + let mut parts = tag.split(['-', '_']); + let ok = |s: &&str| (1..=8).contains(&s.len()) && s.chars().all(|c| c.is_ascii_alphanumeric()); + let ns = if suffix.is_empty() { "fo" } else { "style" }; + let mut p = String::new(); + if let Some(lang) = parts.next().filter(ok) { + p.push_str(&format!(" {ns}:language{suffix}=\"{lang}\"")); + if let Some(country) = parts.next().filter(ok) { + p.push_str(&format!(" {ns}:country{suffix}=\"{country}\"")); + } + } + p +} + +/// The Asian and complex-script attributes of `x`: fonts, complex size, weight, posture and the +/// two languages. +pub(in crate::odt) fn script_attrs(x: &wordcraft_doc::props::CharProps) -> String { + let mut p = String::new(); + if let Some(f) = x.font_ea.as_deref() { + p.push_str(&format!(" style:font-family-asian=\"'{}'\"", crate::odt::writer::x(&f.replace('\'', "")))); + } + if let Some(f) = x.font_cs.as_deref() { + p.push_str(&format!(" style:font-family-complex=\"'{}'\"", crate::odt::writer::x(&f.replace('\'', "")))); + } + if let Some(s) = x.size_cs.filter(|s| s.is_finite()) { + p.push_str(&format!(" style:font-size-complex=\"{}\"", pt(s))); + } + if let Some(b) = x.bold_cs { + p.push_str(if b { " style:font-weight-complex=\"bold\"" } else { " style:font-weight-complex=\"normal\"" }); + } + if let Some(i) = x.italic_cs { + p.push_str(if i { " style:font-style-complex=\"italic\"" } else { " style:font-style-complex=\"normal\"" }); + } + if let Some(l) = x.lang_ea.as_deref() { + p.push_str(&lang_attrs(l, "-asian")); + } + if let Some(l) = x.lang_bidi.as_deref() { + p.push_str(&lang_attrs(l, "-complex")); + } + p +} + +pub(in crate::odt) fn text_props(f: &Fmt) -> String { + let mut p = String::new(); + // Complex-script weight and posture of their own are written with the other script extras. + let own_cs = |pick: fn(&wordcraft_doc::props::CharProps) -> Option| f.extra.as_deref().and_then(pick).is_some(); + if f.bold { + p.push_str(" fo:font-weight=\"bold\" style:font-weight-asian=\"bold\""); + if !own_cs(|x| x.bold_cs) { + p.push_str(" style:font-weight-complex=\"bold\""); + } + } + if f.italic { + p.push_str(" fo:font-style=\"italic\" style:font-style-asian=\"italic\""); + if !own_cs(|x| x.italic_cs) { + p.push_str(" style:font-style-complex=\"italic\""); + } + } + let extra = f.extra.as_deref(); + if f.underline { + use wordcraft_doc::props::Underline as U; + let (style, kind, width, mode) = match extra.and_then(|e| e.underline) { + Some(U::Double) => ("solid", "double", "auto", ""), + Some(U::Thick) => ("solid", "single", "bold", ""), + Some(U::Dotted) => ("dotted", "single", "auto", ""), + Some(U::Dash) => ("dash", "single", "auto", ""), + Some(U::DotDash) => ("dot-dash", "single", "auto", ""), + Some(U::DotDotDash) => ("dot-dot-dash", "single", "auto", ""), + Some(U::Wave) => ("wave", "single", "auto", ""), + Some(U::DoubleWave) => ("wave", "double", "auto", ""), + Some(U::Words) => ("solid", "single", "auto", " style:text-underline-mode=\"skip-white-space\""), + _ => ("solid", "single", "auto", ""), + }; + let color = + extra.and_then(|e| e.underline_color).map(|c| format!("#{}", c.hex().to_ascii_lowercase())).unwrap_or_else(|| "font-color".into()); + p.push_str(&format!( + " style:text-underline-style=\"{style}\" style:text-underline-type=\"{kind}\" style:text-underline-width=\"{width}\" style:text-underline-color=\"{color}\"{mode}" + )); + } + if f.strike || extra.is_some_and(|e| e.double_strike == Some(true)) { + p.push_str(" style:text-line-through-style=\"solid\""); + if extra.is_some_and(|e| e.double_strike == Some(true)) { + p.push_str(" style:text-line-through-type=\"double\""); + } + } + if let Some(e) = extra { + p.push_str(&extra_props(e, f.size)); + } + if f.sup { + p.push_str(" style:text-position=\"super 58%\""); + } else if f.sub { + p.push_str(" style:text-position=\"sub 58%\""); + } + if let Some(c) = f.color { + p.push_str(&format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase())); + } + if let Some(c) = f.background { + p.push_str(&format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())); + } + if let Some(s) = f.size { + p.push_str(&format!(" fo:font-size=\"{}\"", pt(s))); + } + if f.code { + p.push_str(&format!(" fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\"", model::MONO_FONT)); + } else if let Some(fam) = &f.font { + p.push_str(&format!(" fo:font-family=\"'{}'\"", x(&fam.replace('\'', "")))); + } + p +} + +/// `style:paragraph-properties` attributes for the spacing, indents and line spacing `sp` sets +/// (ODF 1.3 §20.204–209, §20.225, §20.317). +pub(in crate::odt) fn spacing_props(sp: &model::Spacing) -> String { + let mut p = String::new(); + for (k, v) in [ + ("fo:margin-top", sp.before), + ("fo:margin-bottom", sp.after), + ("fo:margin-left", sp.left), + ("fo:margin-right", sp.right), + ("fo:text-indent", sp.first), + ] { + if let Some(v) = v.filter(|v| v.is_finite()) { + p.push_str(&format!(" {k}=\"{}\"", pt(v))); + } + } + match sp.line { + Some(LineSpacing::Multiple(m)) if m.is_finite() => p.push_str(&format!(" fo:line-height=\"{}%\"", (m * 100.0).round().max(1.0))), + Some(LineSpacing::Exactly(h)) if h.is_finite() => p.push_str(&format!(" fo:line-height=\"{}\"", pt(h))), + Some(LineSpacing::AtLeast(h)) if h.is_finite() => p.push_str(&format!(" style:line-height-at-least=\"{}\"", pt(h))), + _ => {} + } + p +} + +/// `style:tab-stops` (ODF 1.3 §17.7) for `tabs`, measured from the margin (the package's +/// settings say so); bar and clearing stops have no ODF form and are left out. +pub(in crate::odt) fn tab_stops(tabs: &[wordcraft_doc::props::TabStop]) -> String { + use wordcraft_doc::props::{TabAlign, TabLeader}; + let mut s = String::from(""); + for t in tabs.iter().filter(|t| t.pos.is_finite()) { + let kind = match t.align { + TabAlign::Left => "", + TabAlign::Center => " style:type=\"center\"", + TabAlign::Right => " style:type=\"right\"", + TabAlign::Decimal => " style:type=\"char\" style:char=\".\"", + TabAlign::Bar | TabAlign::Clear => continue, + }; + let leader = match t.leader { + TabLeader::None => "", + TabLeader::Dot => " style:leader-style=\"dotted\" style:leader-text=\".\"", + TabLeader::Hyphen => " style:leader-style=\"solid\" style:leader-text=\"-\"", + TabLeader::Underscore => " style:leader-style=\"solid\" style:leader-text=\"_\"", + TabLeader::MiddleDot => " style:leader-style=\"dotted\" style:leader-text=\"·\"", + }; + s.push_str(&format!("", pt(t.pos))); + } + s.push_str(""); + s +} + +/// An ODF style name (an XML NCName, ODF 1.3 §19.75) for document style id `id`: letters, +/// digits and `-`, `.` kept, anything else as `_xHH_` (its code point in hex), and a leading +/// digit, `-` or `.` escaped too. +pub(in crate::odt) fn odf_name(id: &str) -> String { + let mut out = String::new(); + for (i, c) in id.chars().enumerate() { + let ok = c.is_alphanumeric() || (i > 0 && (c == '-' || c == '.' || c.is_ascii_digit())); + if ok && !(i == 0 && c.is_ascii_digit()) { + out.push(c); + } else { + out.push_str(&format!("_x{:X}_", c as u32)); + } + } + if out.is_empty() { "Custom".into() } else { out } +} + +/// `fo:border-*` and `fo:padding-*` attributes for paragraph borders `b`, when a side shows. +pub(in crate::odt) fn border_attrs(b: Option) -> String { + let mut p = String::new(); + let Some(b) = b.filter(|b| b.any_visible()) else { return p }; + for (side, line) in [("top", b.top), ("left", b.left), ("bottom", b.bottom), ("right", b.right)] { + let shown = line.filter(|l| l.is_visible()); + p.push_str(&format!(" fo:border-{side}=\"{}\"", odf_border(shown))); + if let Some(l) = shown { + p.push_str(&format!(" fo:padding-{side}=\"{}\"", pt(l.space.clamp(0.0, 31.0)))); + } + } + p +} + +/// An ODF border value (ODF 1.3 §20.183) for `b`; `none` for no line. +pub(in crate::odt) fn odf_border(b: Option) -> String { + let Some(b) = b.filter(|b| b.is_visible() && b.width.is_finite()) else { return "none".into() }; + let style = match b.style { + BorderStyle::Double | BorderStyle::Triple => "double", + BorderStyle::Dotted => "dotted", + BorderStyle::Dashed => "dashed", + BorderStyle::DotDash => "dot-dash", + _ => "solid", + }; + let color = b.color.unwrap_or(Rgb(0, 0, 0)).hex().to_ascii_lowercase(); + format!("{} {style} #{color}", pt(b.width.clamp(0.05, 12.0))) +} + +pub(in crate::odt) fn parent_style(k: Kind) -> &'static str { + match k { + Kind::Heading(1) => "Heading_20_1", + Kind::Heading(2) => "Heading_20_2", + Kind::Heading(3) => "Heading_20_3", + Kind::Heading(4) => "Heading_20_4", + Kind::Heading(5) => "Heading_20_5", + Kind::Heading(_) => "Heading_20_6", + Kind::Title => "Title", + Kind::Quote => "Quotations", + Kind::Code => "Preformatted_20_Text", + Kind::Normal | Kind::Rule => "Standard", + } +} diff --git a/crates/formats/src/odt/writer/tables.rs b/crates/formats/src/odt/writer/tables.rs new file mode 100644 index 0000000000..dce9217b5f --- /dev/null +++ b/crates/formats/src/odt/writer/tables.rs @@ -0,0 +1,144 @@ +//! Tables written (ODF 1.3 §9.1): columns, rows, cells and their borders, padding and shading. + +use super::{Border, BorderStyle, Borders, FTable, Rgb, VAlign, Writer, model, odf_border, pt}; + +/// Word's default cell margins (top, left, bottom, right), points. +pub(in crate::odt) const WORD_CELL_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4]; + +/// The cell of `row` that starts at grid column `g`. +pub(in crate::odt) fn cell_starting_at(row: &[model::Cell], g: usize) -> Option<&model::Cell> { + let mut s = 0usize; + row.iter().find(|x| { + let here = s == g; + s += x.colspan.clamp(1, 63) as usize; + here + }) +} + +/// The `style:table-cell-properties` attributes of cell `c` of row `ri` of `t`, starting at grid +/// column `g0` of `cols`: its padding, shading, vertical alignment and borders. Each edge two +/// cells share is written once, as LibreOffice writes it (every cell its left and bottom, the +/// first row its top, the last column its right), so lines don't double; a side the cell +/// doesn't state gets the table's grid line, or none in a table without lines. +pub(in crate::odt) fn cell_props(t: &FTable, ri: usize, g0: usize, c: &model::Cell, cols: usize) -> String { + let grid = (!t.borderless).then_some(Border { style: BorderStyle::Single, width: 0.5, color: Some(Rgb(0, 0, 0)), space: 0.0 }); + let line = |c: &model::Cell, pick: fn(&Borders) -> Option| match c.borders.as_ref().and_then(pick) { + Some(b) => Some(b).filter(|b| b.is_visible()), + None => grid, + }; + let row = t.rows.get(ri).map(Vec::as_slice).unwrap_or(&[]); + let left_cell = g0.checked_sub(1).and_then(|g| { + let mut s = 0usize; + row.iter().find(|x| { + let end = s + x.colspan.clamp(1, 63) as usize; + let hit = s <= g && g < end; + s = end; + hit + }) + }); + let below = t.rows.get(ri + 1).and_then(|r| cell_starting_at(r, g0)); + let left = line(c, |b| b.left).or_else(|| left_cell.and_then(|l| line(l, |b| b.right))); + let bottom = line(c, |b| b.bottom).or_else(|| below.and_then(|b| line(b, |x| x.top))); + let top = if ri == 0 { line(c, |b| b.top) } else { None }; + let right = if g0 + c.colspan.clamp(1, 63) as usize >= cols { line(c, |b| b.right) } else { None }; + let m = c.margins.filter(|m| m.iter().all(|v| v.is_finite())).unwrap_or(WORD_CELL_MARGINS); + let mut p = format!( + " fo:padding-top=\"{}\" fo:padding-left=\"{}\" fo:padding-bottom=\"{}\" fo:padding-right=\"{}\"", + pt(m[0].max(0.0)), + pt(m[1].max(0.0)), + pt(m[2].max(0.0)), + pt(m[3].max(0.0)) + ); + p.push_str(&format!( + " fo:border-left=\"{}\" fo:border-right=\"{}\" fo:border-top=\"{}\" fo:border-bottom=\"{}\"", + odf_border(left), + odf_border(right), + odf_border(top), + odf_border(bottom) + )); + if let Some(bg) = c.shading { + p.push_str(&format!(" fo:background-color=\"#{}\"", bg.hex().to_ascii_lowercase())); + } + p.push_str(match c.valign { + Some(VAlign::Top) => " style:vertical-align=\"top\"", + Some(VAlign::Center) => " style:vertical-align=\"middle\"", + Some(VAlign::Bottom) => " style:vertical-align=\"bottom\"", + None => "", + }); + p +} + +impl Writer { + /// The automatic style of a cell with `props` (its `style:table-cell-properties` attributes). + pub(in crate::odt) fn cell_style(&mut self, props: String) -> String { + let k = match self.cell_styles.iter().position(|s| *s == props) { + Some(k) => k, + None => { + self.cell_styles.push(props); + self.cell_styles.len() - 1 + } + }; + format!("{}Cell{}", self.prefix, k + 1) + } + + pub(in crate::odt) fn table(&mut self, t: &FTable, out: &mut String, depth: usize) { + self.close_ins(out); + let cols = t.cols().max(1); + let widths: Vec = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 0.0) { + t.widths.clone() + } else { + vec![468.0 / cols as f32; cols] + }; + self.tables.push(widths); + self.table_starts.push(self.master.take()); + let tn = format!("{}{}", self.prefix, self.tables.len()); + out.push_str(&format!("")); + for c in 0..cols { + out.push_str(&format!("", c + 1)); + } + let header_rows = t.rows.iter().take_while(|r| !r.is_empty() && r.iter().all(|c| c.header)).count(); + for (ri, row) in t.rows.iter().enumerate() { + if ri == 0 && header_rows > 0 { + out.push_str(""); + } + out.push_str(""); + let mut g = 0usize; + for c in row { + let span = c.colspan.clamp(1, 63) as usize; + g += span; + if c.covered { + for _ in 0..span { + out.push_str(""); + } + continue; + } + let st = self.cell_style(cell_props(t, ri, g - span, c, cols)); + let mut attrs = format!(" table:style-name=\"{st}\" office:value-type=\"string\""); + if span > 1 { + attrs.push_str(&format!(" table:number-columns-spanned=\"{span}\"")); + } + if c.rowspan > 1 { + attrs.push_str(&format!(" table:number-rows-spanned=\"{}\"", c.rowspan)); + } + out.push_str(&format!("")); + if c.blocks.is_empty() { + out.push_str(""); + } + self.blocks(&c.blocks, out, depth + 1); + self.close_ins(out); + out.push_str(""); + for _ in 1..span { + out.push_str(""); + } + } + for _ in g..cols { + out.push_str(""); + } + out.push_str(""); + if ri + 1 == header_rows { + out.push_str(""); + } + } + out.push_str("\n"); + } +} diff --git a/crates/formats/src/odt/writer/tracked.rs b/crates/formats/src/odt/writer/tracked.rs new file mode 100644 index 0000000000..1bee77f1ce --- /dev/null +++ b/crates/formats/src/odt/writer/tracked.rs @@ -0,0 +1,220 @@ +//! Tracked changes written (ODF 1.3 §5.5): changed regions, insertions open around the text, and +//! deleted content, paragraph marks included. + +use super::{FBlock, HashMap, Inline, Para, RevMark, Writer, x}; + +/// One piece of a paragraph being written: an inline, or the deleted mark of a paragraph +/// joined to the next one. +pub(in crate::odt) enum Item<'a> { + Inl(&'a Inline), + Mark(&'a Para), +} + +impl Item<'_> { + /// Its tracked insertion and deletion. + pub(in crate::odt) fn marks(&self) -> (Option<&RevMark>, Option<&RevMark>) { + match self { + Item::Inl(Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } | Inline::Field { fmt: f, .. }) => (f.ins.as_ref(), f.del.as_ref()), + Item::Inl(_) => (None, None), + Item::Mark(p) => (p.mark_ins.as_deref(), p.mark_del.as_deref()), + } + } + + /// What a run of content is written as: deleted content is one region whatever was + /// inserted inside it, so only its deletion counts. + pub(in crate::odt) fn segment(&self) -> (Option<&RevMark>, Option<&RevMark>) { + match self.marks() { + (_, Some(del)) => (None, Some(del)), + k => k, + } + } + + pub(in crate::odt) fn inline(&self) -> Option<&Inline> { + match self { + Item::Inl(i) => Some(i), + Item::Mark(_) => None, + } + } +} + +/// Close the insertion open in `st`. +pub(in crate::odt) fn ins_close(st: &mut Option<(RevMark, String)>, out: &mut String) { + if let Some((_, ct)) = st.take() { + out.push_str(&format!("")); + } +} + +/// The blocks after joining at deleted marks, and each joined run's paragraphs by the first one's index. +pub(in crate::odt) type JoinedMarks = (Vec, HashMap>); + +/// `blocks` with each run of paragraphs joined at tracked deleted paragraph marks (ODF keeps +/// such a deletion inside one body paragraph): the first paragraph stands for the run, and the +/// whole run is returned by its index. `None` when nothing is joined. A deleted mark with no +/// paragraph after it (before a table, at the end) is written as a plain mark. +pub(in crate::odt) fn join_deleted_marks(blocks: &[FBlock]) -> Option { + if !blocks.iter().any(|b| matches!(b, FBlock::Para(p) if p.mark_del.is_some())) { + return None; + } + let mut out = Vec::with_capacity(blocks.len()); + let mut chains = HashMap::new(); + let mut i = 0; + while let Some(b) = blocks.get(i) { + i += 1; + let FBlock::Para(p) = b else { + out.push(b.clone()); + continue; + }; + let mut chain = vec![p.clone()]; + while chain.last().is_some_and(|q| q.mark_del.is_some()) + && let Some(FBlock::Para(q)) = blocks.get(i) + { + chain.push(q.clone()); + i += 1; + } + if chain.len() > 1 { + chains.insert(out.len(), chain); + } + out.push(FBlock::Para(p.clone())); + } + Some((out, chains)) +} + +/// Now, as an `xsd:dateTime` (UTC). +pub(in crate::odt) fn now_iso() -> String { + let t = now_unix(); + let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + // Reuse the crate's 400-year-cycle converter (chrono within a cycle, cycles carried in the + // year) so a clock past chrono's range keeps the proleptic Gregorian date the old hand-rolled + // code gave, never the epoch. `{y:04}` rather than `%Y`, which writes a `+` before year 10000. + let (y, m, d) = crate::sheet::civil(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) +} + +/// Seconds since the Unix epoch: the system clock, or the browser's on the web. +pub(in crate::odt) fn now_unix() -> i64 { + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).ok().and_then(|d| i64::try_from(d.as_secs()).ok()).unwrap_or(0) + } + // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] + { + let ms = js_sys::Date::now(); + if ms.is_finite() && ms > 0.0 { (ms / 1000.0) as i64 } else { 0 } + } +} + +impl Writer { + /// A new `text:changed-region` of `kind` by `m` with `body` after its change info; its id. + pub(in crate::odt) fn region(&mut self, kind: &str, m: &RevMark, body: &str) -> String { + let id = format!("ct{}", self.regions.len() + 1); + // `dc:date` is required: a change without a date is dated now. + let date = if m.date.trim().is_empty() { now_iso() } else { m.date.clone() }; + self.regions.push(format!( + "{}{}{body}", + x(&m.author), + x(&date) + )); + id + } + + /// Make `m` the insertion open in `st` (closing another one first). + pub(in crate::odt) fn ins_open(&mut self, st: &mut Option<(RevMark, String)>, m: &RevMark, out: &mut String) { + if st.as_ref().is_some_and(|(o, _)| o == m) { + return; + } + ins_close(st, out); + let ct = self.region("insertion", m, ""); + out.push_str(&format!("")); + *st = Some((m.clone(), ct)); + } + + pub(in crate::odt) fn open_ins(&mut self, m: &RevMark, out: &mut String) { + let mut st = self.open_ins.take(); + self.ins_open(&mut st, m, out); + self.open_ins = st; + } + + pub(in crate::odt) fn close_ins(&mut self, out: &mut String) { + ins_close(&mut self.open_ins, out); + } + + /// Inlines with their insertions as change ranges kept in `st`. + pub(in crate::odt) fn ins_inlines(&mut self, items: &[Item<'_>], st: &mut Option<(RevMark, String)>, out: &mut String) { + let mut k = 0; + while k < items.len() { + let ins = items.get(k).and_then(|i| i.marks().0); + let mut j = k + 1; + while j < items.len() && items.get(j).and_then(|i| i.marks().0) == ins { + j += 1; + } + match ins { + Some(m) => self.ins_open(st, m, out), + None => ins_close(st, out), + } + let seg: Vec = items.get(k..j).unwrap_or(&[]).iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&seg, out); + k = j; + } + } + + /// Deleted content: a region holding it (one paragraph per deleted paragraph mark plus + /// one; text inserted before it was deleted as change ranges inside) and a `text:change` + /// point in the body. + pub(in crate::odt) fn deletion(&mut self, seg: &[Item<'_>], m: &RevMark, out: &mut String) { + let mut body = String::new(); + let mut st: Option<(RevMark, String)> = None; + let mut start = 0; + let mut first = true; + loop { + let end = seg.iter().skip(start).position(|i| matches!(i, Item::Mark(_))).map(|e| start + e); + if !first { + self.part += 1; + } + first = false; + let style = self.chain.get(self.part).cloned().unwrap_or_else(|| self.cur_style.clone()); + body.push_str(&format!("")); + self.ins_inlines(seg.get(start..end.unwrap_or(seg.len())).unwrap_or(&[]), &mut st, &mut body); + // A deleted paragraph mark that had been inserted: the range runs on past it. + match end.and_then(|e| seg.get(e)).and_then(|i| i.marks().0) { + Some(ins) => self.ins_open(&mut st, ins, &mut body), + None => ins_close(&mut st, &mut body), + } + body.push_str(""); + match end { + Some(e) => start = e + 1, + None => break, + } + } + let ct = self.region("deletion", m, &body); + out.push_str(&format!("")); + } + + /// Paragraph content, with tracked insertions as change ranges and deletions as change points. + pub(in crate::odt) fn items(&mut self, items: &[Item<'_>], out: &mut String) { + let mut k = 0; + while k < items.len() { + let key = items.get(k).map(Item::segment).unwrap_or_default(); + let mut j = k + 1; + while j < items.len() && items.get(j).map(Item::segment).unwrap_or_default() == key { + j += 1; + } + let seg = items.get(k..j).unwrap_or(&[]); + match key { + (_, Some(del)) => self.deletion(seg, del, out), + (Some(ins), None) => { + self.open_ins(ins, out); + let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&inl, out); + } + (None, None) => { + self.close_ins(out); + let inl: Vec = seg.iter().filter_map(|i| i.inline().cloned()).collect(); + self.plain_inlines(&inl, out); + } + } + k = j; + } + } +}