From 9a8f9bd66de14181bd67e52c2ddc3e8377418e93 Mon Sep 17 00:00:00 2001 From: taohaoyi Date: Mon, 3 Aug 2026 11:00:30 +0000 Subject: [PATCH 1/4] fix: sync parser common and utility compatibility --- tests/unit/test_caption_extraction.py | 52 ++++++++ tests/unit/test_common_constant.py | 14 +++ tests/unit/test_order_utils_sync.py | 63 ++++++++++ uniparser_tools/common/constant.py | 117 ++++++++++++++++-- uniparser_tools/order/xy_cut_exp.py | 12 +- .../tools/caption_extraction/main.py | 11 +- uniparser_tools/utils/image.py | 13 +- uniparser_tools/utils/processor.py | 15 ++- .../utils/templates/visualize_together.html | 9 +- uniparser_tools/utils/visualize_together.py | 30 ++++- 10 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 tests/unit/test_caption_extraction.py create mode 100644 tests/unit/test_common_constant.py create mode 100644 tests/unit/test_order_utils_sync.py diff --git a/tests/unit/test_caption_extraction.py b/tests/unit/test_caption_extraction.py new file mode 100644 index 0000000..a4d2be8 --- /dev/null +++ b/tests/unit/test_caption_extraction.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from uniparser_tools.common.constant import LayoutType +from uniparser_tools.common.dataclass import BBox, TextualResult +from uniparser_tools.tools.caption_extraction.main import clean_flatten_pages + + +class LegacyTextualResult(TextualResult): + @property + def plain(self) -> str: + return self.text + + +def _textual_result( + *, types: list[LayoutType], textual_result_cls: type[TextualResult] = TextualResult +) -> TextualResult: + item = textual_result_cls( + token="token", + page=0, + block=1, + bbox=BBox(0, 0, 1, 1), + conf=1.0, + page_size=(100, 100), + type=LayoutType.Reference, + bboxes=[BBox(0, 0, 0.2, 0.1), BBox(0.2, 0, 0.8, 0.1), BBox(0.8, 0, 1, 0.1)], + contents=["prefix ", "https://doi.org/10.1/example ", "suffix"], + types=types or [LayoutType.Text] * 3, + ) + if not types: + # Simulate a TextualResult produced by an older version without inline types. + item.types = [] + return item + + +def test_clean_flatten_pages_filters_types_with_doi_content() -> None: + item = _textual_result(types=[LayoutType.Text, LayoutType.Text, LayoutType.Molecule]) + + cleaned = clean_flatten_pages([[item]])[0][0] + + assert cleaned.contents == ["prefix ", "suffix"] + assert cleaned.bboxes == [BBox(0, 0, 0.2, 0.1), BBox(0.8, 0, 1, 0.1)] + assert cleaned.types == [LayoutType.Text, LayoutType.Molecule] + + +def test_clean_flatten_pages_accepts_empty_legacy_types() -> None: + item = _textual_result(types=[], textual_result_cls=LegacyTextualResult) + + cleaned = clean_flatten_pages([[item]])[0][0] + + assert cleaned.contents == ["prefix ", "suffix"] + assert cleaned.bboxes == [BBox(0, 0, 0.2, 0.1), BBox(0.8, 0, 1, 0.1)] + assert cleaned.types == [] diff --git a/tests/unit/test_common_constant.py b/tests/unit/test_common_constant.py new file mode 100644 index 0000000..af2952c --- /dev/null +++ b/tests/unit/test_common_constant.py @@ -0,0 +1,14 @@ +from uniparser_tools.common.constant import PPOCR_LANG, SUPPORTED_LANG, Language, to_ocr_lang + + +def test_ppocr_language_mapping_matches_parser_models() -> None: + assert PPOCR_LANG[Language.German] == "de" + assert PPOCR_LANG[Language.Serbian] == "rs_cyrillic" + assert PPOCR_LANG[Language.Serbian_Latin] == "rs_latin" + assert Language.Persian in SUPPORTED_LANG + + +def test_to_ocr_lang_handles_aliases_and_unknown_values() -> None: + assert to_ocr_lang(Language.Cantonese) == Language.Chinese_Traditional + assert to_ocr_lang(Language.Western_Mari) == Language.Eastern_Mari + assert to_ocr_lang("not-a-language") == Language.Unknown diff --git a/tests/unit/test_order_utils_sync.py b/tests/unit/test_order_utils_sync.py new file mode 100644 index 0000000..3a96da2 --- /dev/null +++ b/tests/unit/test_order_utils_sync.py @@ -0,0 +1,63 @@ +from pathlib import Path + +from PIL import Image + +from uniparser_tools.common.constant import LayoutType +from uniparser_tools.common.dataclass import BBox, GroupedResult, TextualResult +from uniparser_tools.order.xy_cut_exp import sticky_items +from uniparser_tools.utils.image import dump_image_base64_str, load_image_base64_str +from uniparser_tools.utils.processor import tree_repr +from uniparser_tools.utils.visualize_together import plotly_pdf_results + + +def _text(block: int, text: str, bbox: BBox | None = None) -> TextualResult: + return TextualResult( + token="token", + page=0, + block=block, + bbox=bbox or BBox(0.1, 0.1, 0.4, 0.2), + conf=1.0, + page_size=(100, 100), + type=LayoutType.Paragraph, + contents=[text], + types=[LayoutType.Text], + ) + + +def test_sticky_items_keeps_valid_bbox_order() -> None: + items = [ + _text(1, "first", BBox(0.10, 0.10, 0.21, 0.20)), + _text(2, "second", BBox(0.12, 0.11, 0.23, 0.21)), + ] + + snapped = sticky_items(items, offset=5) + + assert all(item.bbox.x1 <= item.bbox.x2 and item.bbox.y1 <= item.bbox.y2 for item in snapped) + + +def test_tree_repr_keeps_legacy_verbose_option() -> None: + grouped = GroupedResult.clone(_text(1, "first"), items=[_text(2, "child")], method="synced") + + representation = tree_repr(grouped, verbose=True) + assert "synced" in representation + assert "[2]" in representation + assert "BBox" in representation + + +def test_image_helpers_support_alpha() -> None: + transparent = Image.new("RGBA", (4, 2), (255, 0, 0, 0)) + decoded = load_image_base64_str(dump_image_base64_str(transparent, quality=100)).convert("RGB") + + assert all(channel >= 245 for channel in decoded.getpixel((0, 0))) + + +def test_visualization_adds_page_thumbnail_without_server_assets(tmp_path: Path) -> None: + image_path = tmp_path / "page.png" + Image.new("RGB", (40, 60), "white").save(image_path) + + rendered = plotly_pdf_results([[]], str(image_path)) + + assert "page-thumb-img" in rendered + assert "data:image/jpeg;base64," in rendered + assert "cdn.jsdelivr.net/npm/mathjax" in rendered + assert "/static/images/parser-logo.png" not in rendered diff --git a/uniparser_tools/common/constant.py b/uniparser_tools/common/constant.py index 0330592..a4e2121 100644 --- a/uniparser_tools/common/constant.py +++ b/uniparser_tools/common/constant.py @@ -531,22 +531,94 @@ class ThirdPartyFormatter(StrEnum): Language.Japanese: "japan", Language.Korean: "korean", Language.English: "en", - Language.Arabic: "ar", - Language.Greek: "el", - Language.Hindi: "hi", - Language.Russian: "ru", + # Latin-script family + Language.Afrikaans: "af", + Language.Albanian: "sq", + Language.Azerbaijani: "az", + Language.Basque: "eu", + Language.Bosnian: "bs", + Language.Catalan: "ca", + Language.Croatian: "hr", + Language.Czech: "cs", + Language.Danish: "da", + Language.Dutch: "nl", + Language.Estonian: "et", + Language.Filipino: "tl", + Language.Finnish: "fi", Language.French: "fr", - Language.German: "german", + Language.Galician: "gl", + Language.German: "de", Language.Hungarian: "hu", + Language.Icelandic: "is", + Language.Indonesian: "id", + Language.Irish: "ga", + Language.Italian: "it", + Language.Kurdish: "ku", + Language.Latin: "la", + Language.Latvian: "lv", + Language.Lithuanian: "lt", + Language.Luxembourgish: "lb", + Language.Malay: "ms", + Language.Maltese: "mt", Language.Norwegian: "no", - Language.Danish: "da", - Language.Portuguese: "pt", + Language.Occitan: "oc", Language.Polish: "pl", - Language.Swedish: "sv", - Language.Spanish: "es", - Language.Serbian: "rs_latin", + Language.Portuguese: "pt", + Language.Quechua: "qu", + Language.Romanian: "ro", + Language.Romansh: "rm", Language.Serbian_Latin: "rs_latin", - Language.Latin: "la", + Language.Slovak: "sk", + Language.Slovenian: "sl", + Language.Spanish: "es", + Language.Swahili: "sw", + Language.Swedish: "sv", + Language.Turkish: "tr", + Language.Uzbek: "uz", + Language.Vietnamese: "vi", + Language.Welsh: "cy", + # East Slavic / Cyrillic families + Language.Avaric: "ava", + Language.Bashkir: "ba", + Language.Belarusian: "be", + Language.Bulgarian: "bg", + Language.Chechen: "che", + Language.Chuvash: "cv", + Language.Eastern_Mari: "mhr", + Language.Kalmyk: "xal", + Language.Kazakh: "kk", + Language.Komi: "kv", + Language.Kyrgyz: "ky", + Language.Lezghian: "lez", + Language.Macedonian: "mk", + Language.Mongolian: "mn", + Language.Ossetic: "os", + Language.Russia_Buriat: "bua", + Language.Russian: "ru", + Language.Serbian: "rs_cyrillic", + Language.Tajik: "tg", + Language.Tatar: "tt", + Language.Tuvinian: "tyv", + Language.Ukrainian: "uk", + Language.Yakut: "sah", + # Arabic-script family + Language.Arabic: "ar", + Language.Pashto: "ps", + Language.Persian: "fa", + Language.Sindhi: "sd", + Language.Urdu: "ur", + Language.Uyghur: "ug", + # Devanagari family + Language.Bihari_languages: "bh", + Language.Goan_Konkani: "gom", + Language.Hindi: "hi", + Language.Maithili: "mai", + Language.Marathi: "mr", + Language.Nepali: "ne", + Language.Newari: "new", + Language.Sanskrit: "sa", + # Specific PP-OCRv5 models + Language.Greek: "el", Language.Tamil: "ta", Language.Telugu: "te", Language.Thai: "th", @@ -554,6 +626,29 @@ class ThirdPartyFormatter(StrEnum): SUPPORTED_LANG = list(PPOCR_LANG.keys()) +# Normalize detected concrete languages to OCR model-language families. The target +# values below are existing Language enum members used as representatives for the +# PaddleOCR model groups documented in "Models and Their Supported Languages". +OCR_LANG_ALIASES = { + # Chinese model family + Language.Cantonese: Language.Chinese_Traditional, + Language.Wu_Chinese: Language.Chinese_Simplified, + # Cyrillic family fallback for UniParser languages not listed in PaddleOCR docs. + Language.Western_Mari: Language.Eastern_Mari, + # arabic_PP-OCRv5_mobile_rec + Language.Central_Kurdish: Language.Arabic, +} + + +def to_ocr_lang(lang: Language) -> Language: + try: + lang = Language(lang) + except (TypeError, ValueError): + return Language.Unknown + if lang in SUPPORTED_LANG: + return lang + return OCR_LANG_ALIASES.get(lang, Language.Unknown) + def to_semantic(layout_type: LayoutType): if layout_type in TextualTypes: diff --git a/uniparser_tools/order/xy_cut_exp.py b/uniparser_tools/order/xy_cut_exp.py index e05c6b7..ffbe5fb 100644 --- a/uniparser_tools/order/xy_cut_exp.py +++ b/uniparser_tools/order/xy_cut_exp.py @@ -133,11 +133,15 @@ def _snap(coords: np.ndarray) -> np.ndarray: # 3. 按 axis 吸合 if axis in ("x", "both"): - bboxes[:, 0] = _snap(x1) # x1 - bboxes[:, 2] = _snap(x2) # x2 + snapped_x1 = _snap(x1) + snapped_x2 = _snap(x2) + bboxes[:, 0] = np.minimum(snapped_x1, snapped_x2) # x1 + bboxes[:, 2] = np.maximum(snapped_x1, snapped_x2) # x2 if axis in ("y", "both"): - bboxes[:, 1] = _snap(y1) # y1 - bboxes[:, 3] = _snap(y2) # y2 + snapped_y1 = _snap(y1) + snapped_y2 = _snap(y2) + bboxes[:, 1] = np.minimum(snapped_y1, snapped_y2) # y1 + bboxes[:, 3] = np.maximum(snapped_y1, snapped_y2) # y2 # 4. 写回对象 new_items = [] diff --git a/uniparser_tools/tools/caption_extraction/main.py b/uniparser_tools/tools/caption_extraction/main.py index db83197..1e027f8 100644 --- a/uniparser_tools/tools/caption_extraction/main.py +++ b/uniparser_tools/tools/caption_extraction/main.py @@ -184,16 +184,21 @@ def clean_flatten_pages(pages: List[List[SemanticItem]]): if isinstance(item, TextualResult): if "doi:10." in item.plain.lower() or "https://doi.org/" in item.plain.lower(): if item.contents: - new_bboxes, new_contents = [], [] - for bbox, line in zip(item.bboxes, item.contents): + new_bboxes, new_contents, new_types = [], [], [] + item_types = getattr(item, "types", None) + for idx, (bbox, line) in enumerate(zip(item.bboxes, item.contents)): if line.lower().startswith("doi:10.") or line.lower().startswith("https://doi.org/"): pass else: new_bboxes.append(bbox) new_contents.append(line) + if item_types: + new_types.append(item_types[idx]) item.bboxes = new_bboxes item.contents = new_contents - item.text = " ".join(item.contents) + if item_types: + item.types = new_types + item.text = "".join(item.contents) else: item.text = re.sub(r"(?:doi:|https://doi\.org/)10\.\S+", "", item.text, flags=re.I) item.text = item.text.replace("F IGU R E", "FIGURE") # preprint_ck_2311.14410v2 diff --git a/uniparser_tools/utils/image.py b/uniparser_tools/utils/image.py index df2e11e..bd0b5d6 100644 --- a/uniparser_tools/utils/image.py +++ b/uniparser_tools/utils/image.py @@ -18,9 +18,20 @@ default_font_path = os.path.join(os.path.dirname(__file__), "DejaVuSerif.ttf") +def _prepare_image_for_jpeg(image: Image.Image) -> Image.Image: + if image.mode == "RGB": + return image + if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info): + rgba = image.convert("RGBA") + background = Image.new("RGB", rgba.size, (255, 255, 255)) + background.paste(rgba, mask=rgba.getchannel("A")) + return background + return image.convert("RGB") + + def dump_image_base64_str(image: Image.Image, quality: int = 85) -> str: img_byte_arr = BytesIO() - image.save(img_byte_arr, format="JPEG", quality=quality) + _prepare_image_for_jpeg(image).save(img_byte_arr, format="JPEG", quality=quality) return base64.b64encode(img_byte_arr.getvalue()).decode("ascii") diff --git a/uniparser_tools/utils/processor.py b/uniparser_tools/utils/processor.py index c30a3d7..fa405e0 100644 --- a/uniparser_tools/utils/processor.py +++ b/uniparser_tools/utils/processor.py @@ -11,7 +11,7 @@ from typing import Dict, List, Union from uniparser_tools.common.constant import LayoutType -from uniparser_tools.common.dataclass import GroupedResult, SemanticItem, TextualResult +from uniparser_tools.common.dataclass import GroupedResult, Item, SemanticItem, TextualResult from uniparser_tools.utils.log import get_root_logger @@ -536,11 +536,18 @@ def tree_repr(item: GroupedResult, output_str: StringIO = None, prefix: str = "" # │ └── legend # └── image caption + def item_repr(item: Item): + if isinstance(item, GroupedResult): + value = f"{item.type} [{item.block} | {item.method}]" + else: + value = f"{item.type} [{item.block}]" + return f"{value}{item.p_bbox if verbose else ''}" + # 打印当前节点 if output_str is None: output_str = StringIO() if not prefix: - output_str.write(f"{prefix}{item.type}{item.p_bbox if verbose else ''}\n") + output_str.write(f"{prefix}{item_repr(item)}\n") if isinstance(item, GroupedResult) and item.items: last_idx = len(item.items) - 1 for idx, child in enumerate(item.items): @@ -551,10 +558,10 @@ def tree_repr(item: GroupedResult, output_str: StringIO = None, prefix: str = "" branch = "├─ " next_prefix = prefix + "│ " if isinstance(child, GroupedResult) and child.items: - output_str.write(f"{prefix}{branch}{child.type}{child.p_bbox if verbose else ''}\n") + output_str.write(f"{prefix}{branch}{item_repr(child)}\n") tree_repr(child, output_str=output_str, prefix=next_prefix, verbose=verbose) else: - output_str.write(f"{prefix}{branch}{child.type}{child.p_bbox if verbose else ''}\n") + output_str.write(f"{prefix}{branch}{item_repr(child)}\n") return output_str.getvalue() diff --git a/uniparser_tools/utils/templates/visualize_together.html b/uniparser_tools/utils/templates/visualize_together.html index cc8036f..5e25f26 100644 --- a/uniparser_tools/utils/templates/visualize_together.html +++ b/uniparser_tools/utils/templates/visualize_together.html @@ -220,6 +220,13 @@ font-weight: bold; } + .page-thumb-img { + width: 100%; + height: auto; + border-radius: 3px; + display: block; + } + .page-thumb-num { font-size: 16px; font-weight: 700; @@ -1411,4 +1418,4 @@ - \ No newline at end of file + diff --git a/uniparser_tools/utils/visualize_together.py b/uniparser_tools/utils/visualize_together.py index d831719..eacaa57 100644 --- a/uniparser_tools/utils/visualize_together.py +++ b/uniparser_tools/utils/visualize_together.py @@ -186,8 +186,8 @@ def render_det_to_html(item: SemanticItem, order: int, pil_image: Image.Image) - copy_type = "text" # 公式 (Equation) elif isinstance(item, EquationResult): - content_html = f'
$${item.latex}$$
' - edit_val = html.escape(item.latex).strip() + content_html = f'
{item.markdown}
' + edit_val = html.escape(item.markdown).strip() copy_type = "latex" # 表格 (Table) elif isinstance(item, (TabularResult, ExpressionResult, ChartResult)): @@ -245,6 +245,7 @@ def plotly_pdf_results_interactive(pages_dict: List[List[Dict]], file_path: str, if page_idx >= total_pages: page_idx = pages[-1] + thumbnails = [] try: pdf = PDF("", 0, Language.Unknown, file_path) if pdf.is_pdf: @@ -255,8 +256,21 @@ def plotly_pdf_results_interactive(pages_dict: List[List[Dict]], file_path: str, max_dpi = min(144, max(1, int(4096 * 72 / max(rect.width, rect.height)))) # max 4096 pixels pix: fitz.Pixmap = page.get_pixmap(dpi=max_dpi) pil_image = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + for i in range(total_pages): + try: + thumb_page = doc[i] + thumb_rect = thumb_page.rect + thumb_dpi = max(8, int(80 * 72 / max(thumb_rect.width, thumb_rect.height, 1))) + thumb_pix = thumb_page.get_pixmap(dpi=thumb_dpi) + thumb_img = Image.frombytes("RGB", (thumb_pix.width, thumb_pix.height), thumb_pix.samples) + thumbnails.append(dump_image_base64_str(thumb_img, quality=50)) + except Exception: + thumbnails.append("") else: pil_image = Image.open(file_path) + thumb = pil_image.copy() + thumb.thumbnail((80, 120)) + thumbnails.append(dump_image_base64_str(thumb, quality=50)) except Exception: get_root_logger().exception("[Viz Error] Load image failed:") pil_image = Image.new("RGB", (800, 1000), "white") @@ -275,6 +289,7 @@ def plotly_pdf_results_interactive(pages_dict: List[List[Dict]], file_path: str, "filename": str(file_path).split("/")[-1], "page_index": page_idx, "total_pages": total_pages, + "thumbnails": thumbnails, } @@ -285,14 +300,21 @@ def create_interactive_html_from_data(vis_data: Dict) -> str: total_pages: int = vis_data["total_pages"] current_page: int = vis_data["page_index"] + thumbnails = vis_data.get("thumbnails", []) sidebar_parts = ['
'] for i in range(total_pages): active_cls = "active" if i == current_page else "" current_page_attr = ' data-current-page="true"' if i == current_page else "" + thumb_b64 = thumbnails[i] if i < len(thumbnails) and thumbnails[i] else "" + thumb_html = ( + f'Page {i + 1}' + if thumb_b64 + else '
PAGE
' + ) sidebar_parts.append( f'
' + f"{thumb_html}" f'
{i + 1}
' - f'
PAGE
' f"
" ) sidebar_parts.append("
") @@ -322,7 +344,7 @@ def create_interactive_html_from_data(vis_data: Dict) -> str: d["anno_smiles"] = item.plain d["anno_svg"] = getattr(item, "drawing", "") elif isinstance(item, EquationResult): - d["anno_latex"] = item.latex + d["anno_latex"] = item.latex_repr else: d["anno_text"] = item.plain items_dicts.append(d) From 84634693c1f28d37250ce2580c6845857d7aefea Mon Sep 17 00:00:00 2001 From: taohaoyi Date: Tue, 4 Aug 2026 14:43:30 +0000 Subject: [PATCH 2/4] fix: preserve caption text without bboxes --- tests/unit/test_caption_extraction.py | 106 +++++++++++++++--- .../tools/caption_extraction/main.py | 11 +- .../utils/templates/visualize_together.html | 4 +- 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/tests/unit/test_caption_extraction.py b/tests/unit/test_caption_extraction.py index a4d2be8..4a35409 100644 --- a/tests/unit/test_caption_extraction.py +++ b/tests/unit/test_caption_extraction.py @@ -5,16 +5,12 @@ from uniparser_tools.tools.caption_extraction.main import clean_flatten_pages -class LegacyTextualResult(TextualResult): - @property - def plain(self) -> str: - return self.text - - def _textual_result( - *, types: list[LayoutType], textual_result_cls: type[TextualResult] = TextualResult + *, + types: list[LayoutType], + bboxes: list[BBox] | None = None, ) -> TextualResult: - item = textual_result_cls( + item = TextualResult( token="token", page=0, block=1, @@ -22,13 +18,10 @@ def _textual_result( conf=1.0, page_size=(100, 100), type=LayoutType.Reference, - bboxes=[BBox(0, 0, 0.2, 0.1), BBox(0.2, 0, 0.8, 0.1), BBox(0.8, 0, 1, 0.1)], + bboxes=([BBox(0, 0, 0.2, 0.1), BBox(0.2, 0, 0.8, 0.1), BBox(0.8, 0, 1, 0.1)] if bboxes is None else bboxes), contents=["prefix ", "https://doi.org/10.1/example ", "suffix"], - types=types or [LayoutType.Text] * 3, + types=types, ) - if not types: - # Simulate a TextualResult produced by an older version without inline types. - item.types = [] return item @@ -42,11 +35,90 @@ def test_clean_flatten_pages_filters_types_with_doi_content() -> None: assert cleaned.types == [LayoutType.Text, LayoutType.Molecule] -def test_clean_flatten_pages_accepts_empty_legacy_types() -> None: - item = _textual_result(types=[], textual_result_cls=LegacyTextualResult) +def test_clean_flatten_pages_preserves_text_when_bboxes_are_empty() -> None: + item = _textual_result( + types=[LayoutType.Text, LayoutType.Text, LayoutType.Molecule], + bboxes=[], + ) cleaned = clean_flatten_pages([[item]])[0][0] + assert cleaned.text == "prefix suffix" assert cleaned.contents == ["prefix ", "suffix"] - assert cleaned.bboxes == [BBox(0, 0, 0.2, 0.1), BBox(0.8, 0, 1, 0.1)] - assert cleaned.types == [] + assert cleaned.bboxes == [] + assert cleaned.types == [LayoutType.Text, LayoutType.Molecule] + + +def test_clean_flatten_pages_supports_legacy_text_only_result() -> None: + text = "Smith et al. https://doi.org/10.1/example Nature 2020." + item = TextualResult( + token="token", + page=0, + block=1, + bbox=BBox(0, 0, 1, 1), + conf=1.0, + page_size=(100, 100), + type=LayoutType.Reference, + text=text, + ) + + cleaned = clean_flatten_pages([[item]])[0][0] + + assert cleaned.text == text + assert cleaned.contents == [text] + assert cleaned.bboxes == [] + assert cleaned.types == [LayoutType.Text] + + +def test_clean_flatten_pages_supports_structured_contents_without_bboxes() -> None: + contents = [ + "在软件管理的领域里存在着被称作“依赖地狱”的死亡之谷。 ", + r"\frac{dy}{dx}=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a}", + " 开放源码软件所广泛使用的 ", + r"c^{2}=a^{2}+b^{2}-2ab\cos C", + " 公共 not html literal @@MATH_TOKEN_0@@ and ", + r"x^{2}", + " ; literal @@CODE_TOKEN_0@@ and `code`", + ] + types = [ + LayoutType.Text, + LayoutType.EquationInline, + LayoutType.Text, + LayoutType.EquationInline, + LayoutType.Text, + LayoutType.EquationInline, + LayoutType.Text, + ] + rich_text = ( + "在软件管理的领域里存在着被称作“依赖地狱”的死亡之谷。 " + r"\(\frac{dy}{dx}=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a}\)" + " 开放源码软件所广泛使用的 " + r"\(c^{2}=a^{2}+b^{2}-2ab\cos C\)" + " 公共 not html literal @@MATH_TOKEN_0@@ and " + r"\(x^{2}\)" + " ; literal @@CODE_TOKEN_0@@ and `code`" + ) + item = TextualResult( + token="token", + page=0, + block=1, + bbox=BBox(0, 0, 1, 1), + conf=1.0, + page_size=(100, 100), + type=LayoutType.Paragraph, + bboxes=[], + contents=contents, + types=types, + text=rich_text, + ) + + cleaned = clean_flatten_pages([[item]])[0][0] + + assert cleaned.contents == contents + assert cleaned.bboxes == [] + assert cleaned.types == types + assert r"\(\frac{dy}{dx}=\frac{-b\pm\sqrt{b^{2}-4ac}}{2a}\)" in cleaned.text + assert r"\(c^{2}=a^{2}+b^{2}-2ab\cos C\)" in cleaned.text + assert "not html" in cleaned.text + assert "@@MATH_TOKEN_0@@" in cleaned.text + assert "@@CODE_TOKEN_0@@ and `code`" in cleaned.text diff --git a/uniparser_tools/tools/caption_extraction/main.py b/uniparser_tools/tools/caption_extraction/main.py index 1e027f8..078a575 100644 --- a/uniparser_tools/tools/caption_extraction/main.py +++ b/uniparser_tools/tools/caption_extraction/main.py @@ -185,20 +185,23 @@ def clean_flatten_pages(pages: List[List[SemanticItem]]): if "doi:10." in item.plain.lower() or "https://doi.org/" in item.plain.lower(): if item.contents: new_bboxes, new_contents, new_types = [], [], [] + item_bboxes = getattr(item, "bboxes", None) item_types = getattr(item, "types", None) - for idx, (bbox, line) in enumerate(zip(item.bboxes, item.contents)): + for idx, line in enumerate(item.contents): if line.lower().startswith("doi:10.") or line.lower().startswith("https://doi.org/"): pass else: - new_bboxes.append(bbox) new_contents.append(line) + if item_bboxes: + new_bboxes.append(item_bboxes[idx]) if item_types: new_types.append(item_types[idx]) - item.bboxes = new_bboxes item.contents = new_contents + if item_bboxes: + item.bboxes = new_bboxes if item_types: item.types = new_types - item.text = "".join(item.contents) + item.text = item.plain else: item.text = re.sub(r"(?:doi:|https://doi\.org/)10\.\S+", "", item.text, flags=re.I) item.text = item.text.replace("F IGU R E", "FIGURE") # preprint_ck_2311.14410v2 diff --git a/uniparser_tools/utils/templates/visualize_together.html b/uniparser_tools/utils/templates/visualize_together.html index 5e25f26..95181a7 100644 --- a/uniparser_tools/utils/templates/visualize_together.html +++ b/uniparser_tools/utils/templates/visualize_together.html @@ -187,7 +187,6 @@ /* 分页缩略图调整 */ .page-thumb { width: 75px; - height: 95px; background: #fff; cursor: pointer; border: 1px solid #cbd5e1; @@ -203,6 +202,9 @@ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); position: relative; user-select: none; + padding: 4px; + gap: 2px; + overflow: hidden; } .page-thumb:hover { From 293671cc5de432d60a55dc1f56af01d144dc236c Mon Sep 17 00:00:00 2001 From: taohaoyi Date: Thu, 20 Aug 2026 06:18:32 +0000 Subject: [PATCH 3/4] fix: align rc.4 formatting and ordering --- tests/unit/test_dataclass_results.py | 44 ++++++++++++- tests/unit/test_format_utils.py | 12 ++++ tests/unit/test_structure_order.py | 78 ++++++++++++++++++++++++ uniparser_tools/common/dataclass.py | 20 +++--- uniparser_tools/order/structure_order.py | 74 ++++++++++++++++------ uniparser_tools/utils/format_utils.py | 13 +--- 6 files changed, 201 insertions(+), 40 deletions(-) create mode 100644 tests/unit/test_format_utils.py create mode 100644 tests/unit/test_structure_order.py diff --git a/tests/unit/test_dataclass_results.py b/tests/unit/test_dataclass_results.py index ec00b39..bf073a8 100644 --- a/tests/unit/test_dataclass_results.py +++ b/tests/unit/test_dataclass_results.py @@ -84,10 +84,36 @@ def test_title_markdown_gets_heading(self) -> None: r = TextualResult(**kw, bboxes=[], contents=[], text="Chapter 1") assert r.markdown == "## Chapter 1" - def test_title_html_uses_h2(self) -> None: + def test_title_html_matches_server_tag(self) -> None: kw = {**ITEM_KWARGS, "type": LayoutType.Title} r = TextualResult(**kw, bboxes=[], contents=[], text="Chapter 1") - assert r.html == '

Chapter 1

' + assert r.html == "

Chapter 1

" + + @pytest.mark.parametrize( + ("item_type", "expected"), + [ + (LayoutType.Caption, "content"), + (LayoutType.PageNote, ""), + (LayoutType.Algorithm, "
content
"), + (LayoutType.DocumentTitle, "

content

"), + ], + ) + def test_html_tags_match_server_output(self, item_type: LayoutType, expected: str) -> None: + kw = {**ITEM_KWARGS, "type": item_type} + r = TextualResult(**kw, bboxes=[], contents=["content"], types=[LayoutType.Text]) + + assert r.html == expected + + def test_algorithm_markdown_preserves_raw_contents(self) -> None: + kw = {**ITEM_KWARGS, "type": LayoutType.Algorithm} + r = TextualResult( + **kw, + bboxes=[], + contents=["print(", "x^2", ")"], + types=[LayoutType.Text, LayoutType.EquationInline, LayoutType.Text], + ) + + assert r.markdown == "```\nprint(x^2)\n```" def test_document_title_latex_uses_title_macro(self) -> None: kw = {**ITEM_KWARGS, "type": LayoutType.DocumentTitle} @@ -154,6 +180,20 @@ def test_markdown_and_latex_are_empty_for_unusable_tables(self, structure: str) assert r.markdown == "" assert r.latex == "" + def test_format_failure_does_not_fall_back_to_plain_dataframe(self, monkeypatch) -> None: + payload = make_tabular_payload(structure="
", placeholders=[], contents=[]) + r = TabularResult(**payload) + + def format_sensitive_df(item_format: FormatFlag) -> pd.DataFrame: + if item_format == FormatFlag.Plain: + return pd.DataFrame({"plain": ["content"]}) + raise ValueError("malformed formatted table") + + monkeypatch.setattr(r, "_df_for_format", format_sensitive_df) + + assert r.markdown == "" + assert r.latex == "" + def test_latex_returns_tabular_env(self, simple_tabular: TabularResult) -> None: latex = simple_tabular.latex assert "\\begin{tabular}" in latex diff --git a/tests/unit/test_format_utils.py b/tests/unit/test_format_utils.py new file mode 100644 index 0000000..faeb63b --- /dev/null +++ b/tests/unit/test_format_utils.py @@ -0,0 +1,12 @@ +from uniparser_tools.common.constant import LayoutType +from uniparser_tools.utils.format_utils import parse_inline_text + + +def test_parse_inline_text_keeps_code_markup_as_text() -> None: + text = "Use CCO and `NCC` as code." + + assert parse_inline_text(text) == ([text], [LayoutType.Text]) + + +def test_parse_inline_text_still_recognizes_explicit_molecule_markup() -> None: + assert parse_inline_text("***CCO***") == (["CCO"], [LayoutType.Molecule]) diff --git a/tests/unit/test_structure_order.py b/tests/unit/test_structure_order.py new file mode 100644 index 0000000..1ff9234 --- /dev/null +++ b/tests/unit/test_structure_order.py @@ -0,0 +1,78 @@ +import logging + +from uniparser_tools.common.constant import LayoutType, OrderingMethod +from uniparser_tools.common.dataclass import BBox, GroupedResult, LayoutItem +from uniparser_tools.order import structure_order + + +def _item(block: int, item_type: LayoutType, bbox: BBox) -> LayoutItem: + return LayoutItem( + token="token", + page=0, + block=block, + bbox=bbox, + conf=1.0, + page_size=(100, 100), + type=item_type, + ) + + +def test_build_page_tree_uses_xy_cut_exp_for_group_children(monkeypatch) -> None: + parent = _item(0, LayoutType.Group, BBox(0, 0, 1, 1)) + child = _item(1, LayoutType.Image, BBox(0.1, 0.1, 0.2, 0.2)) + calls = [] + + def capture_sort(self, items, method="xy_cut", reversed=False, **kwargs): + calls.append((method, reversed, kwargs)) + return items, list(range(len(items))) + + monkeypatch.setattr(structure_order.StructureOrder, "sort", capture_sort) + + structure_order.build_page_tree([parent, child]) + + assert calls == [ + ( + OrderingMethod.XYCutExp, + False, + {"line_height": 1, "primary": "x", "sequential": False}, + ) + ] + + +def test_build_page_tree_promotes_figure_parent_to_figure_group() -> None: + parent = _item(0, LayoutType.Figure, BBox(0, 0, 1, 1)) + child = _item(1, LayoutType.Image, BBox(0.1, 0.1, 0.2, 0.2)) + + result = structure_order.build_page_tree([parent, child])[0] + + assert isinstance(result, GroupedResult) + assert result.type == LayoutType.FigureGroup + assert result.method == "grouped-2" + assert result.items == [parent, child] + + +def test_ensure_unique_item_ids_warns_for_nested_duplicates(caplog) -> None: + first = _item(1, LayoutType.Image, BBox(0, 0, 0.4, 0.4)) + duplicate = _item(1, LayoutType.Image, BBox(0.5, 0.5, 0.9, 0.9)) + group = GroupedResult.clone( + first, + type=LayoutType.Group, + items=[first, duplicate], + ) + + with caplog.at_level(logging.WARNING): + structure_order.ensure_unique_item_ids([group], "test") + + assert "Duplicate item id 0_1_image found during test" in caplog.text + + +def test_intra_page_sorting_runs_final_unique_id_check(monkeypatch) -> None: + calls = [] + monkeypatch.setattr( + structure_order, + "ensure_unique_item_ids", + lambda items, stage: calls.append((items, stage)), + ) + + assert structure_order.intra_page_sorting([[]], OrderingMethod.XYCutExp) == [[]] + assert calls == [([], "intra_page_sorting final check")] diff --git a/uniparser_tools/common/dataclass.py b/uniparser_tools/common/dataclass.py index 158d067..223e946 100644 --- a/uniparser_tools/common/dataclass.py +++ b/uniparser_tools/common/dataclass.py @@ -727,6 +727,10 @@ def markup(self): @property def markdown(self): + if self.type == LayoutType.Algorithm: + body = "".join(self.contents) if self.contents else self.text + return f"```\n{body.rstrip()}\n```" + plain = self._inline_text(FormatFlag.Markdown) if self.type in [LayoutType.Paragraph, LayoutType.Text, LayoutType.Description]: return plain @@ -750,9 +754,6 @@ def markdown(self): LayoutType.PageNote, ]: return f"*{plain}*" - elif self.type == LayoutType.Algorithm: - body = plain.rstrip("\n") - return f"```\n{body}\n```" elif self.type == LayoutType.Title: return f"## {plain}" elif self.type == LayoutType.DocumentTitle: @@ -808,7 +809,6 @@ def latex(self): @property def html(self): plain = self._inline_text(FormatFlag.Html) - type_cls = getattr(self.type, "value", "text") if self.type in [LayoutType.Paragraph, LayoutType.Text, LayoutType.Description]: return f"

{plain}

" elif self.type == LayoutType.Legend: @@ -823,20 +823,20 @@ def html(self): LayoutType.AlgorithmCaption, LayoutType.ExpressionCaption, ]: - return f'{plain}' + return f"{plain}" elif self.type in [ LayoutType.AlgorithmFootnote, LayoutType.ImageFootnote, LayoutType.TableFootnote, LayoutType.PageNote, ]: - return f'' + return f"" elif self.type == LayoutType.Algorithm: - return f'
{plain}
' + return f"
{plain}
" elif self.type == LayoutType.Title: - return f'

{plain}

' + return f"

{plain}

" elif self.type == LayoutType.DocumentTitle: - return f'

{plain}

' + return f"

{plain}

" else: return f"

{plain}

" @@ -1136,7 +1136,7 @@ def _safe_df_for_format(self, item_format: FormatFlag) -> pd.DataFrame: f"{self.token} {self.page} {self.block} {self.type} " f"convert html to {item_format} dataframe error: {repr(self.structure)}" ) - return self.df + return pd.DataFrame() @functools.cached_property def df(self) -> pd.DataFrame: diff --git a/uniparser_tools/order/structure_order.py b/uniparser_tools/order/structure_order.py index 5534b43..058879a 100644 --- a/uniparser_tools/order/structure_order.py +++ b/uniparser_tools/order/structure_order.py @@ -88,6 +88,28 @@ def count_items(item: SemanticItem) -> int: return 1 +def ensure_unique_item_ids(items: List[SemanticItem], stage: str) -> None: + """Warn about duplicate ``page_block_type`` values in a nested item tree.""" + seen = {} + + def visit(item: SemanticItem, path: str) -> None: + item_id = (item.page, item.block, item.type) + previous_path = seen.get(item_id) + if previous_path is not None: + readable_id = f"{item.page}_{item.block}_{item.type.value}" + get_root_logger().warning( + f"Duplicate item id {readable_id} found during {stage}: {previous_path} and {path}" + ) + else: + seen[item_id] = path + if isinstance(item, GroupedResult): + for child_idx, child in enumerate(item.items): + visit(child, f"{path}.items[{child_idx}]") + + for item_idx, item in enumerate(items): + visit(item, f"items[{item_idx}]") + + def build_page_tree( page: List[SemanticItem], thresh: float = 0.95, merge_group: bool = False, flat: bool = True ) -> List[SemanticItem]: @@ -161,13 +183,22 @@ def build_node(node, level=1): if node["children"]: # 递归处理每个子节点 children = [build_node(child, level + 1) for child in node["children"]] - reversed = self_item.type != LayoutType.Group - sorted_children, _ = StructureOrder().sort( - children, - method=OrderingMethod.XYCut, - reversed=reversed, - line_height=1, - ) + if self_item.type == LayoutType.Group: + sorted_children, _ = StructureOrder().sort( + children, + method=OrderingMethod.XYCutExp, + reversed=False, + line_height=1, + primary="x", + sequential=False, + ) + else: + sorted_children, _ = StructureOrder().sort( + children, + method=OrderingMethod.XYCut, + reversed=True, + line_height=1, + ) bbox = deepcopy(self_item.bbox) if len(sorted_children) >= 2 and merge_group: union_bbox = None @@ -182,18 +213,23 @@ def build_node(node, level=1): (self_item.bbox * self_item.page_size).shrink(allowd_pixel, [-1, -1]) / self_item.page_size ).union(union_bbox): bbox = union_bbox - if isinstance(self_item, LayoutItem): + if self_item.type in [ + LayoutTypeTop.Figure, + LayoutTypeTop.Expression, + LayoutTypeTop.Chart, + ]: + return GroupedResult.clone( + self_item, + type=LayoutTypeTop.FigureGroup, + bbox=bbox, + items=[self_item, *sorted_children], + level=level, + method="grouped-2", + ) + elif isinstance(self_item, LayoutItem): return GroupedResult.clone(self_item, bbox=bbox, items=sorted_children, level=level) else: - if self_item.type in [ - LayoutTypeTop.Figure, - LayoutTypeTop.Expression, - LayoutTypeTop.Chart, - ]: - return GroupedResult.clone( - self_item, type=LayoutTypeTop.FigureGroup, bbox=bbox, items=[self_item, *sorted_children] - ) - elif self_item.type in [LayoutTypeBot.Image]: + if self_item.type in [LayoutTypeBot.Image]: return GroupedResult.clone( self_item, type=LayoutTypeBot.Group, bbox=bbox, items=[self_item, *sorted_children] ) @@ -433,4 +469,8 @@ def intra_page_sorting(ordered_pages: List[List[SemanticItem]], default_method: sorted_all_items = sorted_main_items + sorted_margin_items set_item_order(sorted_all_items) ordered_pages[page_id] = sorted_all_items + ensure_unique_item_ids( + [item for page in ordered_pages for item in page], + "intra_page_sorting final check", + ) return ordered_pages diff --git a/uniparser_tools/utils/format_utils.py b/uniparser_tools/utils/format_utils.py index b194861..0937c06 100644 --- a/uniparser_tools/utils/format_utils.py +++ b/uniparser_tools/utils/format_utils.py @@ -17,8 +17,6 @@ def parse_inline_text(text: str) -> Tuple[List[str], List[LayoutType]]: latex_marker = re.compile(r"\\[A-Za-z]+|[\^_{}=]|[A-Za-z]\s*[+\-*/=<>]\s*[A-Za-z0-9\\]") pattern = re.compile( r"(?P\*\*\*(?P.+?)\*\*\*)|" - r"(?P(?P.+?))|" - r"(?P`(?P.+?)`)|" r"(?P" r"\\\[(?P.+?)\\\]|" r"\\\((?P.+?)\\\)|" @@ -35,15 +33,8 @@ def parse_inline_text(text: str) -> Tuple[List[str], List[LayoutType]]: contents.append(text[pos : match.start()]) types.append(LayoutType.Text) - if ( - match.group("molecule") is not None - or match.group("code_molecule") is not None - or match.group("tick_molecule") is not None - ): - molecule_text = ( - match.group("molecule_text") or match.group("code_molecule_text") or match.group("tick_molecule_text") - ) - contents.append(molecule_text.strip()) + if match.group("molecule") is not None: + contents.append(match.group("molecule_text").strip()) types.append(LayoutType.Molecule) else: equation_text = ( From 14f246ca2c3888241455ea90f14260eda3849edb Mon Sep 17 00:00:00 2001 From: taohaoyi Date: Thu, 20 Aug 2026 10:02:15 +0000 Subject: [PATCH 4/4] fix: align rc.4 tree grouping metadata --- tests/unit/test_structure_order.py | 72 +++++++++++++++++++++++- uniparser_tools/order/structure_order.py | 35 +++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/tests/unit/test_structure_order.py b/tests/unit/test_structure_order.py index 1ff9234..6c9a7a4 100644 --- a/tests/unit/test_structure_order.py +++ b/tests/unit/test_structure_order.py @@ -1,7 +1,7 @@ import logging from uniparser_tools.common.constant import LayoutType, OrderingMethod -from uniparser_tools.common.dataclass import BBox, GroupedResult, LayoutItem +from uniparser_tools.common.dataclass import BBox, GroupedResult, LayoutItem, SemanticItem from uniparser_tools.order import structure_order @@ -17,6 +17,18 @@ def _item(block: int, item_type: LayoutType, bbox: BBox) -> LayoutItem: ) +def _semantic_item(block: int, item_type: LayoutType, bbox: BBox) -> SemanticItem: + return SemanticItem( + token="token", + page=0, + block=block, + bbox=bbox, + conf=1.0, + page_size=(100, 100), + type=item_type, + ) + + def test_build_page_tree_uses_xy_cut_exp_for_group_children(monkeypatch) -> None: parent = _item(0, LayoutType.Group, BBox(0, 0, 1, 1)) child = _item(1, LayoutType.Image, BBox(0.1, 0.1, 0.2, 0.2)) @@ -51,6 +63,64 @@ def test_build_page_tree_promotes_figure_parent_to_figure_group() -> None: assert result.items == [parent, child] +def test_build_page_tree_merges_existing_grouped_result_children() -> None: + existing = _item(1, LayoutType.Figure, BBox(0.1, 0.1, 0.2, 0.2)) + parent = GroupedResult( + token="token", + page=0, + block=0, + bbox=BBox(0, 0, 1, 1), + conf=1.0, + page_size=(100, 100), + type=LayoutType.Image, + items=[existing], + ) + child = _item(2, LayoutType.HLine, BBox(0.3, 0.3, 0.4, 0.4)) + + result = structure_order.build_page_tree([parent, child])[0] + + assert result.method == "default" + assert result.level == 1 + assert result.items == [existing, child] + assert parent not in result.items + + +def test_build_page_tree_preserves_nested_image_group_metadata() -> None: + root = _item(0, LayoutType.Group, BBox(0, 0, 1, 1)) + image = _semantic_item(1, LayoutType.Image, BBox(0.1, 0.1, 0.9, 0.9)) + child = _item(2, LayoutType.HLine, BBox(0.2, 0.2, 0.3, 0.3)) + + result = structure_order.build_page_tree([root, image, child])[0] + nested_image = result.items[0] + + assert result.method == "grouped-1" + assert isinstance(nested_image, GroupedResult) + assert nested_image.type == LayoutType.Group + assert nested_image.level == 2 + assert nested_image.method == "grouped-3" + assert nested_image.items == [image, child] + + +def test_build_page_tree_uses_unique_group_type_for_synthetic_container() -> None: + root = _item(0, LayoutType.Group, BBox(0, 0, 1, 1)) + molecule_group = _semantic_item(1, LayoutType.MoleculeGroup, BBox(0.1, 0.1, 0.9, 0.9)) + child = _item(2, LayoutType.HLine, BBox(0.2, 0.2, 0.3, 0.3)) + + result = structure_order.build_page_tree([root, molecule_group, child])[0] + synthetic_group = result.items[0] + + assert isinstance(synthetic_group, GroupedResult) + assert synthetic_group.type == LayoutType.Group + assert synthetic_group.level == 2 + assert synthetic_group.method == "grouped-4" + assert synthetic_group.items == [molecule_group, child] + assert (synthetic_group.page, synthetic_group.block, synthetic_group.type) != ( + molecule_group.page, + molecule_group.block, + molecule_group.type, + ) + + def test_ensure_unique_item_ids_warns_for_nested_duplicates(caplog) -> None: first = _item(1, LayoutType.Image, BBox(0, 0, 0.4, 0.4)) duplicate = _item(1, LayoutType.Image, BBox(0.5, 0.5, 0.9, 0.9)) diff --git a/uniparser_tools/order/structure_order.py b/uniparser_tools/order/structure_order.py index 058879a..c9fdf17 100644 --- a/uniparser_tools/order/structure_order.py +++ b/uniparser_tools/order/structure_order.py @@ -227,14 +227,43 @@ def build_node(node, level=1): method="grouped-2", ) elif isinstance(self_item, LayoutItem): - return GroupedResult.clone(self_item, bbox=bbox, items=sorted_children, level=level) + return GroupedResult.clone( + self_item, + bbox=bbox, + items=sorted_children, + level=level, + method="grouped-1", + ) + elif isinstance(self_item, GroupedResult): + return GroupedResult.clone( + self_item, + bbox=bbox, + items=[*self_item.items, *sorted_children], + level=level, + method="default", + ) else: if self_item.type in [LayoutTypeBot.Image]: return GroupedResult.clone( - self_item, type=LayoutTypeBot.Group, bbox=bbox, items=[self_item, *sorted_children] + self_item, + type=LayoutTypeBot.Group, + bbox=bbox, + items=[self_item, *sorted_children], + level=level, + method="grouped-3", ) else: - return GroupedResult.clone(self_item, bbox=bbox, items=[self_item, *sorted_children]) + # This is a synthetic container around ``self_item``. Keeping + # its original type would duplicate page_block_type between + # the parent and the first child. + return GroupedResult.clone( + self_item, + type=LayoutType.Group, + bbox=bbox, + items=[self_item, *sorted_children], + level=level, + method="grouped-4", + ) else: return self_item