mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
fix(docx): ignore malformed list levels
This commit is contained in:
1 parent
f0f4758c01
commit
7ac384839d
3 files changed
+52
-14
No files matched your search
@@ -389,15 +389,22 @@ impl PropCtx {
|
||||
}
|
||||
"w:numPr" => {
|
||||
let num = k.child_val("w:numId").and_then(u32_of);
|
||||
let ilvl = k.child_val("w:ilvl").and_then(u32_of);
|
||||
let level = ilvl.unwrap_or(0).min(8) as u8;
|
||||
match (num, ilvl) {
|
||||
(Some(num), _) => p.numbering = Some(NumRef { num, level }),
|
||||
// A level without a list: the list comes from the based-on style, as in
|
||||
// a Heading 3 that only says "level 3" of Heading 2's list. Resolved once
|
||||
// the styles are read (`resolve_list_levels`).
|
||||
(None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }),
|
||||
(None, None) => {}
|
||||
let ilvl_node = k.child("w:ilvl");
|
||||
let ilvl =
|
||||
ilvl_node.and_then(|node| node.attr("w:val")).and_then(int).filter(|level| (0..=8).contains(level)).map(|level| level as u32);
|
||||
// A missing level means level zero. A present but malformed/out-of-range
|
||||
// level invalidates this numbering reference instead of silently becoming
|
||||
// level zero; the latter can turn hostile input into a real list.
|
||||
if ilvl_node.is_none() || ilvl.is_some() {
|
||||
let level = ilvl.unwrap_or(0) as u8;
|
||||
match (num, ilvl) {
|
||||
(Some(num), _) => p.numbering = Some(NumRef { num, level }),
|
||||
// A level without a list: the list comes from the based-on style, as in
|
||||
// a Heading 3 that only says "level 3" of Heading 2's list. Resolved once
|
||||
// the styles are read (`resolve_list_levels`).
|
||||
(None, Some(_)) => p.numbering = Some(NumRef { num: LEVEL_ONLY, level }),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"w:pBdr" => p.borders = Some(self.borders(k)),
|
||||
|
||||
@@ -6,6 +6,13 @@ use crate::units::tw_i16;
|
||||
use craft_wordmodel::props::PropChange;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn numbering_level_index(value: Option<&str>) -> Option<usize> {
|
||||
match value {
|
||||
None => Some(0),
|
||||
Some(value) => int(value).filter(|level| (0..=8).contains(level)).map(|level| level as usize),
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in style names Word stores in lower case, and how the UI shows them.
|
||||
pub(crate) const LOWER_NAMES: &[(&str, &str)] = &[
|
||||
("caption", "Caption"),
|
||||
@@ -294,10 +301,8 @@ impl Reader<'_> {
|
||||
let Some(id) = a.attr("w:abstractNumId").and_then(u32_of) else { continue };
|
||||
let mut levels: Vec<Level> = (0..9).map(default_level).collect();
|
||||
for l in a.children("w:lvl") {
|
||||
let i = l.attr("w:ilvl").and_then(u32_of).unwrap_or(0) as usize;
|
||||
if let Some(slot) = levels.get_mut(i) {
|
||||
*slot = self.level(l, i);
|
||||
}
|
||||
let Some(i) = numbering_level_index(l.attr("w:ilvl")) else { continue };
|
||||
levels[i] = self.level(l, i);
|
||||
}
|
||||
if let Some(link) = a.child_val("w:numStyleLink") {
|
||||
links.push((id, link.to_string()));
|
||||
@@ -321,7 +326,8 @@ impl Reader<'_> {
|
||||
let (Some(id), Some(abs)) = (n.attr("w:numId").and_then(u32_of), n.child_val("w:abstractNumId").and_then(u32_of)) else { continue };
|
||||
let (mut start_overrides, mut level_overrides) = (Vec::new(), Vec::new());
|
||||
for o in n.children("w:lvlOverride").take(9) {
|
||||
let lvl = o.attr("w:ilvl").and_then(u32_of).unwrap_or(0).min(8) as u8;
|
||||
let Some(lvl) = numbering_level_index(o.attr("w:ilvl")) else { continue };
|
||||
let lvl = lvl as u8;
|
||||
if let Some(s) = o.child_val("w:startOverride").and_then(u32_of) {
|
||||
start_overrides.push((lvl, s));
|
||||
} else if let Some(s) = o.child("w:lvl").and_then(|l| l.child_val("w:start")).and_then(u32_of) {
|
||||
|
||||
@@ -320,6 +320,31 @@ fn list_identities_survive_and_a_copied_definition_gets_none() {
|
||||
assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_numbering_levels_are_skipped_instead_of_becoming_level_zero() {
|
||||
let numbering = r#"<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl><w:lvl w:ilvl="-1"><w:numFmt w:val="bullet"/><w:lvlText w:val="bad"/></w:lvl><w:lvl w:ilvl="9"><w:numFmt w:val="bullet"/><w:lvlText w:val="bad"/></w:lvl><w:lvl w:ilvl="999999999999999999999"><w:numFmt w:val="bullet"/><w:lvlText w:val="bad"/></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/><w:lvlOverride w:ilvl="-1"><w:startOverride w:val="42"/></w:lvlOverride><w:lvlOverride w:ilvl="9"><w:startOverride w:val="43"/></w:lvlOverride></w:num></w:numbering>"#;
|
||||
let body = r#"<w:p><w:pPr><w:numPr><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>default level</w:t></w:r></w:p><w:p><w:pPr><w:numPr><w:ilvl w:val="2"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>valid level</w:t></w:r></w:p><w:p><w:pPr><w:numPr><w:ilvl w:val="-1"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>negative level</w:t></w:r></w:p><w:p><w:pPr><w:numPr><w:ilvl w:val="9"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>out of range</w:t></w:r></w:p><w:p><w:pPr><w:numPr><w:ilvl w:val="999999999999999999999"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>overflow</w:t></w:r></w:p>"#;
|
||||
let bytes = package(body, &[("rId1", &format!("{REL}/numbering"), "numbering.xml")], &[], &[("word/numbering.xml", numbering)]);
|
||||
let doc = craft_docx::read(&bytes).unwrap();
|
||||
let paragraphs: Vec<_> = doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|block| match &**block {
|
||||
craft_wordmodel::Block::Para(p) => Some(p),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(paragraphs.len(), 5);
|
||||
assert_eq!(paragraphs[0].props.numbering.map(|n| (n.num, n.level)), Some((1, 0)), "an omitted level defaults to zero");
|
||||
assert_eq!(paragraphs[1].props.numbering.map(|n| (n.num, n.level)), Some((1, 2)), "a valid level is preserved");
|
||||
for p in ¶graphs[2..] {
|
||||
assert_eq!(p.props.numbering, None, "malformed explicit level is not treated as a real list: {}", p.text);
|
||||
}
|
||||
let level_zero = doc.numbering.level(1, 0).expect("the valid level zero");
|
||||
assert_eq!(level_zero.format, craft_wordmodel::section::NumFormat::Decimal);
|
||||
assert_eq!(level_zero.start, 1, "invalid level overrides do not affect level zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_the_privacy_setting() {
|
||||
let app = r#"<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"><Template>Normal.dotm</Template><TotalTime>42</TotalTime><Pages>107</Pages><Words>40000</Words><Characters>250000</Characters><Application>Microsoft Office Word</Application><DocSecurity>0</DocSecurity><Lines>2000</Lines><Paragraphs>1700</Paragraphs><ScaleCrop>false</ScaleCrop><Manager>M</Manager><Company>Firma</Company><LinksUpToDate>false</LinksUpToDate><CharactersWithSpaces>290000</CharactersWithSpaces><SharedDoc>false</SharedDoc><HyperlinkBase>https://example.com/</HyperlinkBase><HyperlinksChanged>false</HyperlinksChanged><AppVersion>16.0000</AppVersion></Properties>"#;
|
||||
|
||||
Reference in new issue
Block a user