mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 00:31:10 +00:00
M-ODT gap 19: paragraphs tied together written as read as groups
A comment, tracked change or index mark reaching from one paragraph into another, or a text box continuing in another's, ties paragraphs into a group: written as read while every paragraph of it is as read, else all from the model, which names what ties them afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
14 files changed
+374
-68
No files matched your search
@@ -51,6 +51,11 @@ pub struct KeptXml {
|
||||
pub indexes: HashMap<u64, (usize, u64, Arc<str>)>,
|
||||
/// While reading: by the first paragraph's `rev`, how many paragraphs, and the markup.
|
||||
pub pending_indexes: HashMap<u64, (usize, Arc<str>)>,
|
||||
/// The group of paragraphs written as read together (see `wordcraft_formats::model::Group`)
|
||||
/// each paragraph kept belongs to, by its fingerprint: the group's id and size.
|
||||
pub groups: HashMap<u64, (u32, u32)>,
|
||||
/// While reading: the same by paragraph `rev`, until `seal_kept`.
|
||||
pub pending_groups: HashMap<u64, (u32, u32)>,
|
||||
/// Each text box's, shape's or note's story's [`story_fingerprint`] when read: a paragraph
|
||||
/// showing one is written as read only while it is as read.
|
||||
pub story_fps: HashMap<u32, u64>,
|
||||
@@ -198,6 +203,19 @@ impl KeptXml {
|
||||
self.pending_indexes.insert(rev, (n, xml));
|
||||
}
|
||||
}
|
||||
/// The paragraph whose `rev` is `rev` belongs to group `group` (id, size).
|
||||
pub fn keep_pending_group(&mut self, rev: u64, group: (u32, u32)) {
|
||||
if self.pending_groups.len() < 100_000 {
|
||||
self.pending_groups.insert(rev, group);
|
||||
}
|
||||
}
|
||||
/// The group paragraph `p`'s markup kept as read belongs to (id, size).
|
||||
pub fn group(&self, p: &Paragraph) -> Option<(u32, u32)> {
|
||||
if self.groups.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.groups.get(&fingerprint(p)).copied()
|
||||
}
|
||||
/// Keep wrapper markup for the paragraph whose `rev` is `rev`, within the budget.
|
||||
pub fn keep_pending_wrap(&mut self, rev: u64, wrap: (Vec<Arc<str>>, u32)) {
|
||||
let n: usize = wrap.0.iter().map(|t| t.len()).sum();
|
||||
@@ -543,6 +561,35 @@ impl crate::Document {
|
||||
paras(&p.blocks, 0, &mut all);
|
||||
}
|
||||
let mut kept = std::mem::take(&mut self.kept);
|
||||
// Groups, by fingerprint; a group with a paragraph that has twins (which a writer can't
|
||||
// tell apart) isn't kept.
|
||||
if let Some(k) = kept.get_mut()
|
||||
&& !k.pending_groups.is_empty()
|
||||
{
|
||||
let mut count: HashMap<u64, usize> = HashMap::new();
|
||||
let fps: Vec<(u64, u64)> = all.iter().map(|p| (p.rev, fingerprint(p))).collect();
|
||||
for (_, fp) in &fps {
|
||||
*count.entry(*fp).or_default() += 1;
|
||||
}
|
||||
let pending = std::mem::take(&mut k.pending_groups);
|
||||
let mut broken = std::collections::HashSet::new();
|
||||
let mut members: Vec<(u64, u64, (u32, u32))> = Vec::new();
|
||||
for (rev, fp) in &fps {
|
||||
if let Some(g) = pending.get(rev) {
|
||||
if count.get(fp) != Some(&1) {
|
||||
broken.insert(g.0);
|
||||
}
|
||||
members.push((*rev, *fp, *g));
|
||||
}
|
||||
}
|
||||
for (rev, fp, g) in members {
|
||||
if broken.contains(&g.0) {
|
||||
k.pending.remove(&rev);
|
||||
} else {
|
||||
k.groups.insert(fp, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Wrappers, for paragraphs that are the only one of their kind.
|
||||
if let Some(k) = kept.get_mut()
|
||||
&& !k.pending_wraps.is_empty()
|
||||
|
||||
@@ -28,9 +28,9 @@ pub use from_doc::{
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind,
|
||||
Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing,
|
||||
TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, Img, Inline,
|
||||
Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark,
|
||||
Spacing, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
};
|
||||
pub(crate) use types::{Dropped, begin_import, dropped};
|
||||
|
||||
|
||||
@@ -464,6 +464,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
kept: None,
|
||||
wrap: None,
|
||||
index: None,
|
||||
group: None,
|
||||
};
|
||||
if opts.paras {
|
||||
out.extra = para_extra(doc, &p.props);
|
||||
@@ -505,6 +506,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
kept: None,
|
||||
wrap: None,
|
||||
index: None,
|
||||
group: None,
|
||||
};
|
||||
let prev = std::mem::replace(&mut out, next);
|
||||
if prev.inlines.is_empty() {
|
||||
@@ -630,6 +632,9 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
k.para(p).filter(|xml| (!xml.contains("<office:annotation") || k.comments_as_read(doc, p, xml)) && k.stories_as_read(doc, p))
|
||||
})
|
||||
.cloned();
|
||||
// With the group it is written as read with, if any (see `model::Group`).
|
||||
out.group =
|
||||
out.kept.as_ref().and_then(|_| doc.kept.of("odt")?.group(p)).map(|id| Box::new(crate::model::Group { links: Vec::new(), id: Some(id) }));
|
||||
}
|
||||
// The sections kept as read that it opens or closes.
|
||||
if opts.paras {
|
||||
|
||||
@@ -476,6 +476,7 @@ impl<'a> Builder<'a> {
|
||||
if p.kept.is_none() && p.wrap.is_none() && p.index.is_none() {
|
||||
return;
|
||||
}
|
||||
let group = p.group.as_ref().and_then(|g| g.id).filter(|_| p.kept.is_some());
|
||||
if self.doc.kept.get().is_none() {
|
||||
self.doc.kept = wordcraft_doc::kept::Kept::new(Default::default());
|
||||
}
|
||||
@@ -489,6 +490,9 @@ impl<'a> Builder<'a> {
|
||||
if let Some(ix) = &p.index {
|
||||
k.keep_pending_index(rev, ix.1, ix.0.clone());
|
||||
}
|
||||
if let Some(g) = group {
|
||||
k.keep_pending_group(rev, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -676,6 +676,19 @@ pub struct Para {
|
||||
/// An index (a table of contents…) kept as read that starts with this paragraph
|
||||
/// ([`wordcraft_doc::kept`]): its markup, and how many blocks of the flow it takes.
|
||||
pub index: Option<Box<(std::sync::Arc<str>, usize)>>,
|
||||
/// The group of paragraphs its markup kept as read belongs to (see [`Group`]).
|
||||
pub group: Option<Box<Group>>,
|
||||
}
|
||||
|
||||
/// Paragraphs whose markup kept as read ties them together (a comment from one to another, a
|
||||
/// tracked change, a text box continuing in another's): written as read all, or none.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Group {
|
||||
/// While reading: what ties it to others, each `(key, needed)`: a key it needs another
|
||||
/// paragraph to have (`needed`), or one it has for another to need.
|
||||
pub links: Vec<(String, bool)>,
|
||||
/// The group's id and how many paragraphs it has, once known.
|
||||
pub id: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
|
||||
@@ -39,9 +39,6 @@ pub(super) struct Body<'a> {
|
||||
pub(super) entries: &'a [String],
|
||||
/// The bibliography sources the citations carry.
|
||||
pub(super) sources: Vec<wordcraft_doc::Source>,
|
||||
/// The names of text boxes others continue in (§19.112 `draw:chain-next-name`): markup
|
||||
/// holding one isn't kept, its name being WordCraft's to give.
|
||||
pub(super) chain_targets: Vec<String>,
|
||||
/// The paragraph before had a page break after it: the next one starts a page.
|
||||
break_next: bool,
|
||||
/// Shapes' ids (`xml:id`, `draw:id`) as WordCraft drawing ids, and where the shapes with one
|
||||
@@ -219,7 +216,6 @@ impl<'a> Body<'a> {
|
||||
watermark_shape: None,
|
||||
break_next: false,
|
||||
sources: Vec::new(),
|
||||
chain_targets: Vec::new(),
|
||||
ruby: None,
|
||||
placeholder: None,
|
||||
index_marks: Vec::new(),
|
||||
@@ -632,6 +628,7 @@ impl<'a> Body<'a> {
|
||||
&& let Some(xml) = self.raw.get(k.from..at.1).and_then(|b| std::str::from_utf8(b).ok())
|
||||
{
|
||||
p.kept = Some(std::sync::Arc::from(format!("{}{xml}", k.prefix)));
|
||||
p.group = k.comments.group();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1862,14 +1859,18 @@ struct KeptOpen {
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
/// The comments (§14.1 `office:annotation`) and tracked changes (§5.5.7) in a paragraph being
|
||||
/// kept, which it can be only when they are its own: each named comment and each change begun
|
||||
/// ends in it, each end and each reply names one begun in it.
|
||||
/// The comments (§14.1 `office:annotation`), tracked changes (§5.5.7), index and reference
|
||||
/// marks over text in a paragraph being kept, and the text boxes it shows. Begun and ended in
|
||||
/// it, they are its own; one reaching into another paragraph (a comment ending there, a
|
||||
/// change, a box continuing in another's) ties it to that one, as a link of its [`Group`]
|
||||
/// (`model::Group`): the paragraphs are kept as read together, or not at all.
|
||||
#[derive(Default)]
|
||||
struct KeptComments {
|
||||
/// The names of those begun, and of those ended.
|
||||
/// The keys of those begun, and of those ended.
|
||||
opened: Vec<String>,
|
||||
ended: Vec<String>,
|
||||
/// What ties the paragraph to others: `(key, needed)` (see `model::Group::links`).
|
||||
links: Vec<(String, bool)>,
|
||||
/// The comment or drawing being read: its depth and how many paragraphs were finished
|
||||
/// before it.
|
||||
open: Option<(usize, usize)>,
|
||||
@@ -1877,72 +1878,97 @@ struct KeptComments {
|
||||
paras: usize,
|
||||
}
|
||||
|
||||
/// Most pairs and links a paragraph kept as read has (hostile input).
|
||||
const MAX_PAIRS: usize = 64;
|
||||
|
||||
impl KeptComments {
|
||||
/// Element `q` (`a` its attributes, at `depth`) starting in the paragraph, `done`
|
||||
/// paragraphs finished so far: false when the paragraph can't be kept for it.
|
||||
fn start(&mut self, q: &str, a: &Attrs, depth: usize, done: usize) -> bool {
|
||||
let name = || get(a, "office:name").map(str::to_string);
|
||||
if self.opened.len() + self.links.len() >= MAX_PAIRS {
|
||||
return false;
|
||||
}
|
||||
let comment = |n: &str| format!("\u{3}{n}");
|
||||
match q {
|
||||
// A comment in a drawing's text (its own story): not kept.
|
||||
"office:annotation" if self.open.is_some() => false,
|
||||
"office:annotation" if self.opened.len() < 64 => {
|
||||
if get(a, "loext:parent-name").is_some_and(|p| !self.opened.iter().any(|n| n == p)) {
|
||||
return false;
|
||||
"office:annotation" => {
|
||||
// A reply to one in another paragraph needs it.
|
||||
if let Some(p) = get(a, "loext:parent-name").map(comment)
|
||||
&& !self.opened.contains(&p)
|
||||
{
|
||||
self.links.push((p, true));
|
||||
}
|
||||
if let Some(n) = get(a, "office:name").map(comment) {
|
||||
self.opened.push(n);
|
||||
}
|
||||
self.opened.extend(name());
|
||||
self.open = Some((depth, done));
|
||||
true
|
||||
}
|
||||
"office:annotation-end" => match name() {
|
||||
Some(n) if self.opened.contains(&n) => {
|
||||
self.ended.push(n);
|
||||
"office:annotation-end" => match get(a, "office:name").map(comment) {
|
||||
Some(n) => {
|
||||
self.end_or_need(n);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
None => false,
|
||||
},
|
||||
// A tracked change's start and end both in it (a deletion's place stands alone).
|
||||
"text:change-start" if self.opened.len() < 64 => {
|
||||
self.opened.extend(get(a, "text:change-id").map(|i| format!("\u{0}{i}")));
|
||||
true
|
||||
}
|
||||
"text:change-end" => match get(a, "text:change-id").map(|i| format!("\u{0}{i}")) {
|
||||
Some(n) if self.opened.contains(&n) => {
|
||||
self.ended.push(n);
|
||||
"text:change-start" => match get(a, "text:change-id") {
|
||||
Some(i) => {
|
||||
self.opened.push(format!("\u{0}{i}"));
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
None => false,
|
||||
},
|
||||
"text:change-end" => match get(a, "text:change-id") {
|
||||
Some(i) => {
|
||||
self.end_or_need(format!("\u{0}{i}"));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
"text:change" => get(a, "text:change-id").is_some(),
|
||||
// An index or reference mark over text (§6.6.2, §8.1.10): its start and end in it.
|
||||
// An index or reference mark over text (§6.6.2, §8.1.10).
|
||||
q if mark_pair(q).is_some() => {
|
||||
let Some((kind, attr, start)) = mark_pair(q) else { return false };
|
||||
let Some(key) = get(a, attr).map(|k| format!("\u{2}{kind}\u{2}{k}")) else { return false };
|
||||
if start {
|
||||
if self.opened.len() >= 64 {
|
||||
return false;
|
||||
}
|
||||
self.opened.push(key);
|
||||
true
|
||||
} else if self.opened.contains(&key) {
|
||||
self.ended.push(key);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
self.end_or_need(key);
|
||||
}
|
||||
true
|
||||
}
|
||||
// A text box continuing in another (§19.112 `draw:chain-next-name`) needs it.
|
||||
"draw:text-box" => {
|
||||
if let Some(next) = get(a, "draw:chain-next-name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
self.links.push((format!("\u{4}{next}"), true));
|
||||
}
|
||||
true
|
||||
}
|
||||
// A text box continuing in another box, or continued from one: their names tie them.
|
||||
"draw:text-box" if get(a, "draw:chain-next-name").is_some() => false,
|
||||
// A drawing or a note: the paragraphs of its text (a text box's, a shape's, the
|
||||
// note's) are its story's.
|
||||
// note's) are its story's. A named frame is there for a box continuing in it.
|
||||
q if self.open.is_none() && (q == "text:note" || q.starts_with("draw:") && is_drawing(q)) => {
|
||||
if q == "draw:frame"
|
||||
&& let Some(n) = get(a, "draw:name").map(str::trim).filter(|n| !n.is_empty())
|
||||
{
|
||||
self.links.push((format!("\u{4}{n}"), false));
|
||||
}
|
||||
self.open = Some((depth, done));
|
||||
true
|
||||
}
|
||||
"text:change-start" => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` ended: one begun in the paragraph, or one begun in another, needed.
|
||||
fn end_or_need(&mut self, key: String) {
|
||||
if self.opened.contains(&key) {
|
||||
self.ended.push(key);
|
||||
} else {
|
||||
self.links.push((key, true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the comment or drawing being read is the element ending at `depth`.
|
||||
fn ends_at(&self, depth: usize) -> bool {
|
||||
self.open.is_some_and(|(d, _)| d == depth)
|
||||
@@ -1955,9 +1981,20 @@ impl KeptComments {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every comment begun with a name ended.
|
||||
/// Whether nothing is left open in it (a comment being read); and its links: what it begun
|
||||
/// and didn't end needs the paragraph ending it, and what it begun is there for another
|
||||
/// to need (a reply, an end).
|
||||
fn closed(&self) -> bool {
|
||||
self.open.is_none() && self.opened.iter().all(|n| self.ended.contains(n))
|
||||
self.open.is_none()
|
||||
}
|
||||
|
||||
/// The paragraph's [`Group`](model::Group) links, when it has any.
|
||||
fn group(&self) -> Option<Box<model::Group>> {
|
||||
let mut links = self.links.clone();
|
||||
for k in &self.opened {
|
||||
links.push((k.clone(), !self.ended.contains(k)));
|
||||
}
|
||||
(!links.is_empty()).then(|| Box::new(model::Group { links, id: None }))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,11 +143,6 @@ impl Body<'_> {
|
||||
}
|
||||
// Its name, and the box its text continues in (§19.112 `draw:chain-next-name`).
|
||||
let next = get(a, "draw:chain-next-name").map(str::trim).filter(|n| !n.is_empty()).map(|n| n.chars().take(256).collect::<String>());
|
||||
if let Some(n) = &next
|
||||
&& self.chain_targets.len() < super::MAX_LOOSE
|
||||
{
|
||||
self.chain_targets.push(n.clone());
|
||||
}
|
||||
if let Some(name) = self.frame_name.take().filter(|n| !n.is_empty()) {
|
||||
self.frame_look.chain = Some(Box::new(model::Chain { name, next }));
|
||||
}
|
||||
|
||||
@@ -426,27 +426,142 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> {
|
||||
parse_tracked(bytes, default_paper(), &OdfProfile::default()).map(|p| (p.flow, p.last))
|
||||
}
|
||||
|
||||
/// `blocks` without the markup kept as read that holds a text box another box continues in
|
||||
/// (one of `targets`): WordCraft names both boxes afresh.
|
||||
fn unkeep_chained(blocks: &mut [FBlock], targets: &[String], depth: usize) {
|
||||
if targets.is_empty() {
|
||||
/// The paragraphs kept as read that their links tie together (see `model::Group`), in `blocks`:
|
||||
/// each group given its id and size when every link it needs is there, its paragraphs written
|
||||
/// from the model otherwise (a comment's end in a paragraph not kept, say), and a run kept
|
||||
/// whole (a list) with them.
|
||||
fn resolve_groups(blocks: &mut [FBlock]) {
|
||||
// The linked paragraphs, in order, and the paragraphs holding each key.
|
||||
let mut links: Vec<Vec<(String, bool)>> = Vec::new();
|
||||
walk_grouped(blocks, 0, &mut |g| links.push(g.links.clone()));
|
||||
if links.is_empty() {
|
||||
return;
|
||||
}
|
||||
let names: Vec<String> = targets.iter().map(|t| format!("draw:name=\"{}\"", super::writer::x(t))).collect();
|
||||
let holds = |x: &str| names.iter().any(|n| x.contains(n.as_str()));
|
||||
let mut holders: std::collections::HashMap<&str, Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, l) in links.iter().enumerate() {
|
||||
for (k, _) in l {
|
||||
let h = holders.entry(k.as_str()).or_default();
|
||||
if h.last() != Some(&i) {
|
||||
h.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Groups: paragraphs sharing a key one of them needs.
|
||||
let mut root: Vec<usize> = (0..links.len()).collect();
|
||||
fn find(root: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&r) = root.get(i) {
|
||||
if r == i {
|
||||
break;
|
||||
}
|
||||
i = r;
|
||||
}
|
||||
i
|
||||
}
|
||||
let needed: std::collections::HashSet<&str> = links.iter().flatten().filter(|(_, n)| *n).map(|(k, _)| k.as_str()).collect();
|
||||
for (k, h) in &holders {
|
||||
if needed.contains(k)
|
||||
&& let Some(&first) = h.first()
|
||||
{
|
||||
for &i in h.iter().skip(1) {
|
||||
let (a, b) = (find(&mut root, first), find(&mut root, i));
|
||||
if let Some(r) = root.get_mut(b) {
|
||||
*r = a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A group is whole when each key one of its paragraphs needs another holds.
|
||||
let mut whole: std::collections::HashMap<usize, bool> = std::collections::HashMap::new();
|
||||
let mut size: std::collections::HashMap<usize, u32> = std::collections::HashMap::new();
|
||||
for (i, l) in links.iter().enumerate() {
|
||||
let r = find(&mut root, i);
|
||||
*size.entry(r).or_default() += 1;
|
||||
let ok = l.iter().filter(|(_, n)| *n).all(|(k, _)| holders.get(k.as_str()).is_some_and(|h| h.len() >= 2));
|
||||
let w = whole.entry(r).or_insert(true);
|
||||
*w &= ok;
|
||||
}
|
||||
let mut ids: std::collections::HashMap<usize, u32> = std::collections::HashMap::new();
|
||||
let mut verdicts: Vec<Option<Option<(u32, u32)>>> = Vec::with_capacity(links.len());
|
||||
for i in 0..links.len() {
|
||||
let r = find(&mut root, i);
|
||||
let s = size.get(&r).copied().unwrap_or(1);
|
||||
let needs = links.get(i).is_some_and(|l| l.iter().any(|(_, n)| *n));
|
||||
verdicts.push(match (whole.get(&r).copied().unwrap_or(false), s) {
|
||||
// Alone, needing nothing: its own (what it has, nobody needed).
|
||||
(true, 1) if !needs => Some(None),
|
||||
(true, s) if s > 1 => {
|
||||
let next = u32::try_from(ids.len()).unwrap_or(u32::MAX);
|
||||
Some(Some((*ids.entry(r).or_insert(next), s)))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let mut at = 0usize;
|
||||
apply_groups(blocks, 0, &verdicts, &mut at);
|
||||
}
|
||||
|
||||
/// Each paragraph kept as read with links in `blocks` (its [`model::Group`]), in order.
|
||||
fn walk_grouped(blocks: &[FBlock], depth: usize, f: &mut dyn FnMut(&crate::model::Group)) {
|
||||
for b in blocks {
|
||||
match b {
|
||||
FBlock::Para(p) => p.kept = p.kept.take().filter(|x| !holds(x)),
|
||||
FBlock::Table(t) => {
|
||||
t.kept = t.kept.take().filter(|x| !holds(x));
|
||||
if depth < crate::model::MAX_DEPTH {
|
||||
for r in &mut t.rows {
|
||||
for c in r {
|
||||
unkeep_chained(&mut c.blocks, targets, depth + 1);
|
||||
FBlock::Para(p) => {
|
||||
if let (Some(_), Some(g)) = (&p.kept, &p.group) {
|
||||
f(g);
|
||||
}
|
||||
}
|
||||
FBlock::Table(t) if depth < crate::model::MAX_DEPTH => {
|
||||
for c in t.rows.iter().flatten() {
|
||||
walk_grouped(&c.blocks, depth + 1, f);
|
||||
}
|
||||
}
|
||||
FBlock::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The groups' `verdicts` (in [`walk_grouped`]'s order, from `at`) applied to `blocks`: a group's
|
||||
/// id and size, none, or the paragraph written from the model (with a run kept whole holding it).
|
||||
fn apply_groups(blocks: &mut [FBlock], depth: usize, verdicts: &[Option<Option<(u32, u32)>>], at: &mut usize) {
|
||||
let mut dropped = Vec::new();
|
||||
for (i, b) in blocks.iter_mut().enumerate() {
|
||||
match b {
|
||||
FBlock::Para(p) => {
|
||||
if p.kept.is_some() && p.group.is_some() {
|
||||
match verdicts.get(*at).copied().flatten() {
|
||||
Some(None) => p.group = None,
|
||||
Some(Some(id)) => {
|
||||
if let Some(g) = &mut p.group {
|
||||
g.links.clear();
|
||||
g.id = Some(id);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
(p.kept, p.group) = (None, None);
|
||||
dropped.push(i);
|
||||
}
|
||||
}
|
||||
*at += 1;
|
||||
} else if p.kept.is_none() {
|
||||
p.group = None;
|
||||
}
|
||||
}
|
||||
FBlock::Table(t) if depth < crate::model::MAX_DEPTH => {
|
||||
for c in t.rows.iter_mut().flatten() {
|
||||
apply_groups(&mut c.blocks, depth + 1, verdicts, at);
|
||||
}
|
||||
}
|
||||
FBlock::Table(_) => {}
|
||||
}
|
||||
}
|
||||
// A run kept whole (a list) holding a paragraph written from the model is too.
|
||||
if !dropped.is_empty() {
|
||||
for (i, b) in blocks.iter_mut().enumerate() {
|
||||
if let FBlock::Para(p) = b
|
||||
&& let Some(ix) = p.index.as_deref()
|
||||
&& dropped.iter().any(|d| *d >= i && *d < i + ix.1)
|
||||
{
|
||||
p.index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,7 +788,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul
|
||||
let hyphenate = hyphenation(&mut blocks);
|
||||
let track = body.track;
|
||||
let sources = std::mem::take(&mut body.sources);
|
||||
unkeep_chained(&mut blocks, &body.chain_targets, 0);
|
||||
resolve_groups(&mut blocks);
|
||||
let section_masters = std::mem::take(&mut body.section_masters);
|
||||
// And the pictures paragraphs and tables kept as read show.
|
||||
let mut linking: Vec<(String, std::sync::Arc<str>)> = kept_defs.clone();
|
||||
|
||||
@@ -820,6 +820,33 @@ fn soft_page_breaks(kept: Option<&wordcraft_doc::kept::KeptXml>, body: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The groups of paragraphs written as read together (see `model::Group`) whose paragraphs
|
||||
/// are all still as read in `blocks`: each group's every paragraph has its markup (none edited),
|
||||
/// and no more (none copied).
|
||||
fn whole_groups(blocks: &[FBlock]) -> std::collections::HashSet<u32> {
|
||||
fn count(blocks: &[FBlock], depth: usize, seen: &mut std::collections::HashMap<u32, (u32, u32)>) {
|
||||
for b in blocks {
|
||||
match b {
|
||||
FBlock::Para(p) => {
|
||||
if let (Some(_), Some((id, size))) = (&p.kept, p.group.as_ref().and_then(|g| g.id)) {
|
||||
let e = seen.entry(id).or_insert((0, size));
|
||||
e.0 = e.0.saturating_add(1);
|
||||
}
|
||||
}
|
||||
FBlock::Table(t) if depth < model::MAX_DEPTH => {
|
||||
for c in t.rows.iter().flatten() {
|
||||
count(&c.blocks, depth + 1, seen);
|
||||
}
|
||||
}
|
||||
FBlock::Table(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
count(blocks, 0, &mut seen);
|
||||
seen.into_iter().filter(|(_, (n, size))| n == size).map(|(id, _)| id).collect()
|
||||
}
|
||||
|
||||
/// The prefix of WordCraft's own automatic style names in a document with paragraphs kept as
|
||||
/// read (theirs keep the names they were read with).
|
||||
const KEPT_PREFIX: &str = "wc";
|
||||
@@ -906,6 +933,7 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
|
||||
}));
|
||||
w.kept_defs = std::sync::Arc::new(kept.map(|k| k.defs.clone()).unwrap_or_default());
|
||||
w.reserved = std::sync::Arc::new(kept.map(|k| k.files.keys().cloned().collect()).unwrap_or_default());
|
||||
w.groups_whole = whole_groups(&flow.blocks);
|
||||
// The file's master pages are written as read: what was kept may start them.
|
||||
w.kept_pages = kept.is_some_and(|k| k.pages_as_read(doc));
|
||||
w.accents.clone_from(&accents);
|
||||
|
||||
@@ -314,8 +314,17 @@ const MARKS: Sample = Sample {
|
||||
meta: "",
|
||||
};
|
||||
|
||||
/// What ties paragraphs together: a comment over text from one paragraph into the next, an
|
||||
/// insertion likewise, and a text box continuing in another's in a later paragraph.
|
||||
const GROUPS: Sample = Sample {
|
||||
name: "groups",
|
||||
content: r##"<office:body><office:text><text:tracked-changes><text:changed-region xml:id="ct20" text:id="ct20"><text:insertion><office:change-info><dc:creator>Ada Lovelace</dc:creator><dc:date>2026-11-01T09:00:00</dc:date></office:change-info></text:insertion></text:changed-region></text:tracked-changes><text:p wcx:part="one">The remark <office:annotation office:name="Across" wcx:mark="kept"><dc:creator>Alan Turing</dc:creator><dc:date>2026-11-01T10:00:00</dc:date><text:p>Both paragraphs.</text:p></office:annotation>starts here</text:p><text:p wcx:part="two">and ends<office:annotation-end office:name="Across"/> here; <text:change-start text:change-id="ct20"/>an insertion</text:p><text:p wcx:part="three">going on<text:change-end text:change-id="ct20"/> to here.</text:p><text:p>A box <draw:frame draw:name="First" text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:text-box draw:chain-next-name="Second"><text:p>Words flowing on.</text:p></draw:text-box></draw:frame>here.</text:p><text:p>Its next <draw:frame draw:name="Second" text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:text-box/></draw:frame>there.</text:p></office:text></office:body>"##,
|
||||
styles: "",
|
||||
meta: "",
|
||||
};
|
||||
|
||||
/// Every sample.
|
||||
const SAMPLES: [Sample; 17] = [
|
||||
const SAMPLES: [Sample; 18] = [
|
||||
TEXT,
|
||||
STYLES,
|
||||
META,
|
||||
@@ -333,6 +342,7 @@ const SAMPLES: [Sample; 17] = [
|
||||
NOTES_AND_FORMULAS,
|
||||
LISTS,
|
||||
MARKS,
|
||||
GROUPS,
|
||||
];
|
||||
|
||||
/// Whether `xml` is well-formed: every element closed, in order, once.
|
||||
@@ -841,3 +851,37 @@ fn a_section_opened_by_an_empty_paragraph_is_kept_and_opened_once() {
|
||||
assert_eq!(c.matches("<text:section ").count(), 1, "{c}");
|
||||
assert_eq!(c.matches("</text:section>").count(), 1, "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_edited_anywhere_is_written_from_the_model_whole() {
|
||||
// A paragraph of a group edited: every paragraph of it written from the model, which names
|
||||
// what ties them afresh, consistently.
|
||||
let d = import(&package(&GROUPS)).unwrap();
|
||||
assert!(content_of(&export(&d).unwrap()).contains(r#"wcx:part="two""#));
|
||||
for (start, kept) in [
|
||||
("The remark", "wcx:part"),
|
||||
("and ends", "wcx:part"),
|
||||
("going on", "wcx:part"),
|
||||
("A box", r#"draw:name="First""#),
|
||||
("Its next", r#"draw:name="First""#),
|
||||
] {
|
||||
let mut e = d.clone();
|
||||
let i = e.body.iter().position(|b| b.as_para().is_some_and(|p| p.text.starts_with(start))).unwrap();
|
||||
if let Some(wordcraft_doc::Block::Para(p)) = e.body.get_mut(i).map(std::sync::Arc::make_mut) {
|
||||
p.props.style = Some("Edited".into());
|
||||
p.touch();
|
||||
}
|
||||
let saved = export(&e).unwrap();
|
||||
assert!(parts_well_formed(&saved).is_ok(), "{start}");
|
||||
let c = content_of(&saved);
|
||||
assert!(!c.contains(kept), "{start}: {c}");
|
||||
// What ties them still does: one comment from its start to its end, one insertion, a box
|
||||
// naming the next one there is.
|
||||
let back = import(&saved).unwrap();
|
||||
assert_eq!(back.comments.len(), 1, "{start}");
|
||||
let names: Vec<&str> = c.split(r#"office:name=""#).skip(1).filter_map(|r| r.split('"').next()).collect();
|
||||
assert!(names.len() == 2 && names[0] == names[1], "{start}: {c}");
|
||||
let next = c.split(r#"draw:chain-next-name=""#).nth(1).and_then(|r| r.split('"').next()).unwrap_or("-");
|
||||
assert!(c.contains(&format!(r#"draw:name="{next}""#)), "{start}: {c}");
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,8 @@ pub(super) struct Writer {
|
||||
/// The file's tracked changes markup written as read marks, written as read among the
|
||||
/// regions (each once).
|
||||
kept_changes: std::collections::BTreeSet<String>,
|
||||
/// The groups of paragraphs written as read together that are whole (see `model::Group`).
|
||||
pub(super) groups_whole: std::collections::HashSet<u32>,
|
||||
/// The sections kept as read opened so far (their markup's address: see `open_wrap`).
|
||||
wraps_opened: std::collections::HashSet<usize>,
|
||||
/// Lists kept as read given an `xml:id` (see `kept_run`).
|
||||
@@ -637,9 +639,18 @@ impl Writer {
|
||||
/// `super::kept`); false when it is written from the model.
|
||||
fn kept_para(&mut self, p: &Para, out: &mut String) -> bool {
|
||||
let Some(xml) = &p.kept else { return false };
|
||||
if !self.group_whole(p) {
|
||||
return false;
|
||||
}
|
||||
self.kept_markup(xml, out)
|
||||
}
|
||||
|
||||
/// Whether paragraph `p`'s markup kept as read can be written so: it belongs to no group, or
|
||||
/// to one whose every paragraph is as read.
|
||||
fn group_whole(&self, p: &Para) -> bool {
|
||||
p.group.as_ref().and_then(|g| g.id).is_none_or(|(id, _)| self.groups_whole.contains(&id))
|
||||
}
|
||||
|
||||
/// Markup kept as read (a paragraph's or a table's), with the definitions it names; false
|
||||
/// when one of them can't be written as read (see `super::kept::needed`).
|
||||
pub(in crate::odt) fn kept_markup(&mut self, xml: &str, out: &mut String) -> bool {
|
||||
@@ -770,6 +781,11 @@ impl Writer {
|
||||
fn kept_run(&mut self, blocks: &[FBlock], i: usize, out: &mut String) -> Option<usize> {
|
||||
let Some(FBlock::Para(p)) = blocks.get(i) else { return None };
|
||||
let ix = p.index.as_deref()?;
|
||||
// Its paragraphs' groups whole (one tied to a paragraph since edited writes it anew).
|
||||
let run = blocks.get(i..i + ix.1.max(1)).unwrap_or(&[]);
|
||||
if !run.iter().all(|b| !matches!(b, FBlock::Para(q) if !self.group_whole(q))) {
|
||||
return None;
|
||||
}
|
||||
if !(self.master.is_none() || self.kept_pages) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously triviLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previousLine truncated
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
|
||||
| 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) |
|
||||
| 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) |
|
||||
| 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) |
|
||||
| 2026-11-04 | trivial | ODT: paragraphs tied together are written back as read as a group: a comment over text from one paragraph into another, a tracked change or an index mark likewise, a text box continuing in another's. While every paragraph of the group is as read, all are written so; one edited (or copied), all are written from the model, which names what ties them afresh. |
|
||||
| 2026-11-04 | trivial | ODT: a paragraph showing a chart is written back as read while unchanged: drawing the chart from its data on opening (which changes nothing) no longer stops its markup matching. |
|
||||
| 2026-11-04 | trivial | ODT: a named section inside a table's cell (StarOffice's specification template hides some there) is written as read around the cell's paragraphs, closed in the cell; one starting or ending at a paragraph with twins elsewhere (an empty one, as in LibreOffice's agenda templates) is kept too, by that paragraph's own identity, and opened once whatever is copied. Over the corpus 126 of 133 files round-trip pixel-identical (124); over the 220 from other applications 176 (175), 218 with the same page count (216). |
|
||||
| 2026-11-04 | trivial | ODT element audit ([odt-element-audit.md](odt-element-audit.md)): every element of the ODF 1.3 schema against two corpora (365 files) and a document of our own for each a text document can hold that no file did. Found: index marks over text, plugins and floating frames weren't kept, a form's data source was taken for a control, and password, number, time and grid form controls were dropped; all kept now. |
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: linked text boxes, form controls, comments and changes spanning paragraphs are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ODT element audit
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (paragraphs with charts kept as read); previously trivial (sections in table cells and at empty paragraphs kept); previously trivial (password, number, time and grid form controls kept); previously minor (first version: every element of the ODF 1.3 schema against two corpora and a generated sample of each element never seen)
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (paragraphs tied together kept as read as groups); previously trivial (paragraphs with charts kept as read); previously trivial (sections in table cells and at empty paragraphs kept); previously trivial (password, number, time and grid form controls kept); previously minor (first version: every element of the ODF 1.3 schema against two corpora and a generated sample of each element never seen)
|
||||
|
||||
Every element the ODF 1.3 schema (OASIS, Part 3, RELAX NG) defines, checked against what WordCraft's ODT reader and writer do with it. The question for each: does a file holding it open, and does a save without edits keep it? (See [file-format-parity.md](file-format-parity.md) for what WordCraft models; markup it doesn't model is written back as read while unedited: [odf-interpretation.md](odf-interpretation.md).)
|
||||
|
||||
@@ -102,7 +102,7 @@ The check found these, each fixed with a test (see [file-format-parity.md](file-
|
||||
|
||||
## Left
|
||||
|
||||
- **A paragraph showing a form control or a linked text box, or holding a comment or tracked change reaching into another paragraph**: written from the model, which keeps what WordCraft models of it.
|
||||
- **A paragraph showing a form control**: written from the model, which keeps what WordCraft models of it.
|
||||
- **Calligra's page sequence** (`text:page-sequence`, a layout of fixed pages) and **numbered paragraphs** (`text:numbered-paragraph`): read as text, written from the model.
|
||||
- **Soft page breaks between paragraphs** (a layout hint LibreOffice recomputes) and **unused automatic styles**: not written.
|
||||
|
||||
@@ -114,3 +114,4 @@ The check found these, each fixed with a test (see [file-format-parity.md](file-
|
||||
| 2026-11-04 | trivial | Password, number, time and grid form controls kept |
|
||||
| 2026-11-04 | trivial | Sections in table cells and at empty paragraphs kept |
|
||||
| 2026-11-04 | trivial | Paragraphs with charts kept as read |
|
||||
| 2026-11-04 | trivial | Paragraphs tied together (a comment, a change or a mark reaching into another, linked text boxes) kept as read as groups |
|
||||
Reference in new issue
Block a user