Gap 19: ODT page background pictures

A page layout's background picture (`style:background-image`, §17.3:
LibreOffice's report templates' world map) was dropped. It is now
WordCraft's page picture (Design › Page Color › Fill Effects) and is
written back with its picture:
- repeated: tiled;
- stretched: stretched;
- not repeated: centred at its own size, a new fill that the screen
  renderer and PDF export draw in the middle of the page. DOCX writes
  it stretched, as VML has no centred fill.

LibreOffice's report template with the map goes from 11,719 to 3,602
differing pixels, and shows the map after a save.

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 10:36:41 +00:00
parent 5cdf2608bf
commit 7be387dabc
8 changed files with 153 additions and 6 deletions
+4 -2
View File
@@ -294,8 +294,10 @@ fn write_document_body(doc: &Document, wr: &mut Writer<'_>, rels: &mut PartRels,
wr.used_media.insert(p.media.clone());
w.open("v:background", &[("id", "_x0000_s1025"), ("o:bwmode", "white")]);
let mut fa: Vec<(&str, &str)> = vec![("r:id", &rid)];
if !p.fill_type.is_empty() {
fa.push(("type", &p.fill_type));
// VML has no centred fill (an ODT page background's): stretched to the page.
let kind = if p.fill_type.eq_ignore_ascii_case("center") { "frame" } else { p.fill_type.as_str() };
if !kind.is_empty() {
fa.push(("type", kind));
}
if !p.title.is_empty() {
fa.push(("o:title", &p.title));
+34 -1
View File
@@ -432,6 +432,8 @@ struct Parsed {
kept: super::parts::Kept,
/// Hidden form controls, kept for the next save.
hidden_controls: Vec<String>,
/// The page background picture's bytes and fill.
page_image: Option<(Vec<u8>, &'static str)>,
}
/// Hyphenation is a paragraph's in ODF (`fo:hyphenate`) and the document's in WordCraft (as in
@@ -507,6 +509,8 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
// A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded.
pictures.get(&mut z, path)
};
// The page background picture's bytes.
let page_image = pages.doc.page_image.clone().and_then(|(href, fill)| fetch(&href).map(|b| (b, fill)));
let objects = (entries.as_slice(), &manifest);
let mut parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, objects)).unwrap_or_default();
first_pages(&mut pages);
@@ -567,6 +571,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
sources,
kept,
hidden_controls,
page_image,
})
}
@@ -603,6 +608,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
sources,
kept,
hidden_controls,
page_image,
} = parse_tracked(bytes, (page.page_w, page.page_h))?;
let mut doc = model::to_doc(&flow);
kept.store(&mut doc);
@@ -622,6 +628,13 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
doc.settings.default_tab = t;
}
doc.settings.page_color = page_doc.page_color;
// The page background picture (Design › Page Color › Fill Effects › Picture).
if let Some((bytes, fill)) = page_image
&& let Some(ext) = model::sniff_image(&bytes)
{
let media = doc.add_media(bytes, ext);
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media, fill_type: fill.to_string(), title: String::new() });
}
if let Some((fmt, props)) = page_doc.notes[0] {
(doc.settings.footnote_format, doc.settings.footnote_pr) = (fmt, props);
}
@@ -670,6 +683,9 @@ pub(super) struct Pages {
#[derive(Clone, Default)]
pub(super) struct PageDoc {
pub(super) page_color: Option<wordcraft_doc::props::Rgb>,
/// The first page style's background picture (§17.3 `style:background-image`): its package
/// path and WordCraft's fill for its repeat (tiled, stretched, or centred at its size).
pub(super) page_image: Option<(String, &'static str)>,
/// Footnotes', then endnotes', number format and options.
pub(super) notes: [Option<(wordcraft_doc::section::NumFormat, wordcraft_doc::section::NoteProps)>; 2],
}
@@ -723,6 +739,8 @@ struct Band {
#[derive(Default)]
struct Layout {
attrs: Attrs,
/// Its background picture: package path and fill (see [`PageDoc::page_image`]).
image: Option<(String, &'static str)>,
columns: Option<Columns>,
header: Band,
footer: Band,
@@ -757,6 +775,20 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
layouts.entry(n.clone()).or_default();
}
}
// A page background picture (§17.3, in `style:page-layout-properties`): §20.249
// `style:repeat` (repeat is the default) as WordCraft's tile, stretch, or centre.
"background-image" if q.starts_with("style:") => {
if let (Some(l), Some(href)) =
(cur.as_ref().and_then(|n| layouts.get_mut(n)), get(&a, "xlink:href").map(str::trim).filter(|h| !h.is_empty() && h.len() <= 1024))
{
let fill = match get(&a, "style:repeat").map(str::trim) {
Some("no-repeat") => "center",
Some("stretch") => "frame",
_ => "tile",
};
l.image = Some((href.trim_start_matches("./").to_string(), fill));
}
}
"header-style" => in_footer = false,
"footer-style" => in_footer = true,
"header-footer-properties" => {
@@ -812,7 +844,7 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
});
let mut pages = Pages { doc, next: nexts, ..Default::default() };
for (name, layout, header, footer) in masters {
let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb }) = layouts.get(&layout) else { continue };
let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb, image }) = layouts.get(&layout) else { continue };
// What a layout leaves unsaid: the default paper, and LibreOffice's 2 cm margins.
let mut sec = SectionProps {
page_w: paper.0,
@@ -827,6 +859,7 @@ fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages {
sec.line_numbers.clone_from(&lines);
if pages.first.is_none() {
pages.doc.page_color = get(a, "fo:background-color").and_then(model::parse_color);
pages.doc.page_image.clone_from(image);
}
// ODF starts a header at the page margin and the text after the header's band; Word
// measures the header's distance and the text's margin from the edge. The band's height
+44 -1
View File
@@ -657,7 +657,8 @@ fn export_as(doc: &Document, mime: &str) -> Result<Vec<u8>, String> {
if w.indexes + hw.indexes > 0 {
draw_xml.push_str("<style:style style:name=\"Index_20_Link\" style:display-name=\"Index Link\" style:family=\"text\"/>");
}
let styles = styles_xml(doc, &pages, &parts, &hw.style_defs(), &named).replacen(
let styles = page_background(doc, &mut w, styles_xml(doc, &pages, &parts, &hw.style_defs(), &named));
let styles = styles.replacen(
"</office:styles>",
// The Frame style text boxes derive from (it states nothing: their own styles do).
&format!("{draw_xml}{named_text_xml}<style:style style:name=\"Frame\" style:family=\"graphic\"/><style:style style:name=\"Graphics\" style:family=\"graphic\"/></office:styles>"),
@@ -837,3 +838,45 @@ fn variables(doc: &Document) -> std::collections::BTreeSet<String> {
}
out
}
/// `styles` with the page background picture (Design › Page Color › Fill Effects › Picture) in
/// each page layout (§17.3 `style:background-image`, first in `style:page-layout-properties`),
/// the picture written into the package by `w`: tiled, stretched, or centred at its own size
/// (§20.249 `style:repeat`).
fn page_background(doc: &Document, w: &mut Writer, styles: String) -> String {
let Some(pic) = doc.settings.page_picture.as_ref() else { return styles };
let Some(data) = doc.media.get(&pic.media) else { return styles };
let Some(ext) = model::sniff_image(data) else { return styles };
let name = format!("Pictures/page-background.{ext}");
if !w.pictures.iter().any(|(n, _, _)| *n == name) {
w.pictures.push((name.clone(), data.clone(), model::mime_of(ext).to_string()));
}
let repeat = match pic.fill_type.to_ascii_lowercase().as_str() {
"tile" => "repeat",
"center" => "no-repeat",
_ => "stretch",
};
let el = format!(
"<style:background-image xlink:href=\"{}\" xlink:type=\"simple\" xlink:actuate=\"onLoad\" style:position=\"center center\" style:repeat=\"{repeat}\"/>",
x(&name)
);
// After each layout's properties' opening tag.
let mut out = String::with_capacity(styles.len() + el.len() * 4);
let mut rest = styles.as_str();
while let Some(at) = rest.find("<style:page-layout-properties") {
let Some(end) = rest.get(at..).and_then(|r| r.find('>')).map(|e| at + e) else { break };
let self_closing = rest.get(end.saturating_sub(1)..end) == Some("/");
if self_closing {
out.push_str(rest.get(..end.saturating_sub(1)).unwrap_or_default());
out.push('>');
out.push_str(&el);
out.push_str("</style:page-layout-properties>");
} else {
out.push_str(rest.get(..=end).unwrap_or_default());
out.push_str(&el);
}
rest = rest.get(end + 1..).unwrap_or_default();
}
out.push_str(rest);
out
}
@@ -161,3 +161,41 @@ fn a_page_layout_stating_nothing_has_libreoffices_margins() {
let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&one))).unwrap().unwrap();
assert_eq!((d.last_section.margin_left, d.last_section.margin_top), (72.0, cm2));
}
#[test]
fn a_page_background_picture_is_read_and_written() {
// LibreOffice's report templates: a picture centred on the page, not repeated.
let png = {
let img = image::RgbImage::from_pixel(8, 4, image::Rgb([200, 30, 30]));
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
let ns = crate::odt::tests::NS_T;
let layout = |repeat: &str| {
format!(
r#"<office:document-styles {ns} xmlns:xlink="http://www.w3.org/1999/xlink"><office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in"><style:background-image xlink:href="Pictures/map.png" style:position="center center" {repeat}/></style:page-layout-properties></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#
)
};
let content =
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
for (repeat, fill) in [(r#"style:repeat="no-repeat""#, "center"), (r#"style:repeat="stretch""#, "frame"), ("", "tile")] {
let styles = layout(repeat);
let pkg = crate::odt::tests::zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/map.png", &png)]);
let d = crate::odt::import(&pkg).unwrap();
let pic = d.settings.page_picture.as_ref().expect("a page picture");
assert_eq!(pic.fill_type, fill);
assert_eq!(d.media.get(&pic.media).map(|b| b.as_slice()), Some(png.as_slice()));
// Written back in the page layout, the picture in the package.
let saved = crate::odt::export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
let want_repeat = match fill {
"center" => "no-repeat",
"frame" => "stretch",
_ => "repeat",
};
assert!(s.contains(&format!(r#"style:repeat="{want_repeat}""#)) && s.contains("Pictures/page-background.png"), "{s}");
let again = crate::odt::import(&saved).unwrap();
assert_eq!(again.settings.page_picture.as_ref().map(|p| p.fill_type.as_str()), Some(fill));
}
}
+6
View File
@@ -286,6 +286,12 @@ impl Exporter<'_> {
.media
.get(&pic.media)
.and_then(|b| image::ImageReader::new(std::io::Cursor::new(b.as_slice())).with_guessed_format().ok()?.into_dimensions().ok());
// Centred once at its natural size (an ODT page background that doesn't repeat).
if let Some((pw, ph)) = dims.filter(|_| pic.fill_type.eq_ignore_ascii_case("center")) {
let (iw, ih) = ((pw as f32 * 0.75).min(w * 4.0), (ph as f32 * 0.75).min(h * 4.0));
self.picture(s, &Rect::new((w - iw) / 2.0, (h - ih) / 2.0, iw, ih), &pic.media, &[0.0; 4], 1.0);
return;
}
if let Some((pw, ph)) = dims.filter(|_| pic.fill_type.eq_ignore_ascii_case("tile")) {
let (tw, th) = (pw as f32 * 0.75, ph as f32 * 0.75);
if tw >= 1.0 && th >= 1.0 && (w / tw).ceil() * (h / th).ceil() <= MAX_TILES {
+14
View File
@@ -338,6 +338,20 @@ pub(crate) fn draw_page_picture(ctx: &mut RenderContext, doc: &Document, page: &
return;
}
let tile = pic.fill_type.eq_ignore_ascii_case("tile");
// Centred once at its natural size (an ODT page background that doesn't repeat).
if pic.fill_type.eq_ignore_ascii_case("center") {
let (iw, ih) = (0.75 * nw, 0.75 * nh);
let (x0, y0) = ((w - iw) / 2.0, (h - ih) / 2.0);
ctx.set_transform(view);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm),
sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium).with_extend(peniko::Extend::Pad),
});
ctx.set_paint_transform(Affine::translate((x0, y0)) * Affine::scale_non_uniform(iw / pw, ih / ph));
ctx.fill_rect(&kurbo::Rect::new(x0.max(0.0), y0.max(0.0), (x0 + iw).min(w), (y0 + ih).min(h)));
ctx.reset_paint_transform();
return;
}
// Tiles at the picture's natural size, whatever size its cached pixmap is kept at.
let (sx, sy) = if tile { (0.75 * nw / pw, 0.75 * nh / ph) } else { (w / pw, h / ph) };
let extend = if tile { peniko::Extend::Repeat } else { peniko::Extend::Pad };
+10
View File
@@ -305,6 +305,16 @@ fn page_picture_is_painted_behind_the_text() {
let i = ((300 * img.width + 300) * 4) as usize;
assert_eq!(&img.pixels[i..i + 3], &[10, 200, 30], "{kind}");
}
// Centred once at its own size (8 px = 6 pt), the rest of the page paper.
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "center".into(), title: String::new() });
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
let px = |x: u32, y: u32| {
let i = ((y * img.width + x) * 4) as usize;
[img.pixels[i], img.pixels[i + 1], img.pixels[i + 2]]
};
assert_eq!(px(img.width / 2, img.height / 2), [10, 200, 30], "centred");
assert_eq!(px(5, 5), [255, 255, 255], "only in the middle");
// An undecodable picture leaves the paper alone.
d.media.insert("bg.png".into(), Arc::new(b"junk".to_vec()));
let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
File diff suppressed because one or more lines are too long