diff --git a/mineru/model/flash/html/anchors.py b/mineru/model/flash/html/anchors.py index 09863e22..5b44870e 100644 --- a/mineru/model/flash/html/anchors.py +++ b/mineru/model/flash/html/anchors.py @@ -10,13 +10,31 @@ import hashlib from lxml import etree # type: ignore[reportMissingImports] from .._shared.markup import MarkupStylesheet, TextStyle -from .._shared.markup.projector import local_name, visible_raw_text_with_style +from .._shared.markup.projector import BLOCK_TAGS, SKIPPED_TAGS, local_name, visible_raw_text_with_style _HEADING_TAGS = frozenset({"h1", "h2", "h3", "h4", "h5", "h6"}) _NOTE_TYPES = frozenset({"footnote", "endnote", "rearnote"}) _NOTE_ROLES = frozenset({"doc-footnote", "doc-endnote"}) _XML_ID = "{http://www.w3.org/XML/1998/namespace}id" +_NON_TEXT_BLOCK_TAGS = frozenset( + { + "figure", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "math", + "ol", + "pre", + "svg", + "table", + "ul", + } +) def is_note_element(element: etree._Element) -> bool: @@ -159,7 +177,9 @@ class HtmlAnchorRegistry: notes = [ element for element in root.iter() - if isinstance(element.tag, str) and is_note_element(element) and _visible_element_text(element, stylesheet) + if isinstance(element.tag, str) + and is_note_element(element) + and _note_has_materializable_text_target(element, stylesheet) ] for ordinal, note in enumerate(notes): identity = element_id(note) or f"note-{ordinal}" @@ -226,4 +246,71 @@ def _visible_element_text(element: etree._Element, stylesheet: MarkupStylesheet) return " ".join(value.split()) +def _note_has_materializable_text_target(element: etree._Element, stylesheet: MarkupStylesheet) -> bool: + """判断 note 是否会投影出可挂载 anchor 的顶层文本 block。""" + inherited = TextStyle() + visibility_hidden = False + chain = [ancestor for ancestor in reversed(list(element.iterancestors())) if isinstance(ancestor.tag, str)] + for ancestor in chain: + resolved = stylesheet.resolve(ancestor, inherited, visibility_hidden) + if resolved.subtree_hidden: + return False + inherited = resolved.text + visibility_hidden = resolved.visibility_hidden + resolved = stylesheet.resolve(element, inherited, visibility_hidden) + if resolved.subtree_hidden: + return False + return _container_materializes_text_block( + element, + stylesheet, + resolved.text, + resolved.visibility_hidden, + ) + + +def _container_materializes_text_block( + element: etree._Element, + stylesheet: MarkupStylesheet, + style: TextStyle, + visibility_hidden: bool, +) -> bool: + """按共享 projector 的容器分块规则判断是否会产生顶层文本。""" + if not visibility_hidden and (element.text or "").strip(): + return True + for child in element: + if isinstance(child.tag, str): + resolved = stylesheet.resolve(child, style, visibility_hidden) + if not resolved.subtree_hidden: + name = local_name(child) + if name == "p": + value = visible_raw_text_with_style( + child, + stylesheet, + resolved.text, + resolved.visibility_hidden, + ) + if value.strip(): + return True + elif name in BLOCK_TAGS: + if name not in _NON_TEXT_BLOCK_TAGS and _container_materializes_text_block( + child, + stylesheet, + resolved.text, + resolved.visibility_hidden, + ): + return True + elif name not in SKIPPED_TAGS: + value = visible_raw_text_with_style( + child, + stylesheet, + resolved.text, + resolved.visibility_hidden, + ) + if value.strip(): + return True + if not visibility_hidden and (child.tail or "").strip(): + return True + return False + + __all__ = ["HtmlAnchorRegistry", "append_referenced_notes", "element_id", "is_note_element"] diff --git a/tests/unittest/test_flash_html.py b/tests/unittest/test_flash_html.py index 9b9e7e37..6482810f 100644 --- a/tests/unittest/test_flash_html.py +++ b/tests/unittest/test_flash_html.py @@ -400,6 +400,22 @@ def test_html_referenced_external_footnote_keeps_anchor_and_content() -> None: assert f'id="{footnote.anchor}" class="mineru-page-footnote"' in markdown # type: ignore[union-attr] +def test_html_structured_only_footnote_does_not_create_dangling_anchor() -> None: + """验证只投影为结构化 block 的脚注不会把正文引用改写为悬空 fragment。""" + payload = b"""

Claim [1].

+
""" + + middle, model = doc_analyze(payload, file_suffix="html") + markdown = render_markdown(middle) + rendered_html = render_html(middle, standalone=False) + + assert model.pages[0][0]["content"] == "Claim [1]." + assert model.pages[0][1]["type"] == BlockType.LIST + assert "Only item" in markdown + assert "](#html-" not in markdown + assert 'href="#html-' not in rendered_html + + @pytest.mark.parametrize( "ancestor_attributes", ['style="display:none"', 'style="opacity:0"', "hidden", 'aria-hidden="true"'],