agentpdf 0.1.0__tar.gz

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.
agentpdf-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Manuel Stocker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentpdf
3
+ Version: 0.1.0
4
+ Summary: Convert large PDF documents into small, searchable files for AI coding agents
5
+ Keywords: pdf,markdown,ai,agents,llm,documentation,search
6
+ Author: Manuel Stocker
7
+ Author-email: Manuel Stocker <mensi@mensi.ch>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: pdfplumber>=0.11
20
+ Requires-Dist: pypdf>=5
21
+ Requires-Dist: pypdfium2>=5
22
+ Requires-Python: >=3.12
23
+ Project-URL: Repository, https://github.com/mensi/agentpdf
24
+ Project-URL: Issues, https://github.com/mensi/agentpdf/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # agentpdf
28
+
29
+ > [!WARNING]
30
+ > This is a vibe-coded tool. The code was briefly reviewed by a human but not
31
+ > directly changed / maintained manually.
32
+
33
+ Make large PDFs — specifications, reference manuals, datasheets — usable by AI
34
+ coding agents without reading them into context.
35
+
36
+ agentpdf converts a PDF once into small Markdown section files, images of its
37
+ tables and figures, a table of contents and a full-text search index. Agents
38
+ then search and read only the parts they need, with their usual tools.
39
+
40
+ ```sh
41
+ uvx agentpdf convert spec.pdf -o docs/reference
42
+ uvx agentpdf search -d docs/reference "attach request timer"
43
+ ```
44
+
45
+ ```
46
+ docs/reference/
47
+ ├── AGENTS.md # how agents should use this directory
48
+ ├── search.sqlite # full-text index over all documents
49
+ └── spec/
50
+ ├── INDEX.md # table of contents with page ranges and sizes
51
+ ├── PAGES.md # page → section, table and figure images
52
+ ├── sections/ # Markdown, ≤ ~6k tokens per file, with page markers
53
+ └── images/ # one PNG per table and figure
54
+ ```
55
+
56
+ Requires [uv](https://docs.astral.sh/uv/) (or `pip install agentpdf`). Runs
57
+ locally on CPU, in seconds even for documents with hundreds of pages; no
58
+ models or network access needed.
59
+
60
+ ## Using it with agents
61
+
62
+ **Point the agent at the output.** Add one line to your project's `AGENTS.md`
63
+ or `CLAUDE.md`:
64
+
65
+ ```markdown
66
+ Reference documents (PDF specs) are in docs/reference/; see its AGENTS.md.
67
+ ```
68
+
69
+ The generated `AGENTS.md` explains the layout and the search command in a few
70
+ lines.
71
+
72
+ **Or just ask.** Agents that can run commands can do the conversion
73
+ themselves: *"Convert spec.pdf with `uvx agentpdf` (github.com/mensi/agentpdf)
74
+ and use it to implement …"*
75
+
76
+ **Or install the skill** (Claude Code), so agents convert and search PDFs on
77
+ their own:
78
+
79
+ ```
80
+ /plugin install agentpdf --marketplace mensi/agentpdf
81
+ ```
82
+
83
+ On older versions: `/plugin marketplace add mensi/agentpdf`, then
84
+ `/plugin install agentpdf@agentpdf`. The skill is a plain
85
+ [Agent Skill](https://agentskills.io) in
86
+ [`plugins/agentpdf/skills/agentpdf`](plugins/agentpdf/skills/agentpdf/SKILL.md),
87
+ which you can also copy into `~/.claude/skills/` or another agent's skill
88
+ directory.
89
+
90
+ ## How it works
91
+
92
+ - Text is extracted with [PDFium](https://pdfium.googlesource.com/pdfium/) in
93
+ reading order; running headers, footers and printed tables of contents are
94
+ dropped; paragraphs are re-flowed so phrases are searchable.
95
+ - Sections follow the PDF's bookmarks, or headings inferred from typography.
96
+ Large sections are split at subheadings to fit the size budget.
97
+ - Tables and figures are not converted to Markdown tables, which is
98
+ unreliable. Each is rendered as an image — the source of truth — plus a
99
+ text transcription that keeps characters at their horizontal position.
100
+ - Search uses SQLite FTS5 (BM25 ranking, stemming); hits point to file, line,
101
+ page and heading.
102
+
103
+ Only PDFs with a text layer are supported; scanned pages are rendered as
104
+ images but not OCRed.
105
+
106
+ ## Options
107
+
108
+ ```
109
+ agentpdf convert PDF... -o DIR [--max-tokens 6000] [--dpi 150]
110
+ [--page-images] [--split-pdf] [--no-index]
111
+ [--force]
112
+ agentpdf search QUERY [-d DIR] [-n 10] [--doc NAME] [--any]
113
+ ```
114
+
115
+ `--page-images` also renders every page, `--split-pdf` writes one PDF per
116
+ section, `--force` writes into an existing non-empty output directory.
117
+ Converting again replaces a document's output.
118
+
119
+ ## Development
120
+
121
+ ```sh
122
+ uv sync
123
+ uv run pytest
124
+ uv run ruff check . && uv run ruff format --check .
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,103 @@
1
+ # agentpdf
2
+
3
+ > [!WARNING]
4
+ > This is a vibe-coded tool. The code was briefly reviewed by a human but not
5
+ > directly changed / maintained manually.
6
+
7
+ Make large PDFs — specifications, reference manuals, datasheets — usable by AI
8
+ coding agents without reading them into context.
9
+
10
+ agentpdf converts a PDF once into small Markdown section files, images of its
11
+ tables and figures, a table of contents and a full-text search index. Agents
12
+ then search and read only the parts they need, with their usual tools.
13
+
14
+ ```sh
15
+ uvx agentpdf convert spec.pdf -o docs/reference
16
+ uvx agentpdf search -d docs/reference "attach request timer"
17
+ ```
18
+
19
+ ```
20
+ docs/reference/
21
+ ├── AGENTS.md # how agents should use this directory
22
+ ├── search.sqlite # full-text index over all documents
23
+ └── spec/
24
+ ├── INDEX.md # table of contents with page ranges and sizes
25
+ ├── PAGES.md # page → section, table and figure images
26
+ ├── sections/ # Markdown, ≤ ~6k tokens per file, with page markers
27
+ └── images/ # one PNG per table and figure
28
+ ```
29
+
30
+ Requires [uv](https://docs.astral.sh/uv/) (or `pip install agentpdf`). Runs
31
+ locally on CPU, in seconds even for documents with hundreds of pages; no
32
+ models or network access needed.
33
+
34
+ ## Using it with agents
35
+
36
+ **Point the agent at the output.** Add one line to your project's `AGENTS.md`
37
+ or `CLAUDE.md`:
38
+
39
+ ```markdown
40
+ Reference documents (PDF specs) are in docs/reference/; see its AGENTS.md.
41
+ ```
42
+
43
+ The generated `AGENTS.md` explains the layout and the search command in a few
44
+ lines.
45
+
46
+ **Or just ask.** Agents that can run commands can do the conversion
47
+ themselves: *"Convert spec.pdf with `uvx agentpdf` (github.com/mensi/agentpdf)
48
+ and use it to implement …"*
49
+
50
+ **Or install the skill** (Claude Code), so agents convert and search PDFs on
51
+ their own:
52
+
53
+ ```
54
+ /plugin install agentpdf --marketplace mensi/agentpdf
55
+ ```
56
+
57
+ On older versions: `/plugin marketplace add mensi/agentpdf`, then
58
+ `/plugin install agentpdf@agentpdf`. The skill is a plain
59
+ [Agent Skill](https://agentskills.io) in
60
+ [`plugins/agentpdf/skills/agentpdf`](plugins/agentpdf/skills/agentpdf/SKILL.md),
61
+ which you can also copy into `~/.claude/skills/` or another agent's skill
62
+ directory.
63
+
64
+ ## How it works
65
+
66
+ - Text is extracted with [PDFium](https://pdfium.googlesource.com/pdfium/) in
67
+ reading order; running headers, footers and printed tables of contents are
68
+ dropped; paragraphs are re-flowed so phrases are searchable.
69
+ - Sections follow the PDF's bookmarks, or headings inferred from typography.
70
+ Large sections are split at subheadings to fit the size budget.
71
+ - Tables and figures are not converted to Markdown tables, which is
72
+ unreliable. Each is rendered as an image — the source of truth — plus a
73
+ text transcription that keeps characters at their horizontal position.
74
+ - Search uses SQLite FTS5 (BM25 ranking, stemming); hits point to file, line,
75
+ page and heading.
76
+
77
+ Only PDFs with a text layer are supported; scanned pages are rendered as
78
+ images but not OCRed.
79
+
80
+ ## Options
81
+
82
+ ```
83
+ agentpdf convert PDF... -o DIR [--max-tokens 6000] [--dpi 150]
84
+ [--page-images] [--split-pdf] [--no-index]
85
+ [--force]
86
+ agentpdf search QUERY [-d DIR] [-n 10] [--doc NAME] [--any]
87
+ ```
88
+
89
+ `--page-images` also renders every page, `--split-pdf` writes one PDF per
90
+ section, `--force` writes into an existing non-empty output directory.
91
+ Converting again replaces a document's output.
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ uv sync
97
+ uv run pytest
98
+ uv run ruff check . && uv run ruff format --check .
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "agentpdf"
3
+ version = "0.1.0"
4
+ description = "Convert large PDF documents into small, searchable files for AI coding agents"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ keywords = [
10
+ "pdf",
11
+ "markdown",
12
+ "ai",
13
+ "agents",
14
+ "llm",
15
+ "documentation",
16
+ "search",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Text Processing :: Markup :: Markdown",
27
+ "Topic :: Utilities",
28
+ ]
29
+ dependencies = [
30
+ "pdfplumber>=0.11",
31
+ "pypdf>=5",
32
+ "pypdfium2>=5",
33
+ ]
34
+
35
+ [[project.authors]]
36
+ name = "Manuel Stocker"
37
+ email = "mensi@mensi.ch"
38
+
39
+ [project.urls]
40
+ Repository = "https://github.com/mensi/agentpdf"
41
+ Issues = "https://github.com/mensi/agentpdf/issues"
42
+
43
+ [project.scripts]
44
+ agentpdf = "agentpdf.cli:main"
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.13.1,<0.14.0"]
48
+ build-backend = "uv_build"
49
+
50
+ [dependency-groups]
51
+ dev = [
52
+ "pytest>=9.1.1",
53
+ "ruff>=0.17.1",
54
+ ]
55
+
56
+ [tool.ruff]
57
+ line-length = 110
58
+
59
+ [tool.ruff.lint]
60
+ select = [
61
+ "E",
62
+ "F",
63
+ "W",
64
+ "I",
65
+ "B",
66
+ "UP",
67
+ "SIM",
68
+ "RUF",
69
+ ]
70
+ ignore = ["RUF001"]
71
+
72
+ [tool.pytest.ini_options]
73
+ testpaths = ["tests"]
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "agentpdf"
3
+ version = "0.1.0"
4
+ description = "Convert large PDF documents into small, searchable files for AI coding agents"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Manuel Stocker", email = "mensi@mensi.ch" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ keywords = ["pdf", "markdown", "ai", "agents", "llm", "documentation", "search"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Text Processing :: Markup :: Markdown",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = [
25
+ "pdfplumber>=0.11",
26
+ "pypdf>=5",
27
+ "pypdfium2>=5",
28
+ ]
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/mensi/agentpdf"
32
+ Issues = "https://github.com/mensi/agentpdf/issues"
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.13.1,<0.14.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.1.1",
41
+ "ruff>=0.17.1",
42
+ ]
43
+
44
+ [project.scripts]
45
+ agentpdf = "agentpdf.cli:main"
46
+
47
+ [tool.ruff]
48
+ line-length = 110
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
52
+ # Typographic characters (dashes, bullets) are matched intentionally.
53
+ ignore = ["RUF001"]
54
+
55
+ [tool.pytest.ini_options]
56
+ testpaths = ["tests"]
@@ -0,0 +1,13 @@
1
+ """Convert large PDF documents into small, searchable files for AI coding agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("agentpdf")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.1.0"
9
+
10
+ from .convert import Options, convert
11
+ from .search import Hit, build_index, search
12
+
13
+ __all__ = ["Hit", "Options", "__version__", "build_index", "convert", "search"]
@@ -0,0 +1,265 @@
1
+ """Characters from PDFium, grouped into lines, words and layout text.
2
+
3
+ PDFium follows the content stream order (usually the intended reading order,
4
+ including multi-column layouts), reconstructs word spacing and decodes glyphs
5
+ reliably.
6
+ """
7
+
8
+ import ctypes
9
+ import itertools
10
+ import math
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ import pypdfium2 as pdfium
15
+ import pypdfium2.raw as pdfium_c
16
+
17
+ from .model import BBox, Line, bbox_union
18
+
19
+ # Characters PDFium uses to mark a hyphen at a line break.
20
+ _SOFT_HYPHENS = "\ufffe\xad\u0002"
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class Char:
25
+ text: str
26
+ index: int # position in PDFium's text stream
27
+ bbox: BBox | None # None for whitespace and line breaks
28
+ size: float = 0.0
29
+ bold: bool = False
30
+ upright: bool = True
31
+ soft_hyphen: bool = False
32
+
33
+
34
+ Word = list[Char]
35
+
36
+
37
+ def _display_transform(page: pdfium.PdfPage) -> Callable[[float, float, float, float], BBox]:
38
+ """Map PDF-space boxes (left, bottom, right, top) to displayed page
39
+ coordinates (top-left origin, after /Rotate)."""
40
+ mx0, my0, mx1, my1 = page.get_mediabox()
41
+ width, height = mx1 - mx0, my1 - my0
42
+ rotation = page.get_rotation() % 360
43
+
44
+ def point(x: float, y: float) -> tuple[float, float]:
45
+ x, y = x - mx0, y - my0
46
+ if rotation == 90:
47
+ return y, x
48
+ if rotation == 180:
49
+ return width - x, y
50
+ if rotation == 270:
51
+ return height - y, width - x
52
+ return x, height - y
53
+
54
+ def transform(left: float, bottom: float, right: float, top: float) -> BBox:
55
+ ax, ay = point(left, bottom)
56
+ bx, by = point(right, top)
57
+ return (min(ax, bx), min(ay, by), max(ax, bx), max(ay, by))
58
+
59
+ return transform
60
+
61
+
62
+ def read_chars(page: pdfium.PdfPage) -> list[Char]:
63
+ """Read all characters of a page in stream order."""
64
+ textpage = page.get_textpage()
65
+ try:
66
+ return _read_chars(page, textpage)
67
+ finally:
68
+ textpage.close()
69
+
70
+
71
+ def _read_chars(page: pdfium.PdfPage, textpage: pdfium.PdfTextPage) -> list[Char]:
72
+ to_display = _display_transform(page)
73
+ rotation = math.radians(page.get_rotation())
74
+ raw = textpage.raw
75
+ font_buf = ctypes.create_string_buffer(256)
76
+ font_flags = ctypes.c_int()
77
+ matrix = pdfium_c.FS_MATRIX()
78
+ chars: list[Char] = []
79
+ last_style = None
80
+ font = ""
81
+ # Characters are read by index (not by slicing get_text_range(), whose
82
+ # string offsets can drift from character indices).
83
+ for i in range(textpage.count_chars()):
84
+ code = pdfium_c.FPDFText_GetUnicode(raw, i)
85
+ ch = chr(code) if 0 < code < 0x110000 and not 0xD800 <= code < 0xE000 else "�"
86
+ if ch == "\r":
87
+ continue
88
+ if ch in _SOFT_HYPHENS:
89
+ chars.append(Char("-", i, None, soft_hyphen=True))
90
+ continue
91
+ if ch.isspace() or pdfium_c.FPDFText_IsGenerated(raw, i) == 1:
92
+ chars.append(Char("\n" if ch == "\n" else " ", i, None))
93
+ continue
94
+ # The nominal font size is often 1 with the actual size applied via
95
+ # the text matrix; the effective size accounts for both.
96
+ pdfium_c.FPDFText_GetMatrix(raw, i, ctypes.byref(matrix))
97
+ size = round(pdfium_c.FPDFText_GetFontSize(raw, i) * (math.hypot(matrix.c, matrix.d) or 1.0), 2)
98
+ weight = pdfium_c.FPDFText_GetFontWeight(raw, i)
99
+ if (size, weight) != last_style:
100
+ pdfium_c.FPDFText_GetFontInfo(raw, i, font_buf, len(font_buf), ctypes.byref(font_flags))
101
+ font = font_buf.value.decode("utf-8", "replace")
102
+ last_style = (size, weight)
103
+ # Upright as displayed, i.e. taking the page's /Rotate into account.
104
+ angle = pdfium_c.FPDFText_GetCharAngle(raw, i)
105
+ displayed = (angle + rotation) % (2 * math.pi)
106
+ chars.append(
107
+ Char(
108
+ text=ch,
109
+ index=i,
110
+ bbox=to_display(*textpage.get_charbox(i, loose=True)),
111
+ size=size,
112
+ bold=weight >= 600 or "bold" in font.lower(),
113
+ upright=angle < 0 or min(displayed, 2 * math.pi - displayed) < 0.05,
114
+ )
115
+ )
116
+ return chars
117
+
118
+
119
+ def lines_from_chars(chars: list[Char]) -> list[Line]:
120
+ """Group characters into lines at PDFium's line breaks, and where the
121
+ geometry shows a break PDFium did not report (e.g. on rotated pages)."""
122
+ lines: list[Line] = []
123
+ current: list[Char] = []
124
+
125
+ def flush() -> None:
126
+ visible = [c for c in current if c.bbox is not None]
127
+ text = " ".join("".join(c.text for c in current).split())
128
+ if visible and text:
129
+ lines.append(
130
+ Line(
131
+ text=text,
132
+ bbox=_union(visible),
133
+ size=max(c.size for c in visible),
134
+ bold=sum(c.bold for c in visible) > len(visible) / 2,
135
+ order=visible[0].index,
136
+ soft_hyphen=current[-1].soft_hyphen,
137
+ tabbed=any(
138
+ b.bbox[0] - a.bbox[2] > 1.5 * max(a.size, b.size)
139
+ for a, b in itertools.pairwise(visible)
140
+ ),
141
+ )
142
+ )
143
+ current.clear()
144
+
145
+ prev: Char | None = None
146
+ for c in chars:
147
+ if c.text == "\n":
148
+ flush()
149
+ prev = None
150
+ continue
151
+ if c.bbox is not None:
152
+ if prev is not None and current and _starts_new_line(prev, c):
153
+ flush()
154
+ prev = c
155
+ current.append(c)
156
+ if c.soft_hyphen:
157
+ flush()
158
+ flush()
159
+ return lines
160
+
161
+
162
+ def _starts_new_line(prev: Char, c: Char) -> bool:
163
+ if prev.upright != c.upright:
164
+ return True
165
+ if not c.upright:
166
+ return False
167
+ no_vertical_overlap = c.bbox[1] >= prev.bbox[3] or c.bbox[3] <= prev.bbox[1]
168
+ moved_back = c.bbox[0] < prev.bbox[0] - max(prev.size, c.size)
169
+ return no_vertical_overlap or moved_back
170
+
171
+
172
+ def words(chars: list[Char]) -> list[Word]:
173
+ """Split visible characters into words at whitespace."""
174
+ return [
175
+ list(group) for visible, group in itertools.groupby(chars, lambda c: c.bbox is not None) if visible
176
+ ]
177
+
178
+
179
+ def word_bbox(word: Word) -> BBox:
180
+ return _union(word)
181
+
182
+
183
+ def _union(chars: list[Char]) -> BBox:
184
+ bbox = chars[0].bbox
185
+ for c in chars[1:]:
186
+ bbox = bbox_union(bbox, c.bbox)
187
+ return bbox
188
+
189
+
190
+ def layout_text(chars: list[Char], bbox: BBox) -> str:
191
+ """Render characters as plain text that preserves horizontal positions.
192
+
193
+ Upright characters are placed on a character grid; rows are formed from
194
+ vertically overlapping characters so sub- and superscripts stay on their
195
+ base line. Rotated text (axis labels etc.) is listed separately.
196
+ """
197
+ upright = [c for c in chars if c.bbox is not None and c.upright]
198
+ rotated = [c for c in chars if c.bbox is not None and not c.upright]
199
+ out = _grid_rows(upright, bbox) if upright else []
200
+ if rotated:
201
+ out += ["", "[rotated text] " + " | ".join(_runs(rotated))]
202
+ indent = min((len(s) - len(s.lstrip()) for s in out if s.strip()), default=0)
203
+ return "\n".join(s[indent:] for s in out).strip("\n")
204
+
205
+
206
+ def _grid_rows(chars: list[Char], bbox: BBox) -> list[str]:
207
+ sizes = sorted(c.size for c in chars)
208
+ body = sizes[len(sizes) // 2] or 8.0
209
+ unit = body * 0.5 # approximate width of one monospace column
210
+
211
+ # Tallest glyphs define the rows; smaller ones (subscripts) join a row
212
+ # they overlap substantially.
213
+ rows: list[tuple[float, float, list[Char]]] = []
214
+ for c in sorted(chars, key=lambda c: -c.size):
215
+ top, bottom = c.bbox[1], c.bbox[3]
216
+ for row_top, row_bottom, members in rows:
217
+ overlap = min(bottom, row_bottom) - max(top, row_top)
218
+ if overlap > 0.4 * min(bottom - top, row_bottom - row_top):
219
+ members.append(c)
220
+ break
221
+ else:
222
+ rows.append((top, bottom, [c]))
223
+ rows.sort(key=lambda r: r[0])
224
+
225
+ out: list[str] = []
226
+ prev_bottom = None
227
+ for top, bottom, members in rows:
228
+ if prev_bottom is not None and top - prev_bottom > body * 1.2:
229
+ out.append("")
230
+ prev_bottom = bottom
231
+ members.sort(key=lambda c: c.bbox[0])
232
+ line: list[str] = []
233
+ prev: Char | None = None
234
+ for c in members:
235
+ if prev is not None and c.index == prev.index + 1 and c.bbox[0] - prev.bbox[2] < c.size * 0.2:
236
+ # Continuation of a word: glyph widths vary, so append
237
+ # directly instead of snapping to the grid.
238
+ line.append(c.text)
239
+ else:
240
+ col = max(round((c.bbox[0] - bbox[0]) / unit), len(line) + (1 if line else 0))
241
+ line.extend(" " * (col - len(line)))
242
+ line.append(c.text)
243
+ prev = c
244
+ out.append("".join(line).rstrip())
245
+ return out
246
+
247
+
248
+ def _runs(chars: list[Char]) -> list[str]:
249
+ """Join characters that are consecutive in the stream into labels."""
250
+ labels: list[str] = []
251
+ current: list[str] = []
252
+ last_index = 0
253
+ for c in sorted(chars, key=lambda c: c.index):
254
+ if current:
255
+ step = c.index - last_index
256
+ if step > 2:
257
+ labels.append("".join(current).strip())
258
+ current = []
259
+ elif step == 2: # separated by a space
260
+ current.append(" ")
261
+ current.append(c.text)
262
+ last_index = c.index
263
+ if current:
264
+ labels.append("".join(current).strip())
265
+ return labels