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 +4 -0
- pdfedit/__main__.py +4 -0
- pdfedit/cli.py +252 -0
- pdfedit/engine.py +218 -0
- pdfedit/fonts.py +152 -0
- pdfedit/model.py +121 -0
- pdfedit-0.1.0.dist-info/METADATA +122 -0
- pdfedit-0.1.0.dist-info/RECORD +11 -0
- pdfedit-0.1.0.dist-info/WHEEL +4 -0
- pdfedit-0.1.0.dist-info/entry_points.txt +2 -0
- pdfedit-0.1.0.dist-info/licenses/LICENSE +21 -0
pdfedit/__init__.py
ADDED
pdfedit/__main__.py
ADDED
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,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.
|