From 9c08223154c5cb82a53313f8dd1ff5a8e34bc16e Mon Sep 17 00:00:00 2001 From: liurui Date: Thu, 10 Sep 2026 15:23:38 +0800 Subject: [PATCH 1/2] refactor(render): add structured render result channel --- fastcdm/core.py | 14 ++++++++++---- fastcdm/render/render_worker.py | 23 +++++++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/fastcdm/core.py b/fastcdm/core.py index f986084..7c1d56b 100644 --- a/fastcdm/core.py +++ b/fastcdm/core.py @@ -1,4 +1,4 @@ -from fastcdm.render.render_worker import RenderWorker +from fastcdm.render.render_worker import RenderResult, RenderWorker from fastcdm.matcher import update_inliers, HungarianMatcher, SimpleAffineTransform from fastcdm.clean import ( clean, @@ -291,17 +291,23 @@ def render(self, latex_list: list) -> list: latex_strings = [ f"$${s}$$" if not s.startswith("$$") else s for s in latex_list ] - imgs = self.render_worker.render(latex_strings) + results = self.render_worker.render(latex_strings) except Exception as e: print("Rendering failed:") print("=" * 30) print(traceback.format_exc()) return [] - assert len(imgs) == len( + assert len(results) == len( latex_strings ), "Number of rendered images must match number of input strings" - return imgs + return [result.image for result in results] + + def render_results(self, latex_list: list) -> List[RenderResult]: + latex_strings = [ + f"$${s}$$" if not s.startswith("$$") else s for s in latex_list + ] + return self.render_worker.render(latex_strings) def compute(self, gt: str, pred: str, visualize: bool = False) -> tuple: """ diff --git a/fastcdm/render/render_worker.py b/fastcdm/render/render_worker.py index 111a586..3d35dff 100644 --- a/fastcdm/render/render_worker.py +++ b/fastcdm/render/render_worker.py @@ -2,7 +2,8 @@ import cv2 import random import numpy as np -from typing import List +from dataclasses import dataclass +from typing import List, Optional from selenium import webdriver from selenium.webdriver.chrome.service import Service as ChromeService @@ -13,6 +14,16 @@ from webdriver_manager.chrome import ChromeDriverManager +@dataclass +class RenderResult: + image: Optional[np.ndarray] + error: bool + error_text: Optional[str] + width: int + height: int + error_type: Optional[str] = None + + class RenderWorker: """ 一个使用 Selenium Headless Chrome 渲染HTML内容的工具类。 @@ -76,7 +87,7 @@ def __init__(self, template_file: str, timeout: int = 15, driver_path: str = Non EC.presence_of_all_elements_located((By.ID, "container")) ) - def render(self, contents: List[str]) -> List[np.ndarray]: + def render(self, contents: List[str]) -> List[RenderResult]: """ 渲染一组内容并返回每个元素的截图。 """ @@ -113,13 +124,13 @@ def render(self, contents: List[str]) -> List[np.ndarray]: # 获取每个渲染元素的边界框 rects = self.get_rects() - cropped_imgs = [] + results = [] img_h, img_w = fullpage_img.shape[:2] # 根据边界框裁剪出每个元素的图像 for rect in rects: if rect is None: - cropped_imgs.append(None) + results.append(RenderResult(None, True, "Invalid capture rectangle", 0, 0, "invalid_capture")) else: x, y, w, h = rect # 计算一个小的随机边距,让截图更自然 @@ -132,9 +143,9 @@ def render(self, contents: List[str]) -> List[np.ndarray]: y2 = min(img_h, y + h + border_size) cropped = fullpage_img[y1:y2, x1:x2] - cropped_imgs.append(cropped) + results.append(RenderResult(cropped, cropped.size == 0, "Empty cropped image" if cropped.size == 0 else None, w, h, "empty_image" if cropped.size == 0 else None)) - return cropped_imgs + return results def get_rects(self) -> list: """ From f60a218a7a7f5540546cb888eebe4b0a1fa61d08 Mon Sep 17 00:00:00 2001 From: liurui Date: Tue, 8 Sep 2026 15:19:22 +0800 Subject: [PATCH 2/2] fix(tokenize): validate and preserve normalized output --- fastcdm/tokenize.py | 50 ++++++++++++++++---- fastcdm/tokenize_latex/preprocess_formula.js | 8 +++- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/fastcdm/tokenize.py b/fastcdm/tokenize.py index 55b7d05..20e5f18 100644 --- a/fastcdm/tokenize.py +++ b/fastcdm/tokenize.py @@ -2,7 +2,10 @@ import subprocess from pathlib import Path from typing import Tuple -import sys + + +class CDMPreprocessingError(RuntimeError): + """Raised when formula normalization fails or produces unusable output.""" IMPLICIT_MULTIPLICATION_TARGETS = [ @@ -124,6 +127,7 @@ "partial", "nabla", "int", + "limits", "oint", "sum", "prod", @@ -198,9 +202,12 @@ PATTERN_LATEX_CMD_CONCAT_CMD = re.compile( r"\\(" + TARGETS_PATTERN + r")" + r"(\\[a-zA-Z])" ) -PATTERN_LATEX_CMD_CONCAT_TEXT = re.compile(r"\\(" + TARGETS_PATTERN + r")([a-zA-Z])") +PATTERN_LATEX_CMD_CONCAT_TEXT = re.compile( + r"\\(?!(?:" + TARGETS_PATTERN + r")(?![a-zA-Z]))" + r"(" + TARGETS_PATTERN + r")([a-zA-Z])" +) PATTERN_NON_CMD_IMPLICIT_MULT = re.compile( - r"\b(" + TARGETS_PATTERN + r")([a-zA-Z][a-zA-Z0-9]*)\b" + r"(? Tuple[bool, str]: +def _validate_normalized(source: str, normalized: str) -> None: + if source.strip() and not normalized.strip(): + raise CDMPreprocessingError("Tokenizer returned empty output for non-empty input") + if "[PROCESSING FAILED]" in normalized: + raise CDMPreprocessingError("Tokenizer returned a processing failure marker") + environments = [] + for match in re.finditer(r"\\(begin|end)\s*\{\s*([^{}]*?)\s*\}", normalized): + # A backslash escaped by another backslash is not a command start. + preceding = len(normalized[:match.start()]) - len(normalized[:match.start()].rstrip("\\")) + if preceding % 2: + continue + command, environment = match.groups() + if command == "begin": + environments.append(environment) + elif not environments or environments.pop() != environment: + raise CDMPreprocessingError("Tokenizer returned mismatched begin/end environments") + if environments: + raise CDMPreprocessingError("Tokenizer returned mismatched begin/end environments") + + +def tokenize(latex_code: str, timeout: float = 30.0) -> Tuple[bool, str]: if not latex_code: return True, "" @@ -280,13 +307,14 @@ def tokenize(latex_code: str) -> Tuple[bool, str]: text=True, check=True, encoding="utf-8", + timeout=timeout, ) normalized_latex = proc.stdout - except (subprocess.CalledProcessError, FileNotFoundError) as e: - print(f"执行 Node.js 脚本(公式)时出错:{e}", file=sys.stderr) - if hasattr(e, "stderr"): - print(f"Node.js stderr:{e.stderr}", file=sys.stderr) - return False, latex_code + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e: + detail = getattr(e, "stderr", None) or str(e) + raise CDMPreprocessingError(f"Node.js tokenizer failed: {detail}") from e + + _validate_normalized(latex_code, normalized_latex) names = [ "\\" + x.replace(" ", "") @@ -295,4 +323,6 @@ def tokenize(latex_code: str) -> Tuple[bool, str]: post = PATTERN_OPERATOR_NAME.sub( lambda match: str(names.pop(0)), normalized_latex ).replace(r"\\ \end{array}", r"\end{array}") - return True, post.strip() + post = post.strip() + _validate_normalized(latex_code, post) + return True, post diff --git a/fastcdm/tokenize_latex/preprocess_formula.js b/fastcdm/tokenize_latex/preprocess_formula.js index f73664c..14df487 100644 --- a/fastcdm/tokenize_latex/preprocess_formula.js +++ b/fastcdm/tokenize_latex/preprocess_formula.js @@ -62,7 +62,7 @@ rl.on('line', function(line){ console.error(line); console.error(norm_str); console.error(e); - console.log(); + process.exitCode = 1; } global_str = "" norm_str = "" @@ -256,7 +256,7 @@ groupTypes.spacing = function(group) { groupTypes.op = function(group) { var node; - if (group.value.symbol) { + if (group.value.symbol || group.value.alwaysHandleSupSub) { // 直接输出符号 norm_str = norm_str + group.value.body + " "; @@ -271,6 +271,10 @@ groupTypes.op = function(group) { } norm_str = norm_str + "} "; } + // The parser sets this flag only for an explicit limit control. + if (group.value.alwaysHandleSupSub) { + norm_str += group.value.limits ? "\\limits " : "\\nolimits "; + } }; groupTypes.katex = function(group) {