Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-CN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 本次更新 — AutoControl

## 本次更新 (2026-06-24) — OCR 行的段落与列表分组

把 OCR 行分组成段落,并检测项目符号 / 编号列表。完整参考:[`docs/source/Zh/doc/new_features/v174_features_doc.rst`](../docs/source/Zh/doc/new_features/v174_features_doc.rst)。

- **`group_paragraphs` / `detect_lists`**(`AC_group_paragraphs`、`AC_detect_lists`):`text_regions` 把字形并成行,但没有功能把那些行分组成段落或检测列表;`ocr/structure` 止于平面行。`group_paragraphs` 在垂直间距超过 `line_gap_factor` × 中位行高处开始新段落;`detect_lists` 识别项目符号(`•`/`-`/`*`)或序号(`1.`/`2)`/`a.`)项目,返回 `{text, marker, indent, box}`。纯标准库,作用于行字典;重用 `table_grid_fill` 的框读取器;不导入 `PySide6`。

## 本次更新 (2026-06-24) — 列感知阅读顺序(XY-Cut)

读多列布局时*完整读完*每一列,而非交错。完整参考:[`docs/source/Zh/doc/new_features/v173_features_doc.rst`](../docs/source/Zh/doc/new_features/v173_features_doc.rst)。
Expand Down
6 changes: 6 additions & 0 deletions README/WHATS_NEW_zh-TW.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# 本次更新 — AutoControl

## 本次更新 (2026-06-24) — OCR 行的段落與清單分組

把 OCR 行分組成段落,並偵測項目符號 / 編號清單。完整參考:[`docs/source/Zh/doc/new_features/v174_features_doc.rst`](../docs/source/Zh/doc/new_features/v174_features_doc.rst)。

- **`group_paragraphs` / `detect_lists`**(`AC_group_paragraphs`、`AC_detect_lists`):`text_regions` 把字形併成行,但沒有功能把那些行分組成段落或偵測清單;`ocr/structure` 止於平面列。`group_paragraphs` 在垂直間距超過 `line_gap_factor` × 中位行高處開始新段落;`detect_lists` 辨識項目符號(`•`/`-`/`*`)或序號(`1.`/`2)`/`a.`)項目,回傳 `{text, marker, indent, box}`。純標準函式庫,作用於行字典;重用 `table_grid_fill` 的框讀取器;不匯入 `PySide6`。

## 本次更新 (2026-06-24) — 欄位感知閱讀順序(XY-Cut)

讀多欄版面時*完整讀完*每一欄,而非交錯。完整參考:[`docs/source/Zh/doc/new_features/v173_features_doc.rst`](../docs/source/Zh/doc/new_features/v173_features_doc.rst)。
Expand Down
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# What's New — AutoControl

## What's new (2026-06-24) — Paragraph & List Grouping of OCR Lines

Group OCR lines into paragraphs and detect bulleted / numbered lists. Full reference: [`docs/source/Eng/doc/new_features/v174_features_doc.rst`](docs/source/Eng/doc/new_features/v174_features_doc.rst).

- **`group_paragraphs` / `detect_lists`** (`AC_group_paragraphs`, `AC_detect_lists`): `text_regions` merges glyphs into lines but nothing grouped those lines into paragraphs or detected lists; `ocr/structure` stops at flat rows. `group_paragraphs` starts a new paragraph wherever the vertical gap exceeds `line_gap_factor` × the median line height; `detect_lists` recognises bullet (`•`/`-`/`*`) or ordinal (`1.`/`2)`/`a.`) items, returning `{text, marker, indent, box}`. Pure-stdlib over line dicts; reuses `table_grid_fill`'s box reader; no `PySide6`.

## What's new (2026-06-24) — Column-Aware Reading Order (XY-Cut)

Read multi-column layouts down each column instead of interleaving them. Full reference: [`docs/source/Eng/doc/new_features/v173_features_doc.rst`](docs/source/Eng/doc/new_features/v173_features_doc.rst).
Expand Down
38 changes: 38 additions & 0 deletions docs/source/Eng/doc/new_features/v174_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
Paragraph & List Grouping of OCR Lines
======================================

``text_regions.find_text_lines`` merges glyphs into lines, but nothing groups those *lines*
into paragraphs or detects lists — ``ocr/structure`` stops at flat rows. ``text_blocks`` adds
that: ``group_paragraphs`` splits lines into paragraphs wherever the vertical gap exceeds a
multiple of the median line height (the standard whitespace-grouping heuristic), and
``detect_lists`` recognises bulleted / numbered items by their leading marker and left indent.

Pure-stdlib over plain line dicts (text + bbox); fully unit-testable with no image and no OCR
engine. Reuses ``table_grid_fill``'s box-bounds reader. Imports no ``PySide6``.

Headless API
------------

.. code-block:: python

from je_auto_control import group_paragraphs, detect_lists

for para in group_paragraphs(ocr_lines, line_gap_factor=1.6):
print(para["n_lines"], para["text"])

for item in detect_lists(ocr_lines):
print(item["marker"], item["indent"], item["text"])

``group_paragraphs`` returns paragraph dicts (``left`` / ``top`` / ``right`` / ``bottom`` /
``text`` / ``n_lines``) — a new paragraph begins when the gap from the previous line's bottom
exceeds ``line_gap_factor`` times the median line height. ``detect_lists`` returns the lines
whose text starts with a bullet (``•`` / ``-`` / ``*``) or an ordinal (``1.`` / ``2)`` /
``a.``), each as ``{text, marker, indent, box}`` (``indent`` is the left x, for nesting).

Executor commands
-----------------

``AC_group_paragraphs`` (``lines`` / ``line_gap_factor`` → ``{count, paragraphs}``) and
``AC_detect_lists`` (``lines`` → ``{count, items}``). They are exposed as the MCP tools
``ac_group_paragraphs`` / ``ac_detect_lists`` (read-only) and as the Script Builder commands
**Group Paragraphs** / **Detect Lists** under **OCR**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ Comprehensive guides for all AutoControl features.
doc/new_features/v171_features_doc
doc/new_features/v172_features_doc
doc/new_features/v173_features_doc
doc/new_features/v174_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
36 changes: 36 additions & 0 deletions docs/source/Zh/doc/new_features/v174_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
OCR 行的段落與清單分組
======================

``text_regions.find_text_lines`` 把字形併成行,但沒有任何功能把那些*行*分組成段落或偵測
清單——``ocr/structure`` 止於平面列。``text_blocks`` 補上這點:``group_paragraphs`` 在垂直
間距超過中位行高的某倍數處把行切成段落(標準的留白分組啟發法),而 ``detect_lists`` 以前導
標記與左縮排辨識項目符號 / 編號項目。

純標準函式庫,作用於純行字典(text + bbox);可在無影像、無 OCR 引擎下完整單元測試。重用
``table_grid_fill`` 的框邊界讀取器。不匯入 ``PySide6``。

無頭 API
--------

.. code-block:: python

from je_auto_control import group_paragraphs, detect_lists

for para in group_paragraphs(ocr_lines, line_gap_factor=1.6):
print(para["n_lines"], para["text"])

for item in detect_lists(ocr_lines):
print(item["marker"], item["indent"], item["text"])

``group_paragraphs`` 回傳段落字典(``left`` / ``top`` / ``right`` / ``bottom`` / ``text`` /
``n_lines``)——當與前一行底部的間距超過 ``line_gap_factor`` 乘以中位行高時,開始新段落。
``detect_lists`` 回傳文字以項目符號(``•`` / ``-`` / ``*``)或序號(``1.`` / ``2)`` /
``a.``)開頭的行,每個為 ``{text, marker, indent, box}``(``indent`` 為左 x,供巢狀用)。

執行器指令
----------

``AC_group_paragraphs``(``lines`` / ``line_gap_factor`` → ``{count, paragraphs}``)與
``AC_detect_lists``(``lines`` → ``{count, items}``)。兩者以 MCP 工具 ``ac_group_paragraphs`` /
``ac_detect_lists``(唯讀)及 Script Builder 指令 **Group Paragraphs** / **Detect Lists**
(位於 **OCR** 分類下)形式提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ AutoControl 所有功能的完整使用指南。
doc/new_features/v171_features_doc
doc/new_features/v172_features_doc
doc/new_features/v173_features_doc
doc/new_features/v174_features_doc
doc/ocr_backends/ocr_backends_doc
doc/observability/observability_doc
doc/operations_layer/operations_layer_doc
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@
from je_auto_control.utils.reading_flow import (
flow_order, to_blocks, xy_cut,
)
# Group OCR lines into paragraphs and bulleted / numbered lists
from je_auto_control.utils.text_blocks import (
detect_lists, group_paragraphs,
)
# Associate form labels with values (multi-direction) + checkbox state
from je_auto_control.utils.form_fields import (
associate_fields, checkbox_state, match_labels_to_widgets,
Expand Down Expand Up @@ -1279,6 +1283,8 @@ def start_autocontrol_gui(*args, **kwargs):
"xy_cut",
"flow_order",
"to_blocks",
"group_paragraphs",
"detect_lists",
"associate_fields",
"match_labels_to_widgets",
"checkbox_state",
Expand Down
20 changes: 20 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,26 @@ def _add_ocr_specs(specs: List[CommandSpec]) -> None:
),
description="Recursive XY-cut region tree (split at widest whitespace).",
))
specs.append(CommandSpec(
"AC_group_paragraphs", "OCR", "Group Paragraphs",
fields=(
FieldSpec("lines", FieldType.STRING,
placeholder='[{"x":0,"y":0,"width":200,"height":20,'
'"text":"line"}]'),
FieldSpec("line_gap_factor", FieldType.FLOAT, optional=True,
default=1.6),
),
description="Group OCR lines into paragraphs by vertical spacing.",
))
specs.append(CommandSpec(
"AC_detect_lists", "OCR", "Detect Lists",
fields=(
FieldSpec("lines", FieldType.STRING,
placeholder='[{"x":0,"y":0,"width":200,"height":20,'
'"text":"* item"}]'),
),
description="Detect bulleted / numbered list items among OCR lines.",
))
specs.append(CommandSpec(
"AC_scroll_to_find", "OCR", "Scroll Until Visible",
fields=(
Expand Down
22 changes: 22 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3517,6 +3517,26 @@ def _xy_cut(boxes: Any, min_gap: Any = 12) -> Dict[str, Any]:
return {"tree": xy_cut(boxes, min_gap=int(min_gap))}


def _group_paragraphs(lines: Any, line_gap_factor: Any = 1.6) -> Dict[str, Any]:
"""Adapter: group OCR lines into paragraphs by vertical spacing."""
import json
from je_auto_control.utils.text_blocks import group_paragraphs
if isinstance(lines, str):
lines = json.loads(lines)
paragraphs = group_paragraphs(lines, line_gap_factor=float(line_gap_factor))
return {"count": len(paragraphs), "paragraphs": paragraphs}


def _detect_lists(lines: Any) -> Dict[str, Any]:
"""Adapter: detect bulleted / numbered list items among OCR lines."""
import json
from je_auto_control.utils.text_blocks import detect_lists
if isinstance(lines, str):
lines = json.loads(lines)
items = detect_lists(lines)
return {"count": len(items), "items": items}


def _find_color_region(rgb: Any, tolerance: Any = 20, min_area: Any = 50,
region: Any = None) -> Dict[str, Any]:
"""Adapter: locate coloured regions on the screen, largest first."""
Expand Down Expand Up @@ -6054,6 +6074,8 @@ def __init__(self):
"AC_match_labels_to_widgets": _match_labels_to_widgets,
"AC_flow_order": _flow_order,
"AC_xy_cut": _xy_cut,
"AC_group_paragraphs": _group_paragraphs,
"AC_detect_lists": _detect_lists,
"AC_ssim_compare": _ssim_compare,
"AC_ssim_changed_regions": _ssim_changed_regions,
"AC_feature_match": _feature_match,
Expand Down
25 changes: 25 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -3950,6 +3950,31 @@ def screen_grid_tools() -> List[MCPTool]:
handler=h.xy_cut,
annotations=READ_ONLY,
),
MCPTool(
name="ac_group_paragraphs",
description=("Group OCR 'lines' (text + bbox) into paragraphs: a new "
"paragraph starts where the vertical gap exceeds "
"'line_gap_factor' x the median line height. Returns {count, "
"paragraphs:[{left,top,right,bottom,text,n_lines}]}."),
input_schema=schema({
"lines": {"type": "array", "items": {"type": "object"}},
"line_gap_factor": {"type": "number"}},
required=["lines"]),
handler=h.group_paragraphs,
annotations=READ_ONLY,
),
MCPTool(
name="ac_detect_lists",
description=("Detect bulleted / numbered list items among OCR 'lines' by "
"their leading marker (•/-/* or 1./2)/a.). Returns {count, "
"items:[{text,marker,indent,box}]}; 'indent' is the left x "
"for nesting."),
input_schema=schema({
"lines": {"type": "array", "items": {"type": "object"}}},
required=["lines"]),
handler=h.detect_lists,
annotations=READ_ONLY,
),
]


Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2195,6 +2195,16 @@ def xy_cut(boxes, min_gap=12):
return _xy_cut(boxes, min_gap)


def group_paragraphs(lines, line_gap_factor=1.6):
from je_auto_control.utils.executor.action_executor import _group_paragraphs
return _group_paragraphs(lines, line_gap_factor)


def detect_lists(lines):
from je_auto_control.utils.executor.action_executor import _detect_lists
return _detect_lists(lines)


def find_color_region(rgb, tolerance=20, min_area=50, region=None):
from je_auto_control.utils.executor.action_executor import (
_find_color_region)
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/text_blocks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Group OCR lines into paragraphs and bulleted / numbered lists."""
from je_auto_control.utils.text_blocks.text_blocks import (
detect_lists, group_paragraphs,
)

__all__ = ["group_paragraphs", "detect_lists"]
82 changes: 82 additions & 0 deletions je_auto_control/utils/text_blocks/text_blocks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Group OCR lines into paragraphs and bulleted / numbered lists.

``text_regions.find_text_lines`` merges glyphs into lines, but nothing groups those *lines*
into paragraphs or detects lists — ``ocr/structure`` stops at flat rows. ``text_blocks`` adds
that: ``group_paragraphs`` splits lines into paragraphs wherever the vertical gap exceeds a
multiple of the median line height (the standard whitespace-grouping heuristic), and
``detect_lists`` recognises bulleted / numbered items by their leading marker and left indent.

Pure-stdlib over plain line dicts (text + bbox); fully unit-testable with no image and no OCR
engine. Reuses ``table_grid_fill``'s box-bounds reader. Imports no ``PySide6``.
"""
import re
from typing import Any, Dict, List, Sequence

from je_auto_control.utils.table_grid_fill.table_grid_fill import _box_bounds

Line = Dict[str, Any]

_MARKER = re.compile(r"^\s*([•‣◦\-\*]|\d+[.)]|[A-Za-z][.)])\s+")


def _height(line: Line) -> int:
_, top, _, bottom = _box_bounds(line)
return bottom - top


def _make_paragraph(lines: Sequence[Line]) -> Dict[str, Any]:
"""Build a paragraph dict (union bbox + joined text) from its lines."""
bounds = [_box_bounds(line) for line in lines]
left = min(b[0] for b in bounds)
top = min(b[1] for b in bounds)
right = max(b[2] for b in bounds)
bottom = max(b[3] for b in bounds)
text = " ".join(str(line.get("text", "")).strip() for line in lines).strip()
return {"left": left, "top": top, "right": right, "bottom": bottom,
"text": text, "n_lines": len(lines)}


def group_paragraphs(lines: Sequence[Line], *,
line_gap_factor: float = 1.6) -> List[Dict[str, Any]]:
"""Group lines into paragraphs, splitting where the vertical gap is large.

A new paragraph starts when the gap from the previous line's bottom exceeds
``line_gap_factor`` times the median line height. Returns paragraph dicts
(``left`` / ``top`` / ``right`` / ``bottom`` / ``text`` / ``n_lines``).
"""
ordered = sorted(lines, key=lambda line: _box_bounds(line)[1])
if not ordered:
return []
heights = sorted(_height(line) for line in ordered)
threshold = heights[len(heights) // 2] * float(line_gap_factor)
paragraphs: List[List[Line]] = [[ordered[0]]]
prev_bottom = _box_bounds(ordered[0])[3]
for line in ordered[1:]:
top, bottom = _box_bounds(line)[1], _box_bounds(line)[3]
if top - prev_bottom > threshold:
paragraphs.append([line])
else:
paragraphs[-1].append(line)
prev_bottom = bottom
return [_make_paragraph(group) for group in paragraphs]


def detect_lists(lines: Sequence[Line]) -> List[Dict[str, Any]]:
"""Return the lines that are list items: ``{text, marker, indent, box}``.

A line is a list item when its text starts with a bullet (``•`` / ``-`` / ``*``)
or an ordinal (``1.`` / ``2)`` / ``a.``); ``indent`` is its left x (for nesting),
``text`` is the content after the marker.
"""
items: List[Dict[str, Any]] = []
for line in lines:
text = str(line.get("text", ""))
match = _MARKER.match(text)
if match is None:
continue
left, top, right, bottom = _box_bounds(line)
items.append({"text": text[match.end():].strip(),
"marker": match.group(1), "indent": left,
"box": {"left": left, "top": top, "right": right,
"bottom": bottom}})
return items
Loading
Loading