Gap 19: ODT in-line frames placed against the text; saved on the baseline

Found with LibreOffice's style templates.

- An in-line frame placed against the text (`style:vertical-rel` "text"
  or "char", with `middle`, `top` or `bottom`) stood on the baseline. It
  is now raised or lowered against the text's height, from the size of
  the text it sits in. Measured in LibreOffice, `middle` centres the
  frame on the text: its middle half the ascent less the descent above
  the baseline. Placement against the line still stands on the
  baseline.
- An in-line picture or chart was saved with no placement style. In
  LibreOffice that puts it at the line's top (measured: the title beside
  the Finances logo moved up 19 pt). It is now saved standing on the
  baseline ("top" to "baseline"), as WordCraft and Word show it.

Two templates' round-trip differences fall from 1,874 and 1,433 pixels
to 521 and 586. Across the corpus the difference falls from 47k to 45k,
and no document gets worse.

Left in these templates:
- template instantiation: LibreOffice refreshes a template's fixed dates
  and fills its sender fields from the user's data when opening it;
- a first-page footer's frame placed against the page content;
- a page background picture centred in the margins rather than on the
  page.
gaps.md row 19 lists them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-02 18:39:08 +00:00
parent 90898f454b
commit 7cdc8d85a4
8 changed files with 92 additions and 27 deletions
+6 -1
View File
@@ -394,6 +394,11 @@ impl Body<'_> {
self.fmt.last().cloned().unwrap_or_default()
}
/// The size of the text being read, points (its own, else the document's).
pub(in crate::odt) fn text_size(&self) -> f32 {
self.cur_fmt().size.or_else(|| self.styles.doc_size()).unwrap_or(12.0)
}
/// Reading the body itself, not a table cell or a frame.
fn in_body(&self) -> bool {
self.containers.len() == 1 && self.tables.is_empty() && self.frame.is_none()
@@ -884,7 +889,7 @@ impl Body<'_> {
let h = get(a, "svg:height").and_then(length);
self.frame = Some((w, h, None, String::new(), None, None, None));
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g));
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size()));
self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None);
// Without a height, a text box in it grows to its text (`fo:min-height` below).
self.frame_look = g.look(h.is_none(), &self.styles.defs);
+1 -1
View File
@@ -314,7 +314,7 @@ impl Body<'_> {
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)
} else {
crate::odt::frames::place(a, w, h, &g)
crate::odt::frames::place(a, w, h, &g, self.text_size())
};
(float.flip_h, float.flip_v) = (flip_h, flip_v);
self.shape_flip = (false, false);
+29 -9
View File
@@ -382,8 +382,9 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g
}
/// Where a frame or shape `w` × `h` (points) sits: anchored as its `text:anchor-type` says, at
/// its `svg:x`/`svg:y`, or turned and moved by its `draw:transform`, in graphic style `g`.
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic) -> Float {
/// its `svg:x`/`svg:y`, or turned and moved by its `draw:transform`, in graphic style `g`, in
/// text `size` points tall.
pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float {
let turned = get(a, "draw:transform").and_then(|t| transform(t, w, h));
let (x, y) = match turned {
Some((x, y, _)) => (Some(pt(x)), Some(pt(y))),
@@ -394,7 +395,7 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic) -> Float {
f.rot = rot;
}
if f.wrap == Wrap::Inline {
f.y = inline_raise(g, y.as_deref().and_then(length), h);
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
}
f
}
@@ -403,14 +404,28 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic) -> Float {
/// baseline (points, negative lowered; WordCraft keeps it in an inline [`Float`]'s `y`): its
/// `style:vertical-pos` relative to the baseline (§20.387, §20.388; LibreOffice's placement
/// when no `style:vertical-rel` is given) — `top` stands on it, `middle` is centred on it,
/// `bottom` hangs from it, `from-top` has its top `svg:y` below it. Placement relative to the
/// character or line stays on the baseline.
fn inline_raise(g: &Graphic, y: Option<f32>, h: f32) -> f32 {
// Measured from the baseline; a relation an in-line frame can't have (a paragraph's, from
// a parent style for anchored frames) is the baseline too, as LibreOffice reads it.
if matches!(g.vrel.as_deref(), Some("char" | "line" | "text")) || !h.is_finite() {
/// `bottom` hangs from it, `from-top` has its top `svg:y` below it. Relative to the text
/// (`char`, `text`, in text `size` points tall), it is placed against the text's height, from
/// its descent to its ascent (a serif face's, as LibreOffice's templates use): `middle` centred
/// on it (measured in LibreOffice), `top` at its top, `bottom` at its bottom. Placement relative
/// to the line stays on the baseline.
fn inline_raise(g: &Graphic, y: Option<f32>, h: f32, size: f32) -> f32 {
if !h.is_finite() || g.vrel.as_deref() == Some("line") {
return 0.0;
}
if matches!(g.vrel.as_deref(), Some("char" | "text")) {
let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 };
let (ascent, descent) = (TEXT_ASCENT * size, TEXT_DESCENT * size);
let raise = match g.vpos.as_deref() {
Some("middle") => (ascent - descent) / 2.0 - h / 2.0,
Some("top") => ascent - h,
Some("bottom") => -descent,
_ => 0.0,
};
return raise.clamp(-MAX_RAISE, MAX_RAISE);
}
// Measured from the baseline; a relation an in-line frame can't have (a paragraph's, from
// a parent style for anchored frames) is the baseline too, as LibreOffice reads it.
let raise = match g.vpos.as_deref() {
Some("middle") => -h / 2.0,
Some("bottom") => -h,
@@ -423,6 +438,11 @@ fn inline_raise(g: &Graphic, y: Option<f32>, h: f32) -> f32 {
/// Most an inline frame is raised or lowered, points (22 inches).
const MAX_RAISE: f32 = 1584.0;
/// The text's height above and below the baseline, of its size (Liberation Serif's ascent and
/// descent), for frames placed against the text.
const TEXT_ASCENT: f32 = 0.891;
const TEXT_DESCENT: f32 = 0.216;
/// Most operations of a `draw:transform` read.
const MAX_TRANSFORMS: usize = 16;
@@ -56,6 +56,50 @@ fn wrap_modes_and_offsets() {
assert_eq!(float(&open("", r#"text:anchor-type="as-char""#)).wrap, Wrap::Inline);
}
#[test]
fn an_inline_frame_placed_against_the_text_is_raised_from_its_size() {
// LibreOffice's style templates: a logo centred on the text (`middle` relative to `text`)
// stands with its middle at the text's (measured: half the text's ascent less its descent
// above the baseline), in the paragraph's 12 pt text.
let pic = {
let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255]));
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
png
};
let raise = |vpos: &str, rel: &str| {
let content = format!(
r#"<office:document-content {NS} {NS_D}><office:automatic-styles><style:style style:name="P" style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:style><style:style style:name="g1" style:family="graphic"><style:graphic-properties style:vertical-pos="{vpos}" style:vertical-rel="{rel}"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P"><draw:frame draw:style-name="g1" text:anchor-type="as-char" svg:width="1in" svg:height="1in"><draw:image xlink:href="Pictures/p.png"/></draw:frame>Title</text:p></office:text></office:body></office:document-content>"#,
NS = NS_T
);
let doc = import(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/p.png", &pic)])).unwrap();
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
let (off, _) = p.objects_with_offsets().next().unwrap();
p.props_at(off + wordcraft_doc::para::OBJ.len_utf8()).position.map_or(0.0, |v| (v * 10.0).round() / 10.0)
};
// (0.891 - 0.216) / 2 × 12 − 36.
assert_eq!(raise("middle", "text"), -32.0);
assert_eq!(raise("middle", "char"), -32.0);
assert_eq!(raise("top", "text"), -61.3);
assert_eq!(raise("bottom", "text"), -2.6);
// Against the line: on the baseline.
assert_eq!(raise("middle", "line"), 0.0);
}
#[test]
fn an_inline_picture_is_saved_standing_on_the_baseline() {
// LibreOffice puts an in-line frame without placement at the line's top (measured): a
// picture standing on the baseline, as in Word, says so.
let d = float_doc_inline();
let out = crate::odt::tests::content_of(&export(&d).unwrap());
assert!(out.contains(r#"style:vertical-pos="top" style:vertical-rel="baseline""#), "{out}");
}
/// A document with one picture in line with its text.
fn float_doc_inline() -> Document {
open("", r#"text:anchor-type="as-char""#)
}
#[test]
fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() {
// LibreOffice's elegant agenda: a rule raised 0.2 in above the baseline (its parent style's
+3 -7
View File
@@ -197,13 +197,9 @@ impl Writer {
(n, link)
}
};
let (anchor, style) = match place {
Place::Text(None) => (" text:anchor-type=\"as-char\"".to_string(), String::new()),
_ => {
let (anchor, props, xy) = placement(place, c.w, c.h);
(anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props)))
}
};
// Always placed by a style (see `image_frame`).
let (anchor, props, xy) = placement(place, c.w, c.h);
let (anchor, style) = (anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props)));
out.push_str(&format!(
"<draw:frame draw:name=\"{}Chart{n}\"{style}{anchor} svg:width=\"{}\" svg:height=\"{}\" draw:z-index=\"0\">{inner}",
self.prefix,
+5 -7
View File
@@ -290,13 +290,11 @@ impl Writer {
fn image_frame(&mut self, img: &model::Img, place: Place<'_>, inner: &str, out: &mut String) {
let n = self.pictures.len();
let clip = clip_attr(img);
let (anchor, style) = match place {
Place::Text(None) if clip.is_empty() => (" text:anchor-type=\"as-char\"".to_string(), String::new()),
_ => {
let (anchor, props, xy) = placement(place, img.w, img.h);
(anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(format!("{}{props}{clip}", super::GRAPHICS_PARENT))))
}
};
// Always placed by a style: in line, standing on the baseline (LibreOffice puts a frame
// with no placement at the line's top).
let (anchor, props, xy) = placement(place, img.w, img.h);
let (anchor, style) =
(anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(format!("{}{props}{clip}", super::GRAPHICS_PARENT))));
out.push_str(&format!(
"<draw:frame draw:name=\"{}Image{n}\"{style}{anchor} svg:width=\"{}\" svg:height=\"{}\" draw:z-index=\"0\">{inner}",
self.prefix,
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long