mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
Merge pull request #1320 from storytold/codex/docx-normal-default-official
Respect default Normal styles in DOCX
This commit is contained in:
3 files changed
+61
-8
No files matched your search
@@ -13,6 +13,10 @@ fn numbering_level_index(value: Option<&str>) -> Option<usize> {
|
||||
}
|
||||
}
|
||||
|
||||
fn style_is_default(style: &El) -> bool {
|
||||
style.attr("w:default").is_some_and(|value| !matches!(value.trim(), "0" | "false" | "off" | "False" | "FALSE"))
|
||||
}
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
|
||||
("caption", "Caption"),
|
||||
@@ -67,9 +71,7 @@ impl Reader<'_> {
|
||||
// The default paragraph style must be called `Normal` in our model.
|
||||
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
|
||||
if !has_normal
|
||||
&& let Some(def) = root
|
||||
.children("w:style")
|
||||
.find(|s| s.attr("w:type") == Some("paragraph") && s.attr("w:default").is_some_and(|v| v == "1" || v == "true"))
|
||||
&& let Some(def) = root.children("w:style").find(|s| s.attr("w:type") == Some("paragraph") && style_is_default(s))
|
||||
&& let Some(id) = def.attr("w:styleId")
|
||||
{
|
||||
self.pc.alias.insert(id.to_string(), "Normal".into());
|
||||
@@ -93,6 +95,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
let mut sheet = StyleSheet::empty();
|
||||
let mut normal_is_default = None;
|
||||
sheet.default_para_present = root.child("w:docDefaults").and_then(|dd| dd.child("w:pPrDefault")).is_some();
|
||||
if let Some(dd) = root.child("w:docDefaults") {
|
||||
if let Some(r) = dd.child("w:rPrDefault").and_then(|d| d.child("w:rPr")) {
|
||||
@@ -120,6 +123,13 @@ impl Reader<'_> {
|
||||
"numbering" => StyleKind::Numbering,
|
||||
_ => StyleKind::Paragraph,
|
||||
};
|
||||
if kind == StyleKind::Paragraph && id == "Normal" {
|
||||
// Word treats the built-in Normal style as the paragraph default when the file
|
||||
// doesn't nominate another style. Keep that fact for an explicitly declared
|
||||
// Normal; the gallery later synthesizes a visually identical Normal when the
|
||||
// file did not declare one, and that synthetic style is not source provenance.
|
||||
normal_is_default = Some(s.attr("w:default").is_none());
|
||||
}
|
||||
let name = s.child_val("w:name").map(ui_style_name).unwrap_or_else(|| id.clone());
|
||||
let r = |n: &str| s.child_val(n).filter(|v| !v.is_empty()).map(|v| self.pc.style_id(v));
|
||||
let mut st = Style {
|
||||
@@ -135,7 +145,7 @@ impl Reader<'_> {
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| matches!(v.trim(), "1" | "true" | "on")),
|
||||
locked: flag(s, "w:locked").unwrap_or(false),
|
||||
// Only where the writer couldn't tell from the id (it flags the English ids).
|
||||
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
|
||||
default: style_is_default(s)
|
||||
&& !matches!(
|
||||
(kind, id.as_str()),
|
||||
(StyleKind::Paragraph, "Normal")
|
||||
@@ -213,6 +223,12 @@ impl Reader<'_> {
|
||||
for (s, n) in sheet.styles.iter_mut().zip(fixed) {
|
||||
s.para.numbering = n;
|
||||
}
|
||||
if !sheet.styles.iter().any(|style| style.kind == StyleKind::Paragraph && style.default)
|
||||
&& normal_is_default == Some(true)
|
||||
&& let Some(normal) = sheet.styles.iter_mut().find(|style| style.kind == StyleKind::Paragraph && style.id == "Normal")
|
||||
{
|
||||
normal.default = true;
|
||||
}
|
||||
let old_fixed: Vec<Option<Option<craft_wordmodel::props::NumRef>>> = sheet
|
||||
.styles
|
||||
.iter()
|
||||
|
||||
@@ -75,9 +75,14 @@ fn style_xml(wr: &mut Writer<'_>, w: &mut W, st: &Style, flagged: &[StyleKind])
|
||||
| (StyleKind::Table, "TableNormal")
|
||||
| (StyleKind::Numbering, "NoList")
|
||||
);
|
||||
let is_default = st.default || (english_default && !flagged.contains(&st.kind));
|
||||
let normal_is_explicitly_off = st.kind == StyleKind::Paragraph && st.id == "Normal" && !st.default && flagged.contains(&StyleKind::Paragraph);
|
||||
let is_default = st.default || (english_default && !flagged.contains(&st.kind) && !normal_is_explicitly_off);
|
||||
let mut a = vec![("w:type", ty), ("w:styleId", st.id.as_str())];
|
||||
if is_default {
|
||||
if normal_is_explicitly_off {
|
||||
// Preserve a model where Normal is explicitly not default. Without this, the fallback
|
||||
// `english_default` marker makes the reader infer a change on round trip.
|
||||
a.push(("w:default", "0"));
|
||||
} else if is_default {
|
||||
a.push(("w:default", "1"));
|
||||
}
|
||||
if !st.builtin {
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
//! `<w:pStyle w:val="Footer"/>` without defining Footer.
|
||||
use craft_wordmodel::props::LineSpacing;
|
||||
use craft_wordmodel::{Block, Document, PartKind};
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn package(normal_id: &str) -> Vec<u8> {
|
||||
package_with_default(normal_id, true)
|
||||
}
|
||||
|
||||
fn package_with_default(normal_id: &str, is_default: bool) -> Vec<u8> {
|
||||
package_with_body_default(normal_id, is_default, false)
|
||||
}
|
||||
|
||||
fn package_with_body_default(normal_id: &str, is_default: bool, has_body_default: bool) -> Vec<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}" xmlns:r="{R}"><w:body><w:p><w:pPr><w:pStyle w:val="Footer"/></w:pPr><w:r><w:t>body</w:t></w:r></w:p><w:p><w:r><w:t>normal</w:t></w:r></w:p><w:sectPr><w:footerReference w:type="default" r:id="rId2"/></w:sectPr></w:body></w:document>"#
|
||||
@@ -16,8 +24,11 @@ fn package(normal_id: &str) -> Vec<u8> {
|
||||
let footer = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr xmlns:w="{W}"><w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="center"/></w:pPr><w:r><w:t>1</w:t></w:r></w:p></w:ftr>"#
|
||||
);
|
||||
let default_attr = if is_default { r#" w:default="1""# } else { "" };
|
||||
let body_style =
|
||||
if has_body_default { r#"<w:style w:type="paragraph" w:default="on" w:styleId="Body"><w:name w:val="Body"/></w:style>"# } else { "" };
|
||||
let styles = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}"><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="{normal_id}"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="20" w:after="40" w:line="259" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="26"/></w:rPr></w:style></w:styles>"#
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}"><w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="200"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph"{default_attr} w:styleId="{normal_id}"><w:name w:val="Normal"/><w:pPr><w:spacing w:before="20" w:after="40" w:line="259" w:lineRule="auto"/></w:pPr><w:rPr><w:sz w:val="26"/></w:rPr></w:style>{body_style}</w:styles>"#
|
||||
);
|
||||
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>"#;
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
@@ -34,6 +45,27 @@ fn package(normal_id: &str) -> Vec<u8> {
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_normal_style_is_the_default_even_without_w_default() {
|
||||
let doc = craft_docx::read(&package_with_default("Normal", false)).unwrap();
|
||||
let normal = doc.styles.get("Normal").expect("the explicit Normal style");
|
||||
assert!(normal.default, "Word treats the explicitly declared built-in Normal style as the default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_body_default_on_prevents_normal_inference() {
|
||||
let doc = craft_docx::read(&package_with_body_default("Normal", false, true)).unwrap();
|
||||
assert!(doc.styles.styles.iter().any(|style| style.id == "Body" && style.default));
|
||||
assert!(!doc.styles.styles.iter().any(|style| style.id == "Normal" && style.default));
|
||||
let saved = craft_docx::write(&doc).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap();
|
||||
let mut file = archive.by_name("word/styles.xml").unwrap();
|
||||
let mut styles = String::new();
|
||||
file.read_to_string(&mut styles).unwrap();
|
||||
assert!(styles.contains(r#"w:styleId="Normal" w:default="0""#) || styles.contains(r#"w:default="0" w:styleId="Normal""#));
|
||||
assert!(styles.contains(r#"w:styleId="Body" w:default="1""#) || styles.contains(r#"w:default="1" w:styleId="Body""#));
|
||||
}
|
||||
|
||||
fn footer_para(d: &Document) -> &craft_wordmodel::Paragraph {
|
||||
let part = d.parts.values().find(|p| p.kind == PartKind::Footer).expect("a footer");
|
||||
match part.blocks.first().map(|b| &**b) {
|
||||
|
||||
Reference in new issue
Block a user