mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-07 03:01:32 +00:00
M5 #19: ODT cell background pictures
LibreOffice's elegant agenda puts a small gradient picture behind cells (§17.3 style:background-image in a table-cell style). Cells can now carry a background picture (CellProps::picture: media, own size, repeat none/tile/stretch, alignment), which layout draws over the cell's shading and under its text, clipped to the cell (Placed::Clip of Placed::Image; tagged as an artifact in PDF). The ODT reader reads it (style:repeat, style:position, the picture from the package) and the writer writes it back with the picture in the package (once per picture). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
16 files changed
+296
-14
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -300,6 +300,38 @@ pub struct CellProps {
|
||||
/// Tracked formatting change (`w:tcPrChange`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub fmt_change: Option<Box<PropChange<CellProps>>>,
|
||||
/// 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<Box<CellPicture>>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -760,6 +760,7 @@ impl<'r> Builder<'r> {
|
||||
valign,
|
||||
margins: None,
|
||||
direction: Default::default(),
|
||||
picture: None,
|
||||
});
|
||||
fmt.background = None;
|
||||
self.containers.push(Vec::new());
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Box<CellPic>>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Box<model::CellPic>> {
|
||||
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) {
|
||||
|
||||
@@ -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<Rgb>,
|
||||
/// 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<VAlign>,
|
||||
cell_direction: Option<wordcraft_doc::props::TextDirection>,
|
||||
@@ -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<Rgb> {
|
||||
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
|
||||
}
|
||||
|
||||
@@ -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#"<style:style style:name="C1" style:family="table-cell"><style:table-cell-properties fo:border="none"><style:background-image xlink:href="Pictures/g.png" xlink:type="simple" xlink:actuate="onLoad" style:position="top left" style:repeat="no-repeat"/></style:table-cell-properties></style:style>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} xmlns:xlink="http://www.w3.org/1999/xlink"><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text><table:table><table:table-column/><table:table-row><table:table-cell table:style-name="C1"><text:p>Title</text:p></table:table-cell></table:table-row></table:table></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
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));
|
||||
}
|
||||
@@ -756,6 +756,22 @@ impl Writer {
|
||||
format!("<office:automatic-styles>{}</office:automatic-styles>", 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}</style:table-cell-properties>") };
|
||||
s.push_str(&format!(
|
||||
"<style:style style:name=\"{}Cell{}\" style:family=\"table-cell\"><style:table-cell-properties{props}{inner}</style:style>",
|
||||
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!(
|
||||
"<style:style style:name=\"{}Cell{}\" style:family=\"table-cell\"><style:table-cell-properties{props}/></style:style>",
|
||||
self.prefix,
|
||||
i + 1
|
||||
));
|
||||
}
|
||||
s.push_str(&self.cell_style_defs());
|
||||
for st in &self.section_styles {
|
||||
s.push_str(st);
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
"<style:background-image xlink:href=\"{}\" xlink:type=\"simple\" xlink:actuate=\"onLoad\" style:position=\"{} {}\" style:repeat=\"{repeat}\"/>",
|
||||
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)));
|
||||
|
||||
@@ -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<Placed> {
|
||||
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<Rect>, Option<Rect>) {
|
||||
let above = (r.y < cut).then(|| Rect::new(r.x, r.y, r.w, (r.bottom().min(cut) - r.y).max(0.0)));
|
||||
|
||||
@@ -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<Rect>)> {
|
||||
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:?}");
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user