From 836bf6553bacde10aa6d0ca211f002de35f83cef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 06:21:30 +0000 Subject: [PATCH] Gap 19: ODT drawing shapes read and written Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry named by its `draw:enhanced-geometry` `draw:type`) open as WordCraft shapes instead of being skipped: size, placement (in line or floating, as frames are), fill and line from the graphic style chain and the default graphic style (LibreOffice's solid fill and hairline where nothing is stated), their text, and a line's direction from its two ends. LibreOffice's type names and its `ooxml-` preset names map onto WordCraft's kinds; a geometry WordCraft has no shape for opens as a rectangle. WordCraft's shapes are saved the same way: a custom shape by its type alone (LibreOffice rebuilds the geometry from it), a line by its ends, connectors as lines. A shape is saved with `draw:auto-grow-*` off unless it resizes to fit its text, so LibreOffice keeps its size. The flow model's text box becomes `Inline::Shape` with a kind; text boxes are the `TextBox` kind and are written as frames, as before. Not yet: shapes in groups, polygons, paths, rotation, arrowheads, dashes, gradients. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/formats/src/html/export.rs | 2 +- crates/formats/src/latex/export.rs | 2 +- crates/formats/src/markdown/export.rs | 2 +- crates/formats/src/model/from_doc.rs | 17 +- crates/formats/src/model/to_doc.rs | 60 ++++--- crates/formats/src/model/types.rs | 18 +- crates/formats/src/odt.rs | 1 + crates/formats/src/odt/body.rs | 113 +++++++++---- crates/formats/src/odt/frames.rs | 86 +++++++--- crates/formats/src/odt/shapes.rs | 82 ++++++++++ crates/formats/src/odt/styles.rs | 17 +- crates/formats/src/odt/tests.rs | 3 +- crates/formats/src/odt/tests_more.rs | 1 + crates/formats/src/odt/tests_more/shapes.rs | 173 ++++++++++++++++++++ crates/formats/src/odt/writer.rs | 115 +++++++++++-- crates/formats/src/rtf/export.rs | 2 +- docs/file-format-parity.md | 3 +- 17 files changed, 589 insertions(+), 108 deletions(-) create mode 100644 crates/formats/src/odt/shapes.rs create mode 100644 crates/formats/src/odt/tests_more/shapes.rs diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 494a54b9e6..461c47fe18 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -137,7 +137,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String { )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), - Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::TextBox { .. } => {} + Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::Shape { .. } => {} Inline::Field { result, fmt, .. } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 9a784b01f0..c3820ed771 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -240,7 +240,7 @@ impl Writer { o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt))); } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), - Inline::Figure(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::TextBox { .. } => {} + Inline::Figure(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::Shape { .. } => {} Inline::Field { result, fmt, .. } => { let s = self.span(result, fmt); push_apart(&mut o, &s); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 0c5cc98292..704ffd68ef 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -135,7 +135,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { Inline::Figure(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 { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::TextBox { .. } => {} + Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) | Inline::Shape { .. } => {} // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} } diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 7e9fa99509..226d56881f 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -278,20 +278,27 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op } } // A text box's content, without notes, comments or boxes of its own. - InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::TextBox, w, h, float, story: Some(id), fill, stroke, stroke_width, .. } - if opts.boxes => + // A drawing shape or text box, with its text but without notes, comments or boxes of its own. A + // freeform's paths have no ODF shape here; a connector is a straight line. + InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, .. } + if opts.boxes && *kind != wordcraft_doc::para::ShapeKind::Freeform => { + use wordcraft_doc::para::ShapeKind; + let kind = match kind { + ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ShapeKind::Line, + k => *k, + }; let inner = FlowOpts { notes: false, comments: false, boxes: false, ..opts }; - let part = doc.parts.get(id); + let part = story.and_then(|id| doc.parts.get(&id)); let blocks = part.map(|p| flow_blocks(doc, &p.blocks, 0, inner)).unwrap_or_default(); let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float.clone())); let look = BoxLook { - fill: fill.as_ref().map(wordcraft_doc::fill::Fill::main_color), + fill: fill.as_ref().filter(|_| kind != ShapeKind::Line).map(wordcraft_doc::fill::Fill::main_color), line: stroke.filter(|_| stroke_width.is_finite() && *stroke_width > 0.0).map(|c| (c, stroke_width.min(12.0))), inset: part.and_then(|p| p.body.inset.is_some().then(|| p.body.insets_pt())), auto_fit: part.is_some_and(|p| p.body.auto_fit), }; - vec![Inline::TextBox { w: *w, h: *h, float, blocks, look }] + vec![Inline::Shape { kind, w: *w, h: *h, float, blocks, look }] } InlineObject::Field { instr, result, .. } if opts.fields => { vec![Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone() }] diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 169d2e0f4b..c66a644bc3 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -376,34 +376,40 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(InlineObject::CommentEnd { id: *doc_id })); } } - Inline::TextBox { w, h, float, blocks, look } => { - let story = self.blocks(blocks, 1); - if let Ok(id) = self.doc.try_add_part(wordcraft_doc::PartKind::TextBox, story) { - if let Some(part) = self.doc.parts.get_mut(&id) { - let emu = |v: f32| if v.is_finite() { (v.clamp(0.0, 1584.0) * 12_700.0).round() as u32 } else { 0 }; - part.body.inset = look.inset.map(|i| i.map(emu)); - part.body.auto_fit = look.auto_fit; - } - let (stroke, stroke_width) = match look.line { - Some((c, w)) if w.is_finite() && w > 0.0 => (Some(c), w.min(12.0)), - _ => (None, 0.0), - }; - let fit = |v: f32| if v.is_finite() { v.clamp(1.0, 1584.0) } else { 72.0 }; - let obj = InlineObject::Shape { - kind: wordcraft_doc::para::ShapeKind::TextBox, - w: fit(*w), - h: fit(*h), - fill: look.fill.map(wordcraft_doc::fill::Fill::solid), - stroke, - stroke_width, - float: float.as_deref().cloned().unwrap_or_default(), - story: Some(id), - freeform: None, - effects: Default::default(), - extra: Default::default(), - }; - append(&mut out, "", &CharProps::default(), Some(obj)); + Inline::Shape { kind, w, h, float, blocks, look } => { + // 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() { + let story = self.blocks(blocks, 1); + self.doc.try_add_part(wordcraft_doc::PartKind::TextBox, story).ok() + } else { + None + }; + if let Some(part) = story.and_then(|id| self.doc.parts.get_mut(&id)) { + let emu = |v: f32| if v.is_finite() { (v.clamp(0.0, 1584.0) * 12_700.0).round() as u32 } else { 0 }; + part.body.inset = look.inset.map(|i| i.map(emu)); + part.body.auto_fit = look.auto_fit; } + let (stroke, stroke_width) = match look.line { + Some((c, w)) if w.is_finite() && w > 0.0 => (Some(c), w.min(12.0)), + _ => (None, 0.0), + }; + // A line may be flat; anything else has some size. + let least = if *kind == wordcraft_doc::para::ShapeKind::Line { 0.0 } else { 1.0 }; + let fit = |v: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 }; + let obj = InlineObject::Shape { + kind: *kind, + w: fit(*w), + h: fit(*h), + fill: look.fill.map(wordcraft_doc::fill::Fill::solid), + stroke, + stroke_width, + float: float.as_deref().cloned().unwrap_or_default(), + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + append(&mut out, "", &CharProps::default(), Some(obj)); } Inline::Note { endnote, custom, blocks } => { if let Some(obj) = self.note(*endnote, custom, blocks) { diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index b45943b177..76fa590af4 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -198,9 +198,11 @@ pub enum Inline { }, /// Where comment `id`'s range ends. CommentEnd(u32), - /// A text box: its size in points, its placement when it floats, and its content. Only flows - /// asked for text boxes ([`FlowOpts::boxes`]) hold them. - TextBox { + /// A drawing shape or text box: its kind, its size in points, its placement when it floats, + /// its text (none for a shape without), and its look. Only flows asked for them + /// ([`FlowOpts::boxes`]) hold them. + Shape { + kind: wordcraft_doc::para::ShapeKind, w: f32, h: f32, float: Option>, @@ -209,8 +211,8 @@ pub enum Inline { }, } -/// How a text box looks: its fill, its outline, its inner margins, and whether it grows to fit -/// its text. +/// How a text box or shape looks: its fill, its outline, its inner margins, and whether it +/// grows to fit its text. #[derive(Clone, Debug, PartialEq)] pub struct BoxLook { /// Fill colour; `None` is no fill. @@ -276,7 +278,7 @@ pub struct FlowOpts { pub notes: bool, /// Comments as [`Inline::Comment`] and [`Inline::CommentEnd`] (otherwise left out). pub comments: bool, - /// Text boxes as [`Inline::TextBox`] (otherwise left out). + /// Text boxes and drawing shapes as [`Inline::Shape`] (otherwise left out). pub boxes: bool, /// Character formatting beyond the [`Fmt`] fields ([`Fmt::extra`]), hidden text kept (as /// hidden), and a monospaced font by its name rather than as code. @@ -533,7 +535,7 @@ impl Para { | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) - | Inline::TextBox { .. } => {} + | Inline::Shape { .. } => {} } } s @@ -555,7 +557,7 @@ impl Para { | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) - | Inline::TextBox { .. } => {} + | Inline::Shape { .. } => {} } } s diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 92aaab9749..a6c55a15e4 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -44,6 +44,7 @@ mod frames; mod import; mod limits; mod package; +mod shapes; mod styles; mod writer; mod xml; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 55776c7971..b1bb8e29c5 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -287,6 +287,10 @@ pub(super) struct Body<'a> { frame_box: Option>, /// The look of the frame being read, should it hold a text box. frame_look: model::BoxLook, + /// The kind of the drawing shape being read (its text is read as a text box's). + shape_kind: Option, + /// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored. + group_at: Option, /// The note or comment being read. note: Option, /// The list being read (its flow number, [`ListInfo::list`]), the last one, the next number, @@ -400,6 +404,8 @@ impl<'a> Body<'a> { frame_float: None, frame_box: None, frame_look: model::BoxLook::default(), + shape_kind: None, + group_at: None, note: None, comment_names: HashMap::new(), next_comment: 0, @@ -790,6 +796,14 @@ impl Body<'_> { if self.tracked_context(q, a) { return; } + // A custom shape's geometry: its kind. Its formulas and handles aren't read. + if q == "draw:enhanced-geometry" && self.shape_kind.is_some() { + if let Some(t) = get(a, "draw:type") { + self.shape_kind = Some(super::shapes::kind_of(t)); + } + self.skip = 1; + return; + } if self.start_inline(q, a) || self.start_note_part(q, a) || self.start_list_part(q, a) { return; } @@ -823,8 +837,20 @@ impl Body<'_> { | "table-of-content-source" => { self.skip = 1; } - // Drawing shapes (ODF 1.3 §10.3), with their text, until shapes are read (#1654). - // Groups are walked, so the pictures in them still are. + // Drawing shapes (ODF 1.3 §10.3) anchored in the text, with their text. + "rect" | "ellipse" | "line" | "custom-shape" + if q.starts_with("draw:") + && self.group_at.is_none() + && self.frame.is_none() + && self.note.is_none() + && self.del.is_none() + && !self.in_tc => + { + self.start_shape(q, a); + } + "g" if q == "draw:g" && self.group_at.is_none() => self.group_at = Some(self.depth), + // Other shapes, and shapes in groups, are left out, with their text. Groups are + // walked, so the pictures in them are still read. "rect" | "line" | "polyline" | "polygon" | "regular-polygon" | "path" | "circle" | "ellipse" | "connector" | "caption" | "measure" | "custom-shape" if q.starts_with("draw:") => @@ -1352,6 +1378,11 @@ impl Body<'_> { if q == "text:note" || q == "office:annotation" || q == "draw:text-box" { return true; } + if self.shape_kind.is_some() && super::shapes::is_shape(q) { + // A drawing shape's own end: it goes into the paragraph, its text read. + self.end_frame(); + return true; + } } // Everything inside a field was its text: only the field's own end does anything. An // outer element ending first (malformed) ends the field, then itself. @@ -1379,6 +1410,55 @@ impl Body<'_> { self.xml_space_end = false; } + /// End a frame or drawing shape: its picture, equation, text box or shape goes into the + /// paragraph. + fn end_frame(&mut self) { + if let Some((w, h, img, alt, eq)) = self.frame.take() { + let boxed = self.frame_box.take(); + let float = self.frame_float.take().filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline); + let inline = match (boxed, eq) { + (Some(blocks), _) => { + let look = std::mem::take(&mut self.frame_look); + // Without a height it grows from nothing to its text. + let kind = self.shape_kind.take().unwrap_or(wordcraft_doc::para::ShapeKind::TextBox); + Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look }) + } + (None, Some((linear, display))) => Some(Inline::Equation { linear, display }), + (None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| { + img.float = float.map(Box::new); + Inline::Image(img) + }), + }; + if let Some(inline) = inline { + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(inline); + } + } + } + self.frame = None; + } + + /// Begin a drawing shape (ODF 1.3 §10.3): its size, placement and look, and its text read + /// as a text box's. A custom shape's kind comes from its geometry, read inside it. + fn start_shape(&mut self, q: &str, a: &Attrs) { + let kind = super::shapes::element_kind(local(q)); + let g = self.styles.shape_graphic(get(a, "draw:style-name")); + let (w, h, x, y, rising) = super::shapes::geometry(kind, a); + let at = |v: Option| v.map(super::writer::pt); + let mut float = super::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g); + float.flip_v = rising; + self.frame_look = g.shape_look(kind); + self.shape_kind = Some(kind); + let frame = (Some((Some(w), Some(h), None, String::new(), None)), Some(float), std::mem::take(&mut self.frame_look)); + self.start_note(a); + if let Some(n) = &mut self.note { + n.text_box = Some(frame); + } + } + fn end(&mut self, q: &str) { self.depth = self.depth.saturating_sub(1); if self.end_note_or_field(q) { @@ -1501,33 +1581,8 @@ impl Body<'_> { let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, ..self.page.clone() }; self.break_section(next); } - "frame" => { - if let Some((w, h, img, alt, eq)) = self.frame.take() { - let boxed = self.frame_box.take(); - let float = self.frame_float.take().filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline); - let inline = match (boxed, eq) { - (Some(blocks), _) => { - let look = std::mem::take(&mut self.frame_look); - // Without a height it grows from nothing to its text. - Some(Inline::TextBox { w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look }) - } - (None, Some((linear, display))) => Some(Inline::Equation { linear, display }), - (None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| { - img.float = float.map(Box::new); - Inline::Image(img) - }), - }; - if let Some(inline) = inline { - if self.para.is_none() { - self.start_para(None, None); - } - if let Some(p) = &mut self.para { - p.inlines.push(inline); - } - } - } - self.frame = None; - } + "frame" => self.end_frame(), + "g" if self.group_at == Some(self.depth + 1) => self.group_at = None, _ => {} } } diff --git a/crates/formats/src/odt/frames.rs b/crates/formats/src/odt/frames.rs index 30333d5335..c655d8dee2 100644 --- a/crates/formats/src/odt/frames.rs +++ b/crates/formats/src/odt/frames.rs @@ -7,7 +7,7 @@ use super::styles::parse_border; use super::writer::pt; use super::xml::{Attrs, get, length}; use crate::model::{BoxLook, parse_color}; -use wordcraft_doc::para::{Anchor, Float, FloatAlign, Wrap, WrapText}; +use wordcraft_doc::para::{Anchor, Float, FloatAlign, ShapeKind, Wrap, WrapText}; use wordcraft_doc::props::BorderStyle; use wordcraft_doc::props::Rgb; @@ -30,11 +30,28 @@ pub(super) struct Graphic { line: Option>, /// `fo:padding-left`, `-top`, `-right`, `-bottom` (or `fo:padding` for each). padding: [Option; 4], - /// Its fill (`draw:fill` and `draw:fill-color`, or `fo:background-color`), or `Some(None)` - /// for none. - fill: Option>, + /// Its fill style (`draw:fill`: none, solid, gradient, hatch, bitmap) and colour + /// (`draw:fill-color`), and its background (`fo:background-color`, `Some(None)` when + /// transparent). + fill: Option, + fill_color: Option, + background: Option>, + /// A drawing shape's line: its style (`draw:stroke`: none, solid, dash), colour and width. + stroke: Option, + stroke_color: Option, + stroke_width: Option, + /// A shape grows to fit its text (`draw:auto-grow-height`). + grow: Option, } +/// The colour LibreOffice draws a shape's line in when its styles give none (#3465a4), and fills +/// it with (#729fcf); its default graphic style states them too. +const SHAPE_LINE: Rgb = Rgb(0x34, 0x65, 0xa4); +const SHAPE_FILL: Rgb = Rgb(0x72, 0x9f, 0xcf); + +/// The width a hairline (`svg:stroke-width` 0, or none stated) is drawn at, points. +const HAIRLINE: f32 = 0.5; + impl Graphic { /// A `style:graphic-properties` element. pub(super) fn read(a: &Attrs) -> Graphic { @@ -54,7 +71,13 @@ impl Graphic { .and_then(parse_border) .map(|b| (b.style != BorderStyle::None && b.width > 0.0).then(|| (b.color.unwrap_or(Rgb::BLACK), b.width.clamp(0.0, 12.0)))), padding: ["left", "top", "right", "bottom"].map(|side| m(&format!("fo:padding-{side}")).or_else(|| m("fo:padding"))), - fill: read_fill(a), + fill: s("draw:fill"), + fill_color: get(a, "draw:fill-color").and_then(parse_color), + background: get(a, "fo:background-color").map(|v| parse_color(v).filter(|_| !v.trim().eq_ignore_ascii_case("transparent"))), + stroke: s("draw:stroke"), + stroke_color: get(a, "svg:stroke-color").and_then(parse_color), + grow: get(a, "draw:auto-grow-height").map(|v| v.trim() == "true"), + stroke_width: get(a, "svg:stroke-width").and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 12.0)), } } @@ -78,27 +101,50 @@ impl Graphic { *m = m.or(p); } self.line = self.line.or(parent.line); - self.fill = self.fill.or(parent.fill); + fill(&mut self.fill, &parent.fill); + self.fill_color = self.fill_color.or(parent.fill_color); + self.background = self.background.or(parent.background); + fill(&mut self.stroke, &parent.stroke); + self.stroke_color = self.stroke_color.or(parent.stroke_color); + self.stroke_width = self.stroke_width.or(parent.stroke_width); + self.grow = self.grow.or(parent.grow); self } + /// The fill a style of `draw:fill` `kind` gives, `default` when it states none: `None` is + /// no fill; a gradient, hatch or bitmap stands as its colour. + fn fill_with(&self, default: Option<&str>) -> Option { + let color = self.fill_color.or(self.background.flatten()); + match self.fill.as_deref().or(default) { + Some("none") => None, + Some("solid") => color, + Some(_) => color.or(Some(Rgb::WHITE)), + None => self.background.flatten(), + } + } + + /// The look of drawing shape `kind` in this style: LibreOffice's solid fill and hairline in + /// its default colours where the styles say nothing; a line has no fill. + pub(super) fn shape_look(&self, kind: ShapeKind) -> BoxLook { + let fill = if kind == ShapeKind::Line { + None + } else { + let mut g = self.clone(); + g.fill_color = g.fill_color.or(Some(SHAPE_FILL)); + g.fill_with(Some("solid")) + }; + let line = match self.stroke.as_deref() { + Some("none") => None, + _ => Some((self.stroke_color.unwrap_or(SHAPE_LINE), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE))), + }; + // Only a shape that says it grows to its text does: WordCraft's fit shrinks it too. + BoxLook { fill, line, inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit: self.grow == Some(true) } + } + /// The look of a text box in a frame of this style, growing to its text when `auto_fit`. /// What the styles leave unsaid is ODF's default: no line, no fill, no padding. pub(super) fn look(&self, auto_fit: bool) -> BoxLook { - BoxLook { fill: self.fill.flatten(), line: self.line.flatten(), inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit } - } -} - -/// A graphic style's fill (ODF 1.3 §20.80 `draw:fill`, §20.82 `draw:fill-color`, §20.175 -/// `fo:background-color`): `Some(None)` for none, a gradient, hatch or bitmap fill as its colour. -fn read_fill(a: &Attrs) -> Option> { - let color = get(a, "draw:fill-color").and_then(parse_color); - let background = get(a, "fo:background-color").map(|v| parse_color(v).filter(|_| !v.trim().eq_ignore_ascii_case("transparent"))); - match get(a, "draw:fill").map(str::trim) { - Some("none") => Some(None), - Some("solid") => Some(color.or(background.flatten())), - Some(_) => Some(color.or(background.flatten()).or(Some(Rgb::WHITE))), - None => background, + BoxLook { fill: self.fill_with(None), line: self.line.flatten(), inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit } } } diff --git a/crates/formats/src/odt/shapes.rs b/crates/formats/src/odt/shapes.rs new file mode 100644 index 0000000000..d276d447b5 --- /dev/null +++ b/crates/formats/src/odt/shapes.rs @@ -0,0 +1,82 @@ +//! Drawing shapes (ODF 1.3 §10.3): `draw:rect`, `draw:ellipse`, `draw:line` and +//! `draw:custom-shape`, whose `draw:enhanced-geometry` (§10.6.2) names its predefined geometry +//! in `draw:type`. WordCraft's shape kinds both ways; LibreOffice rebuilds a predefined +//! geometry from its type alone, so only the type is written. + +use super::xml::{Attrs, get, length}; +use wordcraft_doc::para::ShapeKind; + +/// Largest size or offset read, points (22 inches). +const MAX_LEN: f32 = 1584.0; + +/// Whether `q` is a drawing shape element read as a shape. +pub(super) fn is_shape(q: &str) -> bool { + matches!(q, "draw:rect" | "draw:ellipse" | "draw:line" | "draw:custom-shape") +} + +/// The kind of shape element `name` (local name); a custom shape's geometry says more. +pub(super) fn element_kind(name: &str) -> ShapeKind { + match name { + "ellipse" => ShapeKind::Ellipse, + "line" => ShapeKind::Line, + _ => ShapeKind::Rectangle, + } +} + +/// The kind of a custom shape of `draw:type` `t`: LibreOffice's own type names, and the +/// DrawingML preset names it writes as `ooxml-…` for shapes from Word documents. A geometry +/// WordCraft has no shape for is a rectangle. +pub(super) fn kind_of(t: &str) -> ShapeKind { + let t = t.trim(); + match t.strip_prefix("ooxml-").unwrap_or(t) { + "round-rectangle" | "roundRect" => ShapeKind::RoundedRectangle, + "ellipse" | "flowchart-connector" | "flowChartConnector" => ShapeKind::Ellipse, + "flowchart-or" | "flowChartOr" => ShapeKind::FlowchartOr, + "isosceles-triangle" | "triangle" => ShapeKind::Triangle, + "right-triangle" | "rtTriangle" => ShapeKind::RightTriangle, + "diamond" | "flowchart-decision" | "flowChartDecision" => ShapeKind::Diamond, + "right-arrow" | "rightArrow" => ShapeKind::Arrow, + "left-arrow" | "leftArrow" => ShapeKind::ArrowLeft, + "up-arrow" | "upArrow" => ShapeKind::ArrowUp, + "down-arrow" | "downArrow" => ShapeKind::ArrowDown, + "star5" => ShapeKind::Star, + "heart" => ShapeKind::Heart, + "line" | "straightConnector1" => ShapeKind::Line, + _ => ShapeKind::Rectangle, + } +} + +/// How shape `kind` is written: its element, and a custom shape's `draw:type`. +pub(super) fn element_of(kind: ShapeKind) -> (&'static str, Option<&'static str>) { + let custom = |t| ("draw:custom-shape", Some(t)); + match kind { + ShapeKind::Ellipse => ("draw:ellipse", None), + ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ("draw:line", None), + ShapeKind::RoundedRectangle => custom("round-rectangle"), + ShapeKind::FlowchartOr => custom("flowchart-or"), + ShapeKind::Triangle => custom("isosceles-triangle"), + ShapeKind::RightTriangle => custom("right-triangle"), + ShapeKind::Diamond => custom("diamond"), + ShapeKind::Arrow => custom("right-arrow"), + ShapeKind::ArrowLeft => custom("left-arrow"), + ShapeKind::ArrowUp => custom("up-arrow"), + ShapeKind::ArrowDown => custom("down-arrow"), + ShapeKind::Star => custom("star5"), + ShapeKind::Heart => custom("heart"), + ShapeKind::Rectangle | ShapeKind::TextBox | ShapeKind::Freeform => ("draw:rect", None), + } +} + +/// A shape's size and offset (`svg:width`, `svg:height`, `svg:x`, `svg:y`), points, and whether +/// it runs from its bottom left to its top right. A line's come from its two ends (`svg:x1`…): +/// its box spans them, and it rises when one end is left of and below the other. +pub(super) fn geometry(kind: ShapeKind, a: &Attrs) -> (f32, f32, Option, Option, bool) { + let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite()).map(|v| v.clamp(-MAX_LEN, MAX_LEN)); + if kind == ShapeKind::Line { + let [x1, y1, x2, y2] = ["svg:x1", "svg:y1", "svg:x2", "svg:y2"].map(|k| len(k).unwrap_or(0.0)); + let size = |d: f32| d.abs().min(MAX_LEN); + return (size(x2 - x1), size(y2 - y1), Some(x1.min(x2)), Some(y1.min(y2)), (x2 - x1) * (y2 - y1) < 0.0); + } + let size = |k: &str| len(k).map_or(72.0, |v| v.clamp(1.0, MAX_LEN)); + (size("svg:width"), size("svg:height"), len("svg:x"), len("svg:y"), false) +} diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 5f34f42225..80527375d1 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -744,6 +744,8 @@ pub(super) struct Styles { /// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317; /// off unless it says so). pub(super) default_kern: bool, + /// `style:default-style` for graphics: what every drawing shape's style falls back on. + default_graphic: super::frames::Graphic, } impl Styles { @@ -751,6 +753,7 @@ impl Styles { let mut in_auto = false; let mut cur: Option<(String, StyleInfo)> = None; let mut in_default = false; + let mut in_graphic_default = false; let mut list: Option<(String, Vec)> = None; // The list or outline level being read. let mut level_at: Option = None; @@ -773,7 +776,11 @@ impl Styles { cur = Some((name.to_string(), info)); } } - "default-style" if q.starts_with("style:") => in_default = get(&a, "style:family") == Some("paragraph"), + "default-style" if q.starts_with("style:") => { + in_default = get(&a, "style:family") == Some("paragraph"); + in_graphic_default = get(&a, "style:family") == Some("graphic"); + } + "graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a), "text-properties" if in_default && cur.is_none() => { self.default_font = default_font(&a, &self.faces); self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true"); @@ -830,7 +837,7 @@ impl Styles { }, Ev::End(q) => match local(&q) { "automatic-styles" => in_auto = false, - "default-style" if q.starts_with("style:") => in_default = false, + "default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false), "style" if q.starts_with("style:") => { if let Some((n, s)) = cur.take() && self.map.len() < 100_000 @@ -981,6 +988,12 @@ impl Styles { self.chain(name).iter().filter_map(|(_, s)| s.graphic.as_ref()).fold(super::frames::Graphic::default(), |g, p| g.or(p)) } + /// The graphic properties of drawing shape style `name` (none stated: the default style's), + /// over its chain and then the default graphic style. + pub(super) fn shape_graphic(&self, name: Option<&str>) -> super::frames::Graphic { + name.map(|n| self.graphic(n)).unwrap_or_default().or(&self.default_graphic) + } + /// Vertical alignment of a table-cell style (whole chain). pub(super) fn cell_valign(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_valign) diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index 5c51f18403..df0458eae2 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -227,7 +227,8 @@ fn common_paragraph_styles_give_text_formatting() { #[test] fn text_after_a_drawing_shape_stays_in_its_paragraph() { // #1654: a shape's own text:p closed the paragraph it is anchored in, and the text after - // the shape was dropped. Shapes are skipped like text boxes; the paragraph keeps its text. + // the shape was dropped. A shape's text is its own (`odt/tests_more/shapes.rs`); the + // paragraph keeps its text. let draw = r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#; let content = format!( diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 9e44411664..f99e779a04 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -15,6 +15,7 @@ mod outline; mod para_borders; mod rtl; mod settings; +mod shapes; mod spacing; mod style_look; mod tables; diff --git a/crates/formats/src/odt/tests_more/shapes.rs b/crates/formats/src/odt/tests_more/shapes.rs new file mode 100644 index 0000000000..ebc82370e0 --- /dev/null +++ b/crates/formats/src/odt/tests_more/shapes.rs @@ -0,0 +1,173 @@ +//! Drawing shapes (ODF 1.3 §10.3: `draw:rect`, `draw:ellipse`, `draw:line`, and +//! `draw:custom-shape` with its `draw:enhanced-geometry` type): opened as WordCraft shapes with +//! their size, placement, fill, line and text, and written back as the same elements. + +use crate::odt::tests::{NS_T, content_of, package}; +use crate::odt::{export, import}; +use wordcraft_doc::para::{InlineObject, OBJ, ShapeKind, Wrap}; +use wordcraft_doc::props::Rgb; +use wordcraft_doc::{Document, Paragraph, para_block}; + +const NS_D: &str = + r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#; + +/// A shape as read: (kind, size, fill, line colour and width, floats, drawn bottom-left to top-right, its text). +type Seen = (ShapeKind, (f32, f32), Option, Option<(Rgb, f32)>, bool, bool, String); + +fn shapes(d: &Document) -> Vec { + let mut out = Vec::new(); + for b in d.body.iter() { + let Some(p) = b.as_para() else { continue }; + for o in &p.objects { + if let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } = o { + let text = story + .and_then(|id| d.parts.get(&id)) + .map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::>().join("|")) + .unwrap_or_default(); + let size = ((*w * 10.0).round() / 10.0, (*h * 10.0).round() / 10.0); + let line = stroke.map(|c| (c, (*stroke_width * 100.0).round() / 100.0)); + out.push((*kind, size, fill.as_ref().map(|f| f.main_color()), line, float.wrap != Wrap::Inline, float.flip_v, text)); + } + } + } + out +} + +fn open() -> Document { + let styles = format!( + r##""## + ); + let content = format!( + r##" + + + + + + +A b c. +Shape textBeside. +Under. +"## + ); + import(&package(&content, Some(&styles))).unwrap() +} + +#[test] +fn shapes_open_with_their_kind_size_look_and_text() { + let d = open(); + let blue = Some((Rgb(0x34, 0x65, 0xa4), 0.5)); + assert_eq!( + shapes(&d), + [ + (ShapeKind::Rectangle, (72.0, 36.0), Some(Rgb(255, 204, 0)), Some((Rgb(0, 0, 255), 0.5)), false, false, String::new()), + // No line stated: the default graphic style's, a hairline. + (ShapeKind::Ellipse, (36.0, 36.0), Some(Rgb(0, 170, 0)), blue, false, false, String::new()), + // No fill stated: a solid fill in the style's fill colour. + (ShapeKind::RoundedRectangle, (144.0, 72.0), Some(Rgb(0x99, 0xcc, 0xff)), Some((Rgb(0, 0, 0x80), 0.5)), true, false, "Shape text".into()), + // From (3in, 0) to (1in, 1in): 2in × 1in, rising from its bottom left. + (ShapeKind::Line, (144.0, 72.0), None, Some((Rgb(0, 0, 0), 2.0)), true, true, String::new()), + (ShapeKind::Heart, (72.0, 72.0), None, None, true, false, String::new()), + // A geometry WordCraft has no shape for: a rectangle in its place. + (ShapeKind::Rectangle, (72.0, 72.0), None, None, true, false, String::new()), + ] + ); + // The shape's text isn't the paragraph's. + assert_eq!(d.body[1].as_para().unwrap().text.replace(OBJ, "#"), "#Beside."); + assert_eq!(d.body[0].as_para().unwrap().text.replace(OBJ, "#"), "A # b # c."); + // The line starts at its leftmost point. + let Some(InlineObject::Shape { float, .. }) = d.body[2].as_para().unwrap().objects.first() else { panic!() }; + assert_eq!((float.x, float.y), (72.0, 0.0)); + assert_eq!(float.wrap, Wrap::BehindText); +} + +#[test] +fn shapes_round_trip() { + let d = open(); + let bytes = export(&d).unwrap(); + let c = content_of(&bytes); + for el in [""#.repeat(100); + for inner in [ + deep.as_str(), + r#"unclosed"#, + r#""#, + r#"an"#, + r#"in a note"#, + ] { + let content = format!( + r#"x{inner}"# + ); + let d = import(&package(&content, None)).unwrap(); + let _ = export(&d).unwrap(); + } +} + +#[test] +fn a_shape_keeps_its_size_unless_it_grows_to_its_text() { + let content = format!( + r#"fixedgrows"# + ); + let d = import(&package(&content, None)).unwrap(); + let fits = |d: &Document| -> Vec { + d.body[0].as_para().unwrap().objects.iter().filter_map(|o| o.text_box()).map(|id| d.parts.get(&id).unwrap().body.auto_fit).collect() + }; + assert_eq!(fits(&d), [false, true]); + let bytes = export(&d).unwrap(); + // LibreOffice resizes a shape with text to it unless told not to. + assert!(content_of(&bytes).contains(r#"draw:auto-grow-height="false" draw:auto-grow-width="false""#)); + assert_eq!(fits(&import(&bytes).unwrap()), [false, true]); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index c945ccdc7f..dc09dd39fc 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -442,6 +442,47 @@ fn box_look_attrs(look: &model::BoxLook) -> String { a } +/// How a frame or shape is anchored and placed: its `text:anchor-type`, its graphic style's +/// placement and its `svg:x`/`svg:y`; in line with the text when it doesn't float. +fn placement(float: Option<&wordcraft_doc::para::Float>) -> (&'static str, String, String) { + match float { + Some(f) => super::frames::frame_of(f), + None => ("as-char", " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), String::new()), + } +} + +/// A drawing shape's graphic properties for `look`: its line (§20.167 `draw:stroke`, +/// `svg:stroke-color`, `svg:stroke-width`), fill (§20.118 `draw:fill`, `draw:fill-color`), +/// whether it grows to its text (§20.105 `draw:auto-grow-height`) and the padding around its +/// text. +fn shape_look_attrs(look: &model::BoxLook) -> String { + let hex = |c: Rgb| format!("#{}", c.hex().to_ascii_lowercase()); + let mut a = match look.line { + Some((c, w)) if w.is_finite() && w > 0.0 => { + format!(" draw:stroke=\"solid\" svg:stroke-color=\"{}\" svg:stroke-width=\"{}\"", hex(c), pt(w.min(12.0))) + } + _ => " draw:stroke=\"none\"".to_string(), + }; + match look.fill { + Some(c) => a.push_str(&format!(" draw:fill=\"solid\" draw:fill-color=\"{}\"", hex(c))), + None => a.push_str(" draw:fill=\"none\""), + } + // Its size is its own unless it resizes to fit its text (LibreOffice otherwise grows and + // shrinks a shape with text to it). + a.push_str(&format!(" draw:auto-grow-height=\"{}\" draw:auto-grow-width=\"false\"", look.auto_fit)); + if let Some(inset) = look.inset { + let [l, t, r, b] = inset.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 }); + a.push_str(&format!( + " fo:padding-left=\"{}\" fo:padding-top=\"{}\" fo:padding-right=\"{}\" fo:padding-bottom=\"{}\"", + pt(l), + pt(t), + pt(r), + pt(b) + )); + } + a +} + /// A text box's default inner margins (left, top, right, bottom), points: WordCraft's and /// Word's (0.1" and 0.05"). const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6]; @@ -838,8 +879,12 @@ impl Writer { self.comment(*id, comment, out); prev_space = false; } - Inline::TextBox { w, h, float, blocks, look } => { - self.text_box((*w, *h), float.as_deref(), look, blocks, out); + Inline::Shape { kind, w, h, float, blocks, look } => { + if *kind == wordcraft_doc::para::ShapeKind::TextBox { + self.text_box((*w, *h), float.as_deref(), look, blocks, out); + } else { + self.shape(*kind, (*w, *h), float.as_deref(), look, blocks, out); + } prev_space = false; } Inline::CommentEnd(id) => out.push_str(&format!("", annotation_name(self.prefix, *id))), @@ -909,16 +954,9 @@ impl Writer { let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); self.close_ins(out); let frame = box_look_attrs(look); - let (anchor, props, xy) = match float { - Some(f) => super::frames::frame_of(f), - None => ("as-char", " style:vertical-pos=\"top\" style:vertical-rel=\"baseline\"".to_string(), String::new()), - }; + let (anchor, props, xy) = placement(float); let style = self.graphic_style(format!("{props}{frame}")); - let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); - let mut body = String::new(); - self.blocks(blocks, &mut body, 1); - self.close_ins(&mut body); - (self.cur_style, self.chain, self.part, self.note_parent) = saved; + let mut body = self.story(blocks); if body.is_empty() { body.push_str(""); } @@ -941,6 +979,61 @@ impl Writer { } } + /// A drawing shape (ODF 1.3 §10.3) of `size` (points): a rectangle, ellipse or line, or a + /// custom shape of a predefined geometry, in line with the text or anchored and placed as it + /// floats, with its fill, line and text. + fn shape( + &mut self, + kind: wordcraft_doc::para::ShapeKind, + size: (f32, f32), + float: Option<&wordcraft_doc::para::Float>, + look: &model::BoxLook, + blocks: &[FBlock], + out: &mut String, + ) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + let (anchor, props, xy) = placement(float); + let style = self.graphic_style(format!("{props}{}", shape_look_attrs(look))); + let body = self.story(blocks); + self.boxes += 1; + let (element, geometry) = super::shapes::element_of(kind); + let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 }; + let at = if element == "draw:line" { + { + // A line by its ends: from its box's top left to its bottom right, or bottom left + // to top right when it rises. + let (w, h) = (len(size.0, 0.0), len(size.1, 0.0)); + let off = |v: f32, aligned: bool| if aligned || !v.is_finite() { 0.0 } else { v.clamp(-1584.0, 1584.0) }; + let (x, y) = float.map_or((0.0, 0.0), |f| (off(f.x, f.h_align.is_some()), off(f.y, f.v_align.is_some()))); + let rises = float.is_some_and(|f| f.flip_v != f.flip_h); + let (y1, y2) = if rises { (y + h, y) } else { (y, y + h) }; + format!(" svg:x1=\"{}\" svg:y1=\"{}\" svg:x2=\"{}\" svg:y2=\"{}\"", pt(x), pt(y1), pt(x + w), pt(y2)) + } + } else { + format!(" svg:width=\"{}\" svg:height=\"{}\"{xy}", pt(len(size.0, 1.0)), pt(len(size.1, 1.0))) + }; + let geometry = geometry.map(|t| format!("")).unwrap_or_default(); + out.push_str(&format!( + "<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\" text:anchor-type=\"{anchor}\"{at} draw:z-index=\"0\">{body}{geometry}", + self.prefix, self.boxes, + )); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + + /// The paragraphs of a text box's or shape's text, written apart from the paragraph being + /// written. + fn story(&mut self, blocks: &[FBlock]) -> String { + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); + let mut body = String::new(); + self.blocks(blocks, &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + body + } + /// A footnote or endnote (ODF 1.3 §6.3): its citation (the number LibreOffice shows until it /// renumbers, or the custom mark) and its content. The paragraph being written is put aside /// meanwhile, and an insertion open around the note ends before it and goes on after it. diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 7d457bedac..3bd0e1af36 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -405,7 +405,7 @@ impl Writer { | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) - | Inline::TextBox { .. } => {} + | Inline::Shape { .. } => {} } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7ae68cf867..ebba3d2a4f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | 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 a proportional approximation) 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`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, 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), 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 read as none; 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. Drawing shapes skipped | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | 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 a proportional approximation) 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`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, 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), 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 read as none; 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. 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 in groups (`draw:g`), polygons, paths, connectors, rotation (`draw:transform`), arrowheads, dashes and gradients are not read yet; 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: drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, `draw:custom-shape` with a predefined `draw:type`) open as WordCraft shapes with their size, placement, fill, line, text and direction instead of being skipped, and WordCraft's shapes are saved as them (rectangle, rounded rectangle, ellipse, flowchart or, triangles, diamond, block arrows, star, heart, line; connectors as lines); a shape keeps its own size in LibreOffice unless it resizes to fit its text (`draw:auto-grow-height`). A LibreOffice shapes document now round-trips with all nine shapes and the same text (13,864 px apart, mostly in-line shapes moved onto the baseline) | | 2026-11-01 | trivial | ODT: a text box frame keeps its line, fill and padding from its graphic style chain (LibreOffice's Frame style included) instead of a fixed thin black line on white, and a frame without a height (`fo:min-height`) opens as a box that resizes to fit its text and is written back that way; floating boxes that resize to fit now take their text's height in layout too (a LibreOffice text-box document: 1,117 px apart after a round trip, now 736). A formula sub-document's `content.xml` stays a bare `math:math` root, as ODF 1.3 Part 3 §2.2.9 requires, and one wrapped in `office:math` still reads | | 2026-11-01 | trivial | ODT: Asian and complex-script fonts, the complex script's size, weight and posture, and both script languages (`style:font-name-asian`/`-complex`, `style:font-size-complex`, `style:language-asian`/`-complex`…) are read and written, on runs and as the document defaults, so Hebrew, Arabic and CJK text keep their fonts. `formats::odt::tests_more::chars` | | 2026-11-01 | trivial | ODT: right-to-left paragraphs (`style:writing-mode` `rl-tb`/`rl`, ODF 1.3 §20.404) open as bidirectional paragraphs and are written back, in paragraph and custom styles. `formats::odt::tests_more::rtl` |