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