mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-12 18:13:00 +00:00
Gap 19: ODT form controls keep their place and lines; custom shapes their geometry
Form controls: - Placement: a control hanging from the baseline (as LibreOffice places those it makes) was saved with no placement. LibreOffice then puts it at the line's top, so every control row shrank. Its raise is now kept (`ContentControl::form_raise`, read as in-line frames are) and written, standing on the baseline when it had none. - Multi-line fields: these were saved without LibreOffice's `MultiLine` property, so they showed one centred line. They now carry it. - Text fields get LibreOffice's flat border. - Also: `pt()` no longer writes "-0pt". Custom shapes: - A shape drawn from its own geometry was saved as WordCraft's path of it, and a preset WordCraft draws as a rectangle (a cloud) was saved as a rectangle. The geometry as read (type, path, equations, handles) is now kept (`ShapeExtra::odf_geometry`) and written back, mirrored as the shape now is. - `shape.change` and the ink-to-shape conversion drop the kept geometry, since the shape is then another kind. `ShapeExtra` holds it as an `Arc<String>` to keep `Placed::Shape` small. - The reader's `end` hands skipped elements to `end_skipped`. Results: - The forms document round-trips pixel-identical (2,949 pixels before), with its text the same. - The shape presets fall from 2,467 to 1,220. The rest is LibreOffice's hairline outline, which WordCraft keeps as 0.5 pt. - Across the corpus the round-trip difference falls from 45k to 41k pixels. 42 of 125 documents round-trip pixel-identical, 92 with their text the same, and none gets worse. gaps.md row 19 and the ROADMAP log are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
+3
-1
File diff suppressed because one or more lines are too long
@@ -83,6 +83,12 @@ pub struct ShapeExtra {
|
||||
/// main colour (the shape's `stroke`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub outline_fill: Option<crate::fill::Fill>,
|
||||
/// An ODF custom shape's geometry as read (its `draw:enhanced-geometry`, equations and
|
||||
/// handles), whose path the shape's freeform was worked out from: saved back as it was, so
|
||||
/// LibreOffice draws its own shape (or a preset WordCraft draws as a rectangle: kept with
|
||||
/// it). Dropped when the shape is made another kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub odf_geometry: Option<std::sync::Arc<String>>,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u32) -> bool {
|
||||
|
||||
@@ -258,6 +258,10 @@ pub struct ContentControl {
|
||||
/// `svg:height`), points: kept for saving back as one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_size: Option<[f32; 2]>,
|
||||
/// How far that form control stands raised from the baseline (points, negative lowered:
|
||||
/// an ODF control hanging from the baseline is lowered by its height), kept for saving back.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub form_raise: Option<f32>,
|
||||
/// A form control WordCraft has no kind for (an ODF button, image button, label…): its form
|
||||
/// XML, kept verbatim and saved back as it was. It shows its label, locked.
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
|
||||
@@ -880,6 +880,7 @@ fn set_shape(s: &mut Session, pos: &Pos, found: &InkShape) -> Result<(), CmdErro
|
||||
};
|
||||
*kind = new_kind;
|
||||
*freeform = geometry;
|
||||
extra.odf_geometry = None;
|
||||
*fill = None;
|
||||
let [_, _, raw_w, raw_h] = found.bounds();
|
||||
(*w, *h) = if matches!(found, InkShape::Arrow { .. }) { (raw_w, raw_h) } else { (bw, bh) };
|
||||
|
||||
@@ -309,8 +309,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("shape.change", "Change Shape", "Shape Format › Insert Shapes", |s, v| {
|
||||
let kind: wordcraft_doc::para::ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Shape { kind: k, .. } = o {
|
||||
if let InlineObject::Shape { kind: k, extra, .. } = o {
|
||||
*k = kind;
|
||||
// A geometry kept from a file is no longer this shape's.
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6847,6 +6847,26 @@ fn password_protected_documents_round_trip() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_shape_drops_the_geometry_kept_from_a_file() {
|
||||
// An ODT custom shape keeps its geometry as read, to save back; made another shape, it isn't
|
||||
// that shape any more.
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.shape", json!({"kind": "rectangle"}));
|
||||
let geometry = |s: &Session| match cmd::objects::selected(s) {
|
||||
Some((_, wordcraft_doc::InlineObject::Shape { extra, .. })) => extra.odf_geometry.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
let Some((pos, _)) = cmd::objects::selected(&s) else { panic!() };
|
||||
let para = s.doc.para_mut(pos.story, &pos.path).unwrap();
|
||||
if let Some(wordcraft_doc::InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) {
|
||||
extra.odf_geometry = Some(std::sync::Arc::new(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#.to_string()));
|
||||
}
|
||||
assert!(geometry(&s).is_some());
|
||||
run(&mut s, "shape.change", json!({"kind": "ellipse"}));
|
||||
assert_eq!(geometry(&s), None);
|
||||
}
|
||||
|
||||
/// Shape Format › Shape Effects (#275): presets and custom values apply to the selected shape,
|
||||
/// an omitted key is kept, hostile numbers are clamped and each change is one undo step.
|
||||
#[test]
|
||||
|
||||
@@ -320,6 +320,8 @@ fn drawing_inline(doc: &Document, o: &InlineObject, opts: FlowOpts, depth: usize
|
||||
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),
|
||||
vert: part.map(|p| p.body.vert).unwrap_or_default(),
|
||||
// Kept only while it is still the freeform it was read as.
|
||||
geometry: extra.odf_geometry.clone().filter(|_| (kind == ShapeKind::Freeform && freeform.is_some()) || kind == ShapeKind::Rectangle),
|
||||
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 })),
|
||||
// A text box is named after its story; a linked one names the box it continues in.
|
||||
|
||||
@@ -432,6 +432,10 @@ impl<'a> Builder<'a> {
|
||||
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),
|
||||
odf_geometry: look.geometry.clone().filter(|_| {
|
||||
(*kind == wordcraft_doc::para::ShapeKind::Freeform && path.is_some())
|
||||
|| *kind == wordcraft_doc::para::ShapeKind::Rectangle
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
@@ -314,6 +314,8 @@ pub struct BoxLook {
|
||||
pub chain: Option<Box<Chain>>,
|
||||
/// Which way a text box's text runs (turned down or up).
|
||||
pub vert: wordcraft_doc::props::TextVert,
|
||||
/// A custom shape's geometry as read (ODT; see `ShapeExtra::odf_geometry`).
|
||||
pub geometry: Option<std::sync::Arc<String>>,
|
||||
}
|
||||
|
||||
/// A text box's name, and the name of the box its text continues in ([`BoxLook::chain`]).
|
||||
@@ -344,6 +346,7 @@ impl Default for BoxLook {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: Default::default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,10 @@ pub(super) struct Body<'a> {
|
||||
/// A custom shape's geometry being read, when its type has no WordCraft kind: drawn from its
|
||||
/// own path and equations.
|
||||
geometry: Option<Box<super::geometry::Pending>>,
|
||||
/// That geometry's markup as read (see `ShapeExtra::odf_geometry`): being read, then
|
||||
/// finished for the shape it ends in.
|
||||
geometry_xml: Option<String>,
|
||||
pub(in crate::odt) shape_geometry: Option<std::sync::Arc<String>>,
|
||||
/// The turn of a group's member being read, from its `draw:transform`.
|
||||
member_rot: Option<f32>,
|
||||
/// The depth of the `draw:g` group being read: its shapes are placed by it, not anchored.
|
||||
@@ -219,6 +223,8 @@ impl<'a> Body<'a> {
|
||||
shape_flip: (false, false),
|
||||
shape_path: None,
|
||||
geometry: None,
|
||||
geometry_xml: None,
|
||||
shape_geometry: None,
|
||||
member_rot: None,
|
||||
group_at: None,
|
||||
index: None,
|
||||
@@ -739,6 +745,12 @@ impl Body<'_> {
|
||||
{
|
||||
g.equation(a);
|
||||
}
|
||||
if self.skip == 1
|
||||
&& (q == "draw:equation" || q == "draw:handle")
|
||||
&& let Some(xml) = &mut self.geometry_xml
|
||||
{
|
||||
xml.push_str(&element_xml(q, a, true));
|
||||
}
|
||||
self.skip += 1;
|
||||
return;
|
||||
}
|
||||
@@ -762,6 +774,10 @@ impl Body<'_> {
|
||||
let t = get(a, "draw:type").unwrap_or_default();
|
||||
self.shape_kind = Some(super::shapes::kind_of(t));
|
||||
self.geometry = super::geometry::Pending::of(t, a);
|
||||
// Kept as read, its mirroring aside (written from the shape's own).
|
||||
let kept: Attrs = a.iter().filter(|(k, _)| !k.starts_with("draw:mirror-")).cloned().collect();
|
||||
self.geometry_xml = (self.geometry.is_some() || super::shapes::unknown_preset(t)).then(|| element_xml(q, &kept, false));
|
||||
self.shape_geometry = None;
|
||||
// Mirrored (§20.112 `draw:mirror-horizontal`, §20.113 `draw:mirror-vertical`).
|
||||
let on = |k: &str| get(a, k).is_some_and(|v| v.trim() == "true");
|
||||
self.shape_flip = (on("draw:mirror-horizontal"), on("draw:mirror-vertical"));
|
||||
@@ -958,6 +974,10 @@ impl Body<'_> {
|
||||
let len = |k: &str| get(a, k).and_then(length).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1584.0));
|
||||
if let (Some(w), Some(h)) = (len("svg:width"), len("svg:height")) {
|
||||
control.form_size = Some([w, h]);
|
||||
// Where it stands on the line (§20.387), as an in-line frame does.
|
||||
let g = self.styles.shape_graphic(get(a, "draw:style-name"));
|
||||
let raise = super::frames::place(a, w, h, &g, self.text_size()).y;
|
||||
control.form_raise = (raise.is_finite() && raise != 0.0).then_some(raise);
|
||||
}
|
||||
let fmt = self.cur_fmt();
|
||||
self.push_inline(Inline::Control { control: Box::new(control), text, fmt });
|
||||
@@ -1193,6 +1213,27 @@ impl Body<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// A skipped element ends; the end of a custom shape's geometry works out its path and
|
||||
/// keeps the geometry as read.
|
||||
fn end_skipped(&mut self) {
|
||||
self.skip -= 1;
|
||||
if self.skip > 0 {
|
||||
return;
|
||||
}
|
||||
let xml = self.geometry_xml.take();
|
||||
let keep = |x: Option<String>| x.filter(|x| x.len() < MAX_GEOMETRY_XML).map(|x| std::sync::Arc::new(format!("{x}</draw:enhanced-geometry>")));
|
||||
if let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
self.shape_geometry = keep(xml);
|
||||
} else if self.shape_kind == Some(wordcraft_doc::para::ShapeKind::Rectangle) {
|
||||
// A preset WordCraft draws as a rectangle: its geometry kept as read.
|
||||
self.shape_geometry = keep(xml);
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&mut self, q: &str) {
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
if self.end_note_or_field(q) {
|
||||
@@ -1215,14 +1256,7 @@ impl Body<'_> {
|
||||
return;
|
||||
}
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
if self.skip == 0
|
||||
&& let Some(g) = self.geometry.take()
|
||||
&& let Some(f) = g.freeform()
|
||||
{
|
||||
self.shape_kind = Some(wordcraft_doc::para::ShapeKind::Freeform);
|
||||
self.shape_path = Some(std::sync::Arc::new(f));
|
||||
}
|
||||
self.end_skipped();
|
||||
return;
|
||||
}
|
||||
if !self.in_text {
|
||||
@@ -1336,3 +1370,12 @@ impl Body<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most markup a custom shape's kept geometry may take (see `ShapeExtra::odf_geometry`).
|
||||
const MAX_GEOMETRY_XML: usize = 256 * 1024;
|
||||
|
||||
/// Element `q` with attributes `a` as markup: empty (`<q …/>`) or an opening tag.
|
||||
fn element_xml(q: &str, a: &Attrs, empty: bool) -> String {
|
||||
let attrs: String = a.iter().map(|(k, v)| format!(" {k}=\"{}\"", super::writer::x(v))).collect();
|
||||
format!("<{q}{attrs}{}>", if empty { "/" } else { "" })
|
||||
}
|
||||
|
||||
@@ -104,9 +104,14 @@ impl Body<'_> {
|
||||
float.filter(|f| f.wrap != wordcraft_doc::para::Wrap::Inline || crate::odt::frames::turned(f) || f.flip_h || f.flip_v || f.y != 0.0);
|
||||
let inline = match (boxed, eq) {
|
||||
(Some(blocks), _) => {
|
||||
let look = std::mem::take(&mut self.frame_look);
|
||||
let mut 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);
|
||||
// A custom shape's geometry as read, kept with the path worked out from it.
|
||||
let geometry = self.shape_geometry.take();
|
||||
if kind == wordcraft_doc::para::ShapeKind::Freeform || kind == wordcraft_doc::para::ShapeKind::Rectangle {
|
||||
look.geometry = geometry;
|
||||
}
|
||||
// A shape whose paragraphs are all empty (LibreOffice writes `<text:p/>` in
|
||||
// each) holds no text.
|
||||
let empty = blocks.iter().all(|b| matches!(b, FBlock::Para(p) if p.inlines.is_empty()));
|
||||
|
||||
@@ -301,7 +301,18 @@ pub(in crate::odt) fn form_xml(c: &ContentControl, text: &str, id: &str, n: usiz
|
||||
(format!("<form:text{common}{}{} form:current-value=\"{}\"/>", impl_("TextField"), label, x(&v)), width(&v), 16.0)
|
||||
}
|
||||
ControlKind::Text { multi_line: true } => {
|
||||
(format!("<form:textarea{common}{}{} form:current-value=\"{}\"/>", impl_("TextField"), label, x(&v)), 216.0, 48.0)
|
||||
// Several lines (LibreOffice's `MultiLine` property): without it LibreOffice shows
|
||||
// one line, centred in the box.
|
||||
(
|
||||
format!(
|
||||
"<form:textarea{common}{}{} form:current-value=\"{}\"><form:properties><form:property form:property-name=\"MultiLine\" office:value-type=\"boolean\" office:boolean-value=\"true\"/></form:properties></form:textarea>",
|
||||
impl_("TextField"),
|
||||
label,
|
||||
x(&v)
|
||||
),
|
||||
216.0,
|
||||
48.0,
|
||||
)
|
||||
}
|
||||
ControlKind::CheckBox { checked, checked_char, .. } if *checked_char == RADIO_ON => (
|
||||
format!("<form:radio{common}{}{label} form:current-selected=\"{checked}\" form:selected=\"{checked}\"/>", impl_("RadioButton")),
|
||||
|
||||
@@ -264,6 +264,7 @@ impl Graphic {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: Default::default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +292,7 @@ impl Graphic {
|
||||
conn: None,
|
||||
chain: None,
|
||||
vert: self.vert.unwrap_or_default(),
|
||||
geometry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ pub(super) fn element_kind(name: &str) -> ShapeKind {
|
||||
/// 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.
|
||||
/// A preset geometry type WordCraft has no kind for: drawn as a rectangle (see [`kind_of`]), its
|
||||
/// geometry kept to be written back.
|
||||
pub(super) fn unknown_preset(t: &str) -> bool {
|
||||
let t = t.trim();
|
||||
kind_of(t) == ShapeKind::Rectangle
|
||||
&& !matches!(t.strip_prefix("ooxml-").unwrap_or(t), "" | "rectangle" | "rect" | "flowchart-process" | "flowChartProcess" | "non-primitive")
|
||||
}
|
||||
|
||||
pub(super) fn kind_of(t: &str) -> ShapeKind {
|
||||
let t = t.trim();
|
||||
match t.strip_prefix("ooxml-").unwrap_or(t) {
|
||||
|
||||
@@ -160,7 +160,17 @@ fn hostile_geometry_never_panics_and_draws_what_it_can() {
|
||||
fn a_freeform_with_open_and_closed_paths_is_saved_as_a_custom_shape() {
|
||||
// A face and a mouth: the face filled, the mouth not. A path with an open part would be
|
||||
// drawn unfilled whole, so it is saved as a custom shape whose path marks the mouth unfilled.
|
||||
let d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
let mut d = open(&shape(r#"draw:type="smiley" draw:enhanced-path="M 0 0 L 21600 0 21600 21600 Z N M 5000 15000 L 16000 15000 F N""#, ""));
|
||||
// As WordCraft's own freeform (the geometry read is written back whole otherwise).
|
||||
for b in d.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Shape { extra, .. } = o {
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(c.contains(r#"draw:type="non-primitive""#) && c.contains(" F N"), "{c}");
|
||||
assert!(!c.contains("<draw:path"), "{c}");
|
||||
@@ -191,3 +201,40 @@ fn points_far_outside_the_view_box_stay_finite() {
|
||||
let Some(InlineObject::Shape { freeform: Some(f), .. }) = d.body[0].as_para().unwrap().objects.first() else { panic!("a freeform") };
|
||||
assert!(f.paths.iter().flat_map(|p| p.pts.iter()).all(|[x, y]| x.is_finite() && y.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_shapes_geometry_is_written_back_as_it_was_read() {
|
||||
// LibreOffice's shape presets: saved as their own geometry (type, path, equations, handles),
|
||||
// so LibreOffice draws its own shape, not WordCraft's path of it; a preset WordCraft draws as
|
||||
// a rectangle (a cloud) keeps its geometry too. A shape made another kind loses it.
|
||||
let hexagon = shape(
|
||||
r#"svg:viewBox="0 0 21600 21600" draw:type="hexagon" draw:modifiers="5400" draw:mirror-horizontal="true" draw:enhanced-path="M ?f0 0 L ?f1 0 21600 10800 ?f1 21600 ?f0 21600 0 10800 Z N""#,
|
||||
r#"<draw:equation draw:name="f0" draw:formula="$0 "/><draw:equation draw:name="f1" draw:formula="right-$0"/><draw:handle draw:handle-position="$0 top"/>"#,
|
||||
);
|
||||
let cloud = r#"<draw:custom-shape text:anchor-type="paragraph" svg:width="1in" svg:height="1in"><draw:enhanced-geometry draw:type="cloud"/></draw:custom-shape>"#;
|
||||
let d = open(&format!("{hexagon}{cloud}"));
|
||||
assert_eq!(drawn(&d).iter().map(|s| s.0).collect::<Vec<_>>(), [ShapeKind::Freeform, ShapeKind::Rectangle]);
|
||||
let out = crate::odt::tests::content_of(&export(&d).unwrap());
|
||||
assert!(out.contains(r#"draw:type="hexagon" draw:modifiers="5400""#) && out.contains(r#"draw:formula="right-$0""#), "{out}");
|
||||
assert!(out.contains(r#"<draw:handle draw:handle-position="$0 top"/>"#), "{out}");
|
||||
// Its mirroring is the shape's own, written once.
|
||||
assert_eq!(out.matches("draw:mirror-horizontal=\"true\"").count(), 1, "{out}");
|
||||
assert!(out.contains(r#"<draw:enhanced-geometry draw:type="cloud"></draw:enhanced-geometry>"#), "{out}");
|
||||
// Read back, it is the same shape again.
|
||||
let back = import(&export(&d).unwrap()).unwrap();
|
||||
assert_eq!(drawn(&back), drawn(&d));
|
||||
// Made an ellipse: written as one.
|
||||
let mut changed = d.clone();
|
||||
for b in changed.body.iter_mut() {
|
||||
if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) {
|
||||
for o in &mut p.objects {
|
||||
if let InlineObject::Shape { kind, extra, .. } = o {
|
||||
*kind = ShapeKind::Ellipse;
|
||||
extra.odf_geometry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out = crate::odt::tests::content_of(&export(&changed).unwrap());
|
||||
assert!(!out.contains("hexagon") && !out.contains("cloud"), "{out}");
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ fn an_inline_frame_keeps_its_place_on_the_baseline() {
|
||||
let saved = crate::odt::tests::content_of(&crate::odt::export(&doc).unwrap());
|
||||
if want != 0.0 {
|
||||
assert!(saved.contains(r#"style:vertical-pos="from-top" style:vertical-rel="baseline""#), "{saved}");
|
||||
let top = -(72.0 + want);
|
||||
let top = -(72.0 + want) + 0.0;
|
||||
assert!(saved.contains(&format!(r#"svg:y="{}pt""#, top)), "{vpos}{y}: {saved}");
|
||||
}
|
||||
let again = crate::odt::import(&crate::odt::export(&doc).unwrap()).unwrap();
|
||||
|
||||
@@ -196,3 +196,28 @@ fn hidden_controls_are_kept() {
|
||||
let c = entry(&export(&import(&package(&only, None)).unwrap()).unwrap(), "content.xml");
|
||||
assert!(c.contains("<office:forms ") && c.contains("<form:hidden "), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_keep_their_place_on_the_line_and_a_text_area_its_lines() {
|
||||
// LibreOffice hangs controls it makes from the baseline (`from-top`, `svg:y` 0); one saved
|
||||
// without a placement stands at the line's top. A text area is several lines only with its
|
||||
// `MultiLine` property.
|
||||
let content = doc_xml()
|
||||
.replace(r#"<office:body>"#, r#"<office:automatic-styles><style:style style:name="gr1" style:family="graphic"><style:graphic-properties style:vertical-pos="from-top"/></style:style></office:automatic-styles><office:body>"#)
|
||||
.replace(r#"<draw:control text:anchor-type="as-char""#, r#"<draw:control draw:style-name="gr1" text:anchor-type="as-char" svg:y="0in""#);
|
||||
let doc = import(&package(&content, None)).unwrap();
|
||||
let raises: Vec<Option<f32>> = doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| if let wordcraft_doc::para::InlineObject::ControlStart { control } = o { Some(control.form_raise) } else { None })
|
||||
.collect();
|
||||
assert!(!raises.is_empty() && raises.iter().all(|r| r.is_some_and(|v| (v + 14.4).abs() < 0.01)), "{raises:?}");
|
||||
let out = entry(&export(&doc).unwrap(), "content.xml");
|
||||
assert!(out.contains(r#"style:vertical-pos="from-top" style:vertical-rel="baseline""#) && out.contains(r#"svg:y="0pt""#), "{out}");
|
||||
assert!(out.contains(r#"form:property-name="MultiLine" office:value-type="boolean" office:boolean-value="true""#), "{out}");
|
||||
// Saved without a raise, a control still says it stands on the baseline.
|
||||
let plain = entry(&export(&import(&package(&doc_xml(), None)).unwrap()).unwrap(), "content.xml");
|
||||
assert!(plain.contains(r#"style:vertical-pos="top" style:vertical-rel="baseline""#), "{plain}");
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ fn starts_column(p: &Para) -> bool {
|
||||
|
||||
pub(super) fn pt(v: f32) -> String {
|
||||
let s = format!("{:.2}", v);
|
||||
format!("{}pt", s.trim_end_matches('0').trim_end_matches('.'))
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
// A value rounding to nothing is 0, not "-0".
|
||||
format!("{}pt", if s == "-0" { "0" } else { s })
|
||||
}
|
||||
|
||||
/// A section that starts a page: the master page it uses and where page numbering restarts.
|
||||
@@ -512,9 +514,18 @@ impl Writer {
|
||||
let id = format!("control{}", self.forms.len() + 1);
|
||||
match super::forms::form_xml(control, text, &id, self.forms.len() + 1).filter(|_| self.forms_allowed) {
|
||||
Some((xml, w, h)) => {
|
||||
// Text boxes with a flat border (`fo:border="solid"`, LibreOffice's for
|
||||
// its own text fields), not its sunken 3D one.
|
||||
let flat = if xml.starts_with("<form:text ") || xml.starts_with("<form:textarea") { " fo:border=\"solid\"" } else { "" };
|
||||
self.forms.push(xml);
|
||||
// Placed by a style, standing on the baseline or raised as it was read
|
||||
// (LibreOffice puts a control with no placement at the line's top).
|
||||
let raise = control.form_raise.filter(|r| r.is_finite()).map(|r| r.clamp(-1584.0, 1584.0)).unwrap_or(0.0);
|
||||
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Inline, y: raise, ..Default::default() };
|
||||
let (_, props, xy) = drawing::placement(drawing::Place::Text(Some(&float)), w, h);
|
||||
let style = self.graphic_style(format!("{props}{flat}"));
|
||||
out.push_str(&format!(
|
||||
"<draw:control text:anchor-type=\"as-char\" draw:z-index=\"0\" svg:width=\"{}\" svg:height=\"{}\" draw:control=\"{id}\"/>",
|
||||
"<draw:control draw:style-name=\"{style}\" text:anchor-type=\"as-char\"{xy} draw:z-index=\"0\" svg:width=\"{}\" svg:height=\"{}\" draw:control=\"{id}\"/>",
|
||||
pt(w),
|
||||
pt(h)
|
||||
));
|
||||
|
||||
@@ -420,7 +420,13 @@ impl Writer {
|
||||
let style = self.graphic_style(format!("{props}{look_attrs}"));
|
||||
let body = self.story(blocks);
|
||||
self.boxes += 1;
|
||||
// A custom shape's geometry as read: written back whole, LibreOffice's own shape.
|
||||
let kept = look
|
||||
.geometry
|
||||
.as_deref()
|
||||
.filter(|_| (kind == wordcraft_doc::para::ShapeKind::Freeform && path.is_some()) || kind == wordcraft_doc::para::ShapeKind::Rectangle);
|
||||
let (element, geometry) = match crate::odt::shapes::element_of(kind) {
|
||||
_ if kept.is_some() => ("draw:custom-shape", None),
|
||||
// A freeform without paths has nothing to draw but its frame.
|
||||
("draw:path", _) if path.is_none() => ("draw:rect", None),
|
||||
// Paths filled and unfilled: a custom shape, whose path says which is which
|
||||
@@ -463,7 +469,22 @@ impl Writer {
|
||||
// Mirrored, as a custom shape's geometry says (§20.112, §20.113).
|
||||
let mirror = |on: bool, k: &str| if on { format!(" draw:mirror-{k}=\"true\"") } else { String::new() };
|
||||
let (fh, fv) = float.map_or((false, false), |f| (f.flip_h, f.flip_v));
|
||||
let geometry = geometry
|
||||
let geometry = match kept {
|
||||
// Its own mirroring, now, in the kept opening tag.
|
||||
Some(g) => match g.find('>') {
|
||||
Some(i) if g.get(..i).is_some_and(|h| !h.ends_with('/')) => {
|
||||
format!(
|
||||
"{}{}{}{}",
|
||||
g.get(..i).unwrap_or_default(),
|
||||
mirror(fh, "horizontal"),
|
||||
mirror(fv, "vertical"),
|
||||
g.get(i..).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
_ => g.to_string(),
|
||||
},
|
||||
None => String::new(),
|
||||
} + &geometry
|
||||
.map(|t| {
|
||||
if custom_path.is_empty() {
|
||||
custom_path = " svg:viewBox=\"0 0 21600 21600\"".into();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user