diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index e8c2f54b1e..cf6281d534 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -28,7 +28,8 @@ pub use char_props::{ pub use color::{Rgb, TextColor}; pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame}; pub use table_props::{ - CellProps, HeightRule, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, anchor_ooxml, valign_from_anchor, + CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge, + anchor_ooxml, valign_from_anchor, }; #[cfg(test)] diff --git a/crates/doc/src/props/table_props.rs b/crates/doc/src/props/table_props.rs index ec6de3846b..312644d8bb 100644 --- a/crates/doc/src/props/table_props.rs +++ b/crates/doc/src/props/table_props.rs @@ -300,6 +300,38 @@ pub struct CellProps { /// Tracked formatting change (`w:tcPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// A picture behind the cell's content (an ODF cell's `style:background-image`; Word has + /// none). + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option>, +} + +/// A cell's background picture: drawn at its own size where `align` puts it, tiled, or stretched +/// over the cell, and cut off at the cell's edges. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CellPicture { + /// Key into [`crate::Document::media`]. + pub media: String, + /// Its own size, points. + pub size: [f32; 2], + pub repeat: PictureRepeat, + /// Where an unrepeated picture sits: 0 the left (top) edge, 0.5 the middle, 1 the right + /// (bottom) edge. + pub align: [f32; 2], +} + +/// How a background picture fills its area (ODF §20.348 `style:repeat`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PictureRepeat { + /// Once, at its own size. + #[default] + None, + /// Repeated across the area. + Tile, + /// Stretched over the area. + Stretch, } impl CellProps { diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index 734f8c587a..0c8d090960 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -760,6 +760,7 @@ impl<'r> Builder<'r> { valign, margins: None, direction: Default::default(), + picture: None, }); fmt.background = None; self.containers.push(Vec::new()); diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 7913af6629..a38b441afc 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -27,8 +27,8 @@ 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, Chain, 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, + Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, 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}; diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index d77550794d..b0db6eb0bf 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -965,6 +965,21 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa // Formats that keep cell padding get each cell's, its table's or Word's default. margins: tracked.paras.then(|| c.props.resolved_margins(t.props.cell_margins.unwrap_or(WORD_CELL_MARGINS))), direction: c.props.text_direction, + // Its background picture, for formats that keep cell formatting (ODT). + picture: c.props.picture.as_deref().filter(|_| tracked.paras).and_then(|p| { + let data = doc.media.get(&p.media)?; + let img = Img { + data: data.clone(), + ext: media_ext(&p.media, data), + w: p.size[0], + h: p.size[1], + alt: String::new(), + float: None, + object: None, + crop: [0.0; 4], + }; + Some(Box::new(crate::model::CellPic { img, repeat: p.repeat, align: p.align })) + }), }); } rows.push(cells); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 2bd9d009db..00ffa52a57 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -809,6 +809,11 @@ impl<'a> Builder<'a> { } else { VMerge::None }; + // A background picture goes into the document's media. + let picture = c.picture.as_deref().map(|p| { + let media = self.doc.add_media(p.img.data.as_ref().clone(), &p.img.ext); + Box::new(wordcraft_doc::props::CellPicture { media, size: [p.img.w, p.img.h], repeat: p.repeat, align: p.align }) + }); row.cells.push(wordcraft_doc::Cell { props: CellProps { width: Some(w), @@ -819,6 +824,7 @@ impl<'a> Builder<'a> { margins: c.margins.filter(|m| m.iter().all(|v| v.is_finite())).map(|m| m.map(|v| v.clamp(0.0, 1584.0))), valign: c.valign, text_direction: c.direction, + picture, ..Default::default() }, blocks, diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 0b2f5b62fa..31a7a1b4d6 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -809,6 +809,17 @@ pub struct Cell { pub margins: Option<[f32; 4]>, /// Which way its text runs (turned down or up). pub direction: wordcraft_doc::props::TextDirection, + /// A picture behind its content (ODT). + pub picture: Option>, +} + +/// A cell's background picture: the picture at its own size, how it fills the cell, and where an +/// unrepeated one sits (see [`wordcraft_doc::props::CellPicture`]). +#[derive(Clone, Debug, PartialEq)] +pub struct CellPic { + pub img: Img, + pub repeat: wordcraft_doc::props::PictureRepeat, + pub align: [f32; 2], } impl Default for Cell { @@ -824,6 +835,7 @@ impl Default for Cell { valign: None, margins: None, direction: Default::default(), + picture: None, } } } diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 0a39975987..b422053938 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -893,10 +893,11 @@ impl Body<'_> { let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s)); let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s)); let direction = get(a, "table:style-name").map(|s| self.styles.cell_direction(s)).unwrap_or_default(); + let picture = get(a, "table:style-name").and_then(|s| self.styles.cell_picture(s)).and_then(|p| self.cell_picture(p)); if let Some(t) = self.tables.last_mut() { t.hcover = span as usize - 1; } - self.cells.push(Cell { colspan: span, rowspan: rows, shading, valign, borders, margins, direction, ..Default::default() }); + self.cells.push(Cell { colspan: span, rowspan: rows, shading, valign, borders, margins, direction, picture, ..Default::default() }); self.formulas.push(get(a, "table:formula").and_then(super::cell_formula::word_of)); self.containers.push(Vec::new()); self.lists_saved.push((std::mem::take(&mut self.lists), std::mem::take(&mut self.item_used), self.list_id)); @@ -1235,6 +1236,30 @@ impl Body<'_> { true } + /// A cell's background picture from its package path, `style:repeat` (§20.349: no-repeat, + /// repeat, stretch; unstated, repeat) and `style:position` (§20.335: two of left, center, + /// right, top, bottom; unstated, center). + fn cell_picture(&mut self, (href, repeat, position): (String, String, String)) -> Option> { + let data = (self.zip)(href.trim_start_matches("./"))?; + let img = model::make_img(data, None, None, "")?; + let repeat = match repeat.as_str() { + "no-repeat" => wordcraft_doc::props::PictureRepeat::None, + "stretch" => wordcraft_doc::props::PictureRepeat::Stretch, + _ => wordcraft_doc::props::PictureRepeat::Tile, + }; + let mut align = [0.5, 0.5]; + for word in position.split_whitespace() { + match word { + "left" => align[0] = 0.0, + "right" => align[0] = 1.0, + "top" => align[1] = 0.0, + "bottom" => align[1] = 1.0, + _ => {} + } + } + Some(Box::new(model::CellPic { img, repeat, align })) + } + /// 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) { diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 2aaa29c516..e1895dbde3 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -226,6 +226,9 @@ struct StyleInfo { border_bottom: bool, /// `fo:background-color` of a table-cell style's `style:table-cell-properties`. cell_background: Option, + /// A table-cell style's background picture (§17.3 `style:background-image`): its package + /// path, `style:repeat` and `style:position`. + cell_picture: Option<(String, String, String)>, /// `style:vertical-align` of a table-cell style's `style:table-cell-properties`. cell_valign: Option, cell_direction: Option, @@ -643,6 +646,12 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool { } } "graphic-properties" => s.graphic = Some(super::frames::Graphic::read(a)), + "background-image" if q.starts_with("style:") && s.family.as_deref() == Some("table-cell") => { + if let Some(href) = get(a, "xlink:href").filter(|h| !h.trim().is_empty() && h.len() < 1024) { + let attr = |k: &str| get(a, k).unwrap_or_default().trim().to_string(); + s.cell_picture = Some((href.trim().to_string(), attr("style:repeat"), attr("style:position"))); + } + } _ => return false, } true @@ -1319,6 +1328,11 @@ impl Styles { } /// Background colour of a table-cell style (whole chain). + /// A table-cell style's background picture: its package path, repeat and position. + pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> { + self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone()) + } + pub(super) fn cell_background(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.cell_background) } diff --git a/crates/formats/src/odt/tests_more/tables.rs b/crates/formats/src/odt/tests_more/tables.rs index 41220b7ba0..3803d04473 100644 --- a/crates/formats/src/odt/tests_more/tables.rs +++ b/crates/formats/src/odt/tests_more/tables.rs @@ -142,3 +142,37 @@ fn a_table_styles_look_is_written_into_its_cells() { let body = t.rows[1].cells[1].blocks[0].as_para().unwrap(); assert_ne!(body.runs[0].props.bold, Some(true)); } + +#[test] +fn a_cells_background_picture_is_read_drawn_and_written() { + // LibreOffice's elegant agenda: a small gradient at the top left of a cell (§17.3 + // `style:background-image`, no-repeat, top left), under its text. + let png = { + let img = image::RgbImage::from_pixel(40, 8, image::Rgb([40, 60, 200])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + out.into_inner() + }; + let styles = r#""#; + let content = format!( + r#"{styles}Title"# + ); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + z.start_file("mimetype", o).unwrap(); + std::io::Write::write_all(&mut z, b"application/vnd.oasis.opendocument.text").unwrap(); + z.start_file("content.xml", o).unwrap(); + std::io::Write::write_all(&mut z, content.as_bytes()).unwrap(); + z.start_file("Pictures/g.png", o).unwrap(); + std::io::Write::write_all(&mut z, &png).unwrap(); + let d = import(&z.finish().unwrap().into_inner()).unwrap(); + let pic = cells(&d)[0].picture.clone().expect("the cell's picture"); + assert_eq!((pic.repeat, pic.align), (wordcraft_doc::props::PictureRepeat::None, [0.0, 0.0])); + assert_eq!(pic.size, [30.0, 6.0], "its own size: 40 × 8 pixels at 96 per inch"); + assert!(d.media.contains_key(&pic.media)); + // Written back with the picture in the package. + let saved = export(&d).unwrap(); + let back = import(&saved).unwrap(); + let again = cells(&back)[0].picture.clone().expect("kept"); + assert_eq!((again.repeat, again.align, again.size), (pic.repeat, pic.align, pic.size)); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 1156051023..8613b17ed2 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -756,6 +756,22 @@ impl Writer { format!("{}", self.style_defs()) } + /// The cells' automatic styles: each one's attributes, and what goes inside (a background + /// picture). + fn cell_style_defs(&self) -> String { + let mut s = String::new(); + for (i, key) in self.cell_styles.iter().enumerate() { + let (props, child) = key.split_once(tables::CELL_CHILD).unwrap_or((key.as_str(), "")); + let inner = if child.is_empty() { "/>".to_string() } else { format!(">{child}") }; + s.push_str(&format!( + "", + self.prefix, + i + 1 + )); + } + s + } + /// The automatic styles made so far, without their `office:automatic-styles` element. pub(super) fn style_defs(&self) -> String { let mut s: String = self.data_styles.iter().map(|(.., xml)| xml.as_str()).collect(); @@ -899,13 +915,7 @@ impl Writer { i + 1 )); } - for (i, props) in self.cell_styles.iter().enumerate() { - s.push_str(&format!( - "", - self.prefix, - i + 1 - )); - } + s.push_str(&self.cell_style_defs()); for st in &self.section_styles { s.push_str(st); } diff --git a/crates/formats/src/odt/writer/tables.rs b/crates/formats/src/odt/writer/tables.rs index 3f74cc64f0..0e7f4fba05 100644 --- a/crates/formats/src/odt/writer/tables.rs +++ b/crates/formats/src/odt/writer/tables.rs @@ -74,7 +74,42 @@ pub(in crate::odt) fn cell_props(t: &FTable, ri: usize, g0: usize, c: &model::Ce p } +/// Separates a cell style's `style:table-cell-properties` attributes from its content (a +/// background picture) in [`Writer::cell_style`]'s key. +pub(in crate::odt) const CELL_CHILD: char = '\u{1}'; + impl Writer { + /// A cell's background picture (§17.3 `style:background-image`), the picture written into + /// the package (once for the same bytes). + fn cell_picture(&mut self, pic: &model::CellPic) -> String { + // The same picture in many cells is one file. + let hash = pic.img.data.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)); + let name = format!("Pictures/cell-{hash:016x}.{}", pic.img.ext); + if !self.pictures.iter().any(|(n, _, _)| *n == name) { + self.pictures.push((name.clone(), pic.img.data.clone(), super::mime_of(&pic.img.ext).to_string())); + } + let repeat = match pic.repeat { + wordcraft_doc::props::PictureRepeat::None => "no-repeat", + wordcraft_doc::props::PictureRepeat::Tile => "repeat", + wordcraft_doc::props::PictureRepeat::Stretch => "stretch", + }; + let pos = |a: f32, words: [&'static str; 3]| { + if a < 0.25 { + words[0] + } else if a > 0.75 { + words[2] + } else { + words[1] + } + }; + format!( + "", + x(&name), + pos(pic.align[1], ["top", "center", "bottom"]), + pos(pic.align[0], ["left", "center", "right"]), + ) + } + /// The automatic style of a cell with `props` (its `style:table-cell-properties` attributes). pub(in crate::odt) fn cell_style(&mut self, props: String) -> String { let k = match self.cell_styles.iter().position(|s| *s == props) { @@ -141,7 +176,12 @@ impl Writer { } continue; } - let st = self.cell_style(cell_props(t, ri, g - span, c, cols)); + let mut props = cell_props(t, ri, g - span, c, cols); + if let Some(pic) = &c.picture { + props.push(CELL_CHILD); + props.push_str(&self.cell_picture(pic)); + } + let st = self.cell_style(props); // A formula field: the cell's formula, and the value it shows (§19.646, §19.388). let formula = crate::odt::cell_formula::of_cell(&c.blocks) .and_then(|(instr, result)| Some((crate::odt::cell_formula::odf_of(instr, (ri, g - span), (t.rows.len(), cols))?, result))); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index c213b224c1..c860153c33 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -744,6 +744,15 @@ fn finish_table_rows(ctx: &mut Ctx, input: TableRows<'_>) -> TableLayout { { items.push(Placed::Fill { rect, color: f }); } + // Its background picture, over the shading and under the text, cut off at its edges. + if c.vmerge != VMerge::Continue + && let Some(pic) = t.rows.get(ri).and_then(|r| r.cells.get(ci)).and_then(|cell| cell.props.picture.as_deref()) + { + let images = cell_picture(pic, rect, story, path); + if !images.is_empty() { + items.push(Placed::Clip { rect, spin: wordcraft_geom::Spin::default(), items: images }); + } + } // Behind-text frames count towards vertical alignment even without inline content. // They do not increase the row's flow height. let align_h = c @@ -1107,7 +1116,45 @@ fn line_split(para: &ParaLayout, (l0, l1): (usize, usize), y: f32, limit: f32, e k } -/// The parts of `r` above `cut` and below it, the latter moved up by `shift` (onto the +/// Most copies of a tiled cell picture drawn. +const MAX_TILES: usize = 400; + +/// A cell's background picture in `rect` (the cell's area): once at its own size where its +/// alignment puts it, tiled from the cell's top left, or stretched over the cell. +fn cell_picture(pic: &wordcraft_doc::props::CellPicture, rect: Rect, story: StoryRef, path: &[u32]) -> Vec { + use wordcraft_doc::props::PictureRepeat; + let [w, h] = pic.size; + let image = |r: Rect| Placed::Image { + rect: r, + media: pic.media.clone(), + crop: [0.0; 4], + story, + path: wordcraft_doc::Path(path.to_vec()), + off: usize::MAX, + spin: wordcraft_geom::Spin::default(), + }; + let sized = w.is_finite() && h.is_finite() && w > 0.1 && h > 0.1; + match pic.repeat { + PictureRepeat::Stretch => vec![image(rect)], + PictureRepeat::Tile if sized => { + let (cols, rows) = ((rect.w / w).ceil().clamp(1.0, 400.0) as usize, (rect.h / h).ceil().clamp(1.0, 400.0) as usize); + (0..rows) + .flat_map(|r| (0..cols).map(move |c| (r, c))) + .take(MAX_TILES) + .map(|(r, c)| image(Rect::new(rect.x + c as f32 * w, rect.y + r as f32 * h, w, h))) + .collect() + } + _ if sized => { + let at = |a: f32| if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 }; + let x = rect.x + (rect.w - w) * at(pic.align[0]); + let y = rect.y + (rect.h - h) * at(pic.align[1]); + vec![image(Rect::new(x, y, w, h))] + } + _ => Vec::new(), + } +} + +/// The parts of `r` above `cut` and below it/// The parts of `r` above `cut` and below it, the latter moved up by `shift` (onto the /// continuation of a row split at `cut`). fn split_rect(r: Rect, cut: f32, shift: f32) -> (Option, Option) { let above = (r.y < cut).then(|| Rect::new(r.x, r.y, r.w, (r.bottom().min(cut) - r.y).max(0.0))); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7b52ffeced..cb79c03841 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -12338,3 +12338,42 @@ fn a_page_break_in_a_tables_first_row_moves_the_table() { d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(table))]; assert_eq!(lay(&d).pages.len(), 1); } + +#[test] +fn a_cells_background_picture_is_drawn_clipped_to_the_cell() { + // An ODF cell's background picture: once at its own size at the cell's top left, under the + // text and cut off at the cell's edges; tiled, as many copies as cover the cell. + use wordcraft_doc::props::{CellPicture, PictureRepeat}; + let mut d = Document::new(); + let mut t = wordcraft_doc::Table::new(1, 1, 200.0); + let media = d.add_media(vec![0u8; 8], "png"); + let pic = |repeat| Some(Box::new(CellPicture { media: media.clone(), size: [30.0, 6.0], repeat, align: [0.0, 0.0] })); + t.rows[0].cells[0].props.picture = pic(PictureRepeat::None); + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t.clone())), wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())]; + let clips = |l: &DocLayout| -> Vec<(Rect, Vec)> { + l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Clip { rect, items, .. } => { + Some((*rect, items.iter().filter_map(|i| if let Placed::Image { rect, .. } = i { Some(*rect) } else { None }).collect())) + } + _ => None, + }) + .collect() + }; + let c = clips(&lay(&d)); + assert_eq!(c.len(), 1); + let (cell, images) = &c[0]; + assert_eq!(images.len(), 1); + assert!( + (images[0].x - cell.x).abs() < 0.01 && (images[0].y - cell.y).abs() < 0.01 && (images[0].w - 30.0).abs() < 0.01, + "{images:?} in {cell:?}" + ); + t.rows[0].cells[0].props.picture = pic(PictureRepeat::Tile); + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())]; + let c = clips(&lay(&d)); + let (cell, images) = &c[0]; + let want = (cell.w / 30.0).ceil() as usize * (cell.h / 6.0).ceil() as usize; + assert_eq!(images.len(), want, "{cell:?}"); +} diff --git a/crates/pdf/src/exporter.rs b/crates/pdf/src/exporter.rs index 601cdc48e2..f409c07813 100644 --- a/crates/pdf/src/exporter.rs +++ b/crates/pdf/src/exporter.rs @@ -237,7 +237,12 @@ impl Exporter<'_> { } Placed::Image { story, path, off, .. } => { let obj = self.doc.para(*story, path).and_then(|p| p.object_at(*off)); - let role = if obj.is_some_and(InlineObject::is_decorative) { Role::Artifact(ArtifactType::Other) } else { Role::Figure }; + // A cell's background picture (no object of its own) is decoration too. + let role = if obj.is_some_and(InlineObject::is_decorative) || *off == usize::MAX { + Role::Artifact(ArtifactType::Other) + } else { + Role::Figure + }; let alt = match obj { Some(InlineObject::Image { alt, .. }) => alt.clone(), _ => String::new(), diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ad5f1ffa4..7c8a0520ff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -285,6 +285,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) | +| 2026-11-03 | trivial | ODT cell background pictures (§17.3 `style:background-image` in a cell style: LibreOffice's elegant agenda) are read, drawn under the cell's text and cut off at its edges (once where `style:position` puts it, tiled, or stretched, as `style:repeat` says), and written back; WordCraft's cells can carry one (`CellProps::picture`). DOCX has none, so it stays in WordCraft's model only. | | 2026-11-03 | trivial | ODT page borders: the padding inside a page border was capped at Word's 31 pt border spacing on opening, so text inside a wide padding (WordCraft's own page borders around 1 inch margins) moved towards the border; it is now any distance on the page. | | 2026-11-03 | trivial | ODT watermarks: WordCraft's watermark (Design › Watermark) is saved as LibreOffice's and Word's are, a fontwork shape (`draw:text-path`) named PowerPlusWaterMarkObject behind the text in every header (one written for it alone, inside the top margin, when there is none), which LibreOffice draws; such a shape opens as the watermark (its text, colour, see-through, turn and size), not a shape. Before, the watermark was left out of ODT files. | | 2026-11-03 | trivial | ODT, found by saving WordCraft's own documents and opening them in LibreOffice: a table style's look (header and band fills, its cell borders, bold header text, its paragraph spacing) was left out of the saved file; each cell now states what the style gives it, under the paragraph's own styles as in the layout (ECMA-376 §17.7.2). Tables read from ODT no longer get Word's Table Grid style, whose single spacing changed their text. A columned section states whether its columns end balanced (`text:dont-balance-text-columns`, §20.428, whose value descriptions contradict its name: read as the name says); when a file doesn't say, the ODF interpretation profile decides (`balanceSectionColumns`). SmartArt diagrams (ODF has none) are saved as a group of their shapes and text, which LibreOffice draws as WordCraft does; WordCraft opens them back as a group. |