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:
Claude committed 2026-11-01 08:01:51 +00:00
1 parent 422b59f3bc
commit 74da7e0985
11 files changed
+1980 -1899

No files matched your search

+8 -788
View File
@@ -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()));
}
}
+198
View File
@@ -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);
}
}
}
+317
View File
@@ -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;
}
}
+89
View File
@@ -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 }
}
}
+207
View File
@@ -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
}
}
File diff suppressed because it is too large. Load diff
+396
View File
@@ -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)
}
}
+81
View File
@@ -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);
}
}
}
+308
View File
@@ -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",
}
}
+144
View File
@@ -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");
}
}
+220
View File
@@ -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;
}
}
}