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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *_edited.pdf
5
+ .DS_Store
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .pytest_cache/
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.
@@ -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.