Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/formats/ppt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ impl Extractor {
fn end_segment(&mut self, id: Option<u32>) {
self.flush_shape();
flush_list(&mut self.current, &mut self.list_run);
if !self.current.is_empty() {
let keep_empty_slide = !self.current_is_notes && id.is_some();
if !self.current.is_empty() || keep_empty_slide {
let blocks = std::mem::take(&mut self.current);
self.segments.push((blocks, id, self.current_is_notes));
}
Expand All @@ -342,7 +343,8 @@ impl Extractor {
// slides), which order-based zipping would misattribute.
let mut used = vec![false; notes.len()];
let mut out = Vec::new();
for (sid, blocks) in slides {
for (slide_index, (sid, blocks)) in slides.into_iter().enumerate() {
out.push(Block::Paragraph(vec![Inline::Anchor(format!("slide-{}", slide_index + 1))]));
Comment thread
xianjianlf2 marked this conversation as resolved.
out.extend(blocks);
for (i, (nid, nblocks)) in notes.iter_mut().enumerate() {
if !used[i] && sid.is_some() && *nid == sid {
Expand Down Expand Up @@ -634,3 +636,32 @@ impl Extractor {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn top_level_anchor_ids(blocks: &[Block]) -> Vec<&str> {
blocks
.iter()
.filter_map(|block| match block {
Block::Paragraph(inlines) => inlines.iter().find_map(|inline| match inline {
Inline::Anchor(id) => Some(id.as_str()),
_ => None,
}),
_ => None,
})
.collect()
}

#[test]
fn empty_slides_keep_anchor_positions() {
let mut extractor = Extractor::default();

extractor.end_segment(Some(10));
extractor.current.push(Block::Paragraph(vec![Inline::plain("Third slide")]));
extractor.end_segment(Some(30));

assert_eq!(top_level_anchor_ids(&extractor.into_blocks()), ["slide-1", "slide-2"]);
}
}
21 changes: 4 additions & 17 deletions src/formats/pptx/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const MASTER_REL: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster";
const NOTES_REL: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide";
const SLIDE_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide";

/// Namespaces whose markup this frontend understands; `mc:Choice` branches
/// requiring anything else fall back to `mc:Fallback`.
Expand Down Expand Up @@ -93,9 +92,9 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
let mut blocks: Vec<Block> = Vec::new();
let mut failed = 0usize;
let instance_counter = StdCell::new(0u64);
// Every slide has a start anchor id so internal slide-to-slide links
// resolve after concatenation; the anchor node is emitted only on
// slides some link actually targets.
// Every slide has a start anchor id so downstream consumers can recover
// slide boundaries and internal slide-to-slide links resolve after
// concatenation.
let slide_anchors: HashMap<String, String> = slide_paths
.iter()
.enumerate()
Expand All @@ -105,16 +104,6 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
for p in &slide_paths {
all_rels.push(read_rels(&mut pkg.borrow_mut(), &rels_part_for(p))?);
}
let targeted: std::collections::HashSet<String> = slide_paths
.iter()
.zip(&all_rels)
.flat_map(|(p, rels)| {
rels.iter()
.filter(|(_, r)| r.rel_type == SLIDE_REL && r.mode == TargetMode::Internal)
.filter_map(move |(_, r)| path::resolve(p, &r.target).ok().map(|t| t.path))
})
.filter(|t| slide_anchors.contains_key(t))
.collect();

for (slide_index, slide_path) in slide_paths.iter().enumerate() {
let tree = match pkg.borrow_mut().optional_xml_part(slide_path)? {
Expand Down Expand Up @@ -163,9 +152,7 @@ pub fn parse(bytes: &[u8]) -> Result<Document, ConvertError> {
instance_counter: &instance_counter,
slide_anchors: &slide_anchors,
};
if targeted.contains(slide_path)
&& let Some(anchor) = slide_anchors.get(slide_path)
{
if let Some(anchor) = slide_anchors.get(slide_path) {
Comment thread
xianjianlf2 marked this conversation as resolved.
blocks.push(Block::Paragraph(vec![Inline::Anchor(anchor.clone())]));
}
parse_shapes(sp_tree, &ctx, &mut blocks)?;
Expand Down
7 changes: 5 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ mod shared;

pub use error::ConvertError;

use render::markdown::document_to_markdown;
use render::markdown::{MarkdownOptions, document_to_markdown_with_options};

use std::path::Path;

Expand Down Expand Up @@ -122,7 +122,10 @@ pub fn to_markdown_bytes(
if format == Format::Pdf {
return formats::pdf::to_markdown(bytes);
}
Ok(document_to_markdown(&to_document(bytes, format)?))
let options = MarkdownOptions {
render_unlinked_slide_anchors: matches!(format, Format::Ppt | Format::Pptx),
};
Ok(document_to_markdown_with_options(&to_document(bytes, format)?, options))
}

/// Parse an in-memory document into the document model. Pass a [`Format`] to
Expand Down
4 changes: 0 additions & 4 deletions src/package/relationships.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ impl Relationships {
self.0.get(id).filter(|r| r.mode == TargetMode::Internal).map(|r| r.target.as_str())
}

pub fn iter(&self) -> impl Iterator<Item = (&str, &Relationship)> {
self.0.iter().map(|(k, v)| (k.as_str(), v))
}

/// The internal-mode relationship of a given type, lowest id first so
/// the pick is deterministic when a producer emits duplicates.
pub fn first_of_type(&self, rel_type: &str) -> Option<&Relationship> {
Expand Down
22 changes: 17 additions & 5 deletions src/render/markdown/anchors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
//! heading's GFM auto-generated slug; an anchor a link targets gets a
//! sanitized, stable HTML id rendered as `<a id="..."></a>` at its position.
//!
//! Anchors nothing links to render nothing: producers mark up far more
//! positions than they reference (a bookmark per paragraph, an id per EPUB
//! element), and an unreachable target is only noise in the output.
//! Anchors nothing links to render nothing. Presentation callers may opt into
//! rendering unlinked structural slide boundary anchors, but other unlinked
//! anchors stay hidden: producers mark up far more positions than they
//! reference (a bookmark per paragraph, an id per EPUB element), and an
//! unreachable target is usually only noise in the output.

use crate::model::{Block, Document, Inline, LinkTarget, inlines_to_plain_text};
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -34,7 +36,7 @@ impl AnchorMap {
}
}

pub(crate) fn resolve_anchors(doc: &Document) -> AnchorMap {
pub(crate) fn resolve_anchors(doc: &Document, render_unlinked_slide_anchors: bool) -> AnchorMap {
let mut ids = UniqueIds::default();
let mut resolved: HashMap<String, Resolved> = HashMap::new();

Expand Down Expand Up @@ -66,8 +68,10 @@ pub(crate) fn resolve_anchors(doc: &Document) -> AnchorMap {
});

// Pass 2: every remaining anchor a link targets gets a sanitized HTML id.
// Presentation slide boundary anchors are structural, so callers can opt
// into rendering them even when no internal link targets them.
let mut assign = |id: &str| {
if linked.contains(id)
if (linked.contains(id) || (render_unlinked_slide_anchors && is_slide_boundary_anchor(id)))
Comment thread
xianjianlf2 marked this conversation as resolved.
&& !resolved.contains_key(id)
&& let Some(html) = ids.claim(sanitize_id(id))
{
Expand Down Expand Up @@ -147,6 +151,14 @@ fn for_each_anchor(inlines: &[Inline], f: &mut impl FnMut(&str)) {
}
}

fn is_slide_boundary_anchor(id: &str) -> bool {
let Some(n) = id.strip_prefix("slide-") else {
return false;
};

matches!(n.parse::<usize>(), Ok(value) if value > 0)
}

/// Allocates ids without repeatedly probing used numeric suffixes.
#[derive(Default)]
struct UniqueIds {
Expand Down
18 changes: 17 additions & 1 deletion src/render/markdown/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,24 @@ pub(crate) struct Ctx {
anchors: AnchorMap,
}

#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct MarkdownOptions {
pub(crate) render_unlinked_slide_anchors: bool,
}

#[cfg(test)]
pub fn document_to_markdown(doc: &Document) -> String {
let rc = Ctx { nums: number_notes(doc), anchors: resolve_anchors(doc) };
document_to_markdown_with_options(doc, MarkdownOptions::default())
}

pub(crate) fn document_to_markdown_with_options(
doc: &Document,
options: MarkdownOptions,
) -> String {
let rc = Ctx {
nums: number_notes(doc),
anchors: resolve_anchors(doc, options.render_unlinked_slide_anchors),
};
let mut parts: Vec<String> = doc.blocks.iter().filter_map(|b| render_block(b, &rc)).collect();
let mut rendered_defs: HashSet<usize> = HashSet::new();
let mut ordered: Vec<(&Note, usize)> =
Expand Down
27 changes: 26 additions & 1 deletion src/render/markdown/tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::document_to_markdown;
use super::{MarkdownOptions, document_to_markdown, document_to_markdown_with_options};
use crate::model::{
AnchorId, Block, Cell, Document, GridBuilder, ImageSource, Inline, LinkTarget, List, ListItem,
MarkerKind, Note, NoteKind, Style, Table, TableKind,
Expand All @@ -8,6 +8,13 @@ fn doc(blocks: Vec<Block>) -> String {
document_to_markdown(&Document { blocks, notes: Vec::new(), assets: Vec::new() })
}

fn presentation_doc(blocks: Vec<Block>) -> String {
document_to_markdown_with_options(
&Document { blocks, notes: Vec::new(), assets: Vec::new() },
MarkdownOptions { render_unlinked_slide_anchors: true },
)
}

fn styled(text: &str, style: Style) -> Inline {
Inline::Text { text: text.into(), style }
}
Expand Down Expand Up @@ -576,6 +583,24 @@ fn unreferenced_anchor_renders_nothing() {
assert_eq!(md, "No link points here.\n");
}

#[test]
fn unreferenced_slide_anchor_renders_html_id() {
let md = presentation_doc(vec![Block::Paragraph(vec![
Inline::Anchor("slide-1".into()),
Inline::plain("First slide"),
])]);
assert_eq!(md, "<a id=\"slide-1\"></a>First slide\n");
}

#[test]
fn unreferenced_slide_like_anchor_renders_nothing_without_presentation_context() {
let md = doc(vec![Block::Paragraph(vec![
Inline::Anchor("slide-1".into()),
Inline::plain("Not a presentation slide"),
])]);
assert_eq!(md, "Not a presentation slide\n");
}

#[test]
fn heading_coincident_anchor_uses_slug() {
let md = doc(vec![
Expand Down
41 changes: 41 additions & 0 deletions tests/snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

mod common;

use anydoc::model::{Block, Inline};
use common::{fixture_root, walk};
use std::fmt::Write as _;
use std::path::Path;
Expand Down Expand Up @@ -128,6 +129,46 @@ fn embedded_ole_payload_is_retained() {
assert_eq!(ole.bytes, b"OLE-PAYLOAD-STAND-IN".repeat(4));
}

fn top_level_anchor_ids(blocks: &[Block]) -> Vec<&str> {
blocks
.iter()
.filter_map(|block| match block {
Block::Paragraph(inlines) => inlines.iter().find_map(|inline| match inline {
Inline::Anchor(id) => Some(id.as_str()),
_ => None,
}),
_ => None,
})
.collect()
}

#[test]
fn pptx_emits_slide_anchors_for_every_slide() {
let path = fixture_root().join("pptx").join("handmade-links.pptx");
let bytes = std::fs::read(&path).unwrap();
let doc = anydoc::to_document(&bytes, anydoc::Format::Pptx).unwrap();

assert_eq!(top_level_anchor_ids(&doc.blocks), ["slide-1", "slide-2"]);
}

#[test]
fn pptx_emits_slide_anchor_without_internal_links() {
let path = fixture_root().join("pptx").join("handmade-inherit.pptx");
let bytes = std::fs::read(&path).unwrap();
let doc = anydoc::to_document(&bytes, anydoc::Format::Pptx).unwrap();

assert_eq!(top_level_anchor_ids(&doc.blocks), ["slide-1"]);
}

#[test]
fn ppt_emits_slide_anchors_in_presentation_order() {
let path = fixture_root().join("ppt").join("handmade-multimaster.ppt");
let bytes = std::fs::read(&path).unwrap();
let doc = anydoc::to_document(&bytes, anydoc::Format::Ppt).unwrap();

assert_eq!(top_level_anchor_ids(&doc.blocks), ["slide-1", "slide-2"]);
}

/// Standard Word OLE markup places a VML preview image next to the
/// `o:OLEObject`; the object payload (not the preview) must be retained.
#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

Deck Title Slide

- Top level point
Expand Down
4 changes: 4 additions & 0 deletions tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

- **Alpha master body text**

<a id="slide-2"></a>

*Beta master body text*
4 changes: 4 additions & 0 deletions tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

First slide text

<a id="slide-2"></a>

Second slide text

> Notes for the second slide
4 changes: 4 additions & 0 deletions tests/snapshots/snapshots__ppt__pres.ppt.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

Deck Title Slide

- Top level point
Expand All @@ -12,6 +14,8 @@ Deck Title Slide

> Speaker note for the intro slide.

<a id="slide-2"></a>

Numbers Slide

Region
Expand Down
2 changes: 2 additions & 0 deletions tests/snapshots/snapshots__pptx__handmade-altpath.pptx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

## Relocated deck title
2 changes: 2 additions & 0 deletions tests/snapshots/snapshots__pptx__handmade-inherit.pptx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

## Inherited Title Slide

- iii. **Roman three bold via master**
Expand Down
2 changes: 2 additions & 0 deletions tests/snapshots/snapshots__pptx__handmade-links.pptx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

[Jump to the second slide](#slide-2)

[External link](https://example.com/)
Expand Down
2 changes: 2 additions & 0 deletions tests/snapshots/snapshots__pptx__handmade-order.pptx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
source: tests/snapshots.rs
expression: output
---
<a id="slide-1"></a>

Kicker before the title

## Title placed second
Expand Down
Loading