mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
craft-docx: a file with no default table style gives its tables no cell margins
Port of storytold/wordcraft#3821, whose reader code moved here with storytold/wordcraft#6603. Word's 0.08" cell margin comes from the Normal Table default table style; without a default table style a table that sets no margins (and whose style sets none) has zero margins. The zeros are kept as the table's own, also for the old properties of a tblPrChange, so they survive saving. w:default="on" now marks a default style too.
This commit is contained in:
4 files changed
+108
-1
No files matched your search
@@ -119,6 +119,10 @@ pub(crate) struct Reader<'p> {
|
||||
pub theme_fills: shape_style::ThemeFills,
|
||||
/// The fill of the group being read (what its members' `a:grpFill` stands for).
|
||||
pub group_fill: Option<craft_wordmodel::fill::Fill>,
|
||||
/// The package has no default table style (no styles part, or none marked `w:default`):
|
||||
/// Word then gives tables no cell margins rather than its Normal Table's 0.08". Set by
|
||||
/// `read_styles`, which runs before any story with tables is read.
|
||||
pub no_default_table_style: bool,
|
||||
}
|
||||
|
||||
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
|
||||
@@ -193,6 +197,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
chunks,
|
||||
no_default_table_style: true,
|
||||
};
|
||||
r.doc.missing_pictures.main = main.clone();
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
|
||||
@@ -314,15 +314,32 @@ impl Reader<'_> {
|
||||
|
||||
// ---- tables ----
|
||||
|
||||
/// Word gives a table no cell margins when the file has no default table style and neither the
|
||||
/// table nor its style sets them; saving may write a default Normal Table, so the zeros are
|
||||
/// kept as the table's own. shortcut: they stay direct formatting (a table style applied later
|
||||
/// keeps them), until the model knows the file's default table style. A `w:tblCellMar` that sets
|
||||
/// only some sides keeps them: only the sides it omits become 0.
|
||||
fn no_default_margins(&self, props: &mut craft_wordmodel::props::TableProps) {
|
||||
if self.no_default_table_style
|
||||
&& props.cell_margins.is_none()
|
||||
&& props.style.as_deref().and_then(|id| self.doc.styles.table_style(id)).is_none_or(|s| s.parts.cell_margins.is_none())
|
||||
{
|
||||
props.cell_margins = Some([0.0; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> {
|
||||
let mut table = Table::default();
|
||||
if let Some(p) = t.child("w:tblPr") {
|
||||
table.props = self.pc.tblpr(p);
|
||||
if let Some(ch) = p.child("w:tblPrChange") {
|
||||
let old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default();
|
||||
let mut old = ch.child("w:tblPr").map(|o| self.pc.tblpr(o)).unwrap_or_default();
|
||||
// Rejecting the change brings back these properties: with the same margins.
|
||||
self.no_default_margins(&mut old);
|
||||
table.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old);
|
||||
}
|
||||
}
|
||||
self.no_default_margins(&mut table.props);
|
||||
if let Some(g) = t.child("w:tblGrid") {
|
||||
table.grid = g
|
||||
.children("w:gridCol")
|
||||
|
||||
@@ -64,6 +64,20 @@ fn builtin_heading_level(name: &str) -> Option<u8> {
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Whether a style's kept (last) definition is a default table style.
|
||||
fn has_default_table_style(&self, root: &El) -> bool {
|
||||
let mut defaults: Vec<String> = Vec::new();
|
||||
for s in root.children("w:style").take(10_000) {
|
||||
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
|
||||
let id = self.pc.style_id(raw_id);
|
||||
defaults.retain(|d| *d != id);
|
||||
if s.attr("w:type") == Some("table") && style_is_default(s) {
|
||||
defaults.push(id);
|
||||
}
|
||||
}
|
||||
!defaults.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
|
||||
@@ -207,6 +221,7 @@ impl Reader<'_> {
|
||||
None => sheet.styles.push(st),
|
||||
}
|
||||
}
|
||||
self.no_default_table_style = !self.has_default_table_style(&root);
|
||||
// Styles that give only a list level take the list from the style they are based on
|
||||
// (Heading 3: level 3 of Heading 2's outline list).
|
||||
let fixed: Vec<Option<craft_wordmodel::props::NumRef>> = sheet
|
||||
|
||||
@@ -28,3 +28,73 @@ fn partial_margin_stays_partial() {
|
||||
assert!(!margins.contains("<w:bottom"));
|
||||
assert!(!margins.contains("<w:right"));
|
||||
}
|
||||
|
||||
/// A package with this body and, if given, this styles part.
|
||||
fn package(body: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, xml: &str| {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(xml.as_bytes()).unwrap();
|
||||
};
|
||||
add("word/document.xml", &format!(r#"<w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#));
|
||||
if let Some(styles) = styles {
|
||||
add("word/styles.xml", &format!(r#"<w:styles xmlns:w="{W}">{styles}</w:styles>"#));
|
||||
add(
|
||||
"word/_rels/document.xml.rels",
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#,
|
||||
);
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Without a default table style (no styles part, or none marked `w:default`) Word gives a table
|
||||
/// no cell margins: a cell that sets only its right margin has none on its other sides.
|
||||
#[test]
|
||||
fn no_default_table_style_means_no_cell_margins() {
|
||||
let body = r#"<w:tbl><w:tblPr><w:tblW w:w="6000" w:type="dxa"/></w:tblPr><w:tblGrid><w:gridCol w:w="3000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcMar><w:right w:w="108" w:type="dxa"/></w:tcMar></w:tcPr><w:p/></w:tc></w:tr></w:tbl>"#;
|
||||
let normal_table = r#"<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/><w:tblPr><w:tblCellMar><w:left w:w="108" w:type="dxa"/><w:right w:w="108" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>"#;
|
||||
let margins = |styles: Option<&str>| {
|
||||
let doc = craft_docx::read(&package(body, styles)).unwrap();
|
||||
let table = doc.body[0].as_table().unwrap();
|
||||
(table.props.cell_margins, table.rows[0].cells[0].props.resolved_margins(table.props.cell_margins.unwrap_or([0.0, 5.4, 0.0, 5.4])))
|
||||
};
|
||||
assert_eq!(margins(None), (Some([0.0; 4]), [0.0, 0.0, 0.0, 5.4]));
|
||||
assert_eq!(margins(Some(r#"<w:docDefaults/>"#)), (Some([0.0; 4]), [0.0, 0.0, 0.0, 5.4]));
|
||||
// With Word's usual Normal Table the table keeps no margins of its own (the defaults apply).
|
||||
assert_eq!(margins(Some(normal_table)).0, None);
|
||||
// A style defined twice is its last definition: a default one redefined as not default leaves none.
|
||||
let redefined = format!(r#"{normal_table}<w:style w:type="table" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>"#);
|
||||
assert_eq!(margins(Some(&redefined)).0, Some([0.0; 4]));
|
||||
// A custom default table style marked `on` is a default too, also after saving and reopening.
|
||||
let plain = r#"<w:style w:type="table" w:default="on" w:customStyle="1" w:styleId="Plain"><w:name w:val="Plain"/></w:style>"#;
|
||||
let doc = craft_docx::read(&package(body, Some(plain))).unwrap();
|
||||
assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, None);
|
||||
let reopened = craft_docx::read(&craft_docx::write(&doc).unwrap()).unwrap();
|
||||
assert_eq!(reopened.body[0].as_table().unwrap().props.cell_margins, None);
|
||||
// A table that sets only some sides keeps them; the sides it omits are 0.
|
||||
let partial = body.replace("<w:tblPr>", r#"<w:tblPr><w:tblCellMar><w:left w:w="144" w:type="dxa"/></w:tblCellMar>"#);
|
||||
let doc = craft_docx::read(&package(&partial, None)).unwrap();
|
||||
assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, Some([0.0, 7.2, 0.0, 0.0]));
|
||||
// Rejecting a tracked change to the table's properties brings back the same zero margins.
|
||||
let tracked = body.replace(
|
||||
"</w:tblPr>",
|
||||
r#"<w:tblPrChange w:id="1" w:author="A" w:date="2026-11-13T00:00:00Z"><w:tblPr><w:tblW w:w="5000" w:type="dxa"/></w:tblPr></w:tblPrChange></w:tblPr>"#,
|
||||
);
|
||||
let doc = craft_docx::read(&package(&tracked, None)).unwrap();
|
||||
let change = doc.body[0].as_table().unwrap().props.fmt_change.as_deref().expect("a tracked table change");
|
||||
assert_eq!(change.old.cell_margins, Some([0.0; 4]));
|
||||
// A table whose own style gives margins keeps them.
|
||||
let styled = body.replace("<w:tblPr>", r#"<w:tblPr><w:tblStyle w:val="Boxed"/>"#);
|
||||
let boxed = r#"<w:style w:type="table" w:styleId="Boxed"><w:name w:val="Boxed"/><w:tblPr><w:tblCellMar><w:left w:w="144" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>"#;
|
||||
let doc = craft_docx::read(&package(&styled, Some(boxed))).unwrap();
|
||||
assert_eq!(doc.body[0].as_table().unwrap().props.cell_margins, None);
|
||||
// The zeros survive saving, which writes a default Normal Table.
|
||||
let doc = craft_docx::read(&package(body, None)).unwrap();
|
||||
let bytes = craft_docx::write(&doc).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip::ZipArchive::new(Cursor::new(bytes.clone())).unwrap().by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains("<w:tblCellMar>"), "{xml}");
|
||||
let reopened = craft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(reopened.body[0].as_table().unwrap().props.cell_margins, Some([0.0; 4]));
|
||||
}
|
||||
Reference in new issue
Block a user