diff --git a/src/formats/odf/mod.rs b/src/formats/odf/mod.rs index 94cac91..81b9eb3 100644 --- a/src/formats/odf/mod.rs +++ b/src/formats/odf/mod.rs @@ -75,11 +75,19 @@ fn parse_presentation(pres: &Element, ctx: &Ctx) -> Result, ConvertEr let mut body = Vec::new(); let mut notes = Vec::new(); walk_shapes(page, ctx, &mut title, &mut body, &mut notes)?; - blocks.append(&mut title); - blocks.append(&mut body); + let mut page_blocks: Vec = Vec::new(); + page_blocks.append(&mut title); + page_blocks.append(&mut body); // Speaker notes are included (fixed policy), set off as a quote. if !notes.is_empty() { - blocks.push(Block::BlockQuote(notes)); + page_blocks.push(Block::BlockQuote(notes)); + } + // Separate pages with a thematic break — see the pptx parser for why. + if !page_blocks.is_empty() { + if !blocks.is_empty() { + blocks.push(Block::Rule); + } + blocks.append(&mut page_blocks); } } Ok(blocks) diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs index d0f38de..c7877b7 100644 --- a/src/formats/ppt/mod.rs +++ b/src/formats/ppt/mod.rs @@ -343,6 +343,10 @@ impl Extractor { let mut used = vec![false; notes.len()]; let mut out = Vec::new(); for (sid, blocks) in slides { + // Separate slides with a thematic break — see the pptx parser. + if !blocks.is_empty() && !out.is_empty() { + out.push(Block::Rule); + } out.extend(blocks); for (i, (nid, nblocks)) in notes.iter_mut().enumerate() { if !used[i] && sid.is_some() && *nid == sid { diff --git a/src/formats/pptx/mod.rs b/src/formats/pptx/mod.rs index 800c26e..9d82bfb 100644 --- a/src/formats/pptx/mod.rs +++ b/src/formats/pptx/mod.rs @@ -163,12 +163,23 @@ pub fn parse(bytes: &[u8]) -> Result { instance_counter: &instance_counter, slide_anchors: &slide_anchors, }; + // A slide is a container, not a page: its content must not run into + // the next slide's. A thematic break is the structural separator the + // model already has, and it is emitted BETWEEN slides only — never + // leading, never doubled by an empty slide. + let mut slide_blocks: Vec = Vec::new(); if targeted.contains(slide_path) && let Some(anchor) = slide_anchors.get(slide_path) { - blocks.push(Block::Paragraph(vec![Inline::Anchor(anchor.clone())])); + slide_blocks.push(Block::Paragraph(vec![Inline::Anchor(anchor.clone())])); + } + parse_shapes(sp_tree, &ctx, &mut slide_blocks)?; + if !slide_blocks.is_empty() { + if !blocks.is_empty() { + blocks.push(Block::Rule); + } + blocks.append(&mut slide_blocks); } - parse_shapes(sp_tree, &ctx, &mut blocks)?; // Speaker notes, set off as a quote (fixed policy: included). The // tree is loaded before the `if let` so the package borrow is not @@ -636,3 +647,117 @@ fn parse_table(tbl: &Element, ctx: &SlideCtx, blocks: &mut Vec) -> Result blocks.push(Block::Table(table)); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + const PRES_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main"; + const DRAWING_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main"; + const REL_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + const PKG_REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + + /// A slide holding a single text box with `text`, or no shapes at all when + /// `text` is `None` — the "contributes nothing" case the separator must not + /// leave a dangling break for. + fn slide_xml(text: Option<&str>) -> String { + let body = match text { + Some(t) => format!( + r#"{t}"# + ), + None => String::new(), + }; + format!( + r#"{body}"# + ) + } + + /// Minimal .pptx whose slides carry the given text bodies, in order. No + /// layout or master parts: every slide is title-less, which is exactly the + /// shape whose boundary used to vanish. + fn pptx_with_slides(slides: &[Option<&str>]) -> Vec { + let ids: String = (0..slides.len()) + .map(|i| format!(r#""#, 256 + i, i + 1)) + .collect(); + let rels: String = (0..slides.len()) + .map(|i| { + format!( + r#""#, + i + 1, + i + 1 + ) + }) + .collect(); + + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let mut put = |name: &str, body: &str| { + w.start_file(name, opts).unwrap(); + w.write_all(body.as_bytes()).unwrap(); + }; + put( + "_rels/.rels", + &format!( + r#""# + ), + ); + put( + "ppt/presentation.xml", + &format!( + r#"{ids}"# + ), + ); + put( + "ppt/_rels/presentation.xml.rels", + &format!( + r#"{rels}"# + ), + ); + for (i, text) in slides.iter().enumerate() { + put(&format!("ppt/slides/slide{}.xml", i + 1), &slide_xml(*text)); + } + w.finish().unwrap().into_inner() + } + + fn blocks(slides: &[Option<&str>]) -> Vec { + parse(&pptx_with_slides(slides)).expect("minimal pptx should convert").blocks + } + + #[test] + fn untitled_slides_are_separated() { + // The defect: with no title placeholder neither slide contributes a + // heading, so the two paragraphs used to be indistinguishable from two + // paragraphs of one slide. + let out = blocks(&[Some("first"), Some("second")]); + assert!( + matches!(out.as_slice(), [Block::Paragraph(_), Block::Rule, Block::Paragraph(_)]), + "expected a separator between the two slides, got {out:?}" + ); + } + + #[test] + fn separator_never_leads() { + let out = blocks(&[Some("only")]); + assert!( + !matches!(out.first(), Some(Block::Rule)), + "a deck must not open with a separator, got {out:?}" + ); + assert_eq!(out.len(), 1, "single slide should yield exactly its own content: {out:?}"); + } + + #[test] + fn empty_slides_leave_no_dangling_separator() { + // An empty slide contributes no blocks, so it must not push a break of + // its own — neither doubled between two real slides nor trailing. + let out = blocks(&[Some("first"), None, Some("second")]); + let rules = out.iter().filter(|b| matches!(b, Block::Rule)).count(); + assert_eq!(rules, 1, "empty slide must not add a separator, got {out:?}"); + + let trailing = blocks(&[Some("first"), None]); + assert!( + !matches!(trailing.last(), Some(Block::Rule)), + "a trailing empty slide must not leave a dangling separator, got {trailing:?}" + ); + } +} diff --git a/tests/snapshots/snapshots__odp__pres.odp.snap b/tests/snapshots/snapshots__odp__pres.odp.snap index d46d347..4f3e359 100644 --- a/tests/snapshots/snapshots__odp__pres.odp.snap +++ b/tests/snapshots/snapshots__odp__pres.odp.snap @@ -11,6 +11,8 @@ Deck Title Slide > Speaker note for the intro slide. +--- + Numbers Slide | Region | Total | diff --git a/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap b/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap index e5e99b5..79f2352 100644 --- a/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap +++ b/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap @@ -4,4 +4,6 @@ expression: output --- - **Alpha master body text** +--- + *Beta master body text* diff --git a/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap b/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap index 812a4e5..4b8b11b 100644 --- a/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap +++ b/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap @@ -4,6 +4,8 @@ expression: output --- First slide text +--- + Second slide text > Notes for the second slide diff --git a/tests/snapshots/snapshots__ppt__pres.ppt.snap b/tests/snapshots/snapshots__ppt__pres.ppt.snap index 67a4c63..38a868d 100644 --- a/tests/snapshots/snapshots__ppt__pres.ppt.snap +++ b/tests/snapshots/snapshots__ppt__pres.ppt.snap @@ -12,6 +12,8 @@ Deck Title Slide > Speaker note for the intro slide. +--- + Numbers Slide Region diff --git a/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap index aae0a01..b11df89 100644 --- a/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap @@ -6,6 +6,8 @@ expression: output [External link](https://example.com/) +--- + Second slide content diff --git a/tests/snapshots/snapshots__pptx__pres.pptx.snap b/tests/snapshots/snapshots__pptx__pres.pptx.snap index 61f7f60..a7b9a90 100644 --- a/tests/snapshots/snapshots__pptx__pres.pptx.snap +++ b/tests/snapshots/snapshots__pptx__pres.pptx.snap @@ -12,6 +12,8 @@ Deck Title Slide > Speaker note for the intro slide. +--- + Numbers Slide | Region | Total |