fix: address HTML review edge cases

This commit is contained in:
myhloli
2026-08-28 00:10:16 +08:00
parent 0729928a0f
commit 1bc08ff8a3
5 changed files with 97 additions and 9 deletions
+10 -7
View File
@@ -99,7 +99,7 @@ _FOOTNOTE_TOKENS = frozenset(
}
)
_VISUAL_ELEMENT_TAGS = frozenset({"img", "image", "pre", "svg", "table"})
_LIST_PAGE_BLOCK_TAGS = frozenset({"figure", "image", "img", "pre", "svg", "table"})
_LIST_PAGE_BLOCK_TAGS = frozenset({"figure", "image", "img", "math", "pre", "svg", "table"})
_InlineProjectionSegment: TypeAlias = str | dict[str, object]
@@ -287,11 +287,12 @@ class MarkupProjector:
flush_inline()
blocks.extend(self._parse_block(child, style, visibility_hidden))
else:
rendered, extras = self._render_inline_element(child, style, visibility_hidden)
inline_parts.append(rendered)
if extras:
flush_inline()
blocks.extend(extras)
for segment in self._render_inline_element_ordered(child, style, visibility_hidden):
if isinstance(segment, str):
inline_parts.append(segment)
else:
flush_inline()
blocks.append(segment)
if not visibility_hidden:
inline_parts.append(self._render_text(child.tail, style))
flush_inline()
@@ -470,6 +471,8 @@ class MarkupProjector:
return []
formula = self._formula_extraction(element)
if formula is not None:
if formula.display == "block":
return [{"type": BlockType.EQUATION, "content": formula.latex}]
return [f"<eq>{html.escape(formula.latex, quote=False)}</eq>"]
fallback = self._visible_plain_text(element, resolved.text, resolved.visibility_hidden)
return [html.escape(fallback, quote=False)] if fallback else []
@@ -1009,7 +1012,7 @@ class MarkupProjector:
@staticmethod
def _list_contains_page_blocks(element: etree._Element) -> bool:
"""判断列表是否含必须提升为页面兄弟的 visual/code 子树。"""
"""判断列表是否含可能提升为页面兄弟的 visualcode 或公式子树。"""
return any(
isinstance(candidate.tag, str) and local_name(candidate) in _LIST_PAGE_BLOCK_TAGS
for candidate in element.iterdescendants()
+2 -2
View File
@@ -91,7 +91,7 @@ class HtmlResourceContext:
if normalized is None:
return None
if normalized.startswith("#"):
fragment = normalized[1:].strip()
fragment = unquote(normalized[1:]).strip()
return fragment or None
source_uri = (self.source_context.source_uri or "").strip()
if not source_uri:
@@ -102,7 +102,7 @@ class HtmlResourceContext:
target_parts = urlsplit(urljoin(base_uri, normalized))
except ValueError:
return None
fragment = target_parts.fragment.strip()
fragment = unquote(target_parts.fragment).strip()
if not fragment or _document_url_identity(target_parts) != _document_url_identity(source_parts):
return None
return fragment
+12
View File
@@ -95,6 +95,7 @@ from ....types import (
TextBlock,
TitleBlockBase,
)
from ....utils.image_payload import validate_remote_image_url
_SVG_BLIP_NAMESPACE = "http://schemas.microsoft.com/office/drawing/2016/SVG/main"
_SVG_BLIP_EXTENSION_URI = "{96DAC541-7B7A-43D3-8B79-37D633B846F1}"
@@ -773,6 +774,17 @@ class _DocxRenderer:
max_width_emu: int,
) -> None:
"""安全加载表格单元格 img,并限制到紧凑的单元格宽度。"""
try:
remote_source = validate_remote_image_url(source)
except ValueError:
remote_source = None
if remote_source is not None:
append_inline_nodes(
paragraph,
[InlineLink([InlineText(alt_text.strip() or "remote image")], remote_source)],
context=context,
)
return
try:
prepared = prepare_html_image(source, self.asset_resolver)
except DocxAssetError as exc:
+17
View File
@@ -610,6 +610,23 @@ def test_html_table_fallback_rolls_back_partial_table_and_relationships() -> Non
assert len(media) == 1
def test_html_table_remote_cell_image_uses_safe_link_fallback() -> None:
"""验证远程单元格图片输出可点击 alt,而不是中断整份 DOCX。"""
html = '<table><tr><td><img src="https://example.com/logo.png" alt="Logo"></td></tr></table>'
table = TableBlock(
type="table",
index=0,
content=[TableBodyBlock(type="table_body", index=0, content=html)],
)
result = render_docx(_middle(_page(0, table)))
document = Document(BytesIO(result))
assert document.tables[0].cell(0, 0).text == "Logo"
assert len(document.inline_shapes) == 0
assert "https://example.com/logo.png" in _part(result, "word/_rels/document.xml.rels")
def test_html_table_cell_image_is_limited_to_merged_cell_width() -> None:
"""验证窄列图片宽度不超过扣除单元格左右内边距后的 tcW。"""
cells = [f"<td>{'<img src=' + repr(_png_uri(size=(300, 100))) + '/>' if index == 0 else index}</td>" for index in range(10)]
+56
View File
@@ -331,6 +331,32 @@ def test_html_auto_selection_appends_notes_for_all_same_document_url_forms(href:
assert "https://example.com/page.html#fn1" not in markdown
@pytest.mark.parametrize(
("href", "note_id"),
[
("#fn%31", "fn1"),
("page.html#note%20one", "note one"),
("https://example.com/page.html#%E8%84%9A%E6%B3%A8", "脚注"),
],
)
def test_html_auto_selection_decodes_same_document_note_fragments(href: str, note_id: str) -> None:
"""验证数字、空格与非 ASCII fragment 解码后可关联正文外脚注。"""
detail = "Useful main article text " * 20
payload = f"""<html><head><meta charset="utf-8"></head><body><main><article><h1>Title</h1><p>{detail}
<a href="{href}">[1]</a></p></article></main>
<footer><aside id="{note_id}" role="doc-footnote"><p>Outside footnote.</p></aside></footer>
</body></html>""".encode()
context = HtmlSourceContext(source_uri="https://example.com/page.html")
middle = doc_analyze(payload, file_suffix="html", source_context=context)[0]
markdown = render_markdown(middle)
footnote = next(block for block in middle.pages[0].blocks if block.type == BlockType.PAGE_FOOTNOTE)
assert footnote.content == "Outside footnote." # type: ignore[union-attr]
assert f"](#{footnote.anchor})" in markdown # type: ignore[union-attr]
assert href not in markdown
@pytest.mark.parametrize(
("href", "expected_target"),
[
@@ -989,6 +1015,36 @@ def test_html_formula_priority_delimiters_and_supported_mathml_are_normalized()
assert equations == ["display_value"]
def test_html_block_formulas_nested_in_text_containers_preserve_dom_order() -> None:
"""验证文本容器内的 display 公式切成独立 Equation,并保留前后阅读顺序。"""
payload = b"""<html><body><p>Before<script type="math/tex; mode=display">x</script>Between
<span><math display="block" data-tex="y"></math></span>After</p>
<ul><li>Item before<math display="block" data-tex="z"></math>Item after</li></ul></body></html>"""
middle, model = doc_analyze(payload, file_suffix="html")
raw = [(block["type"], block.get("content")) for block in model.pages[0]]
assert raw[:5] == [
(BlockType.TEXT, "Before"),
(BlockType.EQUATION, "x"),
(BlockType.TEXT, "Between"),
(BlockType.EQUATION, "y"),
(BlockType.TEXT, "After"),
]
assert raw[5] == (BlockType.LIST, [{"type": BlockType.TEXT, "content": "Item before"}])
assert raw[6:] == [(BlockType.EQUATION, "z"), (BlockType.TEXT, "Item after")]
assert [block.type for block in middle.pages[0].blocks] == [
BlockType.TEXT,
BlockType.EQUATION,
BlockType.TEXT,
BlockType.EQUATION,
BlockType.TEXT,
BlockType.LIST,
BlockType.EQUATION,
BlockType.TEXT,
]
def test_html_invalid_mathml_and_asciimath_remain_visible_text() -> None:
"""验证未知 MathML 与本轮未支持 AsciiMath 不会伪装为 Equation 或被静默删除。"""
payload = b"""<html><body><h1>Fallback math</h1>