pdfedit 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.
- pdfedit-0.1.0/.gitignore +9 -0
- pdfedit-0.1.0/LICENSE +21 -0
- pdfedit-0.1.0/PKG-INFO +122 -0
- pdfedit-0.1.0/README.md +94 -0
- pdfedit-0.1.0/docs/README.md +84 -0
- pdfedit-0.1.0/docs/architecture.md +96 -0
- pdfedit-0.1.0/docs/concepts.md +146 -0
- pdfedit-0.1.0/docs/glossary.md +68 -0
- pdfedit-0.1.0/docs/modules/cli.md +122 -0
- pdfedit-0.1.0/docs/modules/engine.md +163 -0
- pdfedit-0.1.0/docs/modules/fonts.md +108 -0
- pdfedit-0.1.0/docs/modules/model.md +103 -0
- pdfedit-0.1.0/docs/usage.md +169 -0
- pdfedit-0.1.0/pdfedit/__init__.py +4 -0
- pdfedit-0.1.0/pdfedit/__main__.py +4 -0
- pdfedit-0.1.0/pdfedit/cli.py +252 -0
- pdfedit-0.1.0/pdfedit/engine.py +218 -0
- pdfedit-0.1.0/pdfedit/fonts.py +152 -0
- pdfedit-0.1.0/pdfedit/model.py +121 -0
- pdfedit-0.1.0/pyproject.toml +57 -0
- pdfedit-0.1.0/tests/__init__.py +0 -0
- pdfedit-0.1.0/tests/conftest.py +33 -0
- pdfedit-0.1.0/tests/fixtures/make_sample.py +62 -0
- pdfedit-0.1.0/tests/fixtures/sample.pdf +0 -0
- pdfedit-0.1.0/tests/test_cli_batch.py +82 -0
- pdfedit-0.1.0/tests/test_engine.py +160 -0
- pdfedit-0.1.0/tests/test_fonts.py +62 -0
- pdfedit-0.1.0/tests/test_model.py +70 -0
pdfedit-0.1.0/.gitignore
ADDED
pdfedit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sachin Pandey
|
|
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.
|
pdfedit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdfedit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find and edit any text in a PDF while matching the original font, size, and color
|
|
5
|
+
Project-URL: Homepage, https://github.com/sachinpandey22/pdfedit
|
|
6
|
+
Project-URL: Repository, https://github.com/sachinpandey22/pdfedit
|
|
7
|
+
Project-URL: Issues, https://github.com/sachinpandey22/pdfedit/issues
|
|
8
|
+
Author-email: Sachin Pandey <xachin300@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: edit,font,pdf,pymupdf,redaction,text
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pymupdf>=1.28.2
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# pdfedit
|
|
30
|
+
|
|
31
|
+
A command-line tool that finds and edits **any** text in a PDF while matching
|
|
32
|
+
the original font, size, and color — not a fixed find-and-replace on known
|
|
33
|
+
form fields. It walks every text span via [PyMuPDF](https://pymupdf.readthedocs.io/),
|
|
34
|
+
resolves each span's real font (embedded extraction, or the closest Base-14
|
|
35
|
+
fallback), then redacts the old glyphs and redraws the replacement in the
|
|
36
|
+
same font, size, and color at the same position.
|
|
37
|
+
|
|
38
|
+
The original file is never modified — every edit writes `<name>_edited.pdf`
|
|
39
|
+
next to the input.
|
|
40
|
+
|
|
41
|
+
> **Status:** alpha, pre-1.0 — the API and CLI may still change.
|
|
42
|
+
> White-fill redaction only (colored backgrounds not yet sampled); no
|
|
43
|
+
> paragraph reflow; scanned pages are detected and warned about, not OCR'd.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pdfedit
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This puts a `pdfedit` command on your PATH. You can also run it as
|
|
52
|
+
`python -m pdfedit`.
|
|
53
|
+
|
|
54
|
+
From source (for development):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone https://github.com/sachinpandey22/pdfedit && cd pdfedit
|
|
58
|
+
python -m venv .venv && source .venv/bin/activate
|
|
59
|
+
pip install -e ".[dev]" # editable install + pytest
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pdfedit inspect your-document.pdf # read-only: list every text span with its id, font, size, color
|
|
66
|
+
pdfedit edit your-document.pdf # interactive: pick a span id, type a replacement, `save`
|
|
67
|
+
pdfedit batch your-document.pdf edits.json # non-interactive: apply a list of edits
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `inspect`
|
|
71
|
+
|
|
72
|
+
Prints one row per span. The `ID` (`p<page>-b<block>-l<line>-s<span>`) is the
|
|
73
|
+
handle you pass to `edit` or a batch file.
|
|
74
|
+
|
|
75
|
+
### `edit`
|
|
76
|
+
|
|
77
|
+
A small REPL. Enter a span id, then the replacement text; the edit is
|
|
78
|
+
**queued** with a before/after preview (and the resolved font, and an
|
|
79
|
+
auto-shrink note if the text had to be made smaller to fit). `save` applies
|
|
80
|
+
every queued edit at once and writes the output. `quit` discards everything.
|
|
81
|
+
|
|
82
|
+
### `batch`
|
|
83
|
+
|
|
84
|
+
`edits.json` is a JSON array; each entry is either:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
[
|
|
88
|
+
{ "id": "p1-b0-l0-s0", "new_text": "New Name" },
|
|
89
|
+
{ "find": "Old Company", "new_text": "New Company" }
|
|
90
|
+
]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- `id` targets one exact span.
|
|
94
|
+
- `find` replaces that substring in **every** span containing it. Multiple
|
|
95
|
+
`find` entries against the same span compose in order.
|
|
96
|
+
|
|
97
|
+
The whole file is validated first — if any entry is malformed or names a
|
|
98
|
+
missing span id, nothing is written and the command exits non-zero.
|
|
99
|
+
|
|
100
|
+
### Autofit
|
|
101
|
+
|
|
102
|
+
If a replacement is wider than the original box, the font size shrinks (down
|
|
103
|
+
to 60% of the original) until it fits. Past that floor it's drawn at the
|
|
104
|
+
minimum size and a warning is printed rather than overflowing silently.
|
|
105
|
+
|
|
106
|
+
### Encrypted PDFs
|
|
107
|
+
|
|
108
|
+
You're prompted for the password. Note: the `_edited.pdf` copy is written
|
|
109
|
+
**without** encryption.
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
pytest # run the test suite
|
|
115
|
+
python tests/fixtures/make_sample.py # regenerate the synthetic test fixture
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
There's no separate lint config. See
|
|
119
|
+
[`CLAUDE.md`](https://github.com/sachinpandey22/pdfedit/blob/main/CLAUDE.md)
|
|
120
|
+
for the architecture and the two redraw pitfalls the engine works around,
|
|
121
|
+
and [`docs/`](https://github.com/sachinpandey22/pdfedit/tree/main/docs) for
|
|
122
|
+
longer background.
|
pdfedit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# pdfedit
|
|
2
|
+
|
|
3
|
+
A command-line tool that finds and edits **any** text in a PDF while matching
|
|
4
|
+
the original font, size, and color — not a fixed find-and-replace on known
|
|
5
|
+
form fields. It walks every text span via [PyMuPDF](https://pymupdf.readthedocs.io/),
|
|
6
|
+
resolves each span's real font (embedded extraction, or the closest Base-14
|
|
7
|
+
fallback), then redacts the old glyphs and redraws the replacement in the
|
|
8
|
+
same font, size, and color at the same position.
|
|
9
|
+
|
|
10
|
+
The original file is never modified — every edit writes `<name>_edited.pdf`
|
|
11
|
+
next to the input.
|
|
12
|
+
|
|
13
|
+
> **Status:** alpha, pre-1.0 — the API and CLI may still change.
|
|
14
|
+
> White-fill redaction only (colored backgrounds not yet sampled); no
|
|
15
|
+
> paragraph reflow; scanned pages are detected and warned about, not OCR'd.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install pdfedit
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
This puts a `pdfedit` command on your PATH. You can also run it as
|
|
24
|
+
`python -m pdfedit`.
|
|
25
|
+
|
|
26
|
+
From source (for development):
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/sachinpandey22/pdfedit && cd pdfedit
|
|
30
|
+
python -m venv .venv && source .venv/bin/activate
|
|
31
|
+
pip install -e ".[dev]" # editable install + pytest
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pdfedit inspect your-document.pdf # read-only: list every text span with its id, font, size, color
|
|
38
|
+
pdfedit edit your-document.pdf # interactive: pick a span id, type a replacement, `save`
|
|
39
|
+
pdfedit batch your-document.pdf edits.json # non-interactive: apply a list of edits
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `inspect`
|
|
43
|
+
|
|
44
|
+
Prints one row per span. The `ID` (`p<page>-b<block>-l<line>-s<span>`) is the
|
|
45
|
+
handle you pass to `edit` or a batch file.
|
|
46
|
+
|
|
47
|
+
### `edit`
|
|
48
|
+
|
|
49
|
+
A small REPL. Enter a span id, then the replacement text; the edit is
|
|
50
|
+
**queued** with a before/after preview (and the resolved font, and an
|
|
51
|
+
auto-shrink note if the text had to be made smaller to fit). `save` applies
|
|
52
|
+
every queued edit at once and writes the output. `quit` discards everything.
|
|
53
|
+
|
|
54
|
+
### `batch`
|
|
55
|
+
|
|
56
|
+
`edits.json` is a JSON array; each entry is either:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
[
|
|
60
|
+
{ "id": "p1-b0-l0-s0", "new_text": "New Name" },
|
|
61
|
+
{ "find": "Old Company", "new_text": "New Company" }
|
|
62
|
+
]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- `id` targets one exact span.
|
|
66
|
+
- `find` replaces that substring in **every** span containing it. Multiple
|
|
67
|
+
`find` entries against the same span compose in order.
|
|
68
|
+
|
|
69
|
+
The whole file is validated first — if any entry is malformed or names a
|
|
70
|
+
missing span id, nothing is written and the command exits non-zero.
|
|
71
|
+
|
|
72
|
+
### Autofit
|
|
73
|
+
|
|
74
|
+
If a replacement is wider than the original box, the font size shrinks (down
|
|
75
|
+
to 60% of the original) until it fits. Past that floor it's drawn at the
|
|
76
|
+
minimum size and a warning is printed rather than overflowing silently.
|
|
77
|
+
|
|
78
|
+
### Encrypted PDFs
|
|
79
|
+
|
|
80
|
+
You're prompted for the password. Note: the `_edited.pdf` copy is written
|
|
81
|
+
**without** encryption.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pytest # run the test suite
|
|
87
|
+
python tests/fixtures/make_sample.py # regenerate the synthetic test fixture
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
There's no separate lint config. See
|
|
91
|
+
[`CLAUDE.md`](https://github.com/sachinpandey22/pdfedit/blob/main/CLAUDE.md)
|
|
92
|
+
for the architecture and the two redraw pitfalls the engine works around,
|
|
93
|
+
and [`docs/`](https://github.com/sachinpandey22/pdfedit/tree/main/docs) for
|
|
94
|
+
longer background.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pdfedit — Documentation
|
|
2
|
+
|
|
3
|
+
`pdfedit` is a command-line tool that finds and edits **any** text in a PDF while
|
|
4
|
+
matching the original font, size, and color — not just a fixed find-and-replace on
|
|
5
|
+
known form fields.
|
|
6
|
+
|
|
7
|
+
These docs serve two purposes:
|
|
8
|
+
|
|
9
|
+
- **Learning** — how PDFs store text, and how PyMuPDF lets us take it apart and
|
|
10
|
+
put it back together. See [concepts.md](concepts.md).
|
|
11
|
+
- **Using** — how to run the CLI to inspect and edit a real document. See
|
|
12
|
+
[usage.md](usage.md).
|
|
13
|
+
|
|
14
|
+
## Map of the docs
|
|
15
|
+
|
|
16
|
+
| Doc | What it covers |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| [concepts.md](concepts.md) | Background: PDF text model, spans, fonts, redaction. Read this first if the code is confusing. |
|
|
19
|
+
| [architecture.md](architecture.md) | How the four modules fit together and the flow of an edit from keystroke to saved file. |
|
|
20
|
+
| [usage.md](usage.md) | Every CLI subcommand, with examples. |
|
|
21
|
+
| [modules/model.md](modules/model.md) | `pdfedit/model.py` — span extraction, the text model. |
|
|
22
|
+
| [modules/fonts.md](modules/fonts.md) | `pdfedit/fonts.py` — font resolution (embedded extraction + Base-14 fallback). |
|
|
23
|
+
| [modules/engine.md](modules/engine.md) | `pdfedit/engine.py` — the redact-and-rewrite engine. |
|
|
24
|
+
| [modules/cli.md](modules/cli.md) | `pdfedit/cli.py` — argument parsing and the interactive REPL. |
|
|
25
|
+
| [glossary.md](glossary.md) | Quick definitions of terms used throughout. |
|
|
26
|
+
| [maintaining-these-docs.md](maintaining-these-docs.md) | How these docs stay in sync with the code, plus a change log. |
|
|
27
|
+
|
|
28
|
+
## Project status
|
|
29
|
+
|
|
30
|
+
The build is milestone-driven (see [pdf-text-editor-plan.md](../pdf-text-editor-plan.md)).
|
|
31
|
+
As of this writing (2026-09-10):
|
|
32
|
+
|
|
33
|
+
- **M1 done** — span extraction + `inspect` command.
|
|
34
|
+
- **M2 done** — font resolution (Base-14 fallback verified pixel-identical;
|
|
35
|
+
embedded-font extraction implemented but not yet verified against a real
|
|
36
|
+
embedded-font PDF).
|
|
37
|
+
- **M3 done** — single/multi-span queued replace via the `edit` REPL, with the
|
|
38
|
+
redaction-collision fix (`_shrink_to_avoid`, plus a containment guard so a
|
|
39
|
+
neighbor box that straddles the target doesn't invert the redaction rect).
|
|
40
|
+
- **M4 done** — autofit: replacement text that overflows is shrunk analytically
|
|
41
|
+
down to 60% of the original size, then clamped + warned.
|
|
42
|
+
- **M5 done** — `batch file.pdf edits.json`: apply a JSON list of `{id}` or
|
|
43
|
+
`{find}` edits non-interactively, validated up front, all-or-nothing.
|
|
44
|
+
- **Packaged (v0.1.0, not yet tagged)** — `pyproject.toml` (hatchling), a
|
|
45
|
+
`pdfedit` console script, and a `pytest` suite (`tests/`, 48 pass + 1
|
|
46
|
+
skipped) run off a synthetic fixture. See [PLAN.md](PLAN.md).
|
|
47
|
+
- **Removed:** the `find` subcommand (only ever an error stub — substring
|
|
48
|
+
replace lives on as `{"find": ...}` batch entries).
|
|
49
|
+
- **Not done:** M6 background-color sampling, M7 render-diff verification,
|
|
50
|
+
M8 paragraph reflow / OCR.
|
|
51
|
+
|
|
52
|
+
There is also a [CLAUDE.md](../CLAUDE.md) at the repo root — orientation for
|
|
53
|
+
Claude Code sessions. It overlaps these docs; the authority order on conflicts is
|
|
54
|
+
**source code → `pdf-text-editor-plan.md` → these docs**.
|
|
55
|
+
|
|
56
|
+
> **Note:** the code is under active development in a parallel session. If a code
|
|
57
|
+
> snippet here doesn't match the file, trust the file — then fix the doc.
|
|
58
|
+
|
|
59
|
+
## Requirements
|
|
60
|
+
|
|
61
|
+
- Python 3.10+ (developed on 3.14.7)
|
|
62
|
+
- `pymupdf>=1.28.2` — imported as `import pymupdf`, **not** the deprecated `import fitz`
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
python -m venv .venv
|
|
66
|
+
source .venv/bin/activate
|
|
67
|
+
pip install -e ".[dev]" # editable install + pytest; drop [dev] to skip
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This puts a `pdfedit` command on your PATH while the venv is active. You can
|
|
71
|
+
also run it as a module (a `pdfedit/__main__.py` shim makes this work):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
pdfedit inspect tests/fixtures/sample.pdf
|
|
75
|
+
python -m pdfedit inspect tests/fixtures/sample.pdf # equivalent
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`tests/fixtures/sample.pdf` is the committed fixture — a synthetic, fictional
|
|
79
|
+
one-page resume (regenerate with `python tests/fixtures/make_sample.py`).
|
|
80
|
+
|
|
81
|
+
Run the test suite with `pytest` (48 pass + 1 skipped — the skip is the
|
|
82
|
+
embedded-font path, which still needs a real-world PDF). The suite is the
|
|
83
|
+
release gate; the manual render-and-look-at-it check (`page.get_pixmap`) is now
|
|
84
|
+
the backup for any change to the redact/redraw path.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Package layout
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pdfedit/
|
|
7
|
+
├── __init__.py Package marker + `__version__` (hatchling reads it for the build)
|
|
8
|
+
├── __main__.py One-line shim so `python -m pdfedit` calls cli.main()
|
|
9
|
+
├── model.py Text model: extract spans, unpack colors, detect scanned pages
|
|
10
|
+
├── fonts.py Font resolution: embedded extraction + Base-14 fallback + cache
|
|
11
|
+
├── engine.py Edit engine: queue edits, autofit, redact, redraw with TextWriter, save
|
|
12
|
+
└── cli.py Argument parsing + the interactive `edit` REPL + batch mode
|
|
13
|
+
|
|
14
|
+
pyproject.toml hatchling build; `pdfedit` console script → `pdfedit.cli:main`
|
|
15
|
+
tests/ pytest suite (48 pass + 1 skipped) + `fixtures/sample.pdf`
|
|
16
|
+
and `fixtures/make_sample.py` (regenerates the synthetic fixture)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Two ways to invoke: the installed `pdfedit` command, or `python -m pdfedit`.
|
|
20
|
+
|
|
21
|
+
Dependency direction (nothing circular):
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
cli.py ──► engine.py ──► fonts.py ──► model.py
|
|
25
|
+
└─────────────────────────────────────► model.py
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `model.py` depends on nothing but `pymupdf`.
|
|
29
|
+
- `fonts.py` uses `Span` / `SpanStyle` from `model.py`.
|
|
30
|
+
- `engine.py` orchestrates `model.py` + `fonts.py`.
|
|
31
|
+
- `cli.py` is the only module that talks to the user (stdin/stdout, argparse).
|
|
32
|
+
|
|
33
|
+
## The objects
|
|
34
|
+
|
|
35
|
+
| Class | Module | Role |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `Span` | model | One immutable text run: id, page, text, bbox, origin, font, size, color, style. |
|
|
38
|
+
| `SpanStyle` | model | Decoded bold/italic/serif/monospace/superscript booleans + `short_label()`. |
|
|
39
|
+
| `FontResolution` | fonts | Result of resolving one span's font: alias, `embedded` bool, optional `fontbuffer` bytes, optional warning. |
|
|
40
|
+
| `FontResolver` | fonts | Per-document cache. `resolve(span)` → `FontResolution`; `font_object(res)` → `pymupdf.Font`. |
|
|
41
|
+
| `QueuedEdit` | engine | A pending edit: `span`, `new_text`, and the autofit-resolved `font_size` to draw with. |
|
|
42
|
+
| `EditPreview` | engine | What the REPL/batch shows before applying: `font_alias`, `embedded`, `font_size`, `shrunk`, `fits`. |
|
|
43
|
+
| `EditEngine` | engine | Holds the open doc, all spans, and the edit queue. `queue_edit`, `queue_find_replace`, `current_text`, `apply_all`, `save`. |
|
|
44
|
+
|
|
45
|
+
## Flow of one edit
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
user types "p1-b3-l0-s0" then "Jane Doe"
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
cli.cmd_edit REPL loop
|
|
52
|
+
│ engine.get_span(id) → Span
|
|
53
|
+
│ engine.queue_edit(id, text)
|
|
54
|
+
▼
|
|
55
|
+
EditEngine.preview(span, new_text)
|
|
56
|
+
│ resolver.resolve(span) → FontResolution (fonts.py)
|
|
57
|
+
│ resolver.font_object(res) → pymupdf.Font
|
|
58
|
+
│ _fit_font_size(font, text, span.size, bbox width) → (size, fits)
|
|
59
|
+
▼
|
|
60
|
+
EditPreview (printed: before/after, font used, autofit size, shrunk?, fits?)
|
|
61
|
+
│
|
|
62
|
+
user types "save"
|
|
63
|
+
▼
|
|
64
|
+
EditEngine.apply_all()
|
|
65
|
+
│ group queue by page
|
|
66
|
+
│ for each page:
|
|
67
|
+
│ for each edit: build redact rect, _shrink_to_avoid(every other span)
|
|
68
|
+
│ (clamp skipped when a neighbor straddles the box — _MAX_CLAMP_FRACTION)
|
|
69
|
+
│ page.add_redact_annot(rect, fill=white)
|
|
70
|
+
│ page.apply_redactions(images=NONE)
|
|
71
|
+
│ group edits by color
|
|
72
|
+
│ for each color: one TextWriter, append(origin, text, font, edit.font_size), write_text(page, color)
|
|
73
|
+
▼
|
|
74
|
+
EditEngine.save("<name>_edited.pdf") original untouched
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`batch` mode replaces the REPL box with `cmd_batch`: parse JSON, validate every
|
|
78
|
+
entry up front (abort with nothing written on any error), then `queue_edit` /
|
|
79
|
+
`queue_find_replace` per entry → `apply_all` → `save`.
|
|
80
|
+
|
|
81
|
+
## Design choices worth knowing
|
|
82
|
+
|
|
83
|
+
- **Span-granular by default.** Edits target one span. Paragraph reflow is a
|
|
84
|
+
stretch goal (M8), not the default.
|
|
85
|
+
- **Original file is never opened for writing.** Output always goes to a new
|
|
86
|
+
`_edited.pdf` next to the source.
|
|
87
|
+
- **Font extraction happens at most once per unique font name.** `FontResolver`
|
|
88
|
+
caches by `span.font`, and caches the built `pymupdf.Font` objects by alias.
|
|
89
|
+
- **Warnings, not failures.** Overflowing text, Type1 fallback, scanned pages —
|
|
90
|
+
all print a warning and continue.
|
|
91
|
+
- **The engine is UI-free.** `EditEngine` never calls `print()` or `input()`; it
|
|
92
|
+
returns warning strings for `cli.py` to display. This is what let `batch` mode
|
|
93
|
+
reuse it directly with no refactor.
|
|
94
|
+
- **`find`-mode edits chain.** `queue_find_replace` reads `current_text` (the
|
|
95
|
+
already-queued replacement if any, else the original), so several batch entries
|
|
96
|
+
can build on each other instead of clobbering.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Concepts — how PDF text works and why editing it is hard
|
|
2
|
+
|
|
3
|
+
If you've only ever thought of a PDF as "a document," this is the mental model you
|
|
4
|
+
need before the code makes sense.
|
|
5
|
+
|
|
6
|
+
## A PDF is a bag of drawing instructions, not a document
|
|
7
|
+
|
|
8
|
+
A PDF page does not contain paragraphs. It contains a **content stream**: a list of
|
|
9
|
+
low-level operators like "set font to F1 at 11pt", "move to x=72 y=709", "show the
|
|
10
|
+
string `Riley Morgan`", "move to...". There is no notion of "the name field" or
|
|
11
|
+
"the second bullet point." Everything is absolute positioning.
|
|
12
|
+
|
|
13
|
+
Consequences:
|
|
14
|
+
|
|
15
|
+
- **There is no reflow.** If you make a word longer, nothing after it moves. You
|
|
16
|
+
have to manage spacing yourself.
|
|
17
|
+
- **You can't just "replace text."** You must erase the pixels/operators that drew
|
|
18
|
+
the old text, then draw new text at the same spot with the same font.
|
|
19
|
+
- **The same visible letter can be encoded many ways** depending on the font's
|
|
20
|
+
internal character map (cmap). This is why font handling is the hard part.
|
|
21
|
+
|
|
22
|
+
## Spans — the unit we work with
|
|
23
|
+
|
|
24
|
+
PyMuPDF's `page.get_text("dict")` re-groups those raw operators into a tidy
|
|
25
|
+
hierarchy:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
page
|
|
29
|
+
└── block (roughly a paragraph or a distinct region)
|
|
30
|
+
└── line (one visual line of text)
|
|
31
|
+
└── span (a run of characters with ONE uniform style)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
A **span** is the smallest chunk where font, size, color, and style don't change.
|
|
35
|
+
"**Senior** Engineer" is two spans if "Senior" is bold: one bold span, one
|
|
36
|
+
regular span.
|
|
37
|
+
|
|
38
|
+
Each span from PyMuPDF gives us:
|
|
39
|
+
|
|
40
|
+
| Field | Meaning |
|
|
41
|
+
| --- | --- |
|
|
42
|
+
| `text` | the actual characters |
|
|
43
|
+
| `bbox` | `(x0, y0, x1, y1)` bounding box, in PDF points (1/72 inch), origin top-left |
|
|
44
|
+
| `origin` | `(x, y)` of the text **baseline** start — where drawing begins |
|
|
45
|
+
| `font` | font name as embedded, e.g. `ABCDEE+Calibri` or `Helvetica` |
|
|
46
|
+
| `size` | font size in points |
|
|
47
|
+
| `color` | a single packed integer, e.g. `0x1a1a1a` — see below |
|
|
48
|
+
| `flags` | bitfield: bold / italic / serif / monospace / superscript |
|
|
49
|
+
|
|
50
|
+
`pdfedit` wraps each in a [`Span`](modules/model.md) dataclass and gives it a
|
|
51
|
+
**stable ID** like `p1-b2-l1-s0` (page 1, block 2, line 1, span 0) so you can
|
|
52
|
+
refer to it on the command line.
|
|
53
|
+
|
|
54
|
+
### The packed color int
|
|
55
|
+
|
|
56
|
+
`color` is `(r << 16) | (g << 8) | b`, each channel 0–255. To get the 0–1 floats
|
|
57
|
+
PyMuPDF's drawing APIs want:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
r = (color >> 16) & 255
|
|
61
|
+
g = (color >> 8) & 255
|
|
62
|
+
b = color & 255
|
|
63
|
+
rgb01 = (r / 255, g / 255, b / 255)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This is `unpack_color()` in [model.py](modules/model.md).
|
|
67
|
+
|
|
68
|
+
## Fonts — embedded vs. not
|
|
69
|
+
|
|
70
|
+
To redraw text so it looks identical, you need the **same typeface**. Two cases:
|
|
71
|
+
|
|
72
|
+
1. **Font is embedded in the PDF** (common for designed documents). The font
|
|
73
|
+
program bytes are literally inside the file. We can pull them out with
|
|
74
|
+
`doc.extract_font(xref)` and hand them to a `pymupdf.Font(fontbuffer=...)`.
|
|
75
|
+
- Works reliably for TrueType (`ttf`) and OpenType (`otf`).
|
|
76
|
+
- Embedded **Type1** (old PostScript fonts) often comes back in a form the API
|
|
77
|
+
can't reuse → we fall back.
|
|
78
|
+
|
|
79
|
+
2. **Font is not embedded** (e.g. the `tests/fixtures/sample.pdf` fixture uses
|
|
80
|
+
Helvetica, one of the "Base-14" fonts every PDF reader is required to
|
|
81
|
+
have). Nothing to extract. We
|
|
82
|
+
pick the closest of PyMuPDF's 14 built-in aliases (`helv`, `hebo` = Helvetica
|
|
83
|
+
bold, `tiro` = Times roman, `cour` = Courier, ...) using the style flags.
|
|
84
|
+
|
|
85
|
+
[fonts.md](modules/fonts.md) covers the resolution logic and caching.
|
|
86
|
+
|
|
87
|
+
### Why `TextWriter`, not `insert_text`
|
|
88
|
+
|
|
89
|
+
This bit the project during M2. `page.insert_text(..., fontname="helv")` uses a
|
|
90
|
+
legacy simple-encoding code path. If your replacement text has a bullet (`•`), an
|
|
91
|
+
en-dash (`–`), or a curly quote, it renders a placeholder middle-dot **even
|
|
92
|
+
though the font contains the glyph**. The lookup table it uses just doesn't cover
|
|
93
|
+
those characters.
|
|
94
|
+
|
|
95
|
+
`pymupdf.TextWriter` + a real `pymupdf.Font` object resolves glyphs through the
|
|
96
|
+
font's actual cmap and renders correctly. So the engine always draws via
|
|
97
|
+
`TextWriter`. One quirk: `TextWriter` sets color per `write_text()` call, not per
|
|
98
|
+
appended string — so the engine groups edits by color and uses one writer per
|
|
99
|
+
color.
|
|
100
|
+
|
|
101
|
+
## Redaction — erasing the old text
|
|
102
|
+
|
|
103
|
+
To remove text we use PyMuPDF **redaction annotations**:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
page.add_redact_annot(rect, fill=(1, 1, 1)) # queue a white box over rect
|
|
107
|
+
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_NONE) # actually remove
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`apply_redactions()` genuinely deletes the underlying text operators (not just
|
|
111
|
+
covers them), then paints `fill` over the area.
|
|
112
|
+
|
|
113
|
+
**Gotcha handled in the engine:** `apply_redactions()` drops an entire
|
|
114
|
+
text-drawing operation if the redaction rect overlaps it *at all*. Span bboxes use
|
|
115
|
+
full font ascent/descent, so on tight line spacing a span's box can poke a point
|
|
116
|
+
or two into the line above/below — redacting one line would silently wipe its
|
|
117
|
+
neighbor. The engine shrinks the rect along the shallow-overlap axis to avoid
|
|
118
|
+
this (`_shrink_to_avoid` in [engine.md](modules/engine.md)). A guard
|
|
119
|
+
(`_MAX_CLAMP_FRACTION`) skips the clamp and redacts the full box when the cut
|
|
120
|
+
would exceed half the rect — that means the neighbor genuinely straddles or
|
|
121
|
+
contains the target (e.g. a big heading whose metric box spans a small line in
|
|
122
|
+
its whitespace), and clamping would invert the rect to nothing and redact
|
|
123
|
+
nothing at all.
|
|
124
|
+
|
|
125
|
+
Real documents aren't white under the text, so a future milestone (M6) samples the
|
|
126
|
+
actual background color. Right now the fill is hard-coded white.
|
|
127
|
+
|
|
128
|
+
## The full edit, end to end
|
|
129
|
+
|
|
130
|
+
1. **Extract** every span, assign IDs. (`model.py`)
|
|
131
|
+
2. User picks a span ID and types replacement text. (`cli.py`)
|
|
132
|
+
3. **Resolve** that span's font — extract embedded bytes or choose a Base-14
|
|
133
|
+
alias. (`fonts.py`)
|
|
134
|
+
4. **Preview**: measure the new text's width vs. the old box. If it's wider,
|
|
135
|
+
**autofit** solves for the largest font size that fits (down to a 60% floor);
|
|
136
|
+
if it still won't fit at the floor, warn.
|
|
137
|
+
5. On `save`: for each edited page —
|
|
138
|
+
a. redact every edited span's box (shrinking to protect neighbors),
|
|
139
|
+
b. `apply_redactions()`,
|
|
140
|
+
c. group edits by color, and for each color draw all its new strings at the
|
|
141
|
+
original baselines with `TextWriter`, at the autofit-resolved size.
|
|
142
|
+
6. **Save** to `<name>_edited.pdf`. The original file is never modified.
|
|
143
|
+
|
|
144
|
+
`batch` mode runs the same steps 3–6 without the REPL, from a JSON edit list.
|
|
145
|
+
|
|
146
|
+
See [architecture.md](architecture.md) for where each step lives.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Glossary
|
|
2
|
+
|
|
3
|
+
**autofit** — shrinking the replacement text's font size so it fits the original
|
|
4
|
+
span's box width. `pdfedit` solves for the size directly (glyph widths scale
|
|
5
|
+
linearly with size) rather than stepping, and won't go below 60% of the original
|
|
6
|
+
size (`_AUTOFIT_MIN_RATIO`) — past that it clamps and warns.
|
|
7
|
+
|
|
8
|
+
**Base-14 fonts** — the 14 typefaces (Helvetica ×4, Times ×4, Courier ×4, Symbol,
|
|
9
|
+
ZapfDingbats) every PDF viewer must provide. A PDF using only these doesn't embed
|
|
10
|
+
any font. `pdfedit` falls back to these when it can't reuse an embedded font.
|
|
11
|
+
|
|
12
|
+
**bbox** — bounding box, `(x0, y0, x1, y1)` in PDF points. `(x0, y0)` top-left,
|
|
13
|
+
`(x1, y1)` bottom-right. From `get_text("dict")` it spans the full font
|
|
14
|
+
ascent/descent, not just the visible glyph ink.
|
|
15
|
+
|
|
16
|
+
**block** — PyMuPDF's coarsest text grouping, roughly a paragraph or a distinct
|
|
17
|
+
page region. `block["type"]`: `0` = text, `1` = image.
|
|
18
|
+
|
|
19
|
+
**cmap (character map)** — a font's internal table mapping character codes to
|
|
20
|
+
glyph outlines. The reason "replace text" isn't trivial: the same visible letter
|
|
21
|
+
can be encoded differently in different fonts.
|
|
22
|
+
|
|
23
|
+
**content stream** — the actual list of drawing operators for a PDF page. Text is
|
|
24
|
+
"show string at position with font", with no paragraph or field structure.
|
|
25
|
+
|
|
26
|
+
**embedded font** — a font whose program bytes are stored inside the PDF.
|
|
27
|
+
Extractable with `doc.extract_font(xref)`; reusable for TrueType/OpenType.
|
|
28
|
+
|
|
29
|
+
**origin** — `(x, y)` of a span's baseline start. Where `TextWriter` begins
|
|
30
|
+
drawing. Distinct from `bbox` top-left (the baseline sits above the descender).
|
|
31
|
+
|
|
32
|
+
**packed color int** — `(r << 16) | (g << 8) | b`. `unpack_color()` in model.py
|
|
33
|
+
converts to 0–1 float RGB.
|
|
34
|
+
|
|
35
|
+
**point (pt)** — 1/72 inch. PDF's unit for everything. A US Letter page is
|
|
36
|
+
612 × 792 pt.
|
|
37
|
+
|
|
38
|
+
**redaction annotation** — PyMuPDF mechanism to *remove* content in a rectangle
|
|
39
|
+
(not just cover it). `add_redact_annot(rect, fill=...)` queues one;
|
|
40
|
+
`apply_redactions()` executes all queued ones on the page.
|
|
41
|
+
|
|
42
|
+
**span** — a run of characters with uniform font, size, color, and style. The
|
|
43
|
+
smallest unit `pdfedit` edits. `pdfedit` gives each a stable id
|
|
44
|
+
`p<page>-b<block>-l<line>-s<span>`.
|
|
45
|
+
|
|
46
|
+
**span id** — e.g. `p1-b2-l1-s0`. The handle you type in the `edit` REPL. Comes
|
|
47
|
+
from `inspect`.
|
|
48
|
+
|
|
49
|
+
**style flags** — a bitfield on each span: superscript(1), italic(2), serif(4),
|
|
50
|
+
monospace(8), bold(16). Decoded into `SpanStyle`.
|
|
51
|
+
|
|
52
|
+
**subsetting** — embedding only the glyphs a document actually uses. An embedded
|
|
53
|
+
`ABCDEE+Calibri` (the `ABCDEE+` prefix marks a subset) may lack characters your
|
|
54
|
+
replacement text needs.
|
|
55
|
+
|
|
56
|
+
**chaining (batch find edits)** — when several `{find}` entries in one batch
|
|
57
|
+
target the same span, each sees the previous one's result (`EditEngine.current_text`)
|
|
58
|
+
so they compose in order instead of overwriting each other.
|
|
59
|
+
|
|
60
|
+
**Type1 font** — older PostScript font format. Often embedded in a form PyMuPDF
|
|
61
|
+
can't feed back into `pymupdf.Font`, so `pdfedit` falls back to Base-14 for it.
|
|
62
|
+
|
|
63
|
+
**TextWriter** — `pymupdf.TextWriter`, the correct API for drawing new text.
|
|
64
|
+
Resolves glyphs through the font's real cmap (unlike `insert_text`, which mangles
|
|
65
|
+
bullets/dashes/quotes). Color is set per `write_text()` call, not per string.
|
|
66
|
+
|
|
67
|
+
**xref** — a PDF object's cross-reference number, its address within the file.
|
|
68
|
+
Fonts, images, pages are all addressed by xref.
|