pdf-redpen 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdf_redpen/__init__.py +40 -0
- pdf_redpen/__main__.py +5 -0
- pdf_redpen/cli.py +222 -0
- pdf_redpen/data/recorded/page1_turn1.md +9 -0
- pdf_redpen/data/recorded/page1_turn2.md +4 -0
- pdf_redpen/data/recorded/page2_turn1.md +6 -0
- pdf_redpen/data/recorded/page2_turn2.md +1 -0
- pdf_redpen/data/review.md +5 -0
- pdf_redpen/data/sample_report.pdf +0 -0
- pdf_redpen/grounding.py +135 -0
- pdf_redpen/i18n.py +303 -0
- pdf_redpen/llm.py +111 -0
- pdf_redpen/models.py +140 -0
- pdf_redpen/pipeline.py +185 -0
- pdf_redpen/prompts.py +66 -0
- pdf_redpen/quads.py +50 -0
- pdf_redpen/recall.py +83 -0
- pdf_redpen/report.py +162 -0
- pdf_redpen/table.py +125 -0
- pdf_redpen/textlayer.py +114 -0
- pdf_redpen/writer.py +206 -0
- pdf_redpen-0.2.0.dist-info/METADATA +316 -0
- pdf_redpen-0.2.0.dist-info/RECORD +26 -0
- pdf_redpen-0.2.0.dist-info/WHEEL +4 -0
- pdf_redpen-0.2.0.dist-info/entry_points.txt +2 -0
- pdf_redpen-0.2.0.dist-info/licenses/LICENSE +21 -0
pdf_redpen/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""pdf-redpen: ground LLM review quotes to PDF coordinates and mark them up.
|
|
2
|
+
|
|
3
|
+
Small public API (everything else is internal):
|
|
4
|
+
|
|
5
|
+
Finding, ground, annotate_pdf, InputError, __version__
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from .grounding import ground
|
|
13
|
+
from .models import Finding, InputError, Match, Report
|
|
14
|
+
from .writer import DEFAULT_AUTHOR
|
|
15
|
+
|
|
16
|
+
__version__ = "0.2.0"
|
|
17
|
+
__all__ = ["Finding", "Match", "Report", "InputError", "ground", "annotate_pdf", "__version__"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def annotate_pdf(
|
|
21
|
+
src: "str | Path",
|
|
22
|
+
findings: Iterable[Finding],
|
|
23
|
+
dst: "str | Path",
|
|
24
|
+
*,
|
|
25
|
+
max_per_page: int = 20,
|
|
26
|
+
author: str = DEFAULT_AUTHOR,
|
|
27
|
+
lang: "str | None" = None,
|
|
28
|
+
) -> Report:
|
|
29
|
+
"""Highlight each finding's quote in `src` and save the result to `dst`.
|
|
30
|
+
|
|
31
|
+
No model is called. Quotes that cannot be found become one memo per page. The memo tags
|
|
32
|
+
("[not found]", "[over cap]", "(loose match)") follow `lang` ("en" or "ko"); by default the
|
|
33
|
+
language set with `pdf_redpen.i18n.set_lang` (English unless changed).
|
|
34
|
+
"""
|
|
35
|
+
from .pipeline import JsonProvider, run_pipeline
|
|
36
|
+
|
|
37
|
+
return run_pipeline(
|
|
38
|
+
Path(src), Path(dst), JsonProvider(findings), mode="apply",
|
|
39
|
+
max_per_page=max_per_page, author=author, lang=lang,
|
|
40
|
+
)
|
pdf_redpen/__main__.py
ADDED
pdf_redpen/cli.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Command line: review, apply, clear, demo. Exit codes: 0 ok, 2 bad input, 1 unexpected."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from . import __version__, prompts
|
|
11
|
+
from .i18n import LANGS, CodedError, lang_from_argv, resolve_lang, set_lang, t
|
|
12
|
+
from .llm import Client, MockClient, OpenAIChatClient, RecordingClient
|
|
13
|
+
from .models import PAGE_SKIPPED, InputError, findings_from_json
|
|
14
|
+
from .pipeline import JsonProvider, LLMProvider, run_pipeline
|
|
15
|
+
from .report import mock_notice, page_detail, recall_line, stage_line, summary_line, write_reports
|
|
16
|
+
from .writer import DEFAULT_AUTHOR, clear_annotations
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_pages(spec: Optional[str]) -> Optional[list[int]]:
|
|
20
|
+
"""'1-3,5' -> [1, 2, 3, 5]."""
|
|
21
|
+
if not spec:
|
|
22
|
+
return None
|
|
23
|
+
pages: list[int] = []
|
|
24
|
+
try:
|
|
25
|
+
for part in spec.split(","):
|
|
26
|
+
if "-" in part:
|
|
27
|
+
a, b = part.split("-", 1)
|
|
28
|
+
lo, hi = int(a), int(b)
|
|
29
|
+
if lo > hi:
|
|
30
|
+
raise ValueError
|
|
31
|
+
pages += range(lo, hi + 1)
|
|
32
|
+
else:
|
|
33
|
+
pages.append(int(part))
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise InputError("err.pages_format", spec=spec) from None
|
|
36
|
+
if any(p < 1 for p in pages):
|
|
37
|
+
raise InputError("err.pages_start")
|
|
38
|
+
return pages
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _progress(result) -> None:
|
|
42
|
+
"""One stderr line per page; failures (and a failed recall turn) show their cause."""
|
|
43
|
+
if result.detail and result.status != "skipped_no_text_layer":
|
|
44
|
+
line = t("progress.line_detail", page=result.page, status=result.status,
|
|
45
|
+
detail=page_detail(result))
|
|
46
|
+
else:
|
|
47
|
+
line = t("progress.line", page=result.page, status=result.status)
|
|
48
|
+
print(line, file=sys.stderr)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _finish(report, dst: Path, notice_to=None) -> int:
|
|
52
|
+
"""Print the summary; return 1 when every page that was sent to the model failed.
|
|
53
|
+
|
|
54
|
+
The mock notice is printed here (only once a run really replayed answers), to `notice_to`.
|
|
55
|
+
"""
|
|
56
|
+
if "mock" in report.mode:
|
|
57
|
+
print(mock_notice(), file=notice_to or sys.stderr)
|
|
58
|
+
json_path, md_path = write_reports(report, dst)
|
|
59
|
+
print(summary_line(report))
|
|
60
|
+
print(stage_line(report))
|
|
61
|
+
if report.recall_pass:
|
|
62
|
+
print(recall_line(report))
|
|
63
|
+
for path in (dst, json_path, md_path):
|
|
64
|
+
print(t("out.wrote", path=path))
|
|
65
|
+
attempted = [p for p in report.pages if p.status != PAGE_SKIPPED]
|
|
66
|
+
if report.error_pages:
|
|
67
|
+
if attempted and report.error_pages == len(attempted):
|
|
68
|
+
print(t("err.all_failed"), file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
print(t("warn.some_failed", n=report.error_pages), file=sys.stderr)
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_out(src: Path, suffix: str) -> Path:
|
|
75
|
+
return src.with_name(f"{src.stem}.{suffix}.pdf")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_out(src: Path, dst: Path) -> None:
|
|
79
|
+
if src.resolve() == dst.resolve():
|
|
80
|
+
raise InputError("err.overwrite")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_review(a: argparse.Namespace) -> int:
|
|
84
|
+
src = Path(a.input)
|
|
85
|
+
dst = Path(a.output) if a.output else _default_out(src, "reviewed")
|
|
86
|
+
_check_out(src, dst)
|
|
87
|
+
if a.mock and a.record:
|
|
88
|
+
raise InputError("err.mock_record")
|
|
89
|
+
instructions = prompts.DEFAULT_INSTRUCTIONS[a.lang]
|
|
90
|
+
if a.instructions:
|
|
91
|
+
ipath = Path(a.instructions)
|
|
92
|
+
if not ipath.is_file():
|
|
93
|
+
raise InputError("err.instructions_missing", path=ipath)
|
|
94
|
+
instructions = ipath.read_text(encoding="utf-8")
|
|
95
|
+
client: Client
|
|
96
|
+
if a.mock:
|
|
97
|
+
client, mode = MockClient(Path(a.mock)), "review (mock: practice replay)"
|
|
98
|
+
else:
|
|
99
|
+
client, mode = OpenAIChatClient.from_env(), "review"
|
|
100
|
+
if a.record:
|
|
101
|
+
client = RecordingClient(client, Path(a.record))
|
|
102
|
+
provider = LLMProvider(client, instructions, a.recall_pass, a.lang)
|
|
103
|
+
report = run_pipeline(src, dst, provider, mode=mode, max_per_page=a.max_highlights_per_page,
|
|
104
|
+
pages=parse_pages(a.pages), author=a.author, recall=a.recall_pass,
|
|
105
|
+
on_page_done=_progress)
|
|
106
|
+
return _finish(report, dst)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cmd_apply(a: argparse.Namespace) -> int:
|
|
110
|
+
src, fpath = Path(a.input), Path(a.findings)
|
|
111
|
+
dst = Path(a.output)
|
|
112
|
+
_check_out(src, dst)
|
|
113
|
+
if not fpath.is_file():
|
|
114
|
+
raise InputError("err.findings_missing", path=fpath)
|
|
115
|
+
findings = findings_from_json(fpath.read_text(encoding="utf-8"), str(fpath))
|
|
116
|
+
report = run_pipeline(src, dst, JsonProvider(findings), mode="apply",
|
|
117
|
+
max_per_page=a.max_highlights_per_page, author=DEFAULT_AUTHOR)
|
|
118
|
+
return _finish(report, dst)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_clear(a: argparse.Namespace) -> int:
|
|
122
|
+
src, dst = Path(a.input), Path(a.output)
|
|
123
|
+
_check_out(src, dst)
|
|
124
|
+
removed = clear_annotations(src, dst, a.author)
|
|
125
|
+
print(t("out.removed", n=removed, author=a.author, dst=dst))
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_demo(a: argparse.Namespace) -> int:
|
|
130
|
+
out = Path(a.out)
|
|
131
|
+
data = resources.files("pdf_redpen") / "data"
|
|
132
|
+
with resources.as_file(data / "sample_report.pdf") as sample, \
|
|
133
|
+
resources.as_file(data / "recorded") as recorded, \
|
|
134
|
+
resources.as_file(data / "review.md") as instr:
|
|
135
|
+
dst = out / "reviewed.pdf"
|
|
136
|
+
provider = LLMProvider(MockClient(recorded), Path(instr).read_text(encoding="utf-8"),
|
|
137
|
+
recall=True)
|
|
138
|
+
report = run_pipeline(sample, dst, provider, mode="review (mock: practice replay)",
|
|
139
|
+
max_per_page=a.max_highlights_per_page, recall=True,
|
|
140
|
+
on_page_done=_progress)
|
|
141
|
+
return _finish(report, dst, notice_to=sys.stdout)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
145
|
+
"""Build the parser; help texts come from the current screen language (see i18n)."""
|
|
146
|
+
p = argparse.ArgumentParser(prog="pdf-redpen", description=t("help.description"))
|
|
147
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
148
|
+
# Screen language (messages, reports). Not the same as `review --lang` (prompt language).
|
|
149
|
+
p.add_argument("--lang", dest="ui_lang", choices=LANGS, default=None, help=t("help.lang_ui"))
|
|
150
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
151
|
+
|
|
152
|
+
cap = ["--max-highlights-per-page"]
|
|
153
|
+
r = sub.add_parser("review", help=t("help.review"))
|
|
154
|
+
r.add_argument("input")
|
|
155
|
+
r.add_argument("-o", "--output")
|
|
156
|
+
r.add_argument("--instructions", help=t("help.instructions"))
|
|
157
|
+
r.add_argument("--mock", metavar="DIR", help=t("help.mock"))
|
|
158
|
+
r.add_argument("--record", metavar="DIR", help=t("help.record"))
|
|
159
|
+
r.add_argument("--recall-pass", action="store_true", help=t("help.recall_pass"))
|
|
160
|
+
r.add_argument(*cap, type=int, default=20)
|
|
161
|
+
r.add_argument("--pages", help=t("help.pages"))
|
|
162
|
+
r.add_argument("--author", default=DEFAULT_AUTHOR)
|
|
163
|
+
r.add_argument("--lang", choices=("ko", "en"), default="ko", help=t("help.review_lang"))
|
|
164
|
+
r.set_defaults(func=cmd_review)
|
|
165
|
+
|
|
166
|
+
ap = sub.add_parser("apply", help=t("help.apply"))
|
|
167
|
+
ap.add_argument("input")
|
|
168
|
+
ap.add_argument("findings")
|
|
169
|
+
ap.add_argument("-o", "--output", required=True)
|
|
170
|
+
ap.add_argument(*cap, type=int, default=20)
|
|
171
|
+
ap.set_defaults(func=cmd_apply)
|
|
172
|
+
|
|
173
|
+
cl = sub.add_parser("clear", help=t("help.clear"))
|
|
174
|
+
cl.add_argument("input")
|
|
175
|
+
cl.add_argument("-o", "--output", required=True)
|
|
176
|
+
cl.add_argument("--author", default=DEFAULT_AUTHOR)
|
|
177
|
+
cl.set_defaults(func=cmd_clear)
|
|
178
|
+
|
|
179
|
+
d = sub.add_parser("demo", help=t("help.demo"))
|
|
180
|
+
d.add_argument("--out", default="out")
|
|
181
|
+
d.add_argument(*cap, type=int, default=20)
|
|
182
|
+
d.set_defaults(func=cmd_demo)
|
|
183
|
+
return p
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
187
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
188
|
+
# Pick the screen language before parsing so --help and parse errors use it too.
|
|
189
|
+
previous = set_lang(resolve_lang(lang_from_argv(args_list)))
|
|
190
|
+
try:
|
|
191
|
+
return _run(args_list)
|
|
192
|
+
finally:
|
|
193
|
+
set_lang(previous) # do not leak the language into the caller (tests, library use)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _run(args_list: list[str]) -> int:
|
|
197
|
+
args = build_parser().parse_args(args_list)
|
|
198
|
+
try:
|
|
199
|
+
return args.func(args)
|
|
200
|
+
except CodedError as exc: # InputError, LLMError: message id + params, shown in our language
|
|
201
|
+
print(t("err.prefix", message=exc.render()), file=sys.stderr)
|
|
202
|
+
return 2 if isinstance(exc, InputError) else 1
|
|
203
|
+
except OSError as exc: # unreadable/unwritable paths, full disk, ...
|
|
204
|
+
reason = exc.strerror or str(exc)
|
|
205
|
+
if exc.filename:
|
|
206
|
+
path = str(exc.filename)
|
|
207
|
+
if path.endswith(".part"): # internal temp file: show the path the user gave
|
|
208
|
+
path = path[: -len(".part")]
|
|
209
|
+
print(t("err.os", path=path, reason=reason), file=sys.stderr)
|
|
210
|
+
else:
|
|
211
|
+
print(t("err.os_nopath", reason=reason), file=sys.stderr)
|
|
212
|
+
return 2
|
|
213
|
+
except KeyboardInterrupt:
|
|
214
|
+
print(t("err.interrupted"), file=sys.stderr)
|
|
215
|
+
return 1
|
|
216
|
+
except Exception as exc: # last resort: no traceback for users
|
|
217
|
+
print(t("err.unexpected", name=type(exc).__name__, detail=exc), file=sys.stderr)
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
sys.exit(main())
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
1쪽에서 문제가 되는 문장을 찾았습니다.
|
|
2
|
+
|
|
3
|
+
| # | 원문 인용 | 문제점 | 심각도 |
|
|
4
|
+
|---|-----------|--------|--------|
|
|
5
|
+
| 1 | "근거 표가 제시되지 않았습니다" | 매출 증가 주장에 근거 표가 없음 | 높음 |
|
|
6
|
+
| 2 | 신규 고객 수는 1,200명으로 | 다른 절(1,300명)과 숫자가 맞지 않음 | 높음 |
|
|
7
|
+
| 이 줄은 | 열 수가 맞지 않는 깨진 행 |
|
|
8
|
+
| 3 | `The quarterly revenue grew by fifteen-percent!` | Cite the table for the growth figure | 중간 |
|
|
9
|
+
| 4 | intentionally wraps across several lines so that a quoted passage will NOT match exactly | Tail of the quote is paraphrased by the model | 낮음 |
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
| 인용 | 지적 | 심각도 |
|
|
2
|
+
|---|---|---|
|
|
3
|
+
| 결론 문장은 다시 한번 결론 문장으로 반복됩니다 | 같은 문장이 반복됨 (첫 번째) | 낮음 |
|
|
4
|
+
| 결론 문장은 다시 한번 결론 문장으로 반복됩니다 | 같은 문장이 반복됨 (두 번째 출현) | 낮음 |
|
|
5
|
+
| 2025년 매출이 전년 대비 15% 증가했다고 | 모델이 쪽수를 틀림 (실제로는 1쪽 문장) | 중간 |
|
|
6
|
+
| 모든 위험이 완전히 제거되었다고 보고합니다 | 본문에 없는 문장 (모델이 만들어 낸 인용) | 높음 |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
추가로 놓친 지적은 없습니다.
|
|
Binary file
|
pdf_redpen/grounding.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Five-stage quote locator.
|
|
2
|
+
|
|
3
|
+
Models rarely copy a sentence exactly: they change whitespace, drop Korean word
|
|
4
|
+
spacing, alter punctuation, paraphrase the tail, or name the wrong page. Each
|
|
5
|
+
stage below is looser than the one before; the first stage that finds a span of
|
|
6
|
+
characters that is not already used wins. Every stage searches a *projected*
|
|
7
|
+
string and keeps a map back to the original Char indices, so the highlight is
|
|
8
|
+
always drawn from real glyph boxes.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Callable, Optional
|
|
14
|
+
|
|
15
|
+
from .models import Match
|
|
16
|
+
from .textlayer import PageText, fold
|
|
17
|
+
|
|
18
|
+
PREFIX_LEN = 25
|
|
19
|
+
ALNUM_MIN = 4
|
|
20
|
+
STAGE_ORDER = ("exact", "ws_collapse", "no_space", "alnum_only", "prefix25")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _find_unused(hay: str, needle: str, mapping: list, used: set[int]) -> Optional[list[int]]:
|
|
24
|
+
"""First occurrence of needle whose chars are all still unused."""
|
|
25
|
+
start = 0
|
|
26
|
+
while needle:
|
|
27
|
+
pos = hay.find(needle, start)
|
|
28
|
+
if pos < 0:
|
|
29
|
+
return None
|
|
30
|
+
span = {mapping[k] for k in range(pos, pos + len(needle)) if mapping[k] is not None}
|
|
31
|
+
if span and not (span & used):
|
|
32
|
+
return sorted(span)
|
|
33
|
+
start = pos + 1
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _project(flat: str, idx: list, keep: Callable[[str], bool]) -> tuple[str, list]:
|
|
38
|
+
s, m = [], []
|
|
39
|
+
for ch, i in zip(flat, idx):
|
|
40
|
+
if keep(ch):
|
|
41
|
+
s.append(ch)
|
|
42
|
+
m.append(i)
|
|
43
|
+
return "".join(s), m
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _collapse(flat: str, idx: list) -> tuple[str, list]:
|
|
47
|
+
"""Replace every whitespace run by a single space."""
|
|
48
|
+
s, m = [], []
|
|
49
|
+
for ch, i in zip(flat, idx):
|
|
50
|
+
if ch.isspace():
|
|
51
|
+
if s and s[-1] == " ":
|
|
52
|
+
continue
|
|
53
|
+
s.append(" ")
|
|
54
|
+
m.append(i)
|
|
55
|
+
else:
|
|
56
|
+
s.append(ch)
|
|
57
|
+
m.append(i)
|
|
58
|
+
return "".join(s), m
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ground(page: PageText, quote: str, used: set[int]) -> Optional[Match]:
|
|
62
|
+
"""Find `quote` on one page, avoiding char indices in `used`. None if absent."""
|
|
63
|
+
quote = fold(quote).strip()
|
|
64
|
+
if not quote:
|
|
65
|
+
return None
|
|
66
|
+
flat, idx = page.flat, page.index_map
|
|
67
|
+
|
|
68
|
+
# 1. exact substring
|
|
69
|
+
hit = _find_unused(flat, quote, idx, used)
|
|
70
|
+
if hit:
|
|
71
|
+
return Match("exact", hit)
|
|
72
|
+
|
|
73
|
+
# 2. whitespace runs collapsed (double spaces, copied line breaks)
|
|
74
|
+
cflat, cidx = _collapse(flat, idx)
|
|
75
|
+
cq = re.sub(r"\s+", " ", quote)
|
|
76
|
+
hit = _find_unused(cflat, cq, cidx, used)
|
|
77
|
+
if hit:
|
|
78
|
+
return Match("ws_collapse", hit)
|
|
79
|
+
|
|
80
|
+
# 3. first 25 chars, extended to the quote's length (tail was paraphrased)
|
|
81
|
+
prefix_match: Optional[Match] = None
|
|
82
|
+
if len(cq) > PREFIX_LEN:
|
|
83
|
+
pre = cq[:PREFIX_LEN]
|
|
84
|
+
start = 0
|
|
85
|
+
while True:
|
|
86
|
+
pos = cflat.find(pre, start)
|
|
87
|
+
if pos < 0:
|
|
88
|
+
break
|
|
89
|
+
end = min(len(cflat), pos + len(cq))
|
|
90
|
+
span = {cidx[k] for k in range(pos, end) if cidx[k] is not None}
|
|
91
|
+
if span and not (span & used):
|
|
92
|
+
prefix_match = Match("prefix25", sorted(span))
|
|
93
|
+
break
|
|
94
|
+
start = pos + 1
|
|
95
|
+
|
|
96
|
+
# 4. all whitespace removed (Korean spacing errors)
|
|
97
|
+
sflat, sidx = _project(flat, idx, lambda ch: not ch.isspace())
|
|
98
|
+
sq = re.sub(r"\s+", "", quote)
|
|
99
|
+
hit = _find_unused(sflat, sq, sidx, used)
|
|
100
|
+
if hit:
|
|
101
|
+
return Match("no_space", hit)
|
|
102
|
+
|
|
103
|
+
# 5. letters and digits only (punctuation changed)
|
|
104
|
+
aflat, aidx = _project(flat, idx, lambda ch: ch.isalnum())
|
|
105
|
+
aq = "".join(ch for ch in quote if ch.isalnum())
|
|
106
|
+
if len(aq) >= ALNUM_MIN:
|
|
107
|
+
hit = _find_unused(aflat, aq, aidx, used)
|
|
108
|
+
if hit:
|
|
109
|
+
return Match("alnum_only", hit)
|
|
110
|
+
|
|
111
|
+
# The prefix match is the loosest guess (it may paint text the model never
|
|
112
|
+
# quoted), so it is only used when the stricter stages 4 and 5 found nothing.
|
|
113
|
+
return prefix_match
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ground_finding(
|
|
117
|
+
get_page: Callable[[int], PageText],
|
|
118
|
+
n_pages: int,
|
|
119
|
+
page_no: int,
|
|
120
|
+
quote: str,
|
|
121
|
+
used: dict[int, set[int]],
|
|
122
|
+
) -> Optional[tuple[int, Match]]:
|
|
123
|
+
"""Search the claimed page first, then the previous and the next page.
|
|
124
|
+
|
|
125
|
+
Returns (actual 1-based page, Match) or None. `used` holds the already
|
|
126
|
+
painted char indices per 1-based page and is NOT modified here.
|
|
127
|
+
"""
|
|
128
|
+
page_no = min(max(page_no, 1), n_pages)
|
|
129
|
+
for p in (page_no, page_no - 1, page_no + 1):
|
|
130
|
+
if not (1 <= p <= n_pages):
|
|
131
|
+
continue
|
|
132
|
+
m = ground(get_page(p), quote, used.setdefault(p, set()))
|
|
133
|
+
if m:
|
|
134
|
+
return p, m
|
|
135
|
+
return None
|