diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index e143bd9763..6b3014933e 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> 0 | 1 | 4 => PartKind::Header, _ => PartKind::Footer, }; - let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; + let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?; if !blocks.is_empty() { let id = doc.add_part(kind, blocks); hf_ids.push((si, k, id)); @@ -436,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m Ok(table::assemble(paras)) } +/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a +/// non-empty header story ends with a paragraph mark that only separates it from the next story +/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the +/// guard would add an empty paragraph to every header and footer and push the body down when +/// the header reaches past the top margin (#5922). +/// +/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a +/// mark stays a part (an empty header, not one linked to the previous section), and a mark right +/// after a table row stays the paragraph that closes the table. +fn header_story_blocks( + ctx: &WalkCtx<'_>, + a: u32, + b: u32, + pics: &mut media::Pictures, + max_paragraphs: usize, +) -> Result { + let mut paras = walk(ctx, a, b, pics); + if ctx.paragraphs.exceeded.get() { + return Err(paragraph_limit(max_paragraphs)); + } + let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark); + if let [.., prev, last] = paras.as_slice() { + let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none(); + if guard && outside_table(prev) { + paras.pop(); + } + } + Ok(table::assemble(paras)) +} + /// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len` /// CPs, as absolute CPs clamped to that subdocument; `None` when empty. fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> { diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 3acc38cd1b..863801463f 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1157,6 +1157,58 @@ fn header_story_becomes_part() { assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text")))); } +/// The odd header's blocks for a one-section document whose header story is `story`, with +/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`): +/// paragraph texts, a table as ``; `None` when the slot has no part. +fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec)>) -> Option> { + let spec = StructSpec { + text: "body\r", + header_stories: vec![story], + sections: vec![(5, Vec::new())], + chpx_runs: vec![(0, Vec::new())], + papx_entries, + plf_lst: Vec::new(), + plf_lfo: Vec::new(), + note_stories: vec![], + endnote_stories: vec![], + extra_pairs: Vec::new(), + data_stream: Vec::new(), + }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let id = doc.last_section.headers.default?; + let part = doc.parts.get(&id).expect("part"); + Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "
".to_string(), |p| p.text.clone())).collect()) +} + +/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that +/// is not part of its contents. Read as a paragraph, it put an empty line under every header +/// and pushed the first body line down when the header reached past the top margin. +#[test] +fn header_story_guard_mark_is_not_a_paragraph() { + let plain = || vec![(0, 0, Vec::new())]; + // One line typed into the header: content "Header\r" + guard. + assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()])); + // Enter pressed after it: the empty paragraph is content and stays, only the guard goes. + assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()])); + // An empty header (one empty content paragraph + guard) is still a header of its own. + assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()])); + // A story that is only a mark (no guard) keeps its paragraph and its part. + assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()])); + // A story without a guard keeps its last paragraph. + assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()])); +} + +/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that +/// closes the table, not a guard to drop. +#[test] +fn header_story_mark_after_a_table_row_is_kept() { + let in_tbl = grpprl(&[(0x2416, &[0x01])]); + let ttp: Vec = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat(); + // Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8). + let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())]; + assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["
".to_string(), String::new()])); +} + /// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column, /// built per the spec (cb counts the bytes after it, plus one). fn t_def_table(edges: &[i16]) -> Vec {