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 +21 -0
- agentpdf-0.1.0/PKG-INFO +129 -0
- agentpdf-0.1.0/README.md +103 -0
- agentpdf-0.1.0/pyproject.toml +73 -0
- agentpdf-0.1.0/pyproject.toml.orig +56 -0
- agentpdf-0.1.0/src/agentpdf/__init__.py +13 -0
- agentpdf-0.1.0/src/agentpdf/chars.py +265 -0
- agentpdf-0.1.0/src/agentpdf/cli.py +98 -0
- agentpdf-0.1.0/src/agentpdf/convert.py +205 -0
- agentpdf-0.1.0/src/agentpdf/document.py +178 -0
- agentpdf-0.1.0/src/agentpdf/furniture.py +98 -0
- agentpdf-0.1.0/src/agentpdf/headings.py +163 -0
- agentpdf-0.1.0/src/agentpdf/model.py +103 -0
- agentpdf-0.1.0/src/agentpdf/paragraphs.py +132 -0
- agentpdf-0.1.0/src/agentpdf/regions.py +203 -0
- agentpdf-0.1.0/src/agentpdf/search.py +226 -0
- agentpdf-0.1.0/src/agentpdf/sections.py +119 -0
- agentpdf-0.1.0/src/agentpdf/util.py +27 -0
- agentpdf-0.1.0/src/agentpdf/writers.py +289 -0
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.
|
agentpdf-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
agentpdf-0.1.0/README.md
ADDED
|
@@ -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
|