mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706)
* #4696: built-in heading styles have their outline level without w:outlineLvl Some producers write "heading 1"…"heading 9" in styles.xml without w:outlineLvl. Word gives those built-in styles their outline level anyway (checked: Word for Windows reports OutlineLevel 1 and 2, whatever the style id), so their headings show in the Navigation pane. WordCraft read no level, and the Headings tab stayed empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #4696: review — w:customStyle="on" marks a custom style too A custom style named "heading 4" with customStyle="on" (ST_OnOff) was taken for Word's built-in heading and got an outline level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
f774f6f5c0
commit
e855daf279
@@ -42,6 +42,13 @@ pub(crate) fn ui_style_name(n: &str) -> String {
|
||||
n.to_string()
|
||||
}
|
||||
|
||||
/// The outline level (0 for Heading 1) of Word's built-in style named `heading 1` … `heading 9`.
|
||||
fn builtin_heading_level(name: &str) -> Option<u8> {
|
||||
let name = name.trim();
|
||||
let digit = name.get(..8).filter(|p| p.eq_ignore_ascii_case("heading ")).and(name.get(8..))?;
|
||||
digit.parse::<u8>().ok().filter(|d| digit.len() == 1 && (1..=9).contains(d)).map(|d| d - 1)
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
pub(super) fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
|
||||
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
|
||||
@@ -115,7 +122,7 @@ impl Reader<'_> {
|
||||
priority: s.child_val("w:uiPriority").and_then(u32_of),
|
||||
quick: flag(s, "w:qFormat").unwrap_or(false),
|
||||
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
|
||||
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
|
||||
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")
|
||||
@@ -131,6 +138,16 @@ impl Reader<'_> {
|
||||
if let Some(p) = s.child("w:pPr") {
|
||||
st.para = self.pc.ppr(p).0;
|
||||
}
|
||||
// Word gives its built-in heading styles their outline level whether or not the file
|
||||
// writes `w:outlineLvl` (some producers leave it out): "heading 2" is level 2 in the
|
||||
// Navigation pane and the table of contents, whatever its id (#4696).
|
||||
if kind == StyleKind::Paragraph
|
||||
&& st.builtin
|
||||
&& st.para.outline_level.is_none()
|
||||
&& let Some(level) = s.child_val("w:name").and_then(builtin_heading_level)
|
||||
{
|
||||
st.para.outline_level = Some(level);
|
||||
}
|
||||
if let Some(rp) = s.child("w:rPr") {
|
||||
st.chr = self.pc.rpr(rp);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! #4696: Word's built-in heading styles have their outline level even when the file's
|
||||
//! `styles.xml` doesn't write `w:outlineLvl` (some producers leave it out). Word for Windows,
|
||||
//! opening such a file, reports `OutlineLevel` 1 and 2 for "heading 1" and "heading 2" (whatever
|
||||
//! their style ids) and body text for a custom style.
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::Paragraph;
|
||||
|
||||
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
|
||||
fn package(styles: &str, body: &str) -> 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 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"/></Relationships>"#;
|
||||
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W}"><w:body>{body}</w:body></w:document>"#);
|
||||
let styles = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="{W}">{styles}</w:styles>"#);
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, body) in
|
||||
[("_rels/.rels", root), ("word/document.xml", doc.as_str()), ("word/_rels/document.xml.rels", rels), ("word/styles.xml", styles.as_str())]
|
||||
{
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn para(style: &str, text: &str) -> String {
|
||||
format!(r#"<w:p><w:pPr><w:pStyle w:val="{style}"/></w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
fn levels(d: &wordcraft_doc::Document) -> Vec<Option<u8>> {
|
||||
d.body.iter().filter_map(|b| b.as_para()).map(|p: &Paragraph| d.styles.resolve_para(&p.props).outline_level).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_heading_styles_have_their_outline_level_without_outline_lvl() {
|
||||
let styles = r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Titolo2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading9"><w:name w:val="Heading 9"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Below"><w:name w:val="Below heading 2"/><w:basedOn w:val="Titolo2"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="1" w:styleId="MyHead"><w:name w:val="My heading 3"/><w:basedOn w:val="Normal"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading10"><w:name w:val="heading 10"/></w:style>
|
||||
<w:style w:type="paragraph" w:customStyle="on" w:styleId="Fake4"><w:name w:val="heading 4"/></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:pPr><w:outlineLvl w:val="5"/></w:pPr></w:style>"#;
|
||||
let body = ["Heading1", "Titolo2", "Heading9", "Below", "MyHead", "Heading10", "Fake4", "Heading3", "Normal"]
|
||||
.iter()
|
||||
.map(|s| para(s, "x"))
|
||||
.collect::<String>();
|
||||
let d = wordcraft_docx::read(&package(styles, &body)).unwrap();
|
||||
// Heading 1, 2 and 9 by name; a style based on a heading inherits its level; a custom style,
|
||||
// "heading 10", a custom style named "heading 4" (`w:customStyle="on"`) and Normal have none; a
|
||||
// level the file gives is kept.
|
||||
let want = vec![Some(0), Some(1), Some(8), Some(1), None, None, None, Some(5), None];
|
||||
assert_eq!(levels(&d), want);
|
||||
// The level survives a save and reopen.
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(levels(&again), want);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod east_asian_lang;
|
||||
mod east_asian_layout;
|
||||
mod fixtures;
|
||||
mod glossary;
|
||||
mod heading_levels;
|
||||
mod legacy_forms;
|
||||
mod ligatures;
|
||||
mod malformed;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user