pdfedit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdfedit/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """pdfedit — find and edit any text in a PDF, matching the original
2
+ font, size, and color."""
3
+
4
+ __version__ = "0.1.0"
pdfedit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pdfedit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
pdfedit/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """CLI entry point: inspect / edit / batch subcommands."""
2
+
3
+ import argparse
4
+ import getpass
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pymupdf
10
+
11
+ from pdfedit.engine import EditEngine
12
+ from pdfedit.model import extract_spans, is_scanned_page
13
+
14
+
15
+ def _open_doc(path: str) -> pymupdf.Document:
16
+ doc = pymupdf.open(path)
17
+ if doc.needs_pass:
18
+ for _ in range(3):
19
+ password = getpass.getpass(f"'{path}' is encrypted. Password: ")
20
+ if doc.authenticate(password):
21
+ break
22
+ else:
23
+ print("error: could not authenticate to encrypted PDF", file=sys.stderr)
24
+ sys.exit(1)
25
+ return doc
26
+
27
+
28
+ def _warn_scanned_pages(doc: pymupdf.Document) -> None:
29
+ for page_index in range(len(doc)):
30
+ if is_scanned_page(doc, page_index):
31
+ print(
32
+ f"warning: page {page_index + 1} appears to be scanned "
33
+ "(no text layer) — OCR is out of scope, spans on this page "
34
+ "will be empty",
35
+ file=sys.stderr,
36
+ )
37
+
38
+
39
+ def _truncate(text: str, width: int = 40) -> str:
40
+ text = text.replace("\n", "\\n")
41
+ if len(text) > width:
42
+ return text[: width - 1] + "…"
43
+ return text
44
+
45
+
46
+ def _print_span_table(spans: list) -> None:
47
+ if not spans:
48
+ print("No text spans found in this document.")
49
+ return
50
+ header = f"{'ID':<16} {'PG':>3} {'FONT':<22} {'SIZE':>5} {'COLOR':>8} {'STY':<4} TEXT"
51
+ print(header)
52
+ print("-" * len(header))
53
+ for s in spans:
54
+ color_hex = f"#{s.color:06x}"
55
+ print(
56
+ f"{s.id:<16} {s.page:>3} {s.font:<22} {s.size:>5.1f} "
57
+ f"{color_hex:>8} {s.style.short_label():<4} {_truncate(s.text)}"
58
+ )
59
+ print(f"\n{len(spans)} span(s).")
60
+
61
+
62
+ def cmd_inspect(args: argparse.Namespace) -> None:
63
+ doc = _open_doc(args.file)
64
+ _warn_scanned_pages(doc)
65
+ spans = extract_spans(doc)
66
+ _print_span_table(spans)
67
+
68
+
69
+ def cmd_edit(args: argparse.Namespace) -> None:
70
+ doc = _open_doc(args.file)
71
+ _warn_scanned_pages(doc)
72
+ engine = EditEngine(doc)
73
+
74
+ if not engine.spans:
75
+ print("No text spans found in this document.")
76
+ return
77
+
78
+ output_path = str(Path(args.file).with_name(Path(args.file).stem + "_edited.pdf"))
79
+
80
+ print(f"Loaded {len(engine.spans)} span(s). Commands:")
81
+ print(" <span id> start editing that span")
82
+ print(" list show all spans again")
83
+ print(" queue show queued edits")
84
+ print(" save apply all queued edits and write output, then exit")
85
+ print(" quit exit without saving")
86
+
87
+ while True:
88
+ try:
89
+ command = input("\npdfedit> ").strip()
90
+ except (EOFError, KeyboardInterrupt):
91
+ print("\nquit (nothing saved)")
92
+ return
93
+
94
+ if not command:
95
+ continue
96
+ if command in ("quit", "exit"):
97
+ print("quit (nothing saved)")
98
+ return
99
+ if command == "list":
100
+ _print_span_table(engine.spans)
101
+ continue
102
+ if command == "queue":
103
+ if not engine.queue:
104
+ print("(no edits queued)")
105
+ for span_id, queued in engine.queue.items():
106
+ print(f" {span_id}: {_truncate(queued.span.text)!r} -> {_truncate(queued.new_text)!r}")
107
+ continue
108
+ if command == "save":
109
+ if not engine.queue:
110
+ print("No edits queued, nothing to save.")
111
+ continue
112
+ warnings = engine.apply_all()
113
+ for w in warnings:
114
+ print(f"warning: {w}", file=sys.stderr)
115
+ engine.save(output_path)
116
+ print(f"Saved {output_path}")
117
+ return
118
+
119
+ span = engine.get_span(command)
120
+ if span is None:
121
+ print(f"No span with ID '{command}' (try 'list').")
122
+ continue
123
+
124
+ print(f"Current text: {span.text!r}")
125
+ print(f"Font: {span.font} Size: {span.size:.1f} Color: #{span.color:06x}")
126
+ try:
127
+ new_text = input("New text (blank to cancel): ")
128
+ except (EOFError, KeyboardInterrupt):
129
+ print("\ncancelled")
130
+ continue
131
+ if new_text == "":
132
+ print("cancelled")
133
+ continue
134
+
135
+ preview = engine.queue_edit(span.id, new_text)
136
+ font_kind = "embedded" if preview.embedded else "Base-14 fallback"
137
+ size_note = f"{preview.font_size:.1f}pt" if preview.shrunk else f"{preview.font_size:.1f}pt (unchanged)"
138
+ print(f"Before: {preview.span.text!r}")
139
+ print(f"After: {preview.new_text!r} (font: {preview.font_alias}, {font_kind}, size: {size_note})")
140
+ if preview.shrunk:
141
+ print(f"note: text was auto-shrunk from {span.size:.1f}pt to fit the original box")
142
+ if not preview.fits:
143
+ print("warning: replacement text still overflows the original box "
144
+ "even at the minimum autofit size")
145
+ print("Queued.")
146
+
147
+
148
+ def _validate_batch_entries(engine: EditEngine, edits_data: list) -> list:
149
+ errors = []
150
+ for i, entry in enumerate(edits_data):
151
+ if not isinstance(entry, dict):
152
+ errors.append(f"entry {i}: must be an object, got {type(entry).__name__}")
153
+ continue
154
+ has_id = "id" in entry
155
+ has_find = "find" in entry
156
+ if has_id == has_find:
157
+ errors.append(f"entry {i}: must have exactly one of 'id' or 'find'")
158
+ continue
159
+ if "new_text" not in entry or not isinstance(entry["new_text"], str):
160
+ errors.append(f"entry {i}: missing/invalid 'new_text' (must be a string)")
161
+ continue
162
+ if has_id:
163
+ if not isinstance(entry["id"], str) or not entry["id"]:
164
+ errors.append(f"entry {i}: 'id' must be a non-empty string")
165
+ elif engine.get_span(entry["id"]) is None:
166
+ errors.append(f"entry {i}: no span with id '{entry['id']}'")
167
+ else:
168
+ if not isinstance(entry["find"], str) or not entry["find"]:
169
+ errors.append(f"entry {i}: 'find' must be a non-empty string")
170
+ return errors
171
+
172
+
173
+ def cmd_batch(args: argparse.Namespace) -> None:
174
+ doc = _open_doc(args.file)
175
+ _warn_scanned_pages(doc)
176
+ engine = EditEngine(doc)
177
+
178
+ try:
179
+ with open(args.edits_json) as f:
180
+ edits_data = json.load(f)
181
+ except (OSError, json.JSONDecodeError) as e:
182
+ print(f"error: could not read '{args.edits_json}': {e}", file=sys.stderr)
183
+ sys.exit(1)
184
+
185
+ if not isinstance(edits_data, list):
186
+ print("error: edits JSON must be a list of edit objects", file=sys.stderr)
187
+ sys.exit(1)
188
+ if not edits_data:
189
+ print("No edits in file, nothing to do.")
190
+ return
191
+
192
+ # Validate everything up front; abort without touching anything if any
193
+ # entry is invalid, rather than silently writing a half-applied PDF.
194
+ errors = _validate_batch_entries(engine, edits_data)
195
+ if errors:
196
+ print(
197
+ f"error: {len(errors)} invalid edit(s) in '{args.edits_json}', nothing saved:",
198
+ file=sys.stderr,
199
+ )
200
+ for e in errors:
201
+ print(f" - {e}", file=sys.stderr)
202
+ sys.exit(1)
203
+
204
+ unmatched_finds = []
205
+ for entry in edits_data:
206
+ if "id" in entry:
207
+ engine.queue_edit(entry["id"], entry["new_text"])
208
+ else:
209
+ previews = engine.queue_find_replace(entry["find"], entry["new_text"])
210
+ if not previews:
211
+ unmatched_finds.append(entry["find"])
212
+
213
+ for find in unmatched_finds:
214
+ print(f"warning: no span found containing {find!r}", file=sys.stderr)
215
+
216
+ warnings = engine.apply_all()
217
+ for w in warnings:
218
+ print(f"warning: {w}", file=sys.stderr)
219
+
220
+ output_path = str(Path(args.file).with_name(Path(args.file).stem + "_edited.pdf"))
221
+ engine.save(output_path)
222
+ print(f"Applied {len(edits_data)} edit(s). Saved {output_path}")
223
+
224
+
225
+ def build_parser() -> argparse.ArgumentParser:
226
+ parser = argparse.ArgumentParser(prog="pdfedit", description=__doc__)
227
+ sub = parser.add_subparsers(dest="command", required=True)
228
+
229
+ p_inspect = sub.add_parser("inspect", help="read-only dump of every text span")
230
+ p_inspect.add_argument("file")
231
+ p_inspect.set_defaults(func=cmd_inspect)
232
+
233
+ p_edit = sub.add_parser("edit", help="interactive edit REPL")
234
+ p_edit.add_argument("file")
235
+ p_edit.set_defaults(func=cmd_edit)
236
+
237
+ p_batch = sub.add_parser("batch", help="apply a JSON list of edits non-interactively")
238
+ p_batch.add_argument("file")
239
+ p_batch.add_argument("edits_json")
240
+ p_batch.set_defaults(func=cmd_batch)
241
+
242
+ return parser
243
+
244
+
245
+ def main(argv: list | None = None) -> None:
246
+ parser = build_parser()
247
+ args = parser.parse_args(argv)
248
+ args.func(args)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
pdfedit/engine.py ADDED
@@ -0,0 +1,218 @@
1
+ """Redaction & replacement engine: apply queued span edits to a document.
2
+
3
+ White-fill redaction (background sampling is M6). Overflow that can't be
4
+ solved by autofit is warned about, not silently swallowed.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import pymupdf
10
+
11
+ from pdfedit.fonts import FontResolver
12
+ from pdfedit.model import Span, extract_spans
13
+
14
+ # Placeholder fill until M6 adds real background-color sampling.
15
+ _DEFAULT_FILL = (1, 1, 1)
16
+
17
+ # Autofit: never shrink a span below this fraction of its original size —
18
+ # past that point the text reads as visibly wrong-sized rather than a
19
+ # faithful edit, so we stop and warn instead.
20
+ _AUTOFIT_MIN_RATIO = 0.6
21
+ # Slight safety margin so a shrunk size doesn't land exactly on the boundary
22
+ # and overflow by a rounding hair.
23
+ _AUTOFIT_SAFETY = 0.98
24
+
25
+
26
+ def _fit_font_size(font: pymupdf.Font, text: str, original_size: float, max_width: float) -> tuple:
27
+ """Return (chosen_size, fits) for drawing `text` in `font` within
28
+ max_width, starting from original_size and shrinking only as far as
29
+ needed (down to _AUTOFIT_MIN_RATIO of original_size). Glyph advance
30
+ widths scale linearly with font size for a fixed font, so the required
31
+ size can be computed directly rather than stepped iteratively."""
32
+ width = font.text_length(text, fontsize=original_size)
33
+ if width <= max_width:
34
+ return original_size, True
35
+
36
+ per_point_width = font.text_length(text, fontsize=1.0)
37
+ min_size = original_size * _AUTOFIT_MIN_RATIO
38
+ if per_point_width <= 0:
39
+ return original_size, False
40
+
41
+ needed_size = (max_width / per_point_width) * _AUTOFIT_SAFETY
42
+ chosen_size = max(needed_size, min_size)
43
+ fits = font.text_length(text, fontsize=chosen_size) <= max_width
44
+ return chosen_size, fits
45
+
46
+
47
+ # A clamp is only applied if the overlap it needs to cut away is less than
48
+ # this fraction of the redaction rect's extent on that axis. Past that, the
49
+ # neighbor's box isn't merely metric-adjacent to the target — it genuinely
50
+ # straddles or contains it (a big heading whose ascent/descent box spans a
51
+ # small line sitting in its whitespace, multi-size spans on one line, etc.).
52
+ # Clamping there would invert the rect to empty and redact nothing, leaving
53
+ # the old glyphs under the new text. Better to redact the full box and
54
+ # accept the small over-redaction risk (in practice apply_redactions leaves
55
+ # a non-overlapping neighbor's glyphs alone).
56
+ _MAX_CLAMP_FRACTION = 0.5
57
+
58
+
59
+ def _shrink_to_avoid(rect: pymupdf.Rect, other_bbox: tuple) -> None:
60
+ """Shrink rect in place so it no longer overlaps other_bbox. PyMuPDF's
61
+ apply_redactions() removes an entire text-drawing operation if the
62
+ redaction rectangle overlaps it AT ALL — and span bboxes from
63
+ get_text("dict") use full font ascent/descent metrics, which routinely
64
+ overlap a couple points into a vertically adjacent line on tight
65
+ line-spacing even though the glyphs themselves never touch. Without
66
+ this, redacting one span can silently erase an untouched neighboring
67
+ line. We clamp along whichever axis has the shallower intrusion, since
68
+ that's the axis where the two boxes are merely adjacent rather than
69
+ truly overlapping — but only when the cut stays under
70
+ _MAX_CLAMP_FRACTION of the rect (see the constant)."""
71
+ ox0, oy0, ox1, oy1 = other_bbox
72
+ ix0, ix1 = max(rect.x0, ox0), min(rect.x1, ox1)
73
+ iy0, iy1 = max(rect.y0, oy0), min(rect.y1, oy1)
74
+ if ix0 >= ix1 or iy0 >= iy1:
75
+ return # no overlap
76
+ x_overlap, y_overlap = ix1 - ix0, iy1 - iy0
77
+ if y_overlap <= x_overlap:
78
+ if y_overlap >= _MAX_CLAMP_FRACTION * rect.height:
79
+ return
80
+ if oy0 < (rect.y0 + rect.y1) / 2:
81
+ rect.y0 = max(rect.y0, oy1)
82
+ else:
83
+ rect.y1 = min(rect.y1, oy0)
84
+ else:
85
+ if x_overlap >= _MAX_CLAMP_FRACTION * rect.width:
86
+ return
87
+ if ox0 < (rect.x0 + rect.x1) / 2:
88
+ rect.x0 = max(rect.x0, ox1)
89
+ else:
90
+ rect.x1 = min(rect.x1, ox0)
91
+
92
+
93
+ @dataclass
94
+ class QueuedEdit:
95
+ span: Span
96
+ new_text: str
97
+ font_size: float # autofit-resolved size to actually draw with
98
+
99
+
100
+ @dataclass
101
+ class EditPreview:
102
+ span: Span
103
+ new_text: str
104
+ font_alias: str
105
+ embedded: bool
106
+ font_size: float # autofit-resolved size (== span.size if it already fit)
107
+ shrunk: bool
108
+ fits: bool
109
+
110
+
111
+ class EditEngine:
112
+ """Holds an open document, its spans, and a queue of pending edits."""
113
+
114
+ def __init__(self, doc: pymupdf.Document):
115
+ self.doc = doc
116
+ self.resolver = FontResolver(doc)
117
+ self.spans: list[Span] = extract_spans(doc)
118
+ self._spans_by_id: dict[str, Span] = {s.id: s for s in self.spans}
119
+ self.queue: dict[str, QueuedEdit] = {} # keyed by span id
120
+
121
+ def get_span(self, span_id: str) -> Span | None:
122
+ return self._spans_by_id.get(span_id)
123
+
124
+ def preview(self, span: Span, new_text: str) -> EditPreview:
125
+ resolution = self.resolver.resolve(span)
126
+ font = self.resolver.font_object(resolution)
127
+ old_width = span.bbox[2] - span.bbox[0]
128
+ font_size, fits = _fit_font_size(font, new_text, span.size, old_width)
129
+ return EditPreview(
130
+ span=span,
131
+ new_text=new_text,
132
+ font_alias=resolution.alias,
133
+ embedded=resolution.embedded,
134
+ font_size=font_size,
135
+ shrunk=font_size < span.size - 1e-6,
136
+ fits=fits,
137
+ )
138
+
139
+ def queue_edit(self, span_id: str, new_text: str) -> EditPreview | None:
140
+ span = self.get_span(span_id)
141
+ if span is None:
142
+ return None
143
+ preview = self.preview(span, new_text)
144
+ self.queue[span_id] = QueuedEdit(span=span, new_text=new_text, font_size=preview.font_size)
145
+ return preview
146
+
147
+ def unqueue(self, span_id: str) -> bool:
148
+ return self.queue.pop(span_id, None) is not None
149
+
150
+ def current_text(self, span: Span) -> str:
151
+ """The span's text as it would be if applied right now: the
152
+ already-queued replacement if one exists, else its original text."""
153
+ queued = self.queue.get(span.id)
154
+ return queued.new_text if queued is not None else span.text
155
+
156
+ def queue_find_replace(self, find: str, replacement: str) -> list[EditPreview]:
157
+ """Find every span whose current text contains `find` and queue an
158
+ edit replacing that substring with `replacement`, preserving the
159
+ rest of the span's text. Chains against already-queued edits (by
160
+ current_text) so multiple find/replace entries targeting the same
161
+ span in one batch compose in order instead of clobbering each
162
+ other."""
163
+ previews = []
164
+ for span in self.spans:
165
+ text = self.current_text(span)
166
+ if find in text:
167
+ preview = self.queue_edit(span.id, text.replace(find, replacement))
168
+ previews.append(preview)
169
+ return previews
170
+
171
+ def apply_all(self) -> list[str]:
172
+ """Apply every queued edit to self.doc in place. Returns a list of
173
+ warning strings (overflow, font fallback) collected along the way."""
174
+ warnings: list[str] = list(self.resolver.warnings)
175
+ edits_by_page: dict[int, list[QueuedEdit]] = {}
176
+ for edit in self.queue.values():
177
+ edits_by_page.setdefault(edit.span.page - 1, []).append(edit)
178
+
179
+ for page_index, edits in edits_by_page.items():
180
+ page = self.doc[page_index]
181
+ edited_ids = {edit.span.id for edit in edits}
182
+ other_spans = [
183
+ s for s in self.spans if s.page - 1 == page_index and s.id not in edited_ids
184
+ ]
185
+
186
+ for edit in edits:
187
+ rect = pymupdf.Rect(edit.span.bbox)
188
+ for other in other_spans:
189
+ _shrink_to_avoid(rect, other.bbox)
190
+ page.add_redact_annot(rect, fill=_DEFAULT_FILL)
191
+ page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_NONE)
192
+
193
+ by_color: dict[tuple, list[QueuedEdit]] = {}
194
+ for edit in edits:
195
+ by_color.setdefault(edit.span.color_rgb01(), []).append(edit)
196
+
197
+ for color, group in by_color.items():
198
+ tw = pymupdf.TextWriter(page.rect)
199
+ for edit in group:
200
+ resolution = self.resolver.resolve(edit.span)
201
+ font = self.resolver.font_object(resolution)
202
+ old_width = edit.span.bbox[2] - edit.span.bbox[0]
203
+ final_width = font.text_length(edit.new_text, fontsize=edit.font_size)
204
+ if final_width > old_width:
205
+ warnings.append(
206
+ f"{edit.span.id}: replacement text still overflows original "
207
+ f"box at the minimum autofit size "
208
+ f"({edit.font_size:.1f}pt, {_AUTOFIT_MIN_RATIO:.0%} of original "
209
+ f"{edit.span.size:.1f}pt) — {final_width:.1f}pt > {old_width:.1f}pt"
210
+ )
211
+ tw.append(edit.span.origin, edit.new_text, font=font, fontsize=edit.font_size)
212
+ tw.write_text(page, color=color)
213
+
214
+ self.queue.clear()
215
+ return warnings
216
+
217
+ def save(self, output_path: str) -> None:
218
+ self.doc.save(output_path)
pdfedit/fonts.py ADDED
@@ -0,0 +1,152 @@
1
+ """Font resolution engine: for a given span, find the exact embedded font
2
+ program if available, otherwise fall back to the closest Base-14 font based
3
+ on the span's style flags. Resolutions are cached per unique source font
4
+ name so extraction only happens once per document."""
5
+
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ import pymupdf
10
+
11
+ from pdfedit.model import Span, SpanStyle
12
+
13
+ # PyMuPDF's reserved Base-14 font aliases (usable directly as `fontname=`
14
+ # in insert_text/insert_textbox without registering anything).
15
+ _BASE14_HELVETICA = {
16
+ (False, False): "helv",
17
+ (True, False): "hebo",
18
+ (False, True): "heit",
19
+ (True, True): "hebi",
20
+ }
21
+ _BASE14_TIMES = {
22
+ (False, False): "tiro",
23
+ (True, False): "tibo",
24
+ (False, True): "tiit",
25
+ (True, True): "tibi",
26
+ }
27
+ _BASE14_COURIER = {
28
+ (False, False): "cour",
29
+ (True, False): "cobo",
30
+ (False, True): "coit",
31
+ (True, True): "cobi",
32
+ }
33
+
34
+ # extract_font() ext values that insert_font(fontbuffer=...) can reliably
35
+ # consume. Type1 ("n/a" for standard fonts, "pfb"/"cff" bare for embedded
36
+ # Type1) and Type3 fonts are not safe to reuse this way.
37
+ _SUPPORTED_EMBEDDED_EXTS = {"ttf", "otf"}
38
+
39
+ _ALIAS_SANITIZE_RE = re.compile(r"[^A-Za-z0-9]+")
40
+
41
+
42
+ def base14_alias(style: SpanStyle) -> str:
43
+ """Pick the closest Base-14 font alias for a span's style flags."""
44
+ key = (style.bold, style.italic)
45
+ if style.monospace:
46
+ return _BASE14_COURIER[key]
47
+ if style.serif:
48
+ return _BASE14_TIMES[key]
49
+ return _BASE14_HELVETICA[key]
50
+
51
+
52
+ @dataclass
53
+ class FontResolution:
54
+ alias: str # cache key / label, not used directly for text insertion
55
+ embedded: bool
56
+ fontbuffer: bytes | None = None # only set when embedded
57
+ warning: str | None = None
58
+
59
+
60
+ class FontResolver:
61
+ """Resolves and caches fonts for a single open document.
62
+
63
+ Text must be drawn with `pymupdf.TextWriter` against the `pymupdf.Font`
64
+ objects this returns — NOT `page.insert_text`/`insert_textbox` with a
65
+ bare `fontname=` string. The latter uses a legacy simple-encoding path
66
+ that silently mangles any character outside core WinAnsi (bullets,
67
+ en/em dashes, curly quotes all render as a placeholder middle-dot) even
68
+ though the font itself has the glyph (verified via `Font.has_glyph`).
69
+ TextWriter resolves glyphs from the Font object's actual cmap and
70
+ renders them correctly, and registers the font as a page resource
71
+ automatically — no separate `page.insert_font()` call needed.
72
+ """
73
+
74
+ def __init__(self, doc: pymupdf.Document):
75
+ self.doc = doc
76
+ self._cache: dict[str, FontResolution] = {}
77
+ self._font_objects: dict[str, pymupdf.Font] = {} # keyed by alias
78
+ self._page_font_cache: dict[int, list] = {}
79
+ self.warnings: list[str] = []
80
+
81
+ def _page_fonts(self, page_index: int) -> list:
82
+ if page_index not in self._page_font_cache:
83
+ self._page_font_cache[page_index] = self.doc[page_index].get_fonts(full=True)
84
+ return self._page_font_cache[page_index]
85
+
86
+ def _find_xref(self, page_index: int, font_name: str) -> int | None:
87
+ for entry in self._page_fonts(page_index):
88
+ xref, _ext, _type, basefont, _name, _encoding, *_ = entry
89
+ if basefont == font_name:
90
+ return xref
91
+ return None
92
+
93
+ def _alias_for_embedded(self, font_name: str) -> str:
94
+ base = _ALIAS_SANITIZE_RE.sub("", font_name) or "font"
95
+ alias = base
96
+ n = 1
97
+ existing_aliases = {r.alias for r in self._cache.values()}
98
+ while alias in existing_aliases:
99
+ n += 1
100
+ alias = f"{base}{n}"
101
+ return alias
102
+
103
+ def resolve(self, span: Span) -> FontResolution:
104
+ cached = self._cache.get(span.font)
105
+ if cached is not None:
106
+ return cached
107
+
108
+ resolution = self._resolve_embedded(span)
109
+ if resolution is None:
110
+ alias = base14_alias(span.style)
111
+ resolution = FontResolution(
112
+ alias=alias,
113
+ embedded=False,
114
+ warning=(
115
+ f"font '{span.font}' is not embedded (or unsupported); "
116
+ f"falling back to Base-14 '{alias}'"
117
+ ),
118
+ )
119
+
120
+ if resolution.warning:
121
+ self.warnings.append(resolution.warning)
122
+ self._cache[span.font] = resolution
123
+ return resolution
124
+
125
+ def _resolve_embedded(self, span: Span) -> FontResolution | None:
126
+ page_index = span.page - 1
127
+ xref = self._find_xref(page_index, span.font)
128
+ if xref is None:
129
+ return None
130
+
131
+ try:
132
+ basefont, ext, subtype, buffer = self.doc.extract_font(xref)
133
+ except Exception:
134
+ return None
135
+
136
+ if ext not in _SUPPORTED_EMBEDDED_EXTS or not buffer:
137
+ return None
138
+
139
+ alias = self._alias_for_embedded(span.font)
140
+ return FontResolution(alias=alias, embedded=True, fontbuffer=buffer)
141
+
142
+ def font_object(self, resolution: FontResolution) -> pymupdf.Font:
143
+ """Get (and cache) the pymupdf.Font object to draw with via
144
+ TextWriter for this resolution."""
145
+ font = self._font_objects.get(resolution.alias)
146
+ if font is None:
147
+ if resolution.embedded:
148
+ font = pymupdf.Font(fontbuffer=resolution.fontbuffer)
149
+ else:
150
+ font = pymupdf.Font(fontname=resolution.alias)
151
+ self._font_objects[resolution.alias] = font
152
+ return font
pdfedit/model.py ADDED
@@ -0,0 +1,121 @@
1
+ """Text model layer: walk a PDF's pages and extract every text span as a
2
+ stable, addressable unit (font, size, color, style, bbox)."""
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+ import pymupdf
7
+
8
+ # Bit flags used by PyMuPDF in span["flags"].
9
+ _FLAG_SUPERSCRIPT = 1
10
+ _FLAG_ITALIC = 2
11
+ _FLAG_SERIFED = 4
12
+ _FLAG_MONOSPACED = 8
13
+ _FLAG_BOLD = 16
14
+
15
+
16
+ @dataclass
17
+ class SpanStyle:
18
+ superscript: bool
19
+ italic: bool
20
+ serif: bool
21
+ monospace: bool
22
+ bold: bool
23
+
24
+ @classmethod
25
+ def from_flags(cls, flags: int) -> "SpanStyle":
26
+ return cls(
27
+ superscript=bool(flags & _FLAG_SUPERSCRIPT),
28
+ italic=bool(flags & _FLAG_ITALIC),
29
+ serif=bool(flags & _FLAG_SERIFED),
30
+ monospace=bool(flags & _FLAG_MONOSPACED),
31
+ bold=bool(flags & _FLAG_BOLD),
32
+ )
33
+
34
+ def short_label(self) -> str:
35
+ letters = ""
36
+ if self.bold:
37
+ letters += "B"
38
+ if self.italic:
39
+ letters += "I"
40
+ if self.serif:
41
+ letters += "S"
42
+ if self.monospace:
43
+ letters += "M"
44
+ return letters or "-"
45
+
46
+
47
+ @dataclass
48
+ class Span:
49
+ id: str
50
+ page: int # 1-based, human-facing
51
+ block_no: int # 0-based, as reported by get_text("dict")
52
+ line_no: int
53
+ span_no: int
54
+ text: str
55
+ bbox: tuple # (x0, y0, x1, y1)
56
+ origin: tuple # (x, y) baseline origin
57
+ font: str
58
+ size: float
59
+ color: int # packed sRGB int, as reported by PyMuPDF
60
+ style: SpanStyle
61
+ page_rotation: int = field(default=0)
62
+
63
+ def color_rgb01(self) -> tuple:
64
+ """Unpack the span's packed color int into 0-1 float RGB."""
65
+ return unpack_color(self.color)
66
+
67
+
68
+ def unpack_color(color: int) -> tuple:
69
+ """Unpack a packed sRGB int (as PyMuPDF reports span colors) into a
70
+ 0-1 float (r, g, b) tuple usable by insert_text/insert_textbox."""
71
+ r = (color >> 16) & 255
72
+ g = (color >> 8) & 255
73
+ b = color & 255
74
+ return (r / 255.0, g / 255.0, b / 255.0)
75
+
76
+
77
+ def extract_spans(doc: pymupdf.Document) -> list[Span]:
78
+ """Walk every page of doc and return a flat list of Span objects, each
79
+ with a stable ID of the form p{page}-b{block}-l{line}-s{span}."""
80
+ spans: list[Span] = []
81
+ for page_index in range(len(doc)):
82
+ page = doc[page_index]
83
+ page_no = page_index + 1
84
+ rotation = page.rotation
85
+ raw = page.get_text("dict")
86
+ for block in raw.get("blocks", []):
87
+ if block.get("type") != 0:
88
+ continue # skip image blocks
89
+ block_no = block["number"]
90
+ for line_no, line in enumerate(block.get("lines", [])):
91
+ for span_no, s in enumerate(line.get("spans", [])):
92
+ span_id = f"p{page_no}-b{block_no}-l{line_no}-s{span_no}"
93
+ spans.append(
94
+ Span(
95
+ id=span_id,
96
+ page=page_no,
97
+ block_no=block_no,
98
+ line_no=line_no,
99
+ span_no=span_no,
100
+ text=s["text"],
101
+ bbox=tuple(s["bbox"]),
102
+ origin=tuple(s["origin"]),
103
+ font=s["font"],
104
+ size=s["size"],
105
+ color=s["color"],
106
+ style=SpanStyle.from_flags(s["flags"]),
107
+ page_rotation=rotation,
108
+ )
109
+ )
110
+ return spans
111
+
112
+
113
+ def is_scanned_page(doc: pymupdf.Document, page_index: int) -> bool:
114
+ """Heuristic check for a scanned/no-text-layer page: no extractable text
115
+ but at least one image present (a blank page with neither is not
116
+ considered "scanned", just empty)."""
117
+ page = doc[page_index]
118
+ text = page.get_text("text").strip()
119
+ if text:
120
+ return False
121
+ return len(page.get_images()) > 0
@@ -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,11 @@
1
+ pdfedit/__init__.py,sha256=jFI1Oz_-Wvf_hhzF-eqLvi5UoCPO9x35NWU0JJ52raU,119
2
+ pdfedit/__main__.py,sha256=Fr6ARWNJdlHU2tN8t58l01Hz3bPTFPm77q1Zluu5yxM,68
3
+ pdfedit/cli.py,sha256=ueRPlRsimnOPTRhd5apQVnXaT6qWTUu_qSh7S9yBHwk,8759
4
+ pdfedit/engine.py,sha256=pQ-KJ7up0uknbcEMVijNEbC2A1CIBhEb98ReywGB8D8,9271
5
+ pdfedit/fonts.py,sha256=Gbn9cQysrwkhity_BQEipCmE1Je0vlIK0CNCdTXLkro,5452
6
+ pdfedit/model.py,sha256=jmA884etOiOFrnij1LAkE0lVPE8p_YXta3sgm12r1Ms,3894
7
+ pdfedit-0.1.0.dist-info/METADATA,sha256=i3zudw6tsweWF7dA-FER8y7Als-q-zjupCCKtpXMxTE,4328
8
+ pdfedit-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ pdfedit-0.1.0.dist-info/entry_points.txt,sha256=5pFNp3hGvQ8Exwp2GkN72kpij3LrbNAHMJ8FmVFoHnk,45
10
+ pdfedit-0.1.0.dist-info/licenses/LICENSE,sha256=TzbVQ4gnmkXxnv63wVfsN96Wx03tAT4xtmKVbUXC_SU,1070
11
+ pdfedit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdfedit = pdfedit.cli:main
@@ -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.