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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude committed 2026-11-01 06:21:30 +00:00
1 parent e15a86bd55
commit 836bf6553b
17 files changed
+589 -108

No files matched your search

+1 -1
View File
@@ -137,7 +137,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
));
}
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", 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();
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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 { .. } => {}
}
+12 -5
View File
@@ -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() }]
+33 -27
View File
@@ -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) {
+10 -8
View File
@@ -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<Box<wordcraft_doc::para::Float>>,
@@ -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
+1
View File
@@ -44,6 +44,7 @@ mod frames;
mod import;
mod limits;
mod package;
mod shapes;
mod styles;
mod writer;
mod xml;
+84 -29
View File
@@ -287,6 +287,10 @@ pub(super) struct Body<'a> {
frame_box: Option<Vec<FBlock>>,
/// 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<wordcraft_doc::para::ShapeKind>,
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
group_at: Option<usize>,
/// The note or comment being read.
note: Option<OpenNote>,
/// 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<f32>| 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,
_ => {}
}
}
+66 -20
View File
@@ -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<Option<(Rgb, f32)>>,
/// `fo:padding-left`, `-top`, `-right`, `-bottom` (or `fo:padding` for each).
padding: [Option<f32>; 4],
/// Its fill (`draw:fill` and `draw:fill-color`, or `fo:background-color`), or `Some(None)`
/// for none.
fill: Option<Option<Rgb>>,
/// 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<String>,
fill_color: Option<Rgb>,
background: Option<Option<Rgb>>,
/// A drawing shape's line: its style (`draw:stroke`: none, solid, dash), colour and width.
stroke: Option<String>,
stroke_color: Option<Rgb>,
stroke_width: Option<f32>,
/// A shape grows to fit its text (`draw:auto-grow-height`).
grow: Option<bool>,
}
/// 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<Rgb> {
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<Option<Rgb>> {
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 }
}
}
+82
View File
@@ -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<f32>, Option<f32>, 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)
}
+15 -2
View File
@@ -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<ListLevel>)> = None;
// The list or outline level being read.
let mut level_at: Option<usize> = 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<VAlign> {
self.chain(name).iter().find_map(|(_, s)| s.cell_valign)
+2 -1
View File
@@ -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!(
+1
View File
@@ -15,6 +15,7 @@ mod outline;
mod para_borders;
mod rtl;
mod settings;
mod shapes;
mod spacing;
mod style_look;
mod tables;
+173
View File
@@ -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<Rgb>, Option<(Rgb, f32)>, bool, bool, String);
fn shapes(d: &Document) -> Vec<Seen> {
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::<Vec<_>>().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##"<office:document-styles {NS_T} {NS_D}><office:styles><style:default-style style:family="graphic"><style:graphic-properties svg:stroke-color="#3465a4" draw:fill-color="#729fcf"/></style:default-style></office:styles></office:document-styles>"##
);
let content = format!(
r##"<office:document-content {NS_T} {NS_D}><office:automatic-styles>
<style:style style:name="gr1" style:family="graphic"><style:graphic-properties svg:stroke-width="0.5pt" svg:stroke-color="#0000ff" draw:fill="solid" draw:fill-color="#ffcc00"/></style:style>
<style:style style:name="gr2" style:family="graphic"><style:graphic-properties draw:fill="solid" draw:fill-color="#00aa00"/></style:style>
<style:style style:name="gr3" style:family="graphic"><style:graphic-properties svg:stroke-color="#000080" draw:fill-color="#99ccff" style:wrap="parallel" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph"/></style:style>
<style:style style:name="gr4" style:family="graphic"><style:graphic-properties svg:stroke-width="2pt" svg:stroke-color="#000000" style:wrap="run-through" style:run-through="background"/></style:style>
<style:style style:name="gr5" style:family="graphic"><style:graphic-properties draw:stroke="none" draw:fill="none"/></style:style>
</office:automatic-styles><office:body><office:text>
<text:p>A <draw:rect text:anchor-type="as-char" draw:style-name="gr1" svg:width="1in" svg:height="0.5in"><text:p/></draw:rect> b <draw:ellipse text:anchor-type="as-char" draw:style-name="gr2" svg:width="0.5in" svg:height="0.5in"/> c.</text:p>
<text:p><draw:custom-shape text:anchor-type="paragraph" draw:style-name="gr3" svg:width="2in" svg:height="1in" svg:x="1in" svg:y="0in"><text:p>Shape text</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="round-rectangle" draw:modifiers="3600"><draw:equation draw:name="f0" draw:formula="45"/></draw:enhanced-geometry></draw:custom-shape>Beside.</text:p>
<text:p><draw:line text:anchor-type="paragraph" draw:style-name="gr4" svg:x1="3in" svg:y1="0in" svg:x2="1in" svg:y2="1in"><text:p/></draw:line><draw:custom-shape text:anchor-type="paragraph" draw:style-name="gr5" svg:width="1in" svg:height="1in"><draw:enhanced-geometry draw:type="ooxml-heart"/></draw:custom-shape><draw:custom-shape text:anchor-type="paragraph" draw:style-name="gr5" svg:width="1in" svg:height="1in"><draw:enhanced-geometry draw:type="mso-spt100" draw:enhanced-path="M 0 0 L 21600 21600 N"/></draw:custom-shape>Under.</text:p>
</office:text></office:body></office:document-content>"##
);
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 ["<draw:rect ", "<draw:ellipse ", "<draw:line ", r#"draw:type="round-rectangle""#, r#"draw:type="heart""#] {
assert!(c.contains(el), "{el} in {c}");
}
let back = import(&bytes).unwrap();
assert_eq!(shapes(&back), shapes(&d));
assert_eq!(back.body[1].as_para().unwrap().text, d.body[1].as_para().unwrap().text);
}
#[test]
fn every_wordcraft_shape_kind_is_written_and_read_back() {
use ShapeKind::*;
let kinds = [
Rectangle,
RoundedRectangle,
Ellipse,
FlowchartOr,
Triangle,
RightTriangle,
Diamond,
Line,
Arrow,
ArrowLeft,
ArrowUp,
ArrowDown,
Star,
Heart,
];
let mut p = Paragraph::new();
for k in kinds {
p.text.push(OBJ);
p.objects.push(InlineObject::Shape {
kind: k,
w: 50.0,
h: 40.0,
fill: Some(wordcraft_doc::fill::Fill::solid(Rgb(1, 2, 3))),
stroke: Some(Rgb(4, 5, 6)),
stroke_width: 1.5,
float: Default::default(),
story: None,
freeform: None,
effects: Default::default(),
extra: Default::default(),
});
}
p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }];
let mut d = Document::new();
d.body = vec![para_block(p)];
let back = import(&export(&d).unwrap()).unwrap();
let fill = |k| if k == Line { None } else { Some(Rgb(1, 2, 3)) };
assert_eq!(shapes(&back), kinds.map(|k| (k, (50.0, 40.0), fill(k), Some((Rgb(4, 5, 6), 1.5)), false, false, String::new())).to_vec());
}
#[test]
fn hostile_shapes_do_not_panic() {
let deep = r#"<draw:custom-shape svg:width="1in" svg:height="1in"><text:p>"#.repeat(100);
for inner in [
deep.as_str(),
r#"<draw:rect svg:width="-5in" svg:height="NaNin"><text:p>unclosed"#,
r#"<draw:line svg:x1="1e30in" svg:y1="0" svg:x2="-1e30in"/>"#,
r#"<draw:rect svg:width="1in" svg:height="1in"><text:p>a<text:note><text:note-body><text:p>n</text:p></text:note-body></text:note></text:p></draw:rect>"#,
r#"<text:note><text:note-body><text:p><draw:ellipse svg:width="1in" svg:height="1in"><text:p>in a note</text:p></draw:ellipse></text:p></text:note-body></text:note>"#,
] {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>x{inner}</text:p></office:text></office:body></office:document-content>"#
);
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#"<office:document-content {NS_T} {NS_D}><office:automatic-styles><style:style style:name="g" style:family="graphic"><style:graphic-properties draw:auto-grow-height="true"/></style:style></office:automatic-styles><office:body><office:text><text:p><draw:rect svg:width="1in" svg:height="1in"><text:p>fixed</text:p></draw:rect><draw:rect draw:style-name="g" svg:width="1in" svg:height="1in"><text:p>grows</text:p></draw:rect></text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, None)).unwrap();
let fits = |d: &Document| -> Vec<bool> {
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]);
}
+104 -11
View File
@@ -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!("<office:annotation-end office:name=\"{}\"/>", 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("<text:p/>");
}
@@ -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!("<draw:enhanced-geometry svg:viewBox=\"0 0 21600 21600\" draw:type=\"{t}\"/>")).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}</{element}>",
self.prefix, self.boxes,
));
if let Some(m) = reopen {
self.open_ins(&m, out);
}
}
/// The paragraphs of a text box's or shape's text, written apart from the paragraph being
/// written.
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.
+1 -1
View File
@@ -405,7 +405,7 @@ impl Writer {
| Inline::Note { .. }
| Inline::Comment { .. }
| Inline::CommentEnd(_)
| Inline::TextBox { .. } => {}
| Inline::Shape { .. } => {}
}
}
}
+2 -1
View File
@@ -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 odLine truncated
| `.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 odLine truncated
| `.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 (`<html>`, `<!DOCTYPE html>`) 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 `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) 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 `<blockquote>` (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 `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` 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` |