From 3cc5185b1e2ff5f4cd9ba660d150c4534ba250a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 12:43:51 +0000 Subject: [PATCH] Gap 19: ODT charts opened and saved as editable charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chart object (`draw:object` whose sub-document is an `office:chart`, ODF 1.3 §10.4.3, Part 3 §11) used to open as its replacement picture. It is now read into WordCraft's chart model: column, bar (stacked or not), line, area, pie, doughnut and scatter charts, with categories, series names and values (gaps included) taken from the chart's own table through the series' cell range addresses, plus title, legend position and data labels. The engine draws opened charts in the document's theme colours, and Chart Design's commands edit them. Charts WordCraft can't edit as they are (3D, percent-stacked, mixed plot types, a second value axis, radar and the like) keep their picture. WordCraft charts are written as ODF chart objects shaped like LibreOffice's: a data table, each series and slice in its accent colour, pies clockwise from the top. They validate against the ODF 1.3 schema, and LibreOffice draws them like WordCraft does. In flat documents they are written inline as an `office:document`. Bug fixed: flat ODT reading ended the body at the first nested ``, so an inline sub-document dropped the text after it. Element lookup now follows nesting, with a bounded depth. Flow model: new `Inline::Chart`, kept only by flows that ask for boxes (ODT); other formats still get the alt text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/engine/src/io_ext.rs | 9 + crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/odt_charts.rs | 51 ++++ crates/formats/src/html/export.rs | 2 +- crates/formats/src/latex/export.rs | 1 + crates/formats/src/markdown/export.rs | 4 +- crates/formats/src/model.rs | 2 +- crates/formats/src/model/from_doc.rs | 13 +- crates/formats/src/model/to_doc.rs | 40 ++- crates/formats/src/model/types.rs | 17 ++ crates/formats/src/odt.rs | 1 + crates/formats/src/odt/body.rs | 36 ++- crates/formats/src/odt/body/drawing.rs | 11 +- crates/formats/src/odt/chart.rs | 321 ++++++++++++++++++++ crates/formats/src/odt/flat.rs | 37 ++- crates/formats/src/odt/package.rs | 11 +- crates/formats/src/odt/tests.rs | 8 +- crates/formats/src/odt/tests_more.rs | 1 + crates/formats/src/odt/tests_more/charts.rs | 291 ++++++++++++++++++ crates/formats/src/odt/tests_more/flat.rs | 15 + crates/formats/src/odt/writer.rs | 11 +- crates/formats/src/odt/writer/charts.rs | 197 ++++++++++++ crates/formats/src/odt/writer/drawing.rs | 9 +- crates/formats/src/rtf/export.rs | 1 + docs/file-format-parity.md | 5 +- 25 files changed, 1053 insertions(+), 42 deletions(-) create mode 100644 crates/engine/tests/it/odt_charts.rs create mode 100644 crates/formats/src/odt/chart.rs create mode 100644 crates/formats/src/odt/tests_more/charts.rs create mode 100644 crates/formats/src/odt/writer/charts.rs diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 762ac423a1..8bac7c5973 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -14,12 +14,21 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti other => wordcraft_formats::import_on(other, bytes, page).map(|r| { r.map(|mut doc| { crate::io::fit_pictures(&mut doc); + draw_charts(&mut doc); doc }) }), } } +/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the +/// document's theme colours, at their size. +fn draw_charts(doc: &mut Document) { + let theme = doc.settings.theme_colors.clone(); + let undrawn = |o: &wordcraft_doc::para::InlineObject| matches!(o, wordcraft_doc::para::InlineObject::Graphic { graphic, .. } if graphic.chart.is_some() && graphic.items.is_empty()); + doc.objects_mut(&undrawn, &mut |o| crate::cmd::chart::redraw(o, &theme)); +} + /// Save to a format other than plain text / JSON. pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option, String>> { // .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects. diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 64350459d1..99fee7b3c7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -1,6 +1,7 @@ mod comment_ids; mod date_fields; mod macro_packages; +mod odt_charts; mod odt_fields; #[cfg(not(target_arch = "wasm32"))] mod odt_sections; diff --git a/crates/engine/tests/it/odt_charts.rs b/crates/engine/tests/it/odt_charts.rs new file mode 100644 index 0000000000..4ece9e7bea --- /dev/null +++ b/crates/engine/tests/it/odt_charts.rs @@ -0,0 +1,51 @@ +//! A chart in an ODT file (ODF 1.3 §10.4.3, an embedded `office:chart` object) opens as a chart +//! WordCraft draws and edits: Chart Design's commands change it like a chart made here. The ODT +//! is original and synthetic, shaped like LibreOffice's. + +use std::io::Write; + +use serde_json::json; +use wordcraft_doc::chart::ChartType; +use wordcraft_doc::para::InlineObject; +use wordcraft_engine::{Session, io}; + +const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + +fn odt() -> Vec { + let content = format!( + r#"Sales "# + ); + let chart = r#"VisitsSiteMonTueWed"#; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart)] + { + z.start_file(name, o).unwrap(); + z.write_all(data.as_bytes()).unwrap(); + } + z.finish().unwrap().into_inner() +} + +/// The document's charts: their type, title, and whether they're drawn. +fn charts(s: &Session) -> Vec<(ChartType, Option, bool)> { + s.doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + InlineObject::Graphic { graphic, .. } => graphic.chart.as_ref().map(|c| (c.kind, c.title.clone(), !graphic.items.is_empty())), + _ => None, + }) + .collect() +} + +#[test] +fn an_odt_chart_opens_drawn_and_editable() { + let mut s = Session::new(io::open_bytes("chart.odt", &odt()).unwrap()); + assert_eq!(charts(&s), [(ChartType::Line, Some("Visits".to_string()), true)]); + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("chart.type", &json!({"type": "column"})).unwrap(); + s.run("chart.title", &json!({"text": "Weekly visits"})).unwrap(); + assert_eq!(charts(&s), [(ChartType::Column, Some("Weekly visits".to_string()), true)]); +} diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 7ddf181101..1a4cb38163 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -155,7 +155,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String { } } // No picture to show: the browser shows the alt text in its place. - Inline::Figure(alt) => o.push_str(&format!("\"{}\"", esc(alt))), + Inline::Figure(alt) | Inline::Chart(crate::model::Chart { alt, .. }) => o.push_str(&format!("\"{}\"", esc(alt))), // MathML, which browsers, Word and LibreOffice show as an equation (#816). Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)), } diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 6c9e979f5b..c4bf25319d 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -241,6 +241,7 @@ impl Writer { } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), Inline::Figure(_) + | Inline::Chart(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 8e8b811baa..478857b8e5 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -132,7 +132,9 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect(); pieces.push((String::new(), Fmt::default(), Some(format!("")))); } - Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), + Inline::Figure(alt) | Inline::Chart(crate::model::Chart { alt, .. }) => { + pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))) + } Inline::Equation { .. } => {} Inline::Field { result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)), Inline::Note { .. } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 224aba2ddc..e224f18bf2 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -26,7 +26,7 @@ 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, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH, + Affix, BoxLook, CODE_STYLE, Cell, Chart, 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, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object, }; diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 1106682bc1..d82c7bb793 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -489,11 +489,20 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { None => {} } } - Some(InlineObject::Graphic { alt, .. }) => { + Some(InlineObject::Graphic { alt, w, h, float, graphic }) => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); } - if !alt.trim().is_empty() { + // An editable chart, for formats that keep charts (ODT). + if let Some(spec) = graphic.chart.as_ref().filter(|_| opts.boxes) { + out.inlines.push(Inline::Chart(crate::model::Chart { + spec: spec.clone(), + w: *w, + h: *h, + alt: alt.clone(), + float: kept_float(float).filter(|_| opts.paras), + })); + } else if !alt.trim().is_empty() { out.inlines.push(Inline::Figure(alt.clone())); } } diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 65c0f1d4fd..2e5478c6d4 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -321,19 +321,43 @@ impl<'a> Builder<'a> { /// The document object for picture, shape or group `i` (members of groups `depth` deep). fn drawing(&mut self, i: &Inline, depth: usize) -> Option { + // Offsets and distances on the page. + let fit_float = |float: &Option>| { + float.as_deref().cloned().map(|mut f| { + let fit = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 }; + (f.x, f.y) = (fit(f.x), fit(f.y)); + (f.dist, f.dist_top, f.dist_bottom) = (fit(f.dist).max(0.0), fit(f.dist_top).max(0.0), fit(f.dist_bottom).max(0.0)); + f + }) + }; match i { Inline::Image(img) => { let key = self.doc.add_media(img.data.to_vec(), &img.ext); let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0)); - let float = img.float.as_deref().cloned().map(|mut f| { - // Offsets and distances on the page. - let fit = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 }; - (f.x, f.y) = (fit(f.x), fit(f.y)); - (f.dist, f.dist_top, f.dist_bottom) = (fit(f.dist).max(0.0), fit(f.dist_top).max(0.0), fit(f.dist_bottom).max(0.0)); - f - }); + let float = fit_float(&img.float); Some(InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: float.unwrap_or_default(), crop: [0.0; 4], ole: None }) } + // Its drawing is made from the chart by the program that opens the document + // (`wordcraft_engine`'s chart drawing), which has the theme's colours. + Inline::Chart(c) => { + let (w, h) = (finite_or(c.w, 360.0).clamp(1.0, 1584.0), finite_or(c.h, 216.0).clamp(1.0, 1584.0)); + let mut spec = (*c.spec).clone(); + spec.sanitize(); + let graphic = wordcraft_doc::graphic::Graphic { + kind: wordcraft_doc::graphic::GraphicKind::Chart, + w, + h, + chart: Some(Arc::new(spec)), + ..Default::default() + }; + Some(InlineObject::Graphic { + w, + h, + alt: clean_text(&c.alt), + float: fit_float(&c.float).unwrap_or_default(), + graphic: Arc::new(graphic), + }) + } Inline::Shape { kind, w, h, float, blocks, look, path } => { // A text box always has its story; a shape only when it holds text. let story = if *kind == wordcraft_doc::para::ShapeKind::TextBox || !blocks.is_empty() { @@ -450,7 +474,7 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(obj)); } Inline::FieldEnd => append(&mut out, "", &CharProps::default(), Some(InlineObject::FieldEnd)), - Inline::Image(_) | Inline::Shape { .. } | Inline::Group { .. } => { + Inline::Image(_) | Inline::Chart(_) | Inline::Shape { .. } | Inline::Group { .. } => { if let Some(obj) = self.drawing(i, 0) { append(&mut out, "", &CharProps::default(), Some(obj)); } diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index a1c4640706..4fbd39c260 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -152,6 +152,18 @@ pub struct Img { pub float: Option>, } +/// An editable chart in a flow ([`Inline::Chart`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Chart { + pub spec: Arc, + /// Display size, points. + pub w: f32, + pub h: f32, + pub alt: String, + /// How a floating chart is placed and wrapped; `None`: in line with the text. + pub float: Option>, +} + #[derive(Clone, Debug, PartialEq)] pub enum Inline { Text(String, Fmt), @@ -165,6 +177,9 @@ pub enum Inline { /// A chart or diagram known here only by its alt text: written where alt text has a place /// (an HTML or Markdown image), left out of editable formats so it never turns into body text. Figure(String), + /// A chart WordCraft can edit, `w` × `h` points: only in flows asked for boxes + /// ([`FlowOpts::boxes`]); others get its alt text as an [`Inline::Figure`]. + Chart(Chart), /// An equation in the linear format WordCraft keeps them in (`x=(-b±√(b^2-4ac))/2a`). Formats /// without equations write `linear` as text. Equation { @@ -615,6 +630,7 @@ impl Para { Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) + | Inline::Chart(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) @@ -641,6 +657,7 @@ impl Para { Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) + | Inline::Chart(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 87b2b1bf3a..1f57043f6e 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -38,6 +38,7 @@ use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; use crate::model::{self, Cell, FBlock, FComment, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; mod body; +mod chart; mod datastyle; mod fields; mod flat; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 7badbd9588..762b96436a 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -19,7 +19,8 @@ use notes::{OpenField, OpenNote, balance_comments}; use tables::TableB; use tracked::{DelState, Region, change_mark}; -type Frame = (Option, Option, Option>, String, Option<(String, bool)>); +/// An open frame: its size, picture, alt text, equation and chart. +type Frame = (Option, Option, Option>, String, Option<(String, bool)>, Option); pub(super) struct Body<'a> { styles: &'a Styles, @@ -682,7 +683,7 @@ impl Body<'_> { "frame" => { let w = get(a, "svg:width").and_then(length); let h = get(a, "svg:height").and_then(length); - self.frame = Some((w, h, None, String::new(), None)); + self.frame = Some((w, h, None, String::new(), None, None)); let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default(); self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g)); self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None); @@ -694,23 +695,19 @@ impl Body<'_> { && let Some(fr) = &mut self.frame && fr.2.is_none() && fr.4.is_none() + && fr.5.is_none() { let path = href.trim_start_matches("./"); fr.2 = (self.zip)(path); } } // An embedded object (ODF 1.3 §10.4.3): a formula sub-document's `content.xml` holds - // a `math:math` tree (#3099). Other object kinds have none, so the frame still falls - // back to its `draw:image` replacement preview, read at `end` below. + // a `math:math` tree (#3099), a chart's an `office:chart`. Other object kinds (and + // charts WordCraft can't edit) fall back to the frame's `draw:image` replacement + // preview, read at `end` below. "object" => { - if let Some(href) = get(a, "xlink:href") - && let Some(fr) = &mut self.frame - && fr.4.is_none() - { - let path = format!("{}/content.xml", href.trim_start_matches("./").trim_end_matches('/')); - if let Some(xml) = (self.zip)(&path) { - fr.4 = read_formula(&xml); - } + if let Some(href) = get(a, "xlink:href") { + self.read_object(href); } } "desc" | "title" if self.frame.is_some() => self.in_desc = true, @@ -734,6 +731,21 @@ impl Body<'_> { } } + /// The open frame's object at `href`: its formula, or else its chart. + fn read_object(&mut self, href: &str) { + if let Some(fr) = &mut self.frame + && fr.4.is_none() + { + let path = format!("{}/content.xml", href.trim_start_matches("./").trim_end_matches('/')); + if let Some(xml) = (self.zip)(&path) { + fr.4 = read_formula(&xml); + if fr.4.is_none() { + fr.5 = super::chart::read_chart(&xml); + } + } + } + } + fn start_inline(&mut self, q: &str, a: &Attrs) -> bool { // Inside a field everything is its shown text. if self.field.is_some() { diff --git a/crates/formats/src/odt/body/drawing.rs b/crates/formats/src/odt/body/drawing.rs index e7e0033656..7080086539 100644 --- a/crates/formats/src/odt/body/drawing.rs +++ b/crates/formats/src/odt/body/drawing.rs @@ -75,7 +75,7 @@ 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() { + if let Some((w, h, img, alt, eq, chart)) = self.frame.take() { let boxed = self.frame_box.take(); let mut float = self.frame_float.take(); if boxed.is_some() @@ -101,6 +101,13 @@ impl Body<'_> { 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) if let Some(spec) = chart => Some(Inline::Chart(model::Chart { + spec: std::sync::Arc::new(spec), + w: w.unwrap_or(360.0), + h: h.unwrap_or(216.0), + alt: alt.trim().to_string(), + float: float.map(Box::new), + })), (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) @@ -189,7 +196,7 @@ impl Body<'_> { 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)); + let frame = (Some((Some(w), Some(h), None, String::new(), None, None)), Some(float), std::mem::take(&mut self.frame_look)); self.start_note(a); if let Some(n) = &mut self.note { n.text_box = Some(frame); diff --git a/crates/formats/src/odt/chart.rs b/crates/formats/src/odt/chart.rs new file mode 100644 index 0000000000..b29bc40701 --- /dev/null +++ b/crates/formats/src/odt/chart.rs @@ -0,0 +1,321 @@ +//! Charts embedded in a document (ODF 1.3 §10.4.3 `draw:object` whose sub-document is an +//! `office:chart`, Part 3 §11): read into WordCraft's editable chart model when the chart is one +//! WordCraft can edit as it is (one plot type in two dimensions, its data in the chart's own +//! table). Anything else returns `None`, and the frame keeps its replacement picture. +//! +//! The data comes from the chart's `table:table` (§11.1): the series' and categories' cell range +//! addresses (§19.583, `local-table.$B$2:.$B$5`) point into it, counting its header rows and +//! columns like any other. + +use std::collections::HashMap; + +use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_CELLS, MAX_POINTS, MAX_SERIES, MAX_TEXT}; + +use super::xml::Attrs; +use super::{Ev, events, get}; + +/// Most XML events read from a chart sub-document. +const MAX_EVENTS: usize = 2_000_000; +/// Most rows read from a chart's table: a header row and a row per category. +const MAX_ROWS: usize = MAX_POINTS + 1; +/// Most columns read from it: a label column and two per series (a scatter series' x values). +const MAX_COLS: usize = MAX_SERIES * 2 + 1; + +/// One cell of the chart's table: its text, and its number when it holds one (`Some(None)`: a +/// number cell without a usable value, LibreOffice's `NaN` gap). +#[derive(Clone, Default)] +struct DataCell { + text: String, + num: Option>, +} + +/// A `chart:series` element as read. +#[derive(Default)] +struct RawSeries { + values: Option, + label: Option, + class: Option, + style: Option, + domain: Option, + secondary: bool, +} + +#[derive(Default)] +struct Reader { + /// Chart styles' `style:chart-properties`, by style name. + props: HashMap, + style: Option, + class: Option, + plot_style: Option, + title: Option, + in_title: bool, + /// The legend's position, when there is a legend. + legend: Option, + categories: Option, + series: Vec, + in_series: bool, + /// The chart's table: its rows of cells, and how deep in tables the reader is. + rows: Vec>, + table_depth: usize, + /// How many times the open row and cell repeat. + row_repeat: usize, + cell_repeat: usize, + cell: Option, + cells: usize, + in_chart: bool, +} + +/// A count attribute (`table:number-rows-repeated`…): 1 when absent or unusable, at most `max`. +fn repeat(a: &Attrs, k: &str, max: usize) -> usize { + get(a, k).and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, max.max(1)) +} + +impl Reader { + fn start(&mut self, q: &str, a: &Attrs) { + match q { + "style:style" => self.style = get(a, "style:name").map(str::to_string), + "style:chart-properties" => { + if let Some(name) = self.style.clone() { + self.props.insert(name, a.clone()); + } + } + "chart:chart" if !self.in_chart => { + self.in_chart = true; + self.class = get(a, "chart:class").map(str::to_string); + } + "chart:title" => self.in_title = true, + "text:p" if self.in_title => { + let t = self.title.get_or_insert_with(String::new); + if !t.is_empty() { + t.push(' '); + } + } + "chart:legend" => self.legend = Some(get(a, "chart:legend-position").unwrap_or("end").to_string()), + "chart:plot-area" => self.plot_style = get(a, "chart:style-name").map(str::to_string), + "chart:categories" if self.categories.is_none() => self.categories = get(a, "table:cell-range-address").map(str::to_string), + "chart:series" => { + self.in_series = true; + if self.series.len() < MAX_SERIES { + self.series.push(RawSeries { + values: get(a, "chart:values-cell-range-address").map(str::to_string), + label: get(a, "chart:label-cell-address").map(str::to_string), + class: get(a, "chart:class").map(str::to_string), + style: get(a, "chart:style-name").map(str::to_string), + domain: None, + secondary: get(a, "chart:attached-axis").is_some_and(|v| v.starts_with("secondary")), + }); + } + } + "chart:domain" if self.in_series => { + if let Some(s) = self.series.last_mut().filter(|s| s.domain.is_none()) { + s.domain = get(a, "table:cell-range-address").map(str::to_string); + } + } + "table:table" => self.table_depth += 1, + "table:table-row" if self.table_depth == 1 => { + self.row_repeat = repeat(a, "table:number-rows-repeated", MAX_ROWS); + if self.rows.len() < MAX_ROWS { + self.rows.push(Vec::new()); + } + } + "table:table-cell" | "table:covered-table-cell" if self.table_depth == 1 => { + self.cell_repeat = repeat(a, "table:number-columns-repeated", MAX_COLS); + let num = (get(a, "office:value-type") == Some("float") || get(a, "office:value-type") == Some("percentage")) + .then(|| get(a, "office:value").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())); + self.cell = Some(DataCell { text: String::new(), num }); + } + _ => {} + } + } + + fn end(&mut self, q: &str) { + match q { + "style:style" => self.style = None, + "chart:title" => self.in_title = false, + "chart:series" => self.in_series = false, + "table:table" => self.table_depth = self.table_depth.saturating_sub(1), + "table:table-cell" | "table:covered-table-cell" if self.table_depth == 1 => { + if let Some(cell) = self.cell.take() + && let Some(row) = self.rows.last_mut() + { + let n = self.cell_repeat.min(MAX_COLS.saturating_sub(row.len())).min(MAX_CELLS.saturating_sub(self.cells)); + self.cells += n; + row.extend(std::iter::repeat_n(cell, n)); + } + } + "table:table-row" if self.table_depth == 1 => { + // A repeated row: its copies, within the table's and the chart's limits. + if let Some(row) = self.rows.last().cloned() { + let room = MAX_ROWS.saturating_sub(self.rows.len()); + let by_cells = MAX_CELLS.saturating_sub(self.cells).checked_div(row.len()).unwrap_or(room); + let n = self.row_repeat.saturating_sub(1).min(room).min(by_cells); + self.cells += n.saturating_mul(row.len()); + self.rows.extend(std::iter::repeat_n(row, n)); + } + self.row_repeat = 1; + } + _ => {} + } + } + + fn text(&mut self, t: &str) { + if self.in_title + && let Some(title) = &mut self.title + && title.len() < MAX_TEXT * 4 + { + title.push_str(t); + } else if let Some(cell) = &mut self.cell + && cell.text.len() < MAX_TEXT * 4 + { + cell.text.push_str(t); + } + } + + /// The chart properties of style `name`, attribute `k`. + fn prop(&self, name: Option<&str>, k: &str) -> Option<&str> { + name.and_then(|n| self.props.get(n)).and_then(|a| get(a, k)) + } + + fn cell(&self, (col, row): (usize, usize)) -> Option<&DataCell> { + self.rows.get(row).and_then(|r| r.get(col)) + } + + /// The cells a range address covers, in order, at most [`MAX_POINTS`]. + fn range(&self, addr: &str) -> Vec> { + cells_of(addr).into_iter().map(|at| self.cell(at)).collect() + } + + fn chart(&self) -> Option { + let class = self.class.as_deref().map(|c| c.trim_start_matches("chart:"))?; + let plot = self.plot_style.as_deref(); + let on = |k: &str| self.prop(plot, k) == Some("true"); + if on("chart:three-dimensional") || on("chart:percentage") || on("chart:deep") { + return None; + } + let stacked = on("chart:stacked"); + let kind = match class { + "bar" => match (on("chart:vertical"), stacked) { + (false, false) => ChartType::Column, + (false, true) => ChartType::ColumnStacked, + (true, false) => ChartType::Bar, + (true, true) => ChartType::BarStacked, + }, + "line" if !stacked => ChartType::Line, + "area" if !stacked => ChartType::Area, + "circle" => ChartType::Pie, + "ring" => ChartType::Doughnut, + "scatter" => ChartType::Scatter, + _ => return None, + }; + // One plot type on one value axis: a series of another type, or on a second axis, is + // a combination chart WordCraft can't edit. + let own = |c: &str| { + let c = c.trim_start_matches("chart:"); + c == class || (kind == ChartType::Doughnut && c == "circle") + }; + if self.series.is_empty() || self.series.iter().any(|s| s.secondary || !s.class.as_deref().is_none_or(own)) { + return None; + } + let label = |c: Option<&DataCell>| c.map(|c| c.text.trim().to_string()).unwrap_or_default(); + let series = self + .series + .iter() + .enumerate() + .map(|(i, s)| { + // A series without its own range takes the table's next column. + let values = match &s.values { + Some(addr) => self.range(addr).into_iter().map(|c| c.and_then(value)).collect(), + None => self.rows.iter().skip(1).take(MAX_POINTS).map(|r| r.get(i + 1).and_then(value)).collect(), + }; + let name = s.label.as_deref().and_then(|a| cells_of(a).first().copied()).map(|at| label(self.cell(at))).unwrap_or_default(); + let name = if name.is_empty() { format!("Series {}", i + 1) } else { name }; + ChartSeries { name, values } + }) + .collect(); + // A scatter chart's x values: its first series' domain. + let domain = self.series.first().and_then(|s| s.domain.as_deref()).filter(|_| kind == ChartType::Scatter); + let categories = match domain.or(self.categories.as_deref()) { + Some(addr) => self.range(addr).into_iter().map(label).collect(), + None => Vec::new(), + }; + let data_labels = std::iter::once(plot) + .chain(self.series.iter().map(|s| s.style.as_deref())) + .any(|st| matches!(self.prop(st, "chart:data-label-number"), Some("value" | "percentage" | "value-and-percentage"))); + let legend = self.legend.as_deref().map(|p| match p { + "start" => LegendPos::Left, + p if p.starts_with("top") => LegendPos::Top, + p if p.starts_with("bottom") => LegendPos::Bottom, + _ => LegendPos::Right, + }); + let title = self.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); + Some(ChartSpec { kind, categories, series, title, legend, data_labels }.sanitized()) + } +} + +/// A cell's number: its value, or its text read as a number. +fn value(c: &DataCell) -> Option { + match c.num { + Some(v) => v, + None => c.text.trim().parse::().ok().filter(|v| v.is_finite()), + } +} + +/// A cell address (`$B$2`, `local-table.B2`, `'my table'.$B$2`): its column and row, from 0. +fn cell_at(s: &str) -> Option<(usize, usize)> { + // The table name, when there is one, ends at the last dot. + let s = s.rsplit('.').next().unwrap_or(s).trim().trim_start_matches('$'); + let letters = s.bytes().take_while(u8::is_ascii_alphabetic).count(); + if letters == 0 || letters > 3 { + return None; + } + let (col, rest) = s.split_at_checked(letters)?; + let col = col.bytes().try_fold(0usize, |n, b| n.checked_mul(26)?.checked_add(usize::from(b.to_ascii_uppercase() - b'A') + 1))?; + let row: usize = rest.trim_start_matches('$').parse().ok()?; + Some((col.checked_sub(1)?, row.checked_sub(1)?)) +} + +/// The cells of a range address's first range (`local-table.$B$2:.$B$5`), in reading order: down +/// a column, or along a row (a block: down its first column), at most [`MAX_POINTS`]. +fn cells_of(addr: &str) -> Vec<(usize, usize)> { + let first = addr.split_whitespace().next().unwrap_or_default(); + let mut ends = first.splitn(2, ':'); + let Some(a) = ends.next().and_then(cell_at) else { return Vec::new() }; + let b = match ends.next() { + Some(e) => match cell_at(e) { + Some(b) => b, + None => return Vec::new(), + }, + None => a, + }; + let (c0, c1) = (a.0.min(b.0), a.0.max(b.0)); + let (r0, r1) = (a.1.min(b.1), a.1.max(b.1)); + if r0 == r1 && c0 != c1 { (c0..=c1).take(MAX_POINTS).map(|c| (c, r0)).collect() } else { (r0..=r1).take(MAX_POINTS).map(|r| (c0, r)).collect() } +} + +/// The chart a chart sub-document's `content.xml` holds, when WordCraft can edit it. +pub(in crate::odt) fn read_chart(xml: &[u8]) -> Option { + let mut r = Reader::default(); + let mut count = 0usize; + let mut done = false; + events(xml, |ev| { + count += 1; + if done || count > MAX_EVENTS { + return; + } + match ev { + Ev::Start(q, a) => r.start(&q, &a), + Ev::End(q) => { + if q == "chart:chart" && r.in_chart { + done = true; + } + r.end(&q); + } + Ev::Text(t) => r.text(&t), + } + }); + // A sub-document cut off before its chart ended is no chart. + if !done { + return None; + } + r.chart() +} diff --git a/crates/formats/src/odt/flat.rs b/crates/formats/src/odt/flat.rs index 511116ca9e..4ae1fff8d3 100644 --- a/crates/formats/src/odt/flat.rs +++ b/crates/formats/src/odt/flat.rs @@ -35,12 +35,36 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str { if xml.get(..tag_end).is_some_and(|t| t.ends_with('/')) { return xml.get(start..=tag_end).unwrap_or(""); } + // Its end: the close tag at its own depth (a chart object inside holds an `office:body`, + // an `office:document` of its own). let close = format!(""); - match xml.get(tag_end..).and_then(|r| r.find(&close)) { - Some(e) => xml.get(start..tag_end + e + close.len()).unwrap_or(""), - // Unclosed (malformed): to the end. - None => xml.get(start..).unwrap_or(""), + let mut depth = 0usize; + let mut at = tag_end; + // Nesting is bounded like any XML here: deeper is malformed. + for _ in 0..super::limits::MAX_XML_DEPTH.saturating_mul(2) { + let rest = xml.get(at..).unwrap_or(""); + let next_close = rest.find(&close); + let next_open = rest.match_indices(&open).map(|(i, _)| i).find(|&i| { + let after = rest.get(i + open.len()..).unwrap_or(""); + after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()) + // Not self-closed. + && after.find('>').and_then(|e| after.get(..e)).is_some_and(|t| !t.ends_with('/')) + }); + match (next_open, next_close) { + (Some(o), Some(c)) if o < c => { + depth += 1; + at += o + open.len(); + } + (_, Some(c)) if depth > 0 => { + depth -= 1; + at += c + close.len(); + } + (_, Some(c)) => return xml.get(start..at + c + close.len()).unwrap_or(""), + // Unclosed (malformed): to the end. + (_, None) => return xml.get(start..).unwrap_or(""), + } } + xml.get(start..).unwrap_or("") } /// What is inside element `el` (an element string as [`element`] gives). @@ -232,6 +256,11 @@ pub fn export_flat(doc: &Document) -> Result, String> { let math = bytes_of(&format!("{}/content.xml", target.trim_start_matches("./"))); let math = String::from_utf8_lossy(&math); let math = math.split_once("?>").map_or(math.as_ref(), |(_, m)| m).trim(); + // A sub-document (a chart) goes in as an `office:document` (§3.1.2), its root. + let math = match math.strip_prefix("")) { + Some(inner) => format!(""), + None => math.to_string(), + }; // A plain `draw:object` around it: the reference's other attributes go with it. let rest_tag_end = after.find("/>").unwrap_or(0); flat.push_str(before.rfind(" Result, String> { doc, model::FlowOpts { tracked: true, fields: true, notes: true, comments: true, lists: true, chars: true, paras: true, boxes: true }, ); + let accents = doc.settings.theme_colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default(); let mut w = Writer::default(); + w.accents.clone_from(&accents); w.ranged = model::ranged_anchors(&flow.blocks); let mut body = String::new(); let sections = flow_sections(&flow.blocks, &doc.last_section); @@ -570,6 +572,7 @@ pub fn export(doc: &Document) -> Result, String> { w.automatic_styles() ); let mut hw = Writer::with_prefix("M"); + hw.accents = accents; let parts: Vec = pages.iter().map(|(_, sec)| page_parts(doc, sec, &mut hw)).collect(); let mut named = std::mem::take(&mut w.named); named.append(&mut hw.named); @@ -590,7 +593,7 @@ pub fn export(doc: &Document) -> Result, String> { 1, ); w.pictures.append(&mut hw.pictures); - w.formulas.append(&mut hw.formulas); + w.objects.append(&mut hw.objects); let meta = meta_xml(&flow.meta); let mut manifest = String::from( "\n\n\n\n\n\n\n", @@ -598,9 +601,9 @@ pub fn export(doc: &Document) -> Result, String> { for (name, _, mime) in &w.pictures { manifest.push_str(&format!("\n", x(name))); } - for (dir, _) in &w.formulas { + for (dir, _, mime) in &w.objects { manifest.push_str(&format!( - "\n\n", + "\n\n", x(dir) )); } @@ -623,7 +626,7 @@ pub fn export(doc: &Document) -> Result, String> { z.start_file(name, deflated).map_err(err)?; z.write_all(data).map_err(ioerr)?; } - for (dir, xml) in &w.formulas { + for (dir, xml, _) in &w.objects { z.start_file(format!("{dir}/content.xml"), deflated).map_err(err)?; z.write_all(xml.as_bytes()).map_err(ioerr)?; } diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index df0458eae2..dc1f7fbbc7 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -198,7 +198,7 @@ pub(super) fn package(content: &str, styles: Option<&str>) -> Vec { z.finish().unwrap().into_inner() } -fn paras(f: &Flow) -> Vec<&Para> { +pub(super) fn paras(f: &Flow) -> Vec<&Para> { f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect() } @@ -1146,6 +1146,12 @@ pub(super) fn content_of(bytes: &[u8]) -> String { String::from_utf8(read_entry(&mut z, "content.xml").unwrap()).unwrap() } +/// Entry `name` of a package, as text. +pub(super) fn entry(bytes: &[u8], name: &str) -> String { + let mut z = archive(bytes); + String::from_utf8(read_entry(&mut z, name).unwrap()).unwrap() +} + /// The `styles.xml` of a package. pub(super) fn styles_of(bytes: &[u8]) -> String { let mut z = archive(bytes); diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 21dcf0d908..63074f17de 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -3,6 +3,7 @@ mod bookmarks; mod char_styles; mod chars; +mod charts; mod comments; mod drop_caps; mod equations; diff --git a/crates/formats/src/odt/tests_more/charts.rs b/crates/formats/src/odt/tests_more/charts.rs new file mode 100644 index 0000000000..e3c3dea019 --- /dev/null +++ b/crates/formats/src/odt/tests_more/charts.rs @@ -0,0 +1,291 @@ +//! Charts (ODF 1.3 §10.4.3 embedded objects holding an `office:chart` sub-document, Part 3 +//! §11): read into WordCraft's editable chart (type, categories, series, title, legend, data +//! labels) from the chart's own data table, so they're drawn and edited like charts WordCraft +//! makes. Charts WordCraft can't edit keep their replacement picture. + +use std::sync::Arc; + +use crate::model::Inline; +use crate::odt::parse; +use crate::odt::tests::{NS_T, paras, zip_of}; +use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos}; + +const NS_C: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; + +/// LibreOffice's data table for categories Q1–Q4 and series North and South (South has a gap +/// in Q2, written `NaN`), with its labels in the first row and column. +const TABLE: &str = r#"NorthSouthQ11012Q214NaNQ3811Q41613"#; + +/// The two series over [`TABLE`], each `class`, with style `ser` (and its data points). +fn series(class: &str) -> String { + format!( + r#""# + ) +} + +/// A chart sub-document's `content.xml` as LibreOffice writes it: chart class `class`, plot +/// area chart properties `plot`, series style chart properties `ser`, and `inner` (title, +/// legend) before the plot area, holding `series`. +fn chart_xml(class: &str, plot: &str, ser: &str, inner: &str, series: &str) -> String { + format!( + r#"{inner}{series}{TABLE}"# + ) +} + +/// What the document holding chart object `chart` (with a replacement picture) reads as: the +/// paragraph's charts, and whether a picture was read instead. +fn read(chart: &str) -> (Vec, bool) { + let frame = r#""#; + let content = format!( + r#"Before {frame}"# + ); + let img = image::RgbaImage::from_fn(4, 2, |_, _| image::Rgba([0, 80, 160, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let flow = + parse(&zip_of(&[("content.xml", content.as_bytes()), ("Object 1/content.xml", chart.as_bytes()), ("ObjectReplacements/Object 1", &png)])) + .unwrap(); + let inlines: Vec = paras(&flow).into_iter().flat_map(|p| p.inlines.clone()).collect(); + let charts = inlines.iter().filter_map(|i| if let Inline::Chart(c) = i { Some(c.clone()) } else { None }).collect(); + (charts, inlines.iter().any(|i| matches!(i, Inline::Image(_)))) +} + +fn spec(chart: &str) -> Option { + let (charts, _) = read(chart); + charts.first().map(|c| (*c.spec).clone()) +} + +fn north_south() -> Vec { + vec![ + ChartSeries { name: "North".into(), values: vec![Some(10.0), Some(14.0), Some(8.0), Some(16.0)] }, + ChartSeries { name: "South".into(), values: vec![Some(12.0), None, Some(11.0), Some(13.0)] }, + ] +} + +#[test] +fn a_column_chart_reads_its_data_title_and_legend() { + let inner = r#"Sales by quarter"#; + let xml = chart_xml("chart:bar", r#"chart:vertical="false""#, r#"chart:data-label-number="none""#, inner, &series("chart:bar")); + let (charts, picture) = read(&xml); + assert!(!picture, "the chart replaces its preview picture"); + let c = charts.first().expect("a chart"); + assert_eq!( + *c.spec, + ChartSpec { + kind: ChartType::Column, + categories: vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()], + series: north_south(), + title: Some("Sales by quarter".into()), + legend: Some(LegendPos::Right), + data_labels: false, + } + ); + // The frame's size, in points. + assert_eq!(((c.w * 10.0).round(), (c.h * 10.0).round()), (4252.0, 2126.0)); +} + +#[test] +fn bar_stacking_legend_and_labels_come_from_the_styles() { + let cases = [ + (r#"chart:vertical="true""#, ChartType::Bar), + (r#"chart:vertical="true" chart:stacked="true""#, ChartType::BarStacked), + (r#"chart:stacked="true""#, ChartType::ColumnStacked), + ]; + for (plot, kind) in cases { + let xml = chart_xml( + "chart:bar", + plot, + r#"chart:data-label-number="value""#, + r#""#, + &series("chart:bar"), + ); + let s = spec(&xml).expect(plot); + assert_eq!((s.kind, s.legend, s.data_labels, s.title.clone()), (kind, Some(LegendPos::Bottom), true, None), "{plot}"); + assert_eq!(s.series, north_south()); + } + // No legend element: no legend. Legend corners and sides. + for (pos, want) in [ + ("", None), + ("start", Some(LegendPos::Left)), + ("top", Some(LegendPos::Top)), + ("top-end", Some(LegendPos::Top)), + ("bottom-start", Some(LegendPos::Bottom)), + ] { + let legend = if pos.is_empty() { String::new() } else { format!(r#""#) }; + let s = spec(&chart_xml("chart:bar", "", "", &legend, &series("chart:bar"))).unwrap(); + assert_eq!(s.legend, want, "{pos}"); + } +} + +#[test] +fn line_area_pie_doughnut_and_scatter_classes() { + for (class, ser_class, kind) in [ + ("chart:line", "chart:line", ChartType::Line), + ("chart:area", "chart:area", ChartType::Area), + ("chart:circle", "chart:circle", ChartType::Pie), + // LibreOffice writes a doughnut's series as `chart:circle` in a `chart:ring` chart. + ("chart:ring", "chart:circle", ChartType::Doughnut), + ] { + let s = spec(&chart_xml(class, "", "", "", &series(ser_class))).expect(class); + assert_eq!(s.kind, kind, "{class}"); + assert_eq!(s.series, north_south()); + } + // A scatter chart's x values are its first series' domain: they're its categories. + let scatter = r#""#; + let s = spec(&chart_xml("chart:scatter", "", "", "", scatter)).unwrap(); + assert_eq!(s.kind, ChartType::Scatter); + assert_eq!(s.categories, vec!["10", "14", "8", "16"]); + assert_eq!(s.series, vec![ChartSeries { name: "South".into(), values: vec![Some(12.0), None, Some(11.0), Some(13.0)] }]); +} + +#[test] +fn charts_wordcraft_cannot_edit_keep_their_picture() { + // Percent-stacked, a radar chart, a chart mixing columns and a line, 3D: the picture. + let mixed = format!( + "{}{}", + series("chart:bar").split("").next().unwrap_or_default().to_string() + "", + r#""# + ); + for xml in [ + chart_xml("chart:bar", r#"chart:stacked="true" chart:percentage="true""#, "", "", &series("chart:bar")), + chart_xml("chart:radar", "", "", "", &series("chart:radar")), + chart_xml("chart:bar", "", "", "", &mixed), + chart_xml("chart:bar", r#"chart:three-dimensional="true""#, "", "", &series("chart:bar")), + // Not a chart at all, or broken. + "".to_string(), + "".to_string(), + ] { + let (charts, picture) = read(&xml); + assert!(charts.is_empty() && picture, "{xml}"); + } +} + +#[test] +fn hostile_chart_tables_are_bounded() { + // Huge repeat counts, references far outside the table and into reversed or broken ranges: + // never a panic or a huge allocation, and the series stay within the chart's limits. + let table = r#""#; + let series = r#""#; + let xml = format!( + r#"{series}{table}"# + ); + let s = spec(&xml).expect("a chart"); + assert!(s.series.len() <= wordcraft_doc::chart::MAX_SERIES); + assert!(s.series.iter().all(|x| x.values.len() <= wordcraft_doc::chart::MAX_POINTS)); + assert!(s.categories.len() <= wordcraft_doc::chart::MAX_POINTS); +} + +#[test] +fn an_imported_chart_is_an_editable_chart_object() { + let xml = chart_xml("chart:line", "", "", "", &series("chart:line")); + let frame = r#""#; + let content = format!( + r#"{frame}"# + ); + let doc = crate::odt::import(&zip_of(&[("content.xml", content.as_bytes()), ("Object 1/content.xml", xml.as_bytes())])).unwrap(); + let graphics: Vec> = doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects_with_offsets()) + .filter_map( + |(_, o)| if let wordcraft_doc::para::InlineObject::Graphic { graphic, w, h, .. } = o { Some((graphic.clone(), *w, *h)) } else { None }, + ) + .map(|(g, w, h)| { + assert_eq!((w, h), (288.0, 144.0)); + g + }) + .collect(); + let g = graphics.first().expect("a chart object"); + assert_eq!(g.kind, wordcraft_doc::graphic::GraphicKind::Chart); + assert_eq!(g.chart.as_ref().map(|c| c.kind), Some(ChartType::Line)); +} + +/// A document holding `spec` as a chart `w` × `h` points, after some text. +fn doc_with(spec: ChartSpec, float: bool) -> wordcraft_doc::Document { + let flow = crate::model::Flow { + blocks: vec![crate::model::FBlock::Para(crate::model::Para { + inlines: vec![ + Inline::Text("See ".into(), Default::default()), + Inline::Chart(crate::model::Chart { + spec: Arc::new(spec), + w: 300.0, + h: 180.0, + alt: "Sales chart".into(), + float: float.then(|| { + Box::new(wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, x: 20.0, y: 10.0, ..Default::default() }) + }), + }), + ], + ..Default::default() + })], + ..Default::default() + }; + crate::model::to_doc(&flow) +} + +/// The charts the saved document reads back as. +fn reread(bytes: &[u8]) -> Vec { + paras(&parse(bytes).unwrap()) + .into_iter() + .flat_map(|p| p.inlines.clone()) + .filter_map(|i| if let Inline::Chart(c) = i { Some(c) } else { None }) + .collect() +} + +#[test] +fn every_chart_type_is_saved_as_a_chart_object_and_reads_back() { + for kind in ChartType::ALL { + let mut spec = ChartSpec::sample(kind); + spec.title = Some("Q & A ".into()); + spec.data_labels = kind == ChartType::Bar; + spec.legend = Some(LegendPos::Left); + if let Some(s) = spec.series.first_mut() + && let Some(v) = s.values.get_mut(1) + { + *v = None; + } + let bytes = crate::odt::export(&doc_with(spec.clone(), false)).unwrap(); + let back = reread(&bytes); + assert_eq!(back.len(), 1, "{kind:?}"); + assert_eq!(*back[0].spec, spec.clone().sanitized(), "{kind:?}"); + assert_eq!((back[0].w, back[0].h, back[0].alt.as_str()), (300.0, 180.0, "Sales chart"), "{kind:?}"); + // The package lists the chart object. + let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + assert!(z.file_names().any(|n| n == "Object 1/content.xml"), "{kind:?}"); + let manifest = crate::odt::tests::entry(&bytes, "META-INF/manifest.xml"); + assert!( + manifest + .contains(r#"manifest:full-path="Object 1/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.chart""#) + ); + } +} + +#[test] +fn a_floating_chart_keeps_its_place_and_wrap() { + let bytes = crate::odt::export(&doc_with(ChartSpec::sample(ChartType::Column), true)).unwrap(); + let back = reread(&bytes); + let f = back.first().and_then(|c| c.float.as_deref()).expect("a floating chart"); + assert_eq!((f.wrap, f.x.round(), f.y.round()), (wordcraft_doc::para::Wrap::Square, 20.0, 10.0)); +} + +#[test] +fn a_flat_document_holds_its_chart_inline() { + let flat = crate::odt::export_flat(&doc_with(ChartSpec::sample(ChartType::Pie), false)).unwrap(); + let text = String::from_utf8(flat.clone()).unwrap(); + assert!(text.contains(" = crate::odt::import_flat(&flat) + .unwrap() + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.clone()) + .filter_map(|o| match o { + wordcraft_doc::para::InlineObject::Graphic { graphic, w, h, alt, .. } => { + graphic.chart.clone().map(|spec| crate::model::Chart { spec, w, h, alt, float: None }) + } + _ => None, + }) + .collect(); + assert_eq!(back.first().map(|c| c.spec.kind), Some(ChartType::Pie)); +} diff --git a/crates/formats/src/odt/tests_more/flat.rs b/crates/formats/src/odt/tests_more/flat.rs index 220a709a28..86843efabc 100644 --- a/crates/formats/src/odt/tests_more/flat.rs +++ b/crates/formats/src/odt/tests_more/flat.rs @@ -109,3 +109,18 @@ fn hostile_flat_documents_do_not_panic() { } } } + +#[test] +fn an_inline_sub_document_does_not_end_the_body() { + // A chart object inline holds an `office:document` with an `office:body` of its own: the + // outer body goes on past it (the text after the chart stays), and deep nesting is bounded. + let chart = r#""#; + let xml = format!( + r#"Before{chart}After the chart."# + ); + let d = import_flat(xml.as_bytes()).unwrap(); + let text: Vec = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect(); + assert_eq!(text, ["Before", "After the chart."]); + let deep = format!("{}", "".repeat(5000)); + let _ = import_flat(deep.as_bytes()); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index e554c0420d..2acbf59414 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -1,6 +1,7 @@ use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model}; use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing}; +mod charts; mod drawing; mod indexes; mod notes; @@ -143,8 +144,10 @@ pub(super) struct Writer { /// Graphic styles of floating pictures: their `style:graphic-properties` attributes. graphic_styles: Vec, pub(super) pictures: Vec<(String, std::sync::Arc>, String)>, - /// Formula sub-documents: their directory and their `content.xml`. - pub(super) formulas: Vec<(String, String)>, + /// Object sub-documents (formulas, charts): their directory, `content.xml` and media type. + pub(super) objects: Vec<(String, String, &'static str)>, + /// The theme's accent colours, which charts' series are drawn in. + pub(super) accents: Vec, /// `text:changed-region` elements written so far. pub(super) regions: Vec, /// The insertion open in the body, and its region id. @@ -395,6 +398,10 @@ impl Writer { prev_space = false; } Inline::Figure(_) => {} + Inline::Chart(c) => { + self.chart(c, Place::Text(c.float.as_deref()), out); + prev_space = false; + } } } } diff --git a/crates/formats/src/odt/writer/charts.rs b/crates/formats/src/odt/writer/charts.rs new file mode 100644 index 0000000000..683705d91f --- /dev/null +++ b/crates/formats/src/odt/writer/charts.rs @@ -0,0 +1,197 @@ +//! Charts written as ODF chart objects (ODF 1.3 §10.4.3 `draw:object` holding an `office:chart` +//! sub-document, Part 3 §11), shaped like LibreOffice's: the chart's data in its own table +//! (`local-table`, labels in the first row and column), each series pointing at its column, its +//! colour the theme accent WordCraft draws it in. + +use wordcraft_doc::Rgb; +use wordcraft_doc::chart::{ChartSpec, ChartType, LegendPos}; + +use super::drawing::{Place, placement}; +use super::{Writer, pt, x}; +use crate::model; + +/// The table's column `i` (from 0) as a cell address names it: `A`, `B`… `Z`, `AA`… +fn column(i: usize) -> String { + let mut n = i + 1; + let mut s = Vec::new(); + while n > 0 { + let d = (n - 1) % 26; + s.push(b'A' + d as u8); + n = (n - 1) / 26; + } + s.iter().rev().map(|&b| char::from(b)).collect() +} + +/// `local-table.$C$2:.$C$5`: column `col`'s rows 2 to `n + 1` (the data under its label). +fn data_range(col: usize, n: usize) -> String { + let c = column(col); + format!("local-table.${c}$2:.${c}${}", n + 1) +} + +/// Most slices given their own colour (the rest take the chart's own colours). +const MAX_SLICE_STYLES: usize = 64; + +/// The `i`th series' (or slice's) colour, `#rrggbb`: the accents in turn. +fn accent(accents: &[Rgb], i: usize) -> String { + match accents.get(i.checked_rem(accents.len()).unwrap_or(0)) { + Some(c) => format!("#{}", c.hex().to_ascii_lowercase()), + None => "#4472c4".into(), + } +} + +/// A number as a table cell's `office:value`: gaps are `NaN`, as LibreOffice writes them. +fn number(v: Option) -> String { + match v.filter(|v| v.is_finite()) { + Some(v) => format!("{v}"), + None => "NaN".into(), + } +} + +/// A chart sub-document's `content.xml` for `spec`, `w` × `h` points, series in `accents`. +pub(in crate::odt) fn chart_xml(spec: &ChartSpec, w: f32, h: f32, accents: &[Rgb]) -> String { + let spec = spec.clone().sanitized(); + let (class, series_class) = match spec.kind { + ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => ("bar", "bar"), + ChartType::Line => ("line", "line"), + ChartType::Area => ("area", "area"), + ChartType::Pie => ("circle", "circle"), + ChartType::Doughnut => ("ring", "circle"), + ChartType::Scatter => ("scatter", "scatter"), + }; + let vertical = matches!(spec.kind, ChartType::Bar | ChartType::BarStacked); + let stacked = matches!(spec.kind, ChartType::ColumnStacked | ChartType::BarStacked); + let labels = if spec.data_labels { " chart:data-label-number=\"value\"" } else { " chart:data-label-number=\"none\"" }; + let n = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len()); + // Scatter charts are points without lines. + let points = if spec.kind == ChartType::Scatter { " chart:lines=\"false\" chart:symbol-type=\"automatic\"" } else { "" }; + // A white wall, labelled axes in grey, a legend without a frame: as WordCraft draws them. + let mut styles = format!( + "\ + \ + \ + \ + \ + " + ); + // A pie's slices take the accents in turn. + if spec.kind.is_round() { + for k in 0..n.min(MAX_SLICE_STYLES) { + styles.push_str(&format!( + "", + accent(accents, k) + )); + } + } + let mut series = String::new(); + for i in 0..spec.series.len() { + let col = i + 1; + let fill = accent(accents, i); + let line = match spec.kind { + ChartType::Line => format!(" draw:stroke=\"solid\" svg:stroke-color=\"{fill}\" svg:stroke-width=\"0.06cm\""), + _ => " draw:stroke=\"none\"".to_string(), + }; + styles.push_str(&format!( + "" + )); + // A scatter series' x values are the first column's. + let domain = if spec.kind == ChartType::Scatter && n > 0 { + format!("", data_range(0, n)) + } else { + String::new() + }; + let values = if n > 0 { format!(" chart:values-cell-range-address=\"{}\"", data_range(col, n)) } else { String::new() }; + let slices: String = if spec.kind.is_round() { + (0..n.min(MAX_SLICE_STYLES)).map(|k| format!("")).collect() + } else { + String::new() + }; + series.push_str(&format!( + "{domain}{slices}", + column(col) + )); + } + let title = spec.title.as_deref().map(|t| format!("{}", x(t))).unwrap_or_default(); + let legend = spec + .legend + .map(|p| { + let pos = match p { + LegendPos::Right => "end", + LegendPos::Left => "start", + LegendPos::Top => "top", + LegendPos::Bottom => "bottom", + }; + format!("") + }) + .unwrap_or_default(); + let categories = if n > 0 { format!("", data_range(0, n)) } else { String::new() }; + let axes = format!( + "{categories}{}", + // A pie runs clockwise from the top, as WordCraft draws it. + if spec.kind.is_round() { "round" } else { "axis" }, + if spec.kind.is_round() { "" } else { "" } + ); + // The table: the series' names over their columns, a row per category. + let mut table = + String::from(""); + if !spec.series.is_empty() { + table.push_str(&format!( + "", + spec.series.len() + )); + } + table.push_str(""); + for s in &spec.series { + table.push_str(&format!("{}", x(&s.name))); + } + table.push_str(""); + for k in 0..n { + let cat = spec.categories.get(k).map(String::as_str).unwrap_or(""); + table.push_str(""); + // A scatter chart's x values are numbers. + match cat.trim().parse::().ok().filter(|v| v.is_finite() && spec.kind == ChartType::Scatter) { + Some(v) => table.push_str(&format!( + "{}", + x(cat) + )), + None => table.push_str(&format!("{}", x(cat))), + } + for s in &spec.series { + let v = number(s.values.get(k).copied().flatten()); + table.push_str(&format!("{v}")); + } + table.push_str(""); + } + table.push_str(""); + format!( + "\n{styles}{title}{legend}{axes}{series}{table}\n", + pt(w), + pt(h) + ) +} + +impl Writer { + /// A chart: its frame here, placed as its float says, and its chart object in the package. + pub(in crate::odt) fn chart(&mut self, c: &model::Chart, place: Place<'_>, out: &mut String) { + let n = self.objects.len() + 1; + let name = format!("Object {}{n}", self.prefix); + self.objects.push((name.clone(), chart_xml(&c.spec, c.w, c.h, &self.accents), "application/vnd.oasis.opendocument.chart")); + let (anchor, style) = match place { + Place::Text(None) => (" text:anchor-type=\"as-char\"".to_string(), String::new()), + _ => { + let (anchor, props, xy) = placement(place, c.w, c.h); + (anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props))) + } + }; + out.push_str(&format!( + "", + self.prefix, + pt(c.w), + pt(c.h), + x(&name) + )); + if !c.alt.is_empty() { + out.push_str(&format!("{}", x(&c.alt))); + } + out.push_str(""); + } +} diff --git a/crates/formats/src/odt/writer/drawing.rs b/crates/formats/src/odt/writer/drawing.rs index e04f942b33..1b612d83a0 100644 --- a/crates/formats/src/odt/writer/drawing.rs +++ b/crates/formats/src/odt/writer/drawing.rs @@ -236,6 +236,7 @@ impl Writer { 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::Chart(c) => self.chart(c, Place::Member(x, y, c.float.as_deref()), out), Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => { out.push_str(""); self.members(members, (x, y), depth + 1, out); @@ -365,10 +366,14 @@ impl Writer { /// 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 n = self.objects.len() + 1; let name = format!("Object {}{n}", self.prefix); let mathml = crate::mathml::linear_to_mathml(linear, display); - self.formulas.push((name.clone(), format!("\n{mathml}\n"))); + self.objects.push(( + name.clone(), + format!("\n{mathml}\n"), + "application/vnd.oasis.opendocument.formula", + )); 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()); diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index f91da71ef1..d9a5c87be1 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -401,6 +401,7 @@ impl Writer { Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), Inline::Field { result, fmt, .. } => self.run(result, fmt, out), Inline::Figure(_) + | Inline::Chart(_) | Inline::Equation { .. } | Inline::Note { .. } | Inline::Comment { .. } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 260734575c..451ce436ec 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~80% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it (LibreOffice also leaves it after the last line)) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); fixed dates and fields without a Word equivalent (variables, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), page number format (the page style's, and a page number field's own as `PAGE \* Arabic`/`roman`/…), page colour, page borders (ODF's at the margin with the text inside their padding ↔ WordCraft's measured from the page edge), footnote and endnote numbering (`text:notes-configuration`: format, first number, restart, page foot or document end) and line numbering (`text:linenumbering-configuration`, on unless it says otherwise: every how many lines, distance, restart on each page), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes (`text:format-change`) as formatting revisions with their author and date — ODF records no old formatting, so the old is the text's own — and WordCraft's formatting revisions written as format changes; regions named by `xml:id` as ODF 1.3 requires; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back, keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drop caps (`style:drop-cap`: lines and distance; WordCraft drops one letter, so a longer drop cap opens as its first letter) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; LibreOffice's default blue line and fill where the styles say nothing), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for opens as a rectangle; shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors, captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line shape is written sitting on the baseline, as Word places it | +| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~82% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it (LibreOffice also leaves it after the last line)) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); fixed dates and fields without a Word equivalent (variables — `text:variable-set`/`-get`, `text:user-field-get`; WordCraft has no document variables to update them from —, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); charts (a `draw:object` whose sub-document is an `office:chart`: column, bar, stacked, line, area, pie, doughnut and scatter charts; categories, series names and values — gaps included — from the chart's own table through their cell range addresses, title, legend position, data labels) open as WordCraft charts, drawn in the theme's colours and edited with Chart Design's commands, and every WordCraft chart is written back as an ODF chart object shaped like LibreOffice's (its data table, each series in its accent colour, slices clockwise from the top), in line or floating, inline in a flat document; a chart WordCraft can't edit as it is (3D, percent-stacked, stacked lines or areas, radar, stock or bubble charts, mixed plot types, a second value axis, one with no series) keeps its replacement picture, and fonts and axis scaling aren't kept; export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), page number format (the page style's, and a page number field's own as `PAGE \* Arabic`/`roman`/…), page colour, page borders (ODF's at the margin with the text inside their padding ↔ WordCraft's measured from the page edge), footnote and endnote numbering (`text:notes-configuration`: format, first number, restart, page foot or document end) and line numbering (`text:linenumbering-configuration`, on unless it says otherwise: every how many lines, distance, restart on each page), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes (`text:format-change`) as formatting revisions with their author and date — ODF records no old formatting, so the old is the text's own — and WordCraft's formatting revisions written as format changes; regions named by `xml:id` as ODF 1.3 requires; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back, keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drop caps (`style:drop-cap`: lines and distance; WordCraft drops one letter, so a longer drop cap opens as its first letter) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; LibreOffice's default blue line and fill where the styles say nothing), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for opens as a rectangle; shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors, captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line shape is written sitting on the baseline, as Word places it | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-11-01 | trivial | ODT: charts (`draw:object` holding an `office:chart`) open as editable WordCraft charts — type, categories, series and gaps from the chart's own table, title, legend, data labels — instead of their replacement picture, drawn in the theme's colours, and WordCraft charts are saved as ODF chart objects LibreOffice draws and edits (each series and slice in its accent colour); charts WordCraft can't edit keep their picture. Flat ODT: an inline sub-document's own `office:body` no longer ends the document's body early, so text after an inline chart is kept (`odt::tests_more::charts`, `odt::tests_more::flat::an_inline_sub_document_does_not_end_the_body`, `odt_charts.rs`) | | 2026-11-01 | trivial | ODT: leading (`style:line-spacing`, extra space between lines) is read as at least a 12 pt line and the leading instead of a proportional spacing, so the extra space goes above each line as LibreOffice draws it: a LibreOffice spacing document went from 6,190 px apart after a round trip to 2,973, and WordCraft's drawing of it from 8,570 to 5,521 (`leading_is_at_least_a_line_and_the_leading`) | | 2026-11-01 | trivial | ODT: tracked formatting changes (`text:format-change` between change marks) open as formatting revisions with their author and date (the old formatting is the text's own: ODF doesn't record it, LibreOffice can't reject them after reopening either), instead of being dropped, and WordCraft's formatting revisions are written as format changes. Fixed: changed regions were written without the `xml:id` ODF 1.3 requires (and the change marks refer to), so a document with tracked changes didn't validate; a region named by `xml:id` alone is read too | | 2026-11-01 | trivial | ODT: drop caps (`style:drop-cap`, lines and distance) read and written. Fixed: a paragraph with its own tab stops lost the background, borders, contextual spacing and right-to-left direction written after them, which went into the style's tab stops instead of onto it (`tab_stops_and_other_paragraph_properties_together`) |