mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06:00
Gap 19: ODT connectors read and written glued
ODT connectors (`draw:connector`) were left out. They now open as WordCraft connectors: straight (`line`), curved (`curve`), or with bends (`standard`, `lines`). - Gluing: each end is glued to the shape it names (`draw:start-shape`, `draw:end-shape`). The shape's four default glue points (§9.2.19: top, right, bottom, left) become its connection sites. An end glued to a glue point of the shape's own, or to a shape not read, is left unglued. - Placement: a connector's own coordinates are in LibreOffice's drawing layer, not its anchor's. So a connector glued at both ends to shapes anchored in its paragraph is placed between their sites. - Writing: connectors are written back as `draw:connector` with their type and glue, and the shapes they glue to get ids (`xml:id`/`draw:id`). LibreOffice re-routes them from the glue. LibreOffice's connector document round-trips with its connectors drawn between the shapes, and WordCraft draws them between the right sites. Other formats still get connectors as straight lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -27,9 +27,9 @@ pub use from_doc::{
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
Affix, BoxLook, CODE_STYLE, Cell, Chart, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH,
|
||||
MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra, clean_text,
|
||||
equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
Affix, BoxLook, CODE_STYLE, Cell, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo,
|
||||
MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra,
|
||||
clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
};
|
||||
pub(crate) use types::{Dropped, begin_import, dropped};
|
||||
|
||||
|
||||
@@ -302,9 +302,10 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
|
||||
InlineObject::Shape { kind, w, h, float, story, fill, stroke, stroke_width, extra, freeform, .. }
|
||||
if *kind != ShapeKind::Freeform || freeform.is_some() =>
|
||||
{
|
||||
// A connector is a straight line.
|
||||
// A connector stays one in formats that glue them (ODT, with boxes); elsewhere it is a
|
||||
// straight line.
|
||||
let kind = match kind {
|
||||
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ShapeKind::Line,
|
||||
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector if !opts.boxes => ShapeKind::Line,
|
||||
k => *k,
|
||||
};
|
||||
let inner = FlowOpts { notes: false, comments: false, boxes: false, ..opts };
|
||||
@@ -317,6 +318,8 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
|
||||
arrows: (extra.arrow_start, extra.arrow_end),
|
||||
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),
|
||||
conn: (opts.boxes && (extra.id != 0 || extra.start.is_some() || extra.end.is_some()))
|
||||
.then(|| Box::new(crate::model::Conn { id: extra.id, start: extra.start, end: extra.end })),
|
||||
};
|
||||
let path = freeform.clone().filter(|_| kind == ShapeKind::Freeform);
|
||||
Some(Inline::Shape { kind, w: w * sx, h: h * sy, float: kept_float(float), blocks, look, path })
|
||||
|
||||
@@ -406,6 +406,10 @@ impl<'a> Builder<'a> {
|
||||
dash: look.dash.clone(),
|
||||
arrow_start: look.arrows.0,
|
||||
arrow_end: look.arrows.1,
|
||||
// Its drawing id, and what a connector's ends are glued to (sites bounded).
|
||||
id: look.conn.as_ref().map_or(0, |c| c.id),
|
||||
start: look.conn.as_ref().and_then(|c| c.start).filter(|e| e.site <= wordcraft_doc::connector::MAX_SITE),
|
||||
end: look.conn.as_ref().and_then(|c| c.end).filter(|e| e.site <= wordcraft_doc::connector::MAX_SITE),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
@@ -300,6 +300,17 @@ pub struct BoxLook {
|
||||
pub inset: Option<[f32; 4]>,
|
||||
/// The box grows to its text; its height is the least it is.
|
||||
pub auto_fit: bool,
|
||||
/// The drawing id connectors glue to, and a connector's glued ends (see
|
||||
/// [`wordcraft_doc::connector`]).
|
||||
pub conn: Option<Box<Conn>>,
|
||||
}
|
||||
|
||||
/// A shape's drawing id (0: none) and, for a connector, what its ends are glued to.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Conn {
|
||||
pub id: u32,
|
||||
pub start: Option<wordcraft_doc::connector::ConnEnd>,
|
||||
pub end: Option<wordcraft_doc::connector::ConnEnd>,
|
||||
}
|
||||
|
||||
impl Default for BoxLook {
|
||||
@@ -312,6 +323,7 @@ impl Default for BoxLook {
|
||||
arrows: (false, false),
|
||||
inset: None,
|
||||
auto_fit: false,
|
||||
conn: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ pub(super) struct Body<'a> {
|
||||
pub(super) entries: &'a [String],
|
||||
/// The paragraph before had a page break after it: the next one starts a page.
|
||||
break_next: bool,
|
||||
/// Shapes' ids (`xml:id`, `draw:id`) as WordCraft drawing ids, and where the shapes with one
|
||||
/// sit (kind, frame relative to its anchor, turn, placement, the paragraph they are in), for
|
||||
/// connectors glued to them.
|
||||
pub(super) conn_ids: HashMap<String, u32>,
|
||||
pub(super) shape_places: HashMap<u32, (wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize)>,
|
||||
/// Paragraphs started so far: which paragraph a shape is in.
|
||||
pub(super) para_seq: usize,
|
||||
/// Index entries over text whose end hasn't come (in the paragraph being read): their id,
|
||||
/// keys and where they start: an inline, and a byte offset in it when it is text.
|
||||
index_marks: Vec<(String, (Option<String>, Option<String>), (usize, usize))>,
|
||||
@@ -139,6 +146,9 @@ impl<'a> Body<'a> {
|
||||
break_next: false,
|
||||
ruby: None,
|
||||
index_marks: Vec::new(),
|
||||
conn_ids: HashMap::new(),
|
||||
shape_places: HashMap::new(),
|
||||
para_seq: 0,
|
||||
manifest: None,
|
||||
containers: vec![Vec::new()],
|
||||
fmt: Vec::new(),
|
||||
@@ -367,6 +377,10 @@ impl Body<'_> {
|
||||
|
||||
fn start_para(&mut self, style: Option<&str>, heading: Option<u8>) {
|
||||
self.end_para();
|
||||
// Paragraphs of the text (not of a shape's, a note's or a comment's) say where shapes are.
|
||||
if self.note.is_none() {
|
||||
self.para_seq = self.para_seq.wrapping_add(1);
|
||||
}
|
||||
let look = style.map(|s| self.styles.para(s)).unwrap_or_default();
|
||||
let (mut kind, align, mut pb, rule, keep_next, base) = (look.kind, look.align, look.page_break, look.rule, look.keep_next, look.fmt);
|
||||
// The section break starts the new page; no page break besides.
|
||||
@@ -453,6 +467,7 @@ impl Body<'_> {
|
||||
// An index entry is marked within one paragraph.
|
||||
self.index_marks.clear();
|
||||
let Some(mut p) = self.para.take() else { return };
|
||||
self.glue_connectors(&mut p);
|
||||
self.fmt.pop();
|
||||
// A paragraph ending inside an open insertion has an inserted mark. (Paragraphs that
|
||||
// `place_deletion` splits off keep only the marks read inside their region.)
|
||||
@@ -682,7 +697,7 @@ impl Body<'_> {
|
||||
self.skip = 1;
|
||||
}
|
||||
// Drawing shapes (ODF 1.3 §10.3) anchored in the text, with their text.
|
||||
"rect" | "ellipse" | "circle" | "line" | "custom-shape" | "polygon" | "polyline" | "path" | "regular-polygon"
|
||||
"rect" | "ellipse" | "circle" | "line" | "custom-shape" | "polygon" | "polyline" | "path" | "regular-polygon" | "connector"
|
||||
if q.starts_with("draw:") && self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc =>
|
||||
{
|
||||
self.start_shape(q, a);
|
||||
|
||||
@@ -71,6 +71,9 @@ pub(in crate::odt) struct OpenGroup {
|
||||
/// Largest size or offset read in a group, points (22 inches).
|
||||
pub(in crate::odt) const MAX_LEN: f32 = 1584.0;
|
||||
|
||||
/// Most shape ids connectors can glue to.
|
||||
const MAX_CONN_IDS: usize = 10_000;
|
||||
|
||||
impl Body<'_> {
|
||||
/// End a frame or drawing shape: its picture, equation, text box or shape goes into the
|
||||
/// paragraph.
|
||||
@@ -99,7 +102,11 @@ impl Body<'_> {
|
||||
let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty()));
|
||||
let blocks = if kind != wordcraft_doc::para::ShapeKind::TextBox && empty { Vec::new() } else { blocks };
|
||||
let path = self.shape_path.take().filter(|_| kind == wordcraft_doc::para::ShapeKind::Freeform);
|
||||
Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path })
|
||||
let (w, h) = (w.unwrap_or(72.0), h.unwrap_or(1.0));
|
||||
if self.member_at.is_none() {
|
||||
self.note_place(kind, (w, h), &look, float.as_ref());
|
||||
}
|
||||
Some(Inline::Shape { kind, w, h, float: float.map(Box::new), blocks, look, path })
|
||||
}
|
||||
(None, Some((linear, display))) => Some(Inline::Equation { linear, display }),
|
||||
(None, None) if let Some(spec) = chart => Some(Inline::Chart(model::Chart {
|
||||
@@ -143,6 +150,79 @@ impl Body<'_> {
|
||||
self.frame = None;
|
||||
}
|
||||
|
||||
/// A shape's drawing id (its `xml:id` or `draw:id`) and, for a connector, its glued ends (§19.228
|
||||
/// `draw:start-shape`, §19.229 `draw:start-glue-point`…), their glue points as written until
|
||||
/// the paragraph ends (see [`Self::glue_connectors`]). `None` for a shape with neither.
|
||||
fn conn_of(&mut self, a: &Attrs) -> Option<model::Conn> {
|
||||
let mut id_of = |name: &str| -> u32 {
|
||||
let next = u32::try_from(self.conn_ids.len()).unwrap_or(u32::MAX).saturating_add(1);
|
||||
match self.conn_ids.get(name) {
|
||||
Some(id) => *id,
|
||||
None if self.conn_ids.len() < MAX_CONN_IDS => {
|
||||
self.conn_ids.insert(name.to_string(), next);
|
||||
next
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
};
|
||||
let id = get(a, "xml:id").or_else(|| get(a, "draw:id")).map(str::trim).filter(|n| !n.is_empty()).map_or(0, &mut id_of);
|
||||
let mut end = |shape: &str, glue: &str| {
|
||||
let target = get(a, shape).map(str::trim).filter(|n| !n.is_empty()).map(&mut id_of).filter(|id| *id != 0)?;
|
||||
let site = get(a, glue).and_then(|g| g.trim().parse::<u32>().ok()).filter(|g| *g <= wordcraft_doc::connector::MAX_SITE)?;
|
||||
Some(wordcraft_doc::connector::ConnEnd { id: target, site })
|
||||
};
|
||||
let (start, finish) = (end("draw:start-shape", "draw:start-glue-point"), end("draw:end-shape", "draw:end-glue-point"));
|
||||
(id != 0 || start.is_some() || finish.is_some()).then_some(model::Conn { id, start, end: finish })
|
||||
}
|
||||
|
||||
/// Where a shape with a drawing id (`look`) sits, for connectors glued to it.
|
||||
pub(in crate::odt) fn note_place(
|
||||
&mut self,
|
||||
kind: wordcraft_doc::para::ShapeKind,
|
||||
(w, h): (f32, f32),
|
||||
look: &model::BoxLook,
|
||||
float: Option<&wordcraft_doc::para::Float>,
|
||||
) {
|
||||
if let (Some(c), Some(f)) = (look.conn.as_deref().filter(|c| c.id != 0), float)
|
||||
&& self.shape_places.len() < MAX_CONN_IDS
|
||||
{
|
||||
self.shape_places.insert(c.id, (kind, [f.x, f.y, w, h], f.clone(), self.para_seq));
|
||||
}
|
||||
}
|
||||
|
||||
/// The connectors of paragraph `p` glued to shapes (see [`Self::conn_of`]): each end's glue
|
||||
/// point made WordCraft's site of the shape it is glued to (an end glued to a shape not read,
|
||||
/// or to a glue point of the shape's own, is unglued); and a connector glued at both ends to
|
||||
/// shapes anchored in this paragraph, placed alike, runs between their sites. (A connector's
|
||||
/// own coordinates are in LibreOffice's drawing layer, not its anchor's.)
|
||||
pub(in crate::odt) fn glue_connectors(&self, p: &mut model::Para) {
|
||||
use crate::odt::shapes::site_of_glue;
|
||||
for i in &mut p.inlines {
|
||||
let Inline::Shape { kind, w, h, float, look, .. } = i else { continue };
|
||||
let Some(c) = look.conn.as_deref_mut().filter(|_| kind.is_connector()) else { continue };
|
||||
let resolve = |e: Option<wordcraft_doc::connector::ConnEnd>| {
|
||||
let e = e?;
|
||||
let place = self.shape_places.get(&e.id)?;
|
||||
site_of_glue(place.0, e.site).map(|site| (wordcraft_doc::connector::ConnEnd { id: e.id, site }, place))
|
||||
};
|
||||
let (start, end) = (resolve(c.start), resolve(c.end));
|
||||
(c.start, c.end) = (start.map(|(e, _)| e), end.map(|(e, _)| e));
|
||||
let (Some((s, sp)), Some((e, ep)), Some(f)) = (start, end, float.as_deref_mut()) else { continue };
|
||||
let same = |pl: &(wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize)| {
|
||||
pl.3 == self.para_seq && pl.2.h_rel == f.h_rel && pl.2.v_rel == f.v_rel
|
||||
};
|
||||
if !(same(sp) && same(ep)) {
|
||||
continue;
|
||||
}
|
||||
let point = |pl: &(wordcraft_doc::para::ShapeKind, [f32; 4], wordcraft_doc::para::Float, usize), site| {
|
||||
wordcraft_doc::connector::site_point(pl.0, pl.1, pl.2.spin(), site)
|
||||
};
|
||||
let (Some((x1, y1)), Some((x2, y2))) = (point(sp, s.site), point(ep, e.site)) else { continue };
|
||||
(f.x, f.y, f.flip_h, f.flip_v) = (x1.min(x2), y1.min(y2), x2 < x1, y2 < y1);
|
||||
(*w, *h) = ((x2 - x1).abs().min(MAX_LEN), (y2 - y1).abs().min(MAX_LEN));
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame or shape `w` × `h` (attributes `a`) begins: in a group (not in a shape's text), where
|
||||
/// it sits in it, from its `svg:x`/`svg:y` or `draw:transform` (a line's from its ends, `line`).
|
||||
pub(in crate::odt) fn member_start(&mut self, a: &Attrs, w: f32, h: f32, line: Option<(Option<f32>, Option<f32>)>) {
|
||||
@@ -193,10 +273,13 @@ impl Body<'_> {
|
||||
/// 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.
|
||||
pub(in crate::odt) fn start_shape(&mut self, q: &str, a: &Attrs) {
|
||||
let kind = crate::odt::shapes::element_kind(local(q));
|
||||
let mut kind = crate::odt::shapes::element_kind(local(q));
|
||||
if kind.is_connector() {
|
||||
kind = crate::odt::shapes::connector_kind(get(a, "draw:type"));
|
||||
}
|
||||
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
|
||||
let (w, h, x, y, (flip_h, flip_v)) = crate::odt::shapes::geometry(kind, a);
|
||||
let mut float = if kind == wordcraft_doc::para::ShapeKind::Line {
|
||||
let mut float = if kind.is_open() {
|
||||
// A line is placed by its ends.
|
||||
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
|
||||
crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g)
|
||||
@@ -205,11 +288,12 @@ impl Body<'_> {
|
||||
};
|
||||
(float.flip_h, float.flip_v) = (flip_h, flip_v);
|
||||
self.shape_flip = (false, false);
|
||||
self.member_start(a, w, h, (kind == wordcraft_doc::para::ShapeKind::Line).then_some((x, y)));
|
||||
self.member_start(a, w, h, kind.is_open().then_some((x, y)));
|
||||
if let Some(rot) = self.member_rot.take() {
|
||||
float.rot = rot;
|
||||
}
|
||||
self.frame_look = g.shape_look(kind, &self.styles.defs);
|
||||
self.frame_look.conn = self.conn_of(a).map(Box::new);
|
||||
self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
|
||||
self.shape_kind = Some(kind);
|
||||
let frame = (Some((Some(w), Some(h), None, String::new(), None, None, None)), Some(float), std::mem::take(&mut self.frame_look));
|
||||
|
||||
@@ -168,7 +168,7 @@ impl Graphic {
|
||||
};
|
||||
// Only a shape that says it grows to its text does: WordCraft's fit shrinks it too.
|
||||
let (dash, arrows) = self.stroke_extra(line.map_or(HAIRLINE, |l| l.1), defs);
|
||||
BoxLook { fill, line, dash, arrows, inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit: self.grow == Some(true) }
|
||||
BoxLook { fill, line, dash, arrows, inset: Some(self.padding.map(|p| p.unwrap_or(0.0))), auto_fit: self.grow == Some(true), conn: None }
|
||||
}
|
||||
|
||||
/// The look of a text box in a frame of this style, growing to its text when `auto_fit`.
|
||||
@@ -181,6 +181,7 @@ impl Graphic {
|
||||
arrows: (false, false),
|
||||
inset: Some(self.padding.map(|p| p.unwrap_or(0.0))),
|
||||
auto_fit,
|
||||
conn: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +502,35 @@ fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, s
|
||||
}
|
||||
|
||||
/// Write an `.odt` package.
|
||||
/// The kinds of the shapes with drawing ids in `blocks` (paragraphs, tables, groups), by id.
|
||||
fn conn_kinds(blocks: &[FBlock]) -> super::HashMap<u32, wordcraft_doc::para::ShapeKind> {
|
||||
fn walk(blocks: &[FBlock], depth: usize, out: &mut super::HashMap<u32, wordcraft_doc::para::ShapeKind>) {
|
||||
if depth > model::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for b in blocks {
|
||||
match b {
|
||||
FBlock::Para(p) => p.inlines.iter().for_each(|i| inline(i, 0, out)),
|
||||
FBlock::Table(t) => t.rows.iter().flatten().for_each(|c| walk(&c.blocks, depth + 1, out)),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn inline(i: &super::Inline, depth: usize, out: &mut super::HashMap<u32, wordcraft_doc::para::ShapeKind>) {
|
||||
match i {
|
||||
super::Inline::Shape { kind, look, .. } => {
|
||||
if let Some(c) = look.conn.as_deref().filter(|c| c.id != 0) {
|
||||
out.insert(c.id, *kind);
|
||||
}
|
||||
}
|
||||
super::Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => members.iter().for_each(|m| inline(&m.inline, depth + 1, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = super::HashMap::new();
|
||||
walk(blocks, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let flow = model::from_doc_with(
|
||||
doc,
|
||||
@@ -510,6 +539,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let accents = doc.settings.theme_colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default();
|
||||
let mut w = Writer::default();
|
||||
w.accents.clone_from(&accents);
|
||||
w.conn_kinds = conn_kinds(&flow.blocks);
|
||||
w.forms_allowed = true;
|
||||
w.ranged = model::ranged_anchors(&flow.blocks);
|
||||
let mut body = String::new();
|
||||
|
||||
@@ -22,6 +22,7 @@ pub(super) fn is_shape(q: &str) -> bool {
|
||||
| "draw:polyline"
|
||||
| "draw:path"
|
||||
| "draw:regular-polygon"
|
||||
| "draw:connector"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ pub(super) fn element_kind(name: &str) -> ShapeKind {
|
||||
match name {
|
||||
"ellipse" | "circle" => ShapeKind::Ellipse,
|
||||
"line" => ShapeKind::Line,
|
||||
"connector" => ShapeKind::ElbowConnector,
|
||||
"polygon" | "polyline" | "path" | "regular-polygon" => ShapeKind::Freeform,
|
||||
_ => ShapeKind::Rectangle,
|
||||
}
|
||||
@@ -58,12 +60,51 @@ pub(super) fn kind_of(t: &str) -> ShapeKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector's kind by its `draw:type` (§19.229): `line` straight, `curve` curved, `standard`
|
||||
/// (the default) and `lines` with bends.
|
||||
pub(super) fn connector_kind(t: Option<&str>) -> ShapeKind {
|
||||
match t.map(str::trim) {
|
||||
Some("line") => ShapeKind::StraightConnector,
|
||||
Some("curve") => ShapeKind::CurvedConnector,
|
||||
_ => ShapeKind::ElbowConnector,
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector kind's `draw:type`.
|
||||
pub(super) fn connector_type(kind: ShapeKind) -> &'static str {
|
||||
match kind {
|
||||
ShapeKind::StraightConnector => "line",
|
||||
ShapeKind::CurvedConnector => "curve",
|
||||
_ => "standard",
|
||||
}
|
||||
}
|
||||
|
||||
/// WordCraft's connection site of a `kind` shape for ODF glue point `g` (§9.2.19: a shape's
|
||||
/// default glue points are 0 top, 1 right, 2 bottom, 3 left; see
|
||||
/// [`wordcraft_doc::connector::sites`]). `None` for a glue point of its own (4 on) or a shape
|
||||
/// without sides.
|
||||
pub(super) fn site_of_glue(kind: ShapeKind, g: u32) -> Option<u32> {
|
||||
let sides: [u32; 4] = match kind {
|
||||
ShapeKind::Ellipse => [0, 6, 4, 2],
|
||||
ShapeKind::Triangle => [0, 5, 3, 1],
|
||||
k if k.is_open() || k == ShapeKind::RightTriangle => return None,
|
||||
_ => [0, 3, 2, 1],
|
||||
};
|
||||
sides.get(g as usize).copied()
|
||||
}
|
||||
|
||||
/// The ODF glue point of site `site` of a `kind` shape (the inverse of [`site_of_glue`]).
|
||||
pub(super) fn glue_of_site(kind: ShapeKind, site: u32) -> Option<u32> {
|
||||
(0..4).find(|g| site_of_glue(kind, *g) == Some(site))
|
||||
}
|
||||
|
||||
/// 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::Line => ("draw:line", None),
|
||||
ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => ("draw:connector", None),
|
||||
ShapeKind::RoundedRectangle => custom("round-rectangle"),
|
||||
ShapeKind::FlowchartOr => custom("flowchart-or"),
|
||||
ShapeKind::Triangle => custom("isosceles-triangle"),
|
||||
@@ -86,7 +127,7 @@ pub(super) fn element_of(kind: ShapeKind) -> (&'static str, Option<&'static str>
|
||||
/// or upwards is flipped that way (its arrowheads stay at its ends).
|
||||
pub(super) fn geometry(kind: ShapeKind, a: &Attrs) -> (f32, f32, Option<f32>, Option<f32>, (bool, 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 {
|
||||
if kind.is_open() {
|
||||
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));
|
||||
|
||||
@@ -5,6 +5,7 @@ mod char_styles;
|
||||
mod chars;
|
||||
mod charts;
|
||||
mod comments;
|
||||
mod connectors;
|
||||
mod drop_caps;
|
||||
mod encryption;
|
||||
mod equations;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Connectors (ODF 1.3 §10.3.10 `draw:connector`): lines glued to other shapes' glue points
|
||||
//! (§9.2.19), read as WordCraft connectors glued to the shapes' connection sites, placed between
|
||||
//! them, and written back glued.
|
||||
|
||||
use crate::odt::tests::{NS_T, content_of, package};
|
||||
use crate::odt::{export, import};
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
|
||||
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 rectangle (`id1`) and an ellipse (`id2`) anchored to one paragraph, and a connector of
|
||||
/// `kind` from the rectangle's bottom (glue point 2) to the ellipse's top (0), its own
|
||||
/// coordinates in LibreOffice's drawing layer (far from the shapes).
|
||||
fn doc(kind: &str) -> wordcraft_doc::Document {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:rect text:anchor-type="paragraph" xml:id="id1" draw:id="id1" svg:width="1in" svg:height="0.5in" svg:x="0in" svg:y="0in"/><draw:ellipse text:anchor-type="paragraph" xml:id="id2" draw:id="id2" svg:width="1in" svg:height="0.5in" svg:x="3in" svg:y="1in"/><draw:connector text:anchor-type="paragraph"{kind} svg:x1="2in" svg:y1="4in" svg:x2="5in" svg:y2="5in" draw:start-shape="id1" draw:start-glue-point="2" draw:end-shape="id2" draw:end-glue-point="0"/>Text.</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
import(&package(&content, None)).unwrap()
|
||||
}
|
||||
|
||||
/// The paragraph's shapes: (kind, frame x, y, w, h, flips, drawing id, glued ends).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn shapes(d: &wordcraft_doc::Document) -> Vec<(ShapeKind, [f32; 4], (bool, bool), u32, Option<(u32, u32)>, Option<(u32, u32)>)> {
|
||||
d.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Shape { kind, w, h, float, extra, .. } => Some((
|
||||
*kind,
|
||||
[float.x, float.y, *w, *h].map(|v| (v * 10.0).round() / 10.0),
|
||||
(float.flip_h, float.flip_v),
|
||||
extra.id,
|
||||
extra.start.map(|e| (e.id, e.site)),
|
||||
extra.end.map(|e| (e.id, e.site)),
|
||||
)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connector_is_glued_between_its_shapes() {
|
||||
let s = shapes(&doc(""));
|
||||
assert_eq!(s.len(), 3);
|
||||
assert_eq!((s[0].0, s[0].3), (ShapeKind::Rectangle, 1));
|
||||
assert_eq!((s[1].0, s[1].3), (ShapeKind::Ellipse, 2));
|
||||
// From the rectangle's bottom (its site 2) to the ellipse's top (site 0), and placed so:
|
||||
// (36, 36) to (252, 72).
|
||||
assert_eq!(s[2], (ShapeKind::ElbowConnector, [36.0, 36.0, 216.0, 36.0], (false, false), 0, Some((1, 2)), Some((2, 0))));
|
||||
// Its type: straight and curved.
|
||||
assert_eq!(shapes(&doc(r#" draw:type="line""#))[2].0, ShapeKind::StraightConnector);
|
||||
assert_eq!(shapes(&doc(r#" draw:type="curve""#))[2].0, ShapeKind::CurvedConnector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connectors_are_written_glued_and_read_back() {
|
||||
let d = doc("");
|
||||
let c = content_of(&export(&d).unwrap());
|
||||
assert!(c.contains(r#"<draw:rect"#) && c.contains(r#"xml:id="shape1" draw:id="shape1""#), "{c}");
|
||||
assert!(
|
||||
c.contains(r#"draw:type="standard" draw:start-shape="shape1" draw:start-glue-point="2" draw:end-shape="shape2" draw:end-glue-point="0""#),
|
||||
"{c}"
|
||||
);
|
||||
assert_eq!(shapes(&import(&export(&d).unwrap()).unwrap()), shapes(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glue_to_nothing_or_a_glue_point_of_its_own_is_dropped() {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:rect text:anchor-type="paragraph" xml:id="a" svg:width="1in" svg:height="1in" svg:x="0in" svg:y="0in"/><draw:connector text:anchor-type="paragraph" svg:x1="0in" svg:y1="0in" svg:x2="1in" svg:y2="1in" draw:start-shape="a" draw:start-glue-point="7" draw:end-shape="missing" draw:end-glue-point="1"/><draw:connector text:anchor-type="paragraph" svg:x1="0in" svg:y1="0in" svg:x2="1in" svg:y2="1in" draw:start-shape="a" draw:start-glue-point="99999999999"/></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let s = shapes(&import(&package(&content, None)).unwrap());
|
||||
assert_eq!((s[1].4, s[1].5), (None, None));
|
||||
assert_eq!(s[2].4, None);
|
||||
}
|
||||
@@ -159,6 +159,8 @@ pub(super) struct Writer {
|
||||
/// kept objects' folders with their media types, for the manifest.
|
||||
pub(super) object_names: usize,
|
||||
pub(super) object_dirs: Vec<(String, String)>,
|
||||
/// The kinds of the shapes with drawing ids, which connectors' glue points depend on.
|
||||
pub(super) conn_kinds: HashMap<u32, wordcraft_doc::para::ShapeKind>,
|
||||
/// Ruby was written: its automatic ruby style is.
|
||||
ruby_used: bool,
|
||||
/// Form controls written (`form:*` elements for `office:forms`), and whether this writer's
|
||||
|
||||
@@ -367,7 +367,7 @@ impl Writer {
|
||||
e => e,
|
||||
};
|
||||
let len = |v: f32, least: f32| if v.is_finite() { v.clamp(least, 1584.0) } else { 72.0 };
|
||||
let at = if element == "draw:line" {
|
||||
let at = if element == "draw:line" || element == "draw:connector" {
|
||||
{
|
||||
// A line by its ends: from its box's top left to its bottom right, or bottom left
|
||||
// to top right when it rises.
|
||||
@@ -405,8 +405,9 @@ impl Writer {
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let conn = self.conn_attrs(kind, look);
|
||||
out.push_str(&format!(
|
||||
"<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at} draw:z-index=\"0\">{body}{geometry}</{element}>",
|
||||
"<{element} draw:name=\"{}Shape{}\" draw:style-name=\"{style}\"{anchor}{at}{conn} draw:z-index=\"0\">{body}{geometry}</{element}>",
|
||||
self.prefix, self.boxes,
|
||||
));
|
||||
if let Some(m) = reopen {
|
||||
@@ -414,6 +415,30 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape's drawing id as the id connectors name (`xml:id`, `draw:id`), and a connector's
|
||||
/// type and glued ends (§19.228–19.229), each end's site as the ODF glue point of the shape it
|
||||
/// is glued to (an end glued to a shape not written, or to a site ODF has no glue point for,
|
||||
/// is left unglued).
|
||||
fn conn_attrs(&self, kind: wordcraft_doc::para::ShapeKind, look: &model::BoxLook) -> String {
|
||||
let mut a = String::new();
|
||||
if kind.is_connector() {
|
||||
a.push_str(&format!(" draw:type=\"{}\"", crate::odt::shapes::connector_type(kind)));
|
||||
}
|
||||
let Some(c) = look.conn.as_deref() else { return a };
|
||||
if c.id != 0 {
|
||||
a.push_str(&format!(" xml:id=\"{0}shape{1}\" draw:id=\"{0}shape{1}\"", self.prefix, c.id));
|
||||
}
|
||||
if kind.is_connector() {
|
||||
for (which, end) in [("start", c.start), ("end", c.end)] {
|
||||
let glue = end.and_then(|e| self.conn_kinds.get(&e.id).and_then(|k| crate::odt::shapes::glue_of_site(*k, e.site)).map(|g| (e.id, g)));
|
||||
if let Some((id, g)) = glue {
|
||||
a.push_str(&format!(" draw:{which}-shape=\"{}shape{id}\" draw:{which}-glue-point=\"{g}\"", self.prefix));
|
||||
}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
/// The paragraphs of a text box's or shape's text, written apart from the paragraph being
|
||||
/// written.
|
||||
pub(in crate::odt) fn story(&mut self, blocks: &[FBlock]) -> String {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
|
||||
| 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) |
|
||||
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
|
||||
| 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields, connectors, control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~82%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: emphasis marks, rotated text, conditional and variable fields as fields, control sizes, drawing embedded spreadsheets' contents; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) |
|
||||
| 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) |
|
||||
| 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) |
|
||||
|
||||
Reference in New Issue
Block a user