mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 03:01:32 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
11 files changed
+1980
-1899
No files matched your search
@@ -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<El> = Vec::new();
|
||||
let mut math: Option<El> = 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<Cell>]) {
|
||||
let visible = |b: Option<wordcraft_doc::props::Border>| b.filter(|b| b.is_visible());
|
||||
for r in 0..rows.len() {
|
||||
let starts: Vec<usize> = 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<Vec<Cell>>,
|
||||
row: Vec<Cell>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
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<RevisionKind>,
|
||||
/// 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<FBlock>,
|
||||
}
|
||||
|
||||
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<String>, bool)>,
|
||||
item_used: Vec<bool>,
|
||||
/// 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<String>,
|
||||
style: Option<String>,
|
||||
members: Vec<model::Member>,
|
||||
}
|
||||
|
||||
/// 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<f32>, Option<f32>, Option<Vec<u8>>, 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<u32>,
|
||||
resolved: bool,
|
||||
author: String,
|
||||
date: String,
|
||||
initials: String,
|
||||
/// The child whose text is being read: 0 author, 1 date, 2 initials.
|
||||
field: Option<u8>,
|
||||
}
|
||||
|
||||
/// 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<CommentInfo>,
|
||||
/// 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<Frame>, Option<wordcraft_doc::para::Float>, model::BoxLook)>,
|
||||
para: Option<Para>,
|
||||
fmt: Vec<Fmt>,
|
||||
xml_space_end: bool,
|
||||
para_cont: bool,
|
||||
lists: Vec<(Option<String>, bool)>,
|
||||
item_used: Vec<bool>,
|
||||
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<Vec<u8>>) -> 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<FBlock> = 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<FBlock>, mark: RevMark) {
|
||||
let mut paras: Vec<Para> = 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<Inline>| {
|
||||
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 `<text:p/>` 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<f32>, Option<f32>)>) {
|
||||
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<f32>| 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<String, Region>, 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()));
|
||||
}
|
||||
}
|
||||
@@ -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<El> = Vec::new();
|
||||
let mut math: Option<El> = 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<String>,
|
||||
pub(in crate::odt) style: Option<String>,
|
||||
pub(in crate::odt) members: Vec<model::Member>,
|
||||
}
|
||||
|
||||
/// 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 `<text:p/>` 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<f32>, Option<f32>)>) {
|
||||
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<f32>| 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
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<u8>,
|
||||
}
|
||||
|
||||
/// 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<CommentInfo>,
|
||||
/// 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<Frame>, Option<wordcraft_doc::para::Float>, model::BoxLook)>,
|
||||
pub(in crate::odt) para: Option<Para>,
|
||||
pub(in crate::odt) fmt: Vec<Fmt>,
|
||||
pub(in crate::odt) xml_space_end: bool,
|
||||
pub(in crate::odt) para_cont: bool,
|
||||
pub(in crate::odt) lists: Vec<(Option<String>, bool)>,
|
||||
pub(in crate::odt) item_used: Vec<bool>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Cell>]) {
|
||||
let visible = |b: Option<wordcraft_doc::props::Border>| b.filter(|b| b.is_visible());
|
||||
for r in 0..rows.len() {
|
||||
let starts: Vec<usize> = 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<Vec<Cell>>,
|
||||
pub(in crate::odt) row: Vec<Cell>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -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<RevisionKind>,
|
||||
/// 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<FBlock>,
|
||||
}
|
||||
|
||||
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<String>, bool)>,
|
||||
pub(in crate::odt) item_used: Vec<bool>,
|
||||
/// 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<String, Region>, 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<FBlock> = 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<FBlock>, mark: RevMark) {
|
||||
let mut paras: Vec<Para> = 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<Inline>| {
|
||||
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
|
||||
}
|
||||
}
|
||||
+12
-1111
File diff suppressed because it is too large.
Load diff
@@ -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!(
|
||||
"<draw:frame draw:name=\"{}Image{n}\"{style}{anchor} svg:width=\"{}\" svg:height=\"{}\" draw:z-index=\"0\"><draw:image xlink:href=\"{href}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>",
|
||||
self.prefix,
|
||||
pt(img.w),
|
||||
pt(img.h)
|
||||
));
|
||||
if !img.alt.is_empty() {
|
||||
out.push_str(&format!("<svg:desc>{}</svg:desc>", x(&img.alt)));
|
||||
}
|
||||
out.push_str("</draw:frame>");
|
||||
}
|
||||
|
||||
/// 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!("<draw:g draw:name=\"{}Group{}\" draw:style-name=\"{style}\"{anchor} draw:z-index=\"0\">", self.prefix, self.boxes));
|
||||
let at = Place::Text(float).origin();
|
||||
self.members(members, at, 0, out);
|
||||
out.push_str("</draw:g>");
|
||||
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("<draw:g>");
|
||||
self.members(members, (x, y), depth + 1, out);
|
||||
out.push_str("</draw:g>");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("<text:p/>");
|
||||
}
|
||||
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!(
|
||||
"<draw:frame draw:name=\"{}Frame{}\" draw:style-name=\"{style}\"{anchor} svg:width=\"{}\"{height}{xy} draw:z-index=\"0\"><draw:text-box{min}>{body}</draw:text-box></draw:frame>",
|
||||
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!(
|
||||
"<draw:enhanced-geometry svg:viewBox=\"0 0 21600 21600\" draw:type=\"{t}\"{}{}/>",
|
||||
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}</{element}>",
|
||||
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!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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!(
|
||||
"<draw:frame draw:name=\"{}Formula{n}\" draw:style-name=\"{style}\" text:anchor-type=\"as-char\" svg:width=\"{}\" svg:height=\"{}\" draw:z-index=\"0\"><draw:object xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/><svg:desc>{}</svg:desc></draw:frame>",
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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!("<dc:creator>{}</dc:creator>", x(&c.author)));
|
||||
}
|
||||
if !c.date.is_empty() {
|
||||
head.push_str(&format!("<dc:date>{}</dc:date>", x(&c.date)));
|
||||
}
|
||||
if !c.initials.is_empty() {
|
||||
head.push_str(&format!("<meta:creator-initials>{}</meta:creator-initials>", 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!("<office:annotation{attrs}>{head}{body}</office:annotation>"));
|
||||
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!("<text:p text:style-name=\"{parent}\"/>"));
|
||||
}
|
||||
// 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!(
|
||||
"<text:note text:id=\"{id}\" text:note-class=\"{class}\"><text:note-citation{label}>{citation}</text:note-citation><text:note-body>{body}</text:note-body></text:note>"
|
||||
));
|
||||
if let Some(m) = reopen {
|
||||
self.open_ins(&m, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<f32>) -> String {
|
||||
let mut p = String::new();
|
||||
let on = |v: Option<bool>| 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<Align>) -> &'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<bool>| 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("<style:tab-stops>");
|
||||
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!("<style:tab-stop style:position=\"{}\"{kind}{leader}/>", pt(t.pos)));
|
||||
}
|
||||
s.push_str("</style:tab-stops>");
|
||||
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<Borders>) -> 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<Border>) -> 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",
|
||||
}
|
||||
}
|
||||
@@ -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<Border>| 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<f32> = 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!("<table:table table:name=\"Table{tn}\" table:style-name=\"Table{tn}\">"));
|
||||
for c in 0..cols {
|
||||
out.push_str(&format!("<table:table-column table:style-name=\"Table{tn}.C{}\"/>", 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("<table:table-header-rows>");
|
||||
}
|
||||
out.push_str("<table:table-row>");
|
||||
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("<table:covered-table-cell/>");
|
||||
}
|
||||
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!("<table:table-cell{attrs}>"));
|
||||
if c.blocks.is_empty() {
|
||||
out.push_str("<text:p text:style-name=\"Standard\"/>");
|
||||
}
|
||||
self.blocks(&c.blocks, out, depth + 1);
|
||||
self.close_ins(out);
|
||||
out.push_str("</table:table-cell>");
|
||||
for _ in 1..span {
|
||||
out.push_str("<table:covered-table-cell/>");
|
||||
}
|
||||
}
|
||||
for _ in g..cols {
|
||||
out.push_str("<table:table-cell office:value-type=\"string\"><text:p/></table:table-cell>");
|
||||
}
|
||||
out.push_str("</table:table-row>");
|
||||
if ri + 1 == header_rows {
|
||||
out.push_str("</table:table-header-rows>");
|
||||
}
|
||||
}
|
||||
out.push_str("</table:table>\n");
|
||||
}
|
||||
}
|
||||
@@ -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!("<text:change-end text:change-id=\"{ct}\"/>"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<FBlock>, HashMap<usize, Vec<Para>>);
|
||||
|
||||
/// `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<JoinedMarks> {
|
||||
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!(
|
||||
"<text:changed-region text:id=\"{id}\"><text:{kind}><office:change-info><dc:creator>{}</dc:creator><dc:date>{}</dc:date></office:change-info>{body}</text:{kind}></text:changed-region>",
|
||||
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!("<text:change-start text:change-id=\"{ct}\"/>"));
|
||||
*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<Inline> = 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!("<text:p text:style-name=\"{style}\">"));
|
||||
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("</text:p>");
|
||||
match end {
|
||||
Some(e) => start = e + 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let ct = self.region("deletion", m, &body);
|
||||
out.push_str(&format!("<text:change text:change-id=\"{ct}\"/>"));
|
||||
}
|
||||
|
||||
/// 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<Inline> = seg.iter().filter_map(|i| i.inline().cloned()).collect();
|
||||
self.plain_inlines(&inl, out);
|
||||
}
|
||||
(None, None) => {
|
||||
self.close_ins(out);
|
||||
let inl: Vec<Inline> = seg.iter().filter_map(|i| i.inline().cloned()).collect();
|
||||
self.plain_inlines(&inl, out);
|
||||
}
|
||||
}
|
||||
k = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user