diff --git a/mineru/model/flash/csv.py b/mineru/model/flash/csv.py
index 810f6d3a..aefb4371 100644
--- a/mineru/model/flash/csv.py
+++ b/mineru/model/flash/csv.py
@@ -20,6 +20,7 @@ MAX_CSV_ROWS: Final = 1_048_576
MAX_CSV_COLUMNS: Final = 16_384
# CSV 会把每个槽位实体化为 HTML/DOM 节点,预算需显著低于稀疏电子表格投影上限。
MAX_CSV_GRID_SLOTS: Final = 250_000
+MAX_CSV_RENDERED_BYTES: Final = 256 * 1024 * 1024
_DELIMITER_CANDIDATES: Final = (",", ";", "\t", "|")
_DELIMITER_SAMPLE_RECORDS: Final = 20
@@ -255,17 +256,78 @@ def _render_field_html(value: str) -> str:
return html.escape(normalized, quote=True).replace("\n", "
")
+def _rendered_field_utf8_bytes(value: str, remaining_budget: int) -> int:
+ """在不创建转义字符串的前提下计算字段渲染后的 UTF-8 字节数。"""
+ rendered_bytes = 0
+ index = 0
+ while index < len(value):
+ char = value[index]
+ codepoint = ord(char)
+ if char == "\r":
+ if index + 1 < len(value) and value[index + 1] == "\n":
+ index += 1
+ addition = len("
")
+ elif char == "\n":
+ addition = len("
")
+ elif codepoint <= 0x08 or codepoint in {0x0B, 0x0C, 0x7F} or 0x0E <= codepoint <= 0x1F:
+ addition = 3
+ elif 0xD800 <= codepoint <= 0xDFFF:
+ addition = 3
+ elif char == "&":
+ addition = len("&")
+ elif char in {"<", ">"}:
+ addition = len("<")
+ elif char in {'"', "'"}:
+ addition = len(""")
+ elif codepoint <= 0x7F:
+ addition = 1
+ elif codepoint <= 0x7FF:
+ addition = 2
+ elif codepoint <= 0xFFFF:
+ addition = 3
+ else:
+ addition = 4
+ rendered_bytes += addition
+ if rendered_bytes > remaining_budget:
+ raise ValueError(f"CSV exceeds max_rendered_bytes={MAX_CSV_RENDERED_BYTES}")
+ index += 1
+ return rendered_bytes
+
+
+def _charge_rendered_bytes(used_bytes: int, additional_bytes: int) -> int:
+ """累计 CSV HTML 输出预算,并在写入 StringIO 前拒绝超限内容。"""
+ if additional_bytes < 0 or used_bytes > MAX_CSV_RENDERED_BYTES - additional_bytes:
+ raise ValueError(f"CSV exceeds max_rendered_bytes={MAX_CSV_RENDERED_BYTES}")
+ return used_bytes + additional_bytes
+
+
def _rows_to_html(rows: list[list[str]], *, has_header: bool) -> str:
"""增量构造安全表格 HTML,避免为每个单元格保留独立字符串对象。"""
output = StringIO()
+ rendered_bytes = 0
+ rendered_bytes = _charge_rendered_bytes(rendered_bytes, len("
"))
output.write("")
for row_index, row in enumerate(rows):
tag = "th" if has_header and row_index == 0 else "td"
- output.write("\n ")
+ row_prefix = "\n
"
+ rendered_bytes = _charge_rendered_bytes(rendered_bytes, len(row_prefix))
+ output.write(row_prefix)
for value in row:
- output.write(f"\n <{tag}>{_render_field_html(value)}{tag}>")
- output.write("\n
")
- output.write("\n
")
+ cell_prefix = f"\n <{tag}>"
+ cell_suffix = f"{tag}>"
+ rendered_bytes = _charge_rendered_bytes(rendered_bytes, len(cell_prefix) + len(cell_suffix))
+ remaining_budget = MAX_CSV_RENDERED_BYTES - rendered_bytes
+ field_bytes = _rendered_field_utf8_bytes(value, remaining_budget)
+ rendered_bytes = _charge_rendered_bytes(rendered_bytes, field_bytes)
+ output.write(cell_prefix)
+ output.write(_render_field_html(value))
+ output.write(cell_suffix)
+ row_suffix = "\n "
+ rendered_bytes = _charge_rendered_bytes(rendered_bytes, len(row_suffix))
+ output.write(row_suffix)
+ table_suffix = "\n
"
+ _charge_rendered_bytes(rendered_bytes, len(table_suffix))
+ output.write(table_suffix)
return output.getvalue()
diff --git a/mineru/model/flash/epub/xhtml.py b/mineru/model/flash/epub/xhtml.py
index 68c4cb10..9192c84a 100644
--- a/mineru/model/flash/epub/xhtml.py
+++ b/mineru/model/flash/epub/xhtml.py
@@ -489,7 +489,7 @@ class EpubChapterConverter:
if name == "figure":
return self._parse_figure(element, resolved.text)
if name == "svg":
- return self._parse_svg(element)
+ return self._parse_svg(element, resolved.text)
return self._parse_container_contents(element, resolved.text)
def _parse_note_element(self, element: etree._Element, style: TextStyle) -> list[dict[str, object]]:
@@ -622,20 +622,44 @@ class EpubChapterConverter:
blocks.append({"type": BlockType.TEXT, "content": html.escape(caption, quote=False)})
return blocks
- def _parse_svg(self, element: etree._Element) -> list[dict[str, object]]:
+ def _visible_svg_text(self, element: etree._Element, style: TextStyle) -> str:
+ """递归提取 SVG 可见文本,并排除隐藏后代的内容。"""
+ parts = [_clean_text_node(element.text)]
+ for child in element:
+ if not isinstance(child.tag, str):
+ parts.append(_clean_text_node(_entity_text(child)))
+ parts.append(_clean_text_node(child.tail))
+ continue
+ resolved = self.stylesheet.resolve(child, style)
+ if not resolved.hidden:
+ parts.append(self._visible_svg_text(child, resolved.text))
+ parts.append(_clean_text_node(child.tail))
+ return _WHITESPACE_RE.sub(" ", html.unescape("".join(parts))).strip()
+
+ def _parse_svg(self, element: etree._Element, style: TextStyle) -> list[dict[str, object]]:
"""从 SVG 尽力提取 title/desc/text 和包内栅格 image。"""
blocks: list[dict[str, object]] = []
texts: list[str] = []
- for child in element.iter():
- if not isinstance(child.tag, str):
- continue
- name = _local_name(child)
- if name in {"title", "desc", "text"}:
- value = _visible_text(child)
- if value and value not in texts:
- texts.append(value)
- elif name == "image":
- blocks.extend(self._image_blocks(child))
+
+ def visit(parent: etree._Element, inherited: TextStyle) -> None:
+ """按 SVG 树顺序访问可见候选节点,并让祖先隐藏状态截断子树。"""
+ for child in parent:
+ if not isinstance(child.tag, str):
+ continue
+ resolved = self.stylesheet.resolve(child, inherited)
+ if resolved.hidden:
+ continue
+ name = _local_name(child)
+ if name in {"title", "desc", "text"}:
+ value = self._visible_svg_text(child, resolved.text)
+ if value and value not in texts:
+ texts.append(value)
+ elif name == "image":
+ blocks.extend(self._image_blocks(child))
+ else:
+ visit(child, resolved.text)
+
+ visit(element, style)
if texts:
blocks.insert(0, {"type": BlockType.TEXT, "content": html.escape("\n".join(texts), quote=False)})
return blocks
@@ -880,7 +904,8 @@ def convert_svg_spine(
"""把 standalone SVG spine item 尽力转换为文本和包内栅格图片。"""
empty_registry = EpubAnchorRegistry([], package)
converter = EpubChapterConverter(package, chapter_path, root, empty_registry)
- return converter._parse_svg(root)
+ resolved = converter.stylesheet.resolve(root, TextStyle())
+ return [] if resolved.hidden else converter._parse_svg(root, resolved.text)
__all__ = [
diff --git a/mineru/model/flash/office/odf/converters.py b/mineru/model/flash/office/odf/converters.py
index ecbbfc8d..2a77aa3f 100644
--- a/mineru/model/flash/office/odf/converters.py
+++ b/mineru/model/flash/office/odf/converters.py
@@ -3,6 +3,7 @@
from __future__ import annotations
+import html
import re
from dataclasses import dataclass
from typing import Any, BinaryIO, Iterator
@@ -251,6 +252,8 @@ def _parse_odp_pages(context: _OdfContext) -> list[list[dict[str, Any]]]:
for page in context.body:
if page.tag != qname("draw", "page"):
continue
+ if not context.styles.drawing_page_is_visible(page):
+ continue
positioned: list[_PositionedBlocks] = []
for order, (shape, x, y) in enumerate(_iter_slide_shapes(page)):
presentation_class = shape.get(qname("presentation", "class"), "")
@@ -324,7 +327,7 @@ def _parse_ods_pages(context: _OdfContext) -> list[list[dict[str, Any]]]:
continue
if not context.styles.table_is_visible(sheet.get(qname("table", "style-name"))):
continue
- name = sheet.get(qname("table", "name"), "Sheet")
+ name = html.escape(sheet.get(qname("table", "name"), "Sheet"), quote=False)
sheet_pages.append((name, _sheet_blocks(sheet, parser)))
if sum(bool(blocks) for _, blocks in sheet_pages) > 1:
for name, blocks in sheet_pages:
diff --git a/mineru/model/flash/office/odf/metadata.py b/mineru/model/flash/office/odf/metadata.py
index 3a1af22f..069bc1f2 100644
--- a/mineru/model/flash/office/odf/metadata.py
+++ b/mineru/model/flash/office/odf/metadata.py
@@ -73,7 +73,9 @@ def extract_odf_metadata(file_binary: BinaryIO, suffix: OdfSuffix) -> dict[str,
if suffix == "odt":
page_count = _odt_page_count(meta_root)
elif suffix == "odp":
- page_count = sum(1 for child in body if child.tag == qname("draw", "page"))
+ page_count = sum(
+ 1 for child in body if child.tag == qname("draw", "page") and styles.drawing_page_is_visible(child)
+ )
else:
page_count = _visible_sheet_count(body, styles)
return {
diff --git a/mineru/model/flash/office/odf/styles.py b/mineru/model/flash/office/odf/styles.py
index 5ace4b24..8ea914ae 100644
--- a/mineru/model/flash/office/odf/styles.py
+++ b/mineru/model/flash/office/odf/styles.py
@@ -23,6 +23,7 @@ class _StyleDefinition:
text_delta: TextStyleDelta
master_page_name: str | None
table_display: bool | None
+ drawing_page_visible: bool | None
class OdfStyles:
@@ -35,6 +36,7 @@ class OdfStyles:
self._list_styles: dict[str, dict[int, ListLevel]] = {}
self._resolved_text: dict[tuple[str, str], TextStyleDelta] = {}
self._resolved_table_display: dict[str, bool | None] = {}
+ self._resolved_drawing_page_visibility: dict[str, bool | None] = {}
self._master_pages: dict[str, etree._Element] = {}
for root in roots:
if root is not None:
@@ -59,6 +61,7 @@ class OdfStyles:
text_delta=self._text_delta(style),
master_page_name=style.get(qname("style", "master-page-name")),
table_display=self._table_display(style),
+ drawing_page_visible=self._drawing_page_visibility(style),
)
for list_style in root.iter(qname("text", "list-style")):
name = list_style.get(qname("style", "name"))
@@ -133,6 +136,17 @@ class OdfStyles:
return None
return display.casefold() != "false"
+ @staticmethod
+ def _drawing_page_visibility(style: etree._Element) -> bool | None:
+ """读取 drawing-page 样式的 presentation visibility。"""
+ properties = style.find(qname("style", "drawing-page-properties"))
+ if properties is None:
+ return None
+ visibility = properties.get(qname("presentation", "visibility"))
+ if visibility is None:
+ return None
+ return visibility.casefold() != "hidden"
+
@staticmethod
def _parse_list_style(element: etree._Element) -> dict[int, ListLevel]:
"""解析列表样式的层级、类型和通用起始值。"""
@@ -258,6 +272,34 @@ class OdfStyles:
self._resolved_table_display[style_name] = resolved
return resolved is not False
+ def drawing_page_is_visible(self, page: etree._Element) -> bool:
+ """解析 ODP 页面直接属性或 drawing-page 样式中的隐藏状态。"""
+ direct_visibility = page.get(qname("presentation", "visibility"))
+ if direct_visibility is not None:
+ return direct_visibility.casefold() != "hidden"
+ style_name = page.get(qname("draw", "style-name"))
+ if not style_name:
+ return True
+ if style_name in self._resolved_drawing_page_visibility:
+ return self._resolved_drawing_page_visibility[style_name] is not False
+ seen: set[str] = set()
+ current = style_name
+ resolved: bool | None = None
+ while current:
+ if current in seen:
+ logger.warning("ODF style inheritance cycle detected: family=drawing-page, style={}", current)
+ break
+ seen.add(current)
+ definition = self._styles.get(("drawing-page", current))
+ if definition is None:
+ break
+ if definition.drawing_page_visible is not None:
+ resolved = definition.drawing_page_visible
+ break
+ current = definition.parent or ""
+ self._resolved_drawing_page_visibility[style_name] = resolved
+ return resolved is not False
+
def master_page(self, name: str | None) -> etree._Element | None:
"""返回指定 master-page;空名称时优先使用第一个定义。"""
if name and name in self._master_pages:
diff --git a/mineru/model/flash/office/odf/table.py b/mineru/model/flash/office/odf/table.py
index ce83f356..15eb35d5 100644
--- a/mineru/model/flash/office/odf/table.py
+++ b/mineru/model/flash/office/odf/table.py
@@ -344,6 +344,15 @@ def _column_index(label: str) -> int | None:
return result - 1 if result <= MAX_GRID_SLOTS else None
+def _row_index(label: str) -> int | None:
+ """在整数转换前把 A1 地址中的行号约束到共享网格预算。"""
+ normalized = label.lstrip("0")
+ if not normalized or len(normalized) > len(str(MAX_GRID_SLOTS)):
+ return None
+ result = int(normalized)
+ return result - 1 if result <= MAX_GRID_SLOTS else None
+
+
def parse_cell_range_bounds(address: str) -> tuple[int, int, int, int] | None:
"""从 ODF cell-range-address 中提取零基闭区间边界。"""
matches = list(_CELL_ADDRESS_RE.finditer(address or ""))
@@ -351,11 +360,11 @@ def parse_cell_range_bounds(address: str) -> tuple[int, int, int, int] | None:
return None
first = matches[0]
last = matches[-1]
- row_start = int(first.group("row")) - 1
+ row_start = _row_index(first.group("row"))
col_start = _column_index(first.group("col"))
- row_end = int(last.group("row")) - 1
+ row_end = _row_index(last.group("row"))
col_end = _column_index(last.group("col"))
- if col_start is None or col_end is None:
+ if row_start is None or col_start is None or row_end is None or col_end is None:
return None
return min(row_start, row_end), max(row_start, row_end), min(col_start, col_end), max(col_start, col_end)
diff --git a/tests/unittest/test_flash_csv.py b/tests/unittest/test_flash_csv.py
index 1602dadd..924730de 100644
--- a/tests/unittest/test_flash_csv.py
+++ b/tests/unittest/test_flash_csv.py
@@ -146,6 +146,28 @@ def test_csv_grid_limit_short_circuits_before_trailing_malformed_record(monkeypa
CsvModel().predict(BytesIO(b'a,b\n1,2\n"unterminated'))
+def test_csv_rendered_budget_fails_before_materializing_escaped_field(monkeypatch: pytest.MonkeyPatch) -> None:
+ """验证 HTML 展开超限时不会先创建放大的字段字符串。"""
+ monkeypatch.setattr(csv_module, "MAX_CSV_RENDERED_BYTES", 64)
+
+ def unexpected_escape(_value: str) -> str:
+ """输出预算应在进入 html.escape 前拒绝字段。"""
+ pytest.fail("oversized CSV field reached HTML escaping")
+
+ monkeypatch.setattr(csv_module, "_render_field_html", unexpected_escape)
+
+ with pytest.raises(ValueError, match="max_rendered_bytes"):
+ csv_module._rows_to_html([["&" * 20]], has_header=False)
+
+
+def test_csv_rendered_size_estimator_matches_html_escape_semantics() -> None:
+ """验证特殊字符、换行、控制符和非 ASCII 文本的 UTF-8 预算精确。"""
+ value = "&<>\"'\r\n\x01中"
+ rendered = csv_module._render_field_html(value)
+
+ assert csv_module._rendered_field_utf8_bytes(value, 1_000) == len(rendered.encode())
+
+
def test_csv_default_grid_budget_rejects_wide_dom_before_rendering() -> None:
"""验证默认预算在宽空表生成数十万 HTML 节点前拒绝输入。"""
assert csv_module.MAX_CSV_GRID_SLOTS == 250_000
diff --git a/tests/unittest/test_flash_epub.py b/tests/unittest/test_flash_epub.py
index 0c8addc1..3f073c82 100644
--- a/tests/unittest/test_flash_epub.py
+++ b/tests/unittest/test_flash_epub.py
@@ -22,7 +22,7 @@ from mineru.errors import InvalidRequestError
from mineru.model.flash import EpubModel
from mineru.model.flash.epub import EpubEncryptedError, EpubPackage, EpubParseError, EpubResourceLimitError, detect_epub
from mineru.model.flash.epub.styles import EpubStylesheet, TextStyle
-from mineru.model.flash.epub.xhtml import EpubChapterConverter, build_anchor_registry
+from mineru.model.flash.epub.xhtml import EpubChapterConverter, build_anchor_registry, convert_svg_spine
from mineru.parser import MinerUParser, parse, parse_async
from mineru.parser import api_server
from mineru.parser.api_server import CreateJobRequest, FileStore
@@ -502,6 +502,35 @@ def test_epub_corrupt_chapter_keeps_empty_spine_placeholder() -> None:
assert "SVG text" in middle.pages[2].blocks[0].content # type: ignore[union-attr]
+def test_epub_svg_extraction_skips_hidden_descendants_and_hidden_root() -> None:
+ """验证 standalone SVG 不提取隐藏文本、隐藏图片或隐藏祖先子树。"""
+ package = EpubPackage(build_epub_fixture())
+ path = "EPUB/fixed/page.svg"
+ try:
+ root = package.xml_part(path)
+ assert root is not None
+ namespace = etree.QName(root).namespace
+ original_text = next(root.iter(f"{{{namespace}}}text"))
+ original_text.set("style", "display: none")
+ original_image = next(root.iter(f"{{{namespace}}}image"))
+ original_image.set("style", "visibility: hidden")
+ hidden_group = etree.SubElement(root, f"{{{namespace}}}g", style="display: none")
+ etree.SubElement(hidden_group, f"{{{namespace}}}text").text = "hidden group text"
+ etree.SubElement(root, f"{{{namespace}}}text").text = "visible graphic label"
+
+ blocks = convert_svg_spine(package, path, root)
+
+ assert "visible graphic label" in str(blocks)
+ assert "SVG text" not in str(blocks)
+ assert "hidden group text" not in str(blocks)
+ assert all(block["type"] != BlockType.IMAGE for block in blocks)
+
+ root.set("style", "display: none")
+ assert convert_svg_spine(package, path, root) == []
+ finally:
+ package.close()
+
+
def test_epub_malformed_resource_and_link_references_degrade_locally() -> None:
"""验证非法 URI 只丢弃样式、图片或链接目标,不阻断章节正文。"""
package = EpubPackage(build_epub_fixture())
diff --git a/tests/unittest/test_flash_odf.py b/tests/unittest/test_flash_odf.py
index 02c7ba71..2367b173 100644
--- a/tests/unittest/test_flash_odf.py
+++ b/tests/unittest/test_flash_odf.py
@@ -582,6 +582,15 @@ def test_odf_rejects_overlong_chart_columns_before_bigint_conversion(monkeypatch
assert odf_table_module.parse_cell_range_bounds(f"local-table.{'A' * 100_000}1") is None
+def test_odf_rejects_overlong_chart_rows_before_bigint_conversion(monkeypatch: pytest.MonkeyPatch) -> None:
+ """验证 chart A1 行号在 int 转换前受共享网格预算约束。"""
+ monkeypatch.setattr(odf_table_module, "MAX_GRID_SLOTS", 4)
+
+ assert odf_table_module.parse_cell_range_bounds("local-table.A4:B4") == (3, 3, 0, 1)
+ assert odf_table_module.parse_cell_range_bounds("local-table.A5:B5") is None
+ assert odf_table_module.parse_cell_range_bounds(f"local-table.A{'9' * 100_000}") is None
+
+
@pytest.mark.parametrize(
"target",
[
@@ -737,6 +746,70 @@ def test_odf_flattened_titles_and_notes_keep_literal_protocol_escaped() -> None:
assert "javascript:alert(1)" not in relationships
+def test_ods_sheet_titles_escape_literal_inline_protocol() -> None:
+ """验证多 sheet 标题不会把名称中的内部协议重建为活动链接。"""
+ literal = "<hyperlink><text>x</text><url>javascript:alert(1)</url></hyperlink>"
+ content = f"""
+
+
+ A
+
+
+ B
+
+ """
+ middle, model = doc_analyze(build_odf_package("ods", content), file_suffix="ods")
+
+ title = model.pages[0][0]["content"]
+ markdown = render_markdown(middle)
+ docx = render_docx(middle)
+ with ZipFile(BytesIO(docx)) as package:
+ relationships = package.read("word/_rels/document.xml.rels").decode("utf-8")
+
+ assert title.startswith("<hyperlink>")
+ assert "" not in title
+ assert "](javascript:" not in markdown
+ assert "javascript:alert(1)" not in relationships
+
+
+def test_odp_skips_hidden_drawing_page_styles_in_output_and_metadata() -> None:
+ """验证 ODP converter 与 metadata 共用 drawing-page 可见性解析。"""
+ content = """
+
+
+
+
+
+
+
+ visible slide
+
+
+ styled hidden slide
+
+
+ direct hidden slide
+
+ """
+ payload = build_odf_package("odp", content)
+
+ pages = OdpModel().predict(BytesIO(payload))
+ metadata = extract_odf_metadata(BytesIO(payload), "odp")
+
+ assert len(pages) == 1
+ assert "visible slide" in str(pages)
+ assert "hidden slide" not in str(pages)
+ assert metadata["page_count"] == 1
+
+
def test_odf_style_cycle_is_bounded_and_preserves_text() -> None:
"""验证循环 parent-style-name 在有限链路内降级,不阻塞正文解析。"""
content = """