From 44d1e365b60e58d534819e65f8f95971adda8030 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Fri, 14 Aug 2026 11:50:25 +0800 Subject: [PATCH 1/4] Emit slide boundary anchors for presentations --- src/formats/ppt/mod.rs | 3 ++- src/formats/pptx/mod.rs | 21 ++++-------------- src/package/relationships.rs | 4 ---- tests/snapshots.rs | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs index d0f38de..303a17b 100644 --- a/src/formats/ppt/mod.rs +++ b/src/formats/ppt/mod.rs @@ -342,7 +342,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))])); 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..bf29a4b 100644 --- a/src/formats/pptx/mod.rs +++ b/src/formats/pptx/mod.rs @@ -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`. @@ -93,9 +92,9 @@ pub fn parse(bytes: &[u8]) -> Result { let mut blocks: Vec = 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 = slide_paths .iter() .enumerate() @@ -105,16 +104,6 @@ pub fn parse(bytes: &[u8]) -> Result { for p in &slide_paths { all_rels.push(read_rels(&mut pkg.borrow_mut(), &rels_part_for(p))?); } - let targeted: std::collections::HashSet = 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)? { @@ -163,9 +152,7 @@ pub fn parse(bytes: &[u8]) -> Result { 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) { blocks.push(Block::Paragraph(vec![Inline::Anchor(anchor.clone())])); } parse_shapes(sp_tree, &ctx, &mut blocks)?; diff --git a/src/package/relationships.rs b/src/package/relationships.rs index ecb74ff..98b58f2 100644 --- a/src/package/relationships.rs +++ b/src/package/relationships.rs @@ -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 { - 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> { diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 477a1ad..2df89b7 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -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; @@ -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] From aca28142d16a6c62b39849aea87b339581353467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=BC=E5=81=A5=E8=81=AA?= Date: Fri, 14 Aug 2026 15:56:04 +0800 Subject: [PATCH 2/4] Render slide boundary anchors in markdown --- src/formats/ppt/mod.rs | 32 ++++++++++++++++++- src/render/markdown/anchors.rs | 19 ++++++++--- src/render/markdown/tests.rs | 9 ++++++ ...alformed__brokenpersist--recovers.ppt.snap | 2 ++ ...pshots__ppt__handmade-multimaster.ppt.snap | 4 +++ ...pshots__ppt__handmade-sparsenotes.ppt.snap | 4 +++ tests/snapshots/snapshots__ppt__pres.ppt.snap | 4 +++ ...napshots__pptx__handmade-altpath.pptx.snap | 2 ++ ...napshots__pptx__handmade-inherit.pptx.snap | 2 ++ .../snapshots__pptx__handmade-links.pptx.snap | 2 ++ .../snapshots__pptx__handmade-order.pptx.snap | 2 ++ ...snapshots__pptx__handmade-strict.pptx.snap | 2 ++ .../snapshots/snapshots__pptx__pres.pptx.snap | 4 +++ 13 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs index 303a17b..a4af141 100644 --- a/src/formats/ppt/mod.rs +++ b/src/formats/ppt/mod.rs @@ -320,7 +320,8 @@ impl Extractor { fn end_segment(&mut self, id: Option) { 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)); } @@ -635,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"]); + } +} diff --git a/src/render/markdown/anchors.rs b/src/render/markdown/anchors.rs index d56a21c..5ae62d1 100644 --- a/src/render/markdown/anchors.rs +++ b/src/render/markdown/anchors.rs @@ -3,9 +3,10 @@ //! heading's GFM auto-generated slug; an anchor a link targets gets a //! sanitized, stable HTML id rendered as `` 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, except structural slide boundary +//! anchors: 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}; @@ -66,8 +67,10 @@ pub(crate) fn resolve_anchors(doc: &Document) -> AnchorMap { }); // Pass 2: every remaining anchor a link targets gets a sanitized HTML id. + // Slide boundary anchors are structural, so they render even when no + // internal link targets them. let mut assign = |id: &str| { - if linked.contains(id) + if (linked.contains(id) || is_slide_boundary_anchor(id)) && !resolved.contains_key(id) && let Some(html) = ids.claim(sanitize_id(id)) { @@ -147,6 +150,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::(), Ok(value) if value > 0) +} + /// Allocates ids without repeatedly probing used numeric suffixes. #[derive(Default)] struct UniqueIds { diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index a4aa806..abc5c99 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -576,6 +576,15 @@ fn unreferenced_anchor_renders_nothing() { assert_eq!(md, "No link points here.\n"); } +#[test] +fn unreferenced_slide_anchor_renders_html_id() { + let md = doc(vec![Block::Paragraph(vec![ + Inline::Anchor("slide-1".into()), + Inline::plain("First slide"), + ])]); + assert_eq!(md, "First slide\n"); +} + #[test] fn heading_coincident_anchor_uses_slug() { let md = doc(vec![ diff --git a/tests/snapshots/snapshots__malformed__brokenpersist--recovers.ppt.snap b/tests/snapshots/snapshots__malformed__brokenpersist--recovers.ppt.snap index 69dc74d..468bb4c 100644 --- a/tests/snapshots/snapshots__malformed__brokenpersist--recovers.ppt.snap +++ b/tests/snapshots/snapshots__malformed__brokenpersist--recovers.ppt.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + Deck Title Slide - Top level point diff --git a/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap b/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap index e5e99b5..812e9a3 100644 --- a/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap +++ b/tests/snapshots/snapshots__ppt__handmade-multimaster.ppt.snap @@ -2,6 +2,10 @@ source: tests/snapshots.rs 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..f23516c 100644 --- a/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap +++ b/tests/snapshots/snapshots__ppt__handmade-sparsenotes.ppt.snap @@ -2,8 +2,12 @@ source: tests/snapshots.rs 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..d9fb790 100644 --- a/tests/snapshots/snapshots__ppt__pres.ppt.snap +++ b/tests/snapshots/snapshots__ppt__pres.ppt.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + Deck Title Slide - Top level point @@ -12,6 +14,8 @@ Deck Title Slide > Speaker note for the intro slide. + + Numbers Slide Region diff --git a/tests/snapshots/snapshots__pptx__handmade-altpath.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-altpath.pptx.snap index 79d2f70..e062be1 100644 --- a/tests/snapshots/snapshots__pptx__handmade-altpath.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-altpath.pptx.snap @@ -2,4 +2,6 @@ source: tests/snapshots.rs expression: output --- + + ## Relocated deck title diff --git a/tests/snapshots/snapshots__pptx__handmade-inherit.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-inherit.pptx.snap index 59f9f8e..a41091a 100644 --- a/tests/snapshots/snapshots__pptx__handmade-inherit.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-inherit.pptx.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + ## Inherited Title Slide - iii. **Roman three bold via master** diff --git a/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap index aae0a01..a76c994 100644 --- a/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-links.pptx.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + [Jump to the second slide](#slide-2) [External link](https://example.com/) diff --git a/tests/snapshots/snapshots__pptx__handmade-order.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-order.pptx.snap index 782ed77..20afb1d 100644 --- a/tests/snapshots/snapshots__pptx__handmade-order.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-order.pptx.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + Kicker before the title ## Title placed second diff --git a/tests/snapshots/snapshots__pptx__handmade-strict.pptx.snap b/tests/snapshots/snapshots__pptx__handmade-strict.pptx.snap index ca9a992..e69011b 100644 --- a/tests/snapshots/snapshots__pptx__handmade-strict.pptx.snap +++ b/tests/snapshots/snapshots__pptx__handmade-strict.pptx.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + ## Strict slide title Strict body text diff --git a/tests/snapshots/snapshots__pptx__pres.pptx.snap b/tests/snapshots/snapshots__pptx__pres.pptx.snap index 61f7f60..47642e8 100644 --- a/tests/snapshots/snapshots__pptx__pres.pptx.snap +++ b/tests/snapshots/snapshots__pptx__pres.pptx.snap @@ -2,6 +2,8 @@ source: tests/snapshots.rs expression: output --- + + Deck Title Slide - Top level point @@ -12,6 +14,8 @@ Deck Title Slide > Speaker note for the intro slide. + + Numbers Slide | Region | Total | From 9c680f4530f9baa99457e608e45687e9344a65c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=BC=E5=81=A5=E8=81=AA?= Date: Fri, 14 Aug 2026 16:09:11 +0800 Subject: [PATCH 3/4] Scope slide anchors to presentation markdown --- src/lib.rs | 7 +++++-- src/render/markdown/anchors.rs | 4 ++-- src/render/markdown/mod.rs | 18 +++++++++++++++++- src/render/markdown/tests.rs | 20 ++++++++++++++++++-- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index efba6ff..b7a401b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; @@ -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 diff --git a/src/render/markdown/anchors.rs b/src/render/markdown/anchors.rs index 5ae62d1..2d5acec 100644 --- a/src/render/markdown/anchors.rs +++ b/src/render/markdown/anchors.rs @@ -35,7 +35,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 = HashMap::new(); @@ -70,7 +70,7 @@ pub(crate) fn resolve_anchors(doc: &Document) -> AnchorMap { // Slide boundary anchors are structural, so they render even when no // internal link targets them. let mut assign = |id: &str| { - if (linked.contains(id) || is_slide_boundary_anchor(id)) + if (linked.contains(id) || (render_unlinked_slide_anchors && is_slide_boundary_anchor(id))) && !resolved.contains_key(id) && let Some(html) = ids.claim(sanitize_id(id)) { diff --git a/src/render/markdown/mod.rs b/src/render/markdown/mod.rs index d2bfd04..b2dde22 100644 --- a/src/render/markdown/mod.rs +++ b/src/render/markdown/mod.rs @@ -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 = doc.blocks.iter().filter_map(|b| render_block(b, &rc)).collect(); let mut rendered_defs: HashSet = HashSet::new(); let mut ordered: Vec<(&Note, usize)> = diff --git a/src/render/markdown/tests.rs b/src/render/markdown/tests.rs index abc5c99..f9dede5 100644 --- a/src/render/markdown/tests.rs +++ b/src/render/markdown/tests.rs @@ -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, @@ -8,6 +8,13 @@ fn doc(blocks: Vec) -> String { document_to_markdown(&Document { blocks, notes: Vec::new(), assets: Vec::new() }) } +fn presentation_doc(blocks: Vec) -> 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 } } @@ -578,13 +585,22 @@ fn unreferenced_anchor_renders_nothing() { #[test] fn unreferenced_slide_anchor_renders_html_id() { - let md = doc(vec![Block::Paragraph(vec![ + let md = presentation_doc(vec![Block::Paragraph(vec![ Inline::Anchor("slide-1".into()), Inline::plain("First slide"), ])]); assert_eq!(md, "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![ From cb5a21e7daa659ff0ff15581bb282f0b23dfaba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=BC=E5=81=A5=E8=81=AA?= Date: Fri, 14 Aug 2026 16:22:05 +0800 Subject: [PATCH 4/4] Clarify slide anchor rendering option --- src/render/markdown/anchors.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/render/markdown/anchors.rs b/src/render/markdown/anchors.rs index 2d5acec..66a83e7 100644 --- a/src/render/markdown/anchors.rs +++ b/src/render/markdown/anchors.rs @@ -3,10 +3,11 @@ //! heading's GFM auto-generated slug; an anchor a link targets gets a //! sanitized, stable HTML id rendered as `` at its position. //! -//! Anchors nothing links to render nothing, except structural slide boundary -//! anchors: 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. +//! 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}; @@ -67,8 +68,8 @@ pub(crate) fn resolve_anchors(doc: &Document, render_unlinked_slide_anchors: boo }); // Pass 2: every remaining anchor a link targets gets a sanitized HTML id. - // Slide boundary anchors are structural, so they render even when no - // internal link targets them. + // 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) || (render_unlinked_slide_anchors && is_slide_boundary_anchor(id))) && !resolved.contains_key(id)