apdf 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.
apdf/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from apdf.cli import main
2
+ from apdf.engine import replace_pdf_text, replace_pdf_text_detailed
3
+ from apdf.exceptions import FontEncodingError, PdfReplacementError
4
+ from apdf.models import Replacement, ReplacementReport
5
+ __all__ = ['FontEncodingError', 'PdfReplacementError', 'Replacement', 'ReplacementReport', 'main', 'replace_pdf_text', 'replace_pdf_text_detailed']
apdf/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from apdf.cli import main
2
+ raise SystemExit(main())
apdf/cli.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+ import argparse
3
+ import sys
4
+ from importlib.metadata import PackageNotFoundError, version
5
+ from typing import Sequence
6
+ from apdf.engine import replace_pdf_text_detailed
7
+ from apdf.exceptions import PdfReplacementError
8
+ from apdf.utils import _c, _supports_color, _BLUE, _BOLD, _CYAN, _DIM, _GRAY, _GREEN, _MAGENTA, _RED, _YELLOW
9
+
10
+ def _installed_version() -> str:
11
+ try:
12
+ return version('apdf')
13
+ except PackageNotFoundError:
14
+ return '1.0'
15
+ _BANNER = '\n_______ __________________________\n___ | ___ __ \\__ __ \\__ ____' + '/\n__ /| |________ /_/ /_ / / /_ /_ \n_ ___ |/_____/ ____/_ /_/ /_ __/ \n/_/ |_| /_/ /_____/ /_/ \n \n'
16
+
17
+ def _print_banner() -> None:
18
+ _ekiwktyjiqvscw = _installed_version()
19
+ if _supports_color():
20
+ _aomhlakkennlyy = _BANNER.strip('\n').splitlines()
21
+ _vajauyelrxjwor = [_RED, _RED, _BLUE, _BLUE, _CYAN]
22
+ for _orcjqqjrkxdmvn, _bhrcaadxxlskbv in enumerate(_aomhlakkennlyy):
23
+ _aolnsuvmdziujt = _vajauyelrxjwor[_orcjqqjrkxdmvn] if _orcjqqjrkxdmvn < len(_vajauyelrxjwor) else _CYAN
24
+ print(_c(_bhrcaadxxlskbv, _aolnsuvmdziujt, _BOLD), file=sys.stderr)
25
+ else:
26
+ print(_BANNER.strip('\n'), file=sys.stderr)
27
+ _inidpblknczlqe = _c(' Low-level PDF Text Rep' + 'lacer', _DIM)
28
+ _xjirsszckdbhxw = _c(f' v{_ekiwktyjiqvscw} ', _BOLD, _MAGENTA)
29
+ _kcaglkxhkhgurv = _c(' b' + 'y Ab' + 'od' + 'x9', _YELLOW, _BOLD)
30
+ print(f'{_inidpblknczlqe}{_xjirsszckdbhxw}{_kcaglkxhkhgurv}\n', file=sys.stderr)
31
+ print(_c(' ' + '─' * 52, _GRAY), file=sys.stderr)
32
+ print(file=sys.stderr)
33
+
34
+ def _parse_replacements(values: Sequence[str]) -> dict[str, str]:
35
+ _nxodcjjpdthxce: dict[str, str] = {}
36
+ for _phdupmqsoiiqal in values:
37
+ if '=' not in _phdupmqsoiiqal:
38
+ raise ValueError('-r requires ' + 'OLD=NEW')
39
+ old, new = _phdupmqsoiiqal.split('=', 1)
40
+ if not old:
41
+ raise ValueError('OLD cannot be e' + 'mpty')
42
+ _nxodcjjpdthxce[old] = new
43
+ return _nxodcjjpdthxce
44
+
45
+ def main(argv: Sequence[str] | None=None) -> int:
46
+ _print_banner()
47
+ if argv is None:
48
+ argv = sys.argv[1:]
49
+ _gnuqnzyfqlqvpo = argparse.ArgumentParser(description='Replac' + 'e Unicode and Hex text directly in PDF conte' + 'nt stream' + 's.')
50
+ _gnuqnzyfqlqvpo.add_argument('input', help='Input P' + 'D' + 'F')
51
+ _gnuqnzyfqlqvpo.add_argument('output', nargs='?', default='output.pdf', help='Outpu' + 't PD' + 'F')
52
+ _gnuqnzyfqlqvpo.add_argument('-r', action='append', default=[], metavar='OLD=NEW', dest='replace', help='Replace: Unicode rep' + 'lacement;' + ' may be supplied more than ' + 'once')
53
+ _gnuqnzyfqlqvpo.add_argument('-nopa', action='store_true', dest='no_preserve_advance', help='No preserve advance: Do not compensate TJ widths after repla' + 'cements')
54
+ _gnuqnzyfqlqvpo.add_argument('-s', action='store_true', dest='strict', help='Strict: Fail if tex' + 't is missing' + ' or the selected font cannot encode it')
55
+ _gnuqnzyfqlqvpo.add_argument('-ff', metavar='FONT.ttf', dest='fallback_font', help='Fallback font: TrueType font to ' + "embed when the PDF'" + 's subset font lacks a glyph; defaults to a common system sa' + 'ns-serif font')
56
+ _gnuqnzyfqlqvpo.add_argument('-no-ff', action='store_true', dest='no_font_fallback', help='No font fallback: R' + 'eject missing glyphs instead of embeddi' + 'ng a fallback font')
57
+ _hdmovsesznqako = _gnuqnzyfqlqvpo.parse_args(argv)
58
+ try:
59
+ replacements = _parse_replacements(_hdmovsesznqako.replace)
60
+ if not replacements:
61
+ _gnuqnzyfqlqvpo.error('provid' + 'e at least one ' + '-r OLD' + '=NEW')
62
+ _xhlsobehbsgotx = replace_pdf_text_detailed(_hdmovsesznqako.input, _hdmovsesznqako.output, replacements, preserve_advance=not _hdmovsesznqako.no_preserve_advance, strict=_hdmovsesznqako.strict, fallback_font=_hdmovsesznqako.fallback_font, font_fallback=not _hdmovsesznqako.no_font_fallback)
63
+ except (OSError, PdfReplacementError, ValueError) as _pwenbbrodrbzey:
64
+ _gnuqnzyfqlqvpo.exit(2, _c(f'error: {_pwenbbrodrbzey}\n', _RED))
65
+ except Exception as _pwenbbrodrbzey:
66
+ _gnuqnzyfqlqvpo.exit(2, _c(f'error: An unexpected error occurred: {type(_pwenbbrodrbzey).__name__}: {_pwenbbrodrbzey}\n', _RED))
67
+ print(_c(f'Replaced {_xhlsobehbsgotx.replacements} occurrence(s) in {_xhlsobehbsgotx.changed_streams}/{_xhlsobehbsgotx.scanned_streams} stream(s) in {_xhlsobehbsgotx.elapsed_seconds:.3f}s.', _GREEN))
68
+ if _xhlsobehbsgotx.missing:
69
+ print(_c('Not fo' + 'u' + 'nd: ' + ', '.join((repr(value) for value in _xhlsobehbsgotx.missing)), _RED))
70
+ if _xhlsobehbsgotx.encoding_failures:
71
+ print(_c(f'Skipped {len(_xhlsobehbsgotx.encoding_failures)} unencodable replacement(s).', _RED))
72
+ print(_c(f'Saved: {_hdmovsesznqako.output}', _GREEN, _BOLD))
73
+ return 0
apdf/codec.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ from typing import Any, Mapping
4
+ from fontTools.agl import UV2AGL
5
+ from pypdf._font import Font
6
+ from pypdf.generic import DictionaryObject
7
+ from apdf.exceptions import FontEncodingError
8
+ from apdf.utils import _deref
9
+
10
+ class FontCodec:
11
+
12
+ def __init__(self, resource_name: str, font_object: DictionaryObject):
13
+ self.resource_name = resource_name
14
+ self.pdf_font = _deref(font_object)
15
+ self.font = Font.from_font_resource(self.pdf_font)
16
+ self._unicode_to_intermediate: dict[str, str] = {}
17
+ self._intermediate_to_bytes: dict[str, bytes] = {}
18
+ self._difference_glyphs: dict[int, str] = {}
19
+ self._embedded_glyphs: set[str] | None = None
20
+ self._build_inverse_maps()
21
+ self._load_glyph_inventory()
22
+
23
+ @property
24
+ def description(self) -> str:
25
+ _wozpkadibwzfdo = str(self.pdf_font.get('/Subtype', 'unknown'))
26
+ _jrcevktdeljeee = str(self.pdf_font.get('/BaseFont', 'unknown'))
27
+ return f'{self.resource_name} {_wozpkadibwzfdo} {_jrcevktdeljeee}'
28
+
29
+ def _build_inverse_maps(self) -> None:
30
+ for _sqmvsfamvtglze, _uxcpimgqqzwkij in self.font.character_map.items():
31
+ if isinstance(_sqmvsfamvtglze, str) and isinstance(_uxcpimgqqzwkij, str):
32
+ self._unicode_to_intermediate.setdefault(_uxcpimgqqzwkij, _sqmvsfamvtglze)
33
+ encoding = self.font.encoding
34
+ if isinstance(encoding, dict):
35
+ for _vxlaoowljmxybg, _gsujpyyvkhsxxv in encoding.items():
36
+ if isinstance(_vxlaoowljmxybg, int) and 0 <= _vxlaoowljmxybg <= 255 and isinstance(_gsujpyyvkhsxxv, str):
37
+ self._intermediate_to_bytes.setdefault(_gsujpyyvkhsxxv, bytes((_vxlaoowljmxybg,)))
38
+
39
+ def _load_glyph_inventory(self) -> None:
40
+ encoding = _deref(self.pdf_font.get('/Encoding'))
41
+ if isinstance(encoding, DictionaryObject):
42
+ _mrxxjcwxuerymf: int | None = None
43
+ for _xcgndakfwknleb in encoding.get('/Differences', []):
44
+ _xcgndakfwknleb = _deref(_xcgndakfwknleb)
45
+ if isinstance(_xcgndakfwknleb, int):
46
+ _mrxxjcwxuerymf = int(_xcgndakfwknleb)
47
+ elif _mrxxjcwxuerymf is not None:
48
+ self._difference_glyphs[_mrxxjcwxuerymf] = str(_xcgndakfwknleb).lstrip('/')
49
+ _mrxxjcwxuerymf += 1
50
+ _erhylqimslkpma = _deref(self.pdf_font.get('/FontDescriptor'))
51
+ _bdhfnisphgpnnu = _erhylqimslkpma.get('/CharSet') if _erhylqimslkpma else None
52
+ if _bdhfnisphgpnnu:
53
+ self._embedded_glyphs = {name for name in str(_bdhfnisphgpnnu).split('/') if name}
54
+
55
+ def _glyph_name_for_code(self, code: int) -> str | None:
56
+ if code in self._difference_glyphs:
57
+ return self._difference_glyphs[code]
58
+ encoding = self.font.encoding
59
+ if isinstance(encoding, dict):
60
+ _ffpiyggwzspapy = encoding.get(code)
61
+ if isinstance(_ffpiyggwzspapy, str) and len(_ffpiyggwzspapy) == 1:
62
+ return UV2AGL.get(ord(_ffpiyggwzspapy))
63
+ _pbpwakbjyfyhga = str(self.pdf_font.get('/Encoding', ''))
64
+ codec = {'/WinAnsiEncoding': 'cp1252', '/MacRomanEncoding': 'mac_roman'}.get(_pbpwakbjyfyhga, 'latin-1')
65
+ try:
66
+ _ltexlwkjjboggt = bytes((code,)).decode(codec)
67
+ except (UnicodeDecodeError, LookupError):
68
+ return None
69
+ return UV2AGL.get(ord(_ltexlwkjjboggt))
70
+
71
+ def _validate_embedded_glyphs(self, raw: bytes, visible: str) -> None:
72
+ _cmjxrgamnknxhq = str(self.pdf_font.get('/Subtype', ''))
73
+ if _cmjxrgamnknxhq == '/Type3':
74
+ encoding = self.font.encoding
75
+ if isinstance(encoding, dict):
76
+ for _ynczgtjbhzcjld in raw:
77
+ _mbeamyadqmkcmb = encoding.get(_ynczgtjbhzcjld, chr(_ynczgtjbhzcjld))
78
+ if _mbeamyadqmkcmb not in self.font.character_map:
79
+ raise FontEncodingError(f'{self.description} has no explicit Type3 mapping for character code 0x{_ynczgtjbhzcjld:02X}')
80
+ if self.decode(raw) != visible:
81
+ raise FontEncodingError(f'{self.description} has no reversible glyph mapping for {visible!r}')
82
+ return
83
+ if self._embedded_glyphs is not None:
84
+ missing: list[str] = []
85
+ for _ynczgtjbhzcjld in raw:
86
+ _wglsywbrgcuwqn = self._glyph_name_for_code(_ynczgtjbhzcjld)
87
+ if _wglsywbrgcuwqn and _wglsywbrgcuwqn not in self._embedded_glyphs:
88
+ missing.append(_wglsywbrgcuwqn)
89
+ if missing:
90
+ names = ', '.join(sorted(set(missing)))
91
+ raise FontEncodingError(f'{self.description} embedded subset lacks glyph(s): {names}')
92
+ if self.decode(raw) != visible:
93
+ raise FontEncodingError(f'{self.description} does not round-trip replacement {visible!r}')
94
+
95
+ def decode(self, raw: bytes) -> str:
96
+ encoding = self.font.encoding
97
+ if isinstance(encoding, str):
98
+ try:
99
+ _dwjqepqdmzpydx = raw.decode(encoding, errors='surrogatepass')
100
+ except (LookupError, UnicodeError):
101
+ _aiyhqgkozvuxvl = 'utf-16-be' if len(raw) % 2 == 0 else 'latin-1'
102
+ _dwjqepqdmzpydx = raw.decode(_aiyhqgkozvuxvl, errors='replace')
103
+ else:
104
+ _dwjqepqdmzpydx = ''.join((encoding.get(byte, chr(byte)) for byte in raw))
105
+ return ''.join((self.font.character_map.get(character, character) for character in _dwjqepqdmzpydx))
106
+
107
+ @staticmethod
108
+ def _greedy_map(text: str, mapping: Mapping[str, str]) -> str:
109
+ if not mapping:
110
+ return text
111
+ _taysbvduzlwvpf = sorted(mapping, key=len, reverse=True)
112
+ output: list[str] = []
113
+ _rjciwatenvnubt = 0
114
+ while _rjciwatenvnubt < len(text):
115
+ for key in _taysbvduzlwvpf:
116
+ if key and text.startswith(key, _rjciwatenvnubt):
117
+ output.append(mapping[key])
118
+ _rjciwatenvnubt += len(key)
119
+ break
120
+ else:
121
+ output.append(text[_rjciwatenvnubt])
122
+ _rjciwatenvnubt += 1
123
+ return ''.join(output)
124
+
125
+ def encode(self, visible: str) -> bytes:
126
+ _mqcsiaerslqqcw = self._greedy_map(visible, self._unicode_to_intermediate)
127
+ encoding = self.font.encoding
128
+ if isinstance(encoding, str):
129
+ try:
130
+ _zqiehzxforrqfg = _mqcsiaerslqqcw.encode(encoding, errors='strict')
131
+ except (LookupError, UnicodeError) as _ethzaketqjwtcf:
132
+ raise FontEncodingError(f'{self.description} cannot encode {visible!r}') from _ethzaketqjwtcf
133
+ self._validate_embedded_glyphs(_zqiehzxforrqfg, visible)
134
+ return _zqiehzxforrqfg
135
+ _golytujyyqqjgy = sorted(self._intermediate_to_bytes, key=len, reverse=True)
136
+ _npwxcsvwxxfsgx = bytearray()
137
+ _pnqijbrslweiwd = 0
138
+ while _pnqijbrslweiwd < len(_mqcsiaerslqqcw):
139
+ for key in _golytujyyqqjgy:
140
+ if key and _mqcsiaerslqqcw.startswith(key, _pnqijbrslweiwd):
141
+ _npwxcsvwxxfsgx.extend(self._intermediate_to_bytes[key])
142
+ _pnqijbrslweiwd += len(key)
143
+ break
144
+ else:
145
+ raise FontEncodingError(f'{self.description} cannot encode character {_mqcsiaerslqqcw[_pnqijbrslweiwd]!r} in {visible!r}')
146
+ _zqiehzxforrqfg = bytes(_npwxcsvwxxfsgx)
147
+ self._validate_embedded_glyphs(_zqiehzxforrqfg, visible)
148
+ return _zqiehzxforrqfg
149
+
150
+ def width(self, visible: str) -> float:
151
+ try:
152
+ width_method = getattr(self.font, 'get_text_width', None)
153
+ if width_method is None:
154
+ width_method = getattr(self.font, 'text_width')
155
+ return float(width_method(visible))
156
+ except Exception:
157
+ return 0.0
158
+
159
+ def _font_codecs(resources: Any) -> dict[str, FontCodec]:
160
+ resources = _deref(resources)
161
+ fonts = _deref(resources.get('/Font', {})) if resources else {}
162
+ result: dict[str, FontCodec] = {}
163
+ for name, font_ref in getattr(fonts, 'items', lambda: [])():
164
+ resource_name = str(name)
165
+ try:
166
+ result[resource_name] = FontCodec(resource_name, _deref(font_ref))
167
+ except Exception as exc:
168
+ print(f'Warning: Skipping unreadable font {resource_name}: {exc}', file=sys.stderr)
169
+ return result
apdf/engine.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Mapping, Sequence
8
+ from pypdf import PdfReader, PdfWriter
9
+ from pypdf.errors import DependencyError
10
+ from pypdf.generic import ArrayObject, ContentStream, StreamObject
11
+ from apdf.codec import _font_codecs
12
+ from apdf.exceptions import PdfReplacementError, PdfDependencyError, PdfPermissionError, PdfFileNotFoundError
13
+ from apdf.fallback import FallbackFontRegistry
14
+ from apdf.models import Replacement, ReplacementReport
15
+ from apdf.operations import _rewrite_operations
16
+ from apdf.utils import _deref, _object_key, _c, _BLUE
17
+
18
+ def _set_decoded_stream_data(stream: StreamObject, data: bytes) -> None:
19
+ try:
20
+ stream.set_data(data)
21
+ except Exception as _uhoyuxylmrtgqf:
22
+ raise PdfReplacementError('Cannot rewrite a content s' + 'tream with its current PDF ' + 'f' + 'ilters') from _uhoyuxylmrtgqf
23
+
24
+ def _rewrite_stream(stream: StreamObject, pdf: PdfWriter, resources: Any, replacements: Sequence[Replacement], preserve_advance: bool, report: ReplacementReport, matched: set[str], fallback_registry: FallbackFontRegistry) -> None:
25
+ report.scanned_streams += 1
26
+ content = ContentStream(stream, pdf)
27
+ operations = content.operations
28
+ codecs = _font_codecs(resources)
29
+ count = _rewrite_operations(operations, codecs, replacements, preserve_advance, report, matched, lambda original, size, visible: fallback_registry.resolve(original, size, visible, resources))
30
+ if count:
31
+ content.operations = operations
32
+ _set_decoded_stream_data(stream, content.get_data())
33
+ report.changed_streams += 1
34
+
35
+ def _content_streams(owner: Any) -> Iterable[StreamObject]:
36
+ _ypkhvskndrbswp = _deref(owner.get('/Contents'))
37
+ if _ypkhvskndrbswp is None:
38
+ return
39
+ if isinstance(_ypkhvskndrbswp, (list, ArrayObject)):
40
+ for _ebfsolxzfkohsg in _ypkhvskndrbswp:
41
+ _qqwnilrljvsekr = _deref(_ebfsolxzfkohsg)
42
+ if isinstance(_qqwnilrljvsekr, StreamObject):
43
+ yield _qqwnilrljvsekr
44
+ elif isinstance(_ypkhvskndrbswp, StreamObject):
45
+ yield _ypkhvskndrbswp
46
+
47
+ def _rewrite_forms(resources: Any, pdf: PdfWriter, replacements: Sequence[Replacement], preserve_advance: bool, report: ReplacementReport, matched: set[str], visited: set[tuple[Any, ...]], fallback_registry: FallbackFontRegistry) -> None:
48
+ resources = _deref(resources)
49
+ xobjects = _deref(resources.get('/XObject', {})) if resources else {}
50
+ for _, reference in getattr(xobjects, 'items', lambda: [])():
51
+ form = _deref(reference)
52
+ if not isinstance(form, StreamObject) or form.get('/Subtype') != '/Form':
53
+ continue
54
+ key = _object_key(form)
55
+ if key in visited:
56
+ continue
57
+ visited.add(key)
58
+ form_resources = _deref(form.get('/Resources')) or resources
59
+ _rewrite_stream(form, pdf, form_resources, replacements, preserve_advance, report, matched, fallback_registry)
60
+ _rewrite_forms(form_resources, pdf, replacements, preserve_advance, report, matched, visited, fallback_registry)
61
+
62
+ def replace_pdf_text_detailed(input_pdf: str | os.PathLike[str], output_pdf: str | os.PathLike[str], replacements: Mapping[str, str], *, preserve_advance: bool=True, strict: bool=False, fallback_font: str | os.PathLike[str] | None=None, font_fallback: bool=True) -> ReplacementReport:
63
+ _tuztsnbsazjejg = time.perf_counter()
64
+ _usixbitweyswqw = [Replacement(str(old), str(new)) for old, new in replacements.items() if old]
65
+ if not _usixbitweyswqw:
66
+ raise ValueError('At least one non-emp' + 'ty source string is r' + 'equired')
67
+ _bbqveuuofmwyiv = Path(input_pdf)
68
+ _akmbommssynhbk = Path(output_pdf)
69
+ if not _bbqveuuofmwyiv.is_file():
70
+ raise PdfFileNotFoundError(f'File not found: {_bbqveuuofmwyiv}')
71
+ _akmbommssynhbk.parent.mkdir(parents=True, exist_ok=True)
72
+ try:
73
+ _eeutrcnebgnywy = PdfReader(_bbqveuuofmwyiv, strict=False)
74
+ except DependencyError as _cptutzbiolqyrf:
75
+ raise PdfDependencyError(f"Missing dependency for encrypted PDF: {_cptutzbiolqyrf}\nPlease install it, e.g., 'uv pip install cryptography'") from _cptutzbiolqyrf
76
+ except PermissionError as _cptutzbiolqyrf:
77
+ raise PdfPermissionError(f'Permission denied when opening {_bbqveuuofmwyiv}. The PDF might be opened by another application.') from _cptutzbiolqyrf
78
+ if _eeutrcnebgnywy.is_encrypted:
79
+ raise PdfReplacementError('Encrypted PDFs are not supp' + 'orted without a pass' + 'word')
80
+ writer = PdfWriter(clone_from=_eeutrcnebgnywy)
81
+ _ethuwapkqidjlz = FallbackFontRegistry(writer, explicit_font=fallback_font, enabled=font_fallback)
82
+ _qfzabxnfntnkmj = ReplacementReport(pages=len(writer.pages))
83
+ _drcfelcnltzfpi: set[str] = set()
84
+ _jsfnwqqaaosxwm: set[tuple[Any, ...]] = set()
85
+ for _uccdolnszdqhxp, _aaggvlekoobqpv in enumerate(writer.pages, start=1):
86
+ print(_c(f'Processing page {_uccdolnszdqhxp}/{_qfzabxnfntnkmj.pages}', _BLUE), file=sys.stderr)
87
+ _mvvirxvkkdchtt = _deref(_aaggvlekoobqpv.get('/Resources', {}))
88
+ for _rnlzqjzpzbzjwd in _content_streams(_aaggvlekoobqpv):
89
+ key = _object_key(_rnlzqjzpzbzjwd)
90
+ if key in _jsfnwqqaaosxwm:
91
+ continue
92
+ _jsfnwqqaaosxwm.add(key)
93
+ _rewrite_stream(_rnlzqjzpzbzjwd, writer, _mvvirxvkkdchtt, _usixbitweyswqw, preserve_advance, _qfzabxnfntnkmj, _drcfelcnltzfpi, _ethuwapkqidjlz)
94
+ _rewrite_forms(_mvvirxvkkdchtt, writer, _usixbitweyswqw, preserve_advance, _qfzabxnfntnkmj, _drcfelcnltzfpi, _jsfnwqqaaosxwm, _ethuwapkqidjlz)
95
+ _ethuwapkqidjlz.finalize()
96
+ _qfzabxnfntnkmj.missing = [rule.old for rule in _usixbitweyswqw if rule.old not in _drcfelcnltzfpi]
97
+ _qfzabxnfntnkmj.elapsed_seconds = time.perf_counter() - _tuztsnbsazjejg
98
+ if strict and (_qfzabxnfntnkmj.missing or _qfzabxnfntnkmj.encoding_failures):
99
+ _baknxbpjcowtlf = []
100
+ if _qfzabxnfntnkmj.missing:
101
+ _baknxbpjcowtlf.append(f'not found: {_qfzabxnfntnkmj.missing!r}')
102
+ if _qfzabxnfntnkmj.encoding_failures:
103
+ _baknxbpjcowtlf.append(f'encoding failures: {_qfzabxnfntnkmj.encoding_failures!r}')
104
+ raise PdfReplacementError('; '.join(_baknxbpjcowtlf))
105
+ _hroujqvfvrqzkv: str | None = None
106
+ try:
107
+ try:
108
+ with tempfile.NamedTemporaryFile(mode='wb', prefix=f'.{_akmbommssynhbk.name}.', suffix='.tmp', dir=_akmbommssynhbk.parent, delete=False) as _ssuyqgnyzggkki:
109
+ _hroujqvfvrqzkv = _ssuyqgnyzggkki.name
110
+ writer.write(_ssuyqgnyzggkki)
111
+ _stzlaxcsrisprv = PdfReader(_hroujqvfvrqzkv, strict=False)
112
+ if len(_stzlaxcsrisprv.pages) != _qfzabxnfntnkmj.pages:
113
+ raise PdfReplacementError('Output ve' + 'rification found a page-count mism' + 'a' + 'tch')
114
+ os.replace(_hroujqvfvrqzkv, _akmbommssynhbk)
115
+ _hroujqvfvrqzkv = None
116
+ except PermissionError as _cptutzbiolqyrf:
117
+ raise PdfPermissionError(f'Permission denied when saving to {_akmbommssynhbk}. The PDF might be opened by another application.') from _cptutzbiolqyrf
118
+ finally:
119
+ if _hroujqvfvrqzkv and os.path.exists(_hroujqvfvrqzkv):
120
+ os.unlink(_hroujqvfvrqzkv)
121
+ _qfzabxnfntnkmj.elapsed_seconds = time.perf_counter() - _tuztsnbsazjejg
122
+ return _qfzabxnfntnkmj
123
+
124
+ def replace_pdf_text(input_pdf: str | os.PathLike[str], output_pdf: str | os.PathLike[str], replacements: Mapping[str, str], **kwargs: Any) -> int:
125
+ return replace_pdf_text_detailed(input_pdf, output_pdf, replacements, **kwargs).replacements
apdf/exceptions.py ADDED
@@ -0,0 +1,20 @@
1
+ class PdfReplacementError(RuntimeError):
2
+ pass
3
+
4
+ class FontEncodingError(PdfReplacementError):
5
+ pass
6
+
7
+ class PdfDependencyError(PdfReplacementError):
8
+ pass
9
+
10
+ class PdfPermissionError(PdfReplacementError):
11
+ pass
12
+
13
+ class PdfFileNotFoundError(PdfReplacementError):
14
+ pass
15
+
16
+ class PdfPasswordError(PdfReplacementError):
17
+ pass
18
+
19
+ class PdfMalformedError(PdfReplacementError):
20
+ pass
apdf/fallback.py ADDED
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import re
4
+ import sys
5
+ from io import BytesIO
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from fontTools.subset import Options, Subsetter
9
+ from fontTools.ttLib import TTFont
10
+ from pypdf import PdfWriter
11
+ from pypdf.generic import ArrayObject, DecodedStreamObject, DictionaryObject, FloatObject, NameObject, NumberObject, TextStringObject
12
+ from apdf.codec import FontCodec
13
+ from apdf.exceptions import FontEncodingError
14
+ from apdf.utils import _deref
15
+
16
+ def _pdf_name(value: str) -> NameObject:
17
+ _hmziujqhiutwmb = re.sub('[^A-Za-z0-9_.+-]', '', value) or 'Fallback'
18
+ return NameObject('/' + _hmziujqhiutwmb)
19
+
20
+ def _scaled(value: float, units_per_em: int) -> int:
21
+ return int(round(float(value) * 1000.0 / units_per_em))
22
+
23
+ def _name_record(font: TTFont, name_id: int) -> str | None:
24
+ _yfxwfsgwajhylz = font.get('name')
25
+ if _yfxwfsgwajhylz is None:
26
+ return None
27
+ for _sjoqzgtyblyxac in _yfxwfsgwajhylz.names:
28
+ if _sjoqzgtyblyxac.nameID == name_id:
29
+ try:
30
+ return _sjoqzgtyblyxac.toUnicode()
31
+ except Exception:
32
+ continue
33
+ return None
34
+
35
+ def _candidate_fonts(bold: bool) -> list[Path]:
36
+ _izygplgavcxncm = os.environ.get('APDF_FALLBACK_FONT')
37
+ _ixuasrqxiznlxq: list[Path] = [Path(_izygplgavcxncm)] if _izygplgavcxncm else []
38
+ if sys.platform == 'win32':
39
+ _xmssfxrkddtrlv = Path(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts')
40
+ names = ['arialbd.ttf', 'segoeuib.ttf', 'calibrib.ttf'] if bold else ['arial.ttf', 'segoeui.ttf', 'calibri.ttf']
41
+ _ixuasrqxiznlxq.extend((_xmssfxrkddtrlv / name for name in names))
42
+ elif sys.platform == 'darwin':
43
+ names = ['/Syste' + 'm/Library/F' + 'onts/Supple' + 'mental/Arial Bold.ttf' if bold else '/System/Library/Fonts/Supplemental/Arial.ttf', '/System/Library/Fonts/Supplemental/DejaVuSans-Bold.ttf' if bold else '/System/Library/Fonts/Supplemental/DejaVuSans.ttf']
44
+ _ixuasrqxiznlxq.extend((Path(name) for name in names))
45
+ else:
46
+ names = ['/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' if bold else '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', '/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf' if bold else '/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf']
47
+ _ixuasrqxiznlxq.extend((Path(name) for name in names))
48
+ return _ixuasrqxiznlxq
49
+
50
+ def find_fallback_font(explicit: str | os.PathLike[str] | None, *, bold: bool) -> Path | None:
51
+ if explicit:
52
+ path = Path(explicit)
53
+ if not path.is_file():
54
+ raise FileNotFoundError(f'Fallback font not found: {path}')
55
+ return path
56
+ return next((path for path in _candidate_fonts(bold) if path.is_file()), None)
57
+
58
+ class EmbeddedFallbackCodec:
59
+
60
+ def __init__(self, writer: PdfWriter, font_path: Path, resource_name: str) -> None:
61
+ self.resource_name = resource_name
62
+ self.font_path = font_path
63
+ self._font_bytes = font_path.read_bytes()
64
+ self._ttfont = TTFont(BytesIO(self._font_bytes), recalcBBoxes=False, recalcTimestamp=False)
65
+ os2 = self._ttfont.get('OS/2')
66
+ if os2 is not None and int(getattr(os2, 'fsType', 0)) & 2:
67
+ raise FontEncodingError(f'{font_path} forbids font embedding in its OS/2 licensing flags')
68
+ self._cmap = self._ttfont.getBestCmap() or {}
69
+ self._glyph_order = self._ttfont.getGlyphOrder()
70
+ self._glyph_ids = {name: index for index, name in enumerate(self._glyph_order)}
71
+ self._units_per_em = int(self._ttfont['head'].unitsPerEm)
72
+ self._hmtx = self._ttfont['hmtx'].metrics
73
+ self._used: dict[int, str] = {}
74
+ self._writer = writer
75
+ self._to_unicode = DecodedStreamObject()
76
+ self._cid_font = DictionaryObject()
77
+ self._font_file_reference = None
78
+ self._font_reference = self._build_pdf_font()
79
+
80
+ @property
81
+ def description(self) -> str:
82
+ return f'{self.resource_name} embedded fallback {self.font_path.name}'
83
+
84
+ def decode(self, raw: bytes) -> str:
85
+ if len(raw) % 2:
86
+ return '�'
87
+ reverse = {gid: character for gid, character in self._used.items()}
88
+ return ''.join((reverse.get(int.from_bytes(raw[pos:pos + 2], 'big'), '�') for pos in range(0, len(raw), 2)))
89
+
90
+ def encode(self, visible: str) -> bytes:
91
+ output = bytearray()
92
+ for _xpzglmigkwxpji in visible:
93
+ _qhsmhishbjnxkr = self._cmap.get(ord(_xpzglmigkwxpji))
94
+ if _qhsmhishbjnxkr is None:
95
+ raise FontEncodingError(f'{self.description} lacks glyph {_xpzglmigkwxpji!r}')
96
+ _szwztsgxjmbkva = self._glyph_ids[_qhsmhishbjnxkr]
97
+ if _szwztsgxjmbkva > 65535:
98
+ raise FontEncodingError(f'{self.description} glyph ID exceeds Identity-H range')
99
+ _sbszocqtdshpvf = self._used.get(_szwztsgxjmbkva)
100
+ if _sbszocqtdshpvf is not None and _sbszocqtdshpvf != _xpzglmigkwxpji:
101
+ raise FontEncodingError(f'{self.description} maps {_sbszocqtdshpvf!r} and {_xpzglmigkwxpji!r} to one glyph')
102
+ self._used[_szwztsgxjmbkva] = _xpzglmigkwxpji
103
+ output.extend(_szwztsgxjmbkva.to_bytes(2, 'big'))
104
+ return bytes(output)
105
+
106
+ def width(self, visible: str) -> float:
107
+ width = 0.0
108
+ for _nixsgzlzmkhjnc in visible:
109
+ _wbdtruznlyhlyg = self._cmap.get(ord(_nixsgzlzmkhjnc))
110
+ if _wbdtruznlyhlyg is None:
111
+ continue
112
+ _plmbwxcnzdpbud, _ = self._hmtx[_wbdtruznlyhlyg]
113
+ width += _scaled(_plmbwxcnzdpbud, self._units_per_em)
114
+ return width
115
+
116
+ def install(self, resources: Any) -> None:
117
+ resources = _deref(resources)
118
+ _lvcoyogyohfoxn = _deref(resources.get('/Font')) if resources else None
119
+ if _lvcoyogyohfoxn is None:
120
+ _lvcoyogyohfoxn = DictionaryObject()
121
+ resources[NameObject('/Font')] = _lvcoyogyohfoxn
122
+ _lvcoyogyohfoxn[NameObject(self.resource_name)] = self._font_reference
123
+
124
+ def finalize(self) -> None:
125
+ _fjktcramkxbljm = []
126
+ _mvlnscnvxxlgqh = ArrayObject()
127
+ for _noqwzetnpjngoo, _rralsgxsckinba in sorted(self._used.items()):
128
+ _rfyxzfjqgrktuz = _rralsgxsckinba.encode('utf-16-be').hex().upper()
129
+ _fjktcramkxbljm.append(f'<{_noqwzetnpjngoo:04X}> <{_rfyxzfjqgrktuz}>')
130
+ _nzruwniixwvedf = self._glyph_order[_noqwzetnpjngoo]
131
+ _rntiwezcxsuxvl, _ = self._hmtx[_nzruwniixwvedf]
132
+ _mvlnscnvxxlgqh.extend([NumberObject(_noqwzetnpjngoo), ArrayObject([NumberObject(_scaled(_rntiwezcxsuxvl, self._units_per_em))])])
133
+ _tormfuuxkmigib: list[str] = []
134
+ for start in range(0, len(_fjktcramkxbljm), 100):
135
+ _hawfpkgtuzilqg = _fjktcramkxbljm[start:start + 100]
136
+ _tormfuuxkmigib.append(f'{len(_hawfpkgtuzilqg)} beginbfchar\n' + '\n'.join(_hawfpkgtuzilqg) + ('\n' + 'endbf' + 'ch' + 'ar'))
137
+ _qdnxoepgwqdxdj = '/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /APDF-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> ' + '<FFFF>\nendcodespacerange\n' + '\n'.join(_tormfuuxkmigib) + ('\nendcmap\nCMapName currentdict /CMap define' + 'resou' + 'rce pop\nend' + '\nend\n')
138
+ self._to_unicode.set_data(_qdnxoepgwqdxdj.encode('ascii'))
139
+ self._cid_font[NameObject('/W')] = _mvlnscnvxxlgqh
140
+ _hevakmfgjjuizx = DecodedStreamObject()
141
+ _atwivagthmenfm = self._subset_font_bytes()
142
+ _hevakmfgjjuizx.set_data(_atwivagthmenfm)
143
+ _hevakmfgjjuizx[NameObject('/Length1')] = NumberObject(len(_atwivagthmenfm))
144
+ self._writer._replace_object(self._font_file_reference, _hevakmfgjjuizx.flate_encode())
145
+
146
+ def _subset_font_bytes(self) -> bytes:
147
+ font = TTFont(BytesIO(self._font_bytes), recalcBBoxes=False, recalcTimestamp=False)
148
+ options = Options()
149
+ options.retain_gids = True
150
+ options.notdef_glyph = True
151
+ options.notdef_outline = True
152
+ options.drop_tables.append('meta')
153
+ _rpdajnthevqknr = Subsetter(options=options)
154
+ _rpdajnthevqknr.populate(unicodes={ord(character) for character in self._used.values()})
155
+ _rpdajnthevqknr.subset(font)
156
+ output = BytesIO()
157
+ font.save(output, reorderTables=True)
158
+ font.close()
159
+ return output.getvalue()
160
+
161
+ def _build_pdf_font(self):
162
+ head = self._ttfont['head']
163
+ hhea = self._ttfont['hhea']
164
+ os2 = self._ttfont.get('OS/2')
165
+ post = self._ttfont.get('post')
166
+ postscript_name = _name_record(self._ttfont, 6) or self.font_path.stem
167
+ base_font = _pdf_name('APDF+' + postscript_name)
168
+ self._font_file_reference = self._writer._add_object(DecodedStreamObject())
169
+ flags = 32
170
+ if post is not None and getattr(post, 'isFixedPitch', 0):
171
+ flags |= 1
172
+ italic_angle = float(getattr(post, 'italicAngle', 0.0)) if post else 0.0
173
+ if italic_angle:
174
+ flags |= 64
175
+ weight = int(getattr(os2, 'usWeightClass', 400)) if os2 else 400
176
+ if weight >= 600:
177
+ flags |= 262144
178
+ descriptor = DictionaryObject({NameObject('/Type'): NameObject('/FontDescriptor'), NameObject('/FontName'): base_font, NameObject('/Flags'): NumberObject(flags), NameObject('/FontBBox'): ArrayObject([NumberObject(_scaled(head.xMin, self._units_per_em)), NumberObject(_scaled(head.yMin, self._units_per_em)), NumberObject(_scaled(head.xMax, self._units_per_em)), NumberObject(_scaled(head.yMax, self._units_per_em))]), NameObject('/ItalicAngle'): FloatObject(italic_angle), NameObject('/Ascent'): NumberObject(_scaled(hhea.ascent, self._units_per_em)), NameObject('/Descent'): NumberObject(_scaled(hhea.descent, self._units_per_em)), NameObject('/CapHeight'): NumberObject(_scaled(getattr(os2, 'sCapHeight', hhea.ascent), self._units_per_em)), NameObject('/StemV'): NumberObject(120 if weight >= 600 else 80), NameObject('/FontFile2'): self._font_file_reference})
179
+ descriptor_ref = self._writer._add_object(descriptor)
180
+ cid_system = DictionaryObject({NameObject('/Registry'): TextStringObject('Adobe'), NameObject('/Ordering'): TextStringObject('Identity'), NameObject('/Supplement'): NumberObject(0)})
181
+ default_advance, _ = self._hmtx[self._glyph_order[0]]
182
+ self._cid_font.update({NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/CIDFontType2'), NameObject('/BaseFont'): base_font, NameObject('/CIDSystemInfo'): cid_system, NameObject('/FontDescriptor'): descriptor_ref, NameObject('/DW'): NumberObject(_scaled(default_advance, self._units_per_em)), NameObject('/CIDToGIDMap'): NameObject('/Identity')})
183
+ cid_ref = self._writer._add_object(self._cid_font)
184
+ to_unicode_ref = self._writer._add_object(self._to_unicode)
185
+ type0 = DictionaryObject({NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/Type0'), NameObject('/BaseFont'): base_font, NameObject('/Encoding'): NameObject('/Identity-H'), NameObject('/DescendantFonts'): ArrayObject([cid_ref]), NameObject('/ToUnicode'): to_unicode_ref})
186
+ return self._writer._add_object(type0)
187
+
188
+ class FallbackFontRegistry:
189
+
190
+ def __init__(self, writer: PdfWriter, explicit_font: str | os.PathLike[str] | None, enabled: bool) -> None:
191
+ self.writer = writer
192
+ self.explicit_font = explicit_font
193
+ self.enabled = enabled
194
+ self._codecs: dict[bool, EmbeddedFallbackCodec] = {}
195
+
196
+ @staticmethod
197
+ def _wants_bold(original: FontCodec, font_size: float) -> bool:
198
+ _bqsmocbsaxuosd = _deref(original.pdf_font.get('/FontDescriptor'))
199
+ _lyczhenzhgwons = int(_bqsmocbsaxuosd.get('/FontWeight', 0)) if _bqsmocbsaxuosd else 0
200
+ name = str(original.pdf_font.get('/BaseFont', '')).lower()
201
+ return _lyczhenzhgwons >= 600 or 'bold' in name or 'semibold' in name or (font_size >= 18)
202
+
203
+ def resolve(self, original: FontCodec, font_size: float, visible: str, resources: Any) -> EmbeddedFallbackCodec | None:
204
+ if not self.enabled:
205
+ return None
206
+ bold = self._wants_bold(original, font_size)
207
+ codec = self._codecs.get(bold)
208
+ if codec is None:
209
+ path = find_fallback_font(self.explicit_font, bold=bold)
210
+ if path is None:
211
+ return None
212
+ resource_name = '/APDFB' if bold else '/APDFR'
213
+ codec = EmbeddedFallbackCodec(self.writer, path, resource_name)
214
+ self._codecs[bold] = codec
215
+ codec.encode(visible)
216
+ codec.install(resources)
217
+ return codec
218
+
219
+ def finalize(self) -> None:
220
+ for codec in self._codecs.values():
221
+ codec.finalize()
apdf/models.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import TYPE_CHECKING, Any
4
+ if TYPE_CHECKING:
5
+ from apdf.codec import FontCodec
6
+
7
+ @dataclass(frozen=True)
8
+ class Replacement:
9
+ old: str
10
+ new: str
11
+
12
+ @dataclass
13
+ class ReplacementReport:
14
+ replacements: int = 0
15
+ changed_streams: int = 0
16
+ scanned_streams: int = 0
17
+ pages: int = 0
18
+ elapsed_seconds: float = 0.0
19
+ missing: list[str] = field(default_factory=list)
20
+ encoding_failures: list[str] = field(default_factory=list)
21
+
22
+ @dataclass(frozen=True)
23
+ class TextState:
24
+ codec: FontCodec
25
+ font_size: float
26
+ character_spacing: float
27
+ word_spacing: float
28
+
29
+ @dataclass
30
+ class ShowOperation:
31
+ index: int
32
+ operator: bytes
33
+ operands: list[Any]
34
+ state: TextState
35
+ raw: bytes
36
+ text: str
37
+ adjustment: float
38
+
39
+ @dataclass
40
+ class PositionedShow:
41
+ show: ShowOperation
42
+ x: float
43
+ y: float
44
+
45
+ @dataclass(frozen=True)
46
+ class FontSwitch:
47
+ start_index: int
48
+ end_index: int
49
+ original_font: str
50
+ fallback_font: str
51
+ font_size: float
apdf/operations.py ADDED
@@ -0,0 +1,384 @@
1
+ from __future__ import annotations
2
+ import sys
3
+ from typing import Any, Callable, Mapping, Sequence
4
+ from pypdf.generic import ArrayObject, ByteStringObject, FloatObject, NameObject, NumberObject
5
+ from apdf.codec import FontCodec, _font_codecs
6
+ from apdf.exceptions import FontEncodingError
7
+ from apdf.models import PositionedShow, Replacement, ReplacementReport, FontSwitch, ShowOperation, TextState
8
+ from apdf.utils import _is_pdf_string, _raw_bytes
9
+ TEXT_SHOW = {b'Tj', b'TJ', b"'", b'"'}
10
+ FallbackResolver = Callable[[FontCodec, float, str], Any | None]
11
+
12
+ def _encode_replacement(original: FontCodec, visible: str, font_size: float, fallback_resolver: FallbackResolver | None) -> tuple[bytes, Any]:
13
+ try:
14
+ return (original.encode(visible), original)
15
+ except FontEncodingError:
16
+ if fallback_resolver is None:
17
+ raise
18
+ _leccpwednlbhqd = fallback_resolver(original, font_size, visible)
19
+ if _leccpwednlbhqd is None:
20
+ raise
21
+ return (_leccpwednlbhqd.encode(visible), _leccpwednlbhqd)
22
+
23
+ def _show_payload(index: int, operator: bytes, operands: list[Any], state: TextState) -> ShowOperation | None:
24
+ _pxjnxhbjhdttlz: list[Any]
25
+ adjustment = 0.0
26
+ if operator == b'TJ':
27
+ if not operands or not isinstance(operands[0], (list, ArrayObject)):
28
+ return None
29
+ _pxjnxhbjhdttlz = [item for item in operands[0] if _is_pdf_string(item)]
30
+ adjustment = sum((float(item) for item in operands[0] if isinstance(item, (int, float, NumberObject, FloatObject))))
31
+ elif operator == b'"':
32
+ _pxjnxhbjhdttlz = operands[2:3]
33
+ else:
34
+ _pxjnxhbjhdttlz = operands[0:1]
35
+ try:
36
+ raw = b''.join((_raw_bytes(item) for item in _pxjnxhbjhdttlz))
37
+ except (TypeError, UnicodeError):
38
+ return None
39
+ return ShowOperation(index=index, operator=operator, operands=operands, state=state, raw=raw, text=state.codec.decode(raw), adjustment=adjustment)
40
+
41
+ def _apply_replacements(text: str, replacements: Sequence[Replacement]) -> tuple[str, int, set[str]]:
42
+ _kjwwlacckuirmf = text
43
+ count = 0
44
+ _lbujiavuwardtf: set[str] = set()
45
+ for _fjfhutvfdmztxh in replacements:
46
+ _wpcsovaioqsrtt = _kjwwlacckuirmf.count(_fjfhutvfdmztxh.old)
47
+ if _wpcsovaioqsrtt:
48
+ _kjwwlacckuirmf = _kjwwlacckuirmf.replace(_fjfhutvfdmztxh.old, _fjfhutvfdmztxh.new)
49
+ count += _wpcsovaioqsrtt
50
+ _lbujiavuwardtf.add(_fjfhutvfdmztxh.old)
51
+ return (_kjwwlacckuirmf, count, _lbujiavuwardtf)
52
+
53
+ def _compensation(show: Sequence[ShowOperation], old: str, new: str) -> float:
54
+ state = show[0].state
55
+ adjustment = sum((item.adjustment for item in show))
56
+ adjustment += state.codec.width(new) - state.codec.width(old)
57
+ if state.font_size:
58
+ adjustment += (len(new) - len(old)) * state.character_spacing / state.font_size * 1000.0
59
+ adjustment += (new.count(' ') - old.count(' ')) * state.word_spacing / state.font_size * 1000.0
60
+ return adjustment
61
+
62
+ def _set_show_text(operations: list[tuple[list[Any], bytes]], show: Sequence[ShowOperation], encoded: bytes, old_text: str, new_text: str, preserve_advance: bool, replacement_codec: Any, switches: list[FontSwitch], codec_overrides: dict[int, Any]) -> None:
63
+ _obhzghgyiiqyxs = show[0]
64
+ _ozvxtrglfaamca = ByteStringObject(encoded)
65
+ adjustment = _compensation(show, old_text, new_text) if preserve_advance else 0.0
66
+ if _obhzghgyiiqyxs.operator in (b'Tj', b'TJ'):
67
+ _irzgfpcveocgno = ArrayObject([_ozvxtrglfaamca])
68
+ if preserve_advance and abs(adjustment) >= 0.0001:
69
+ _irzgfpcveocgno.append(FloatObject(round(adjustment, 4)))
70
+ operations[_obhzghgyiiqyxs.index] = ([_irzgfpcveocgno], b'TJ')
71
+ elif _obhzghgyiiqyxs.operator == b"'":
72
+ _obhzghgyiiqyxs.operands[0] = _ozvxtrglfaamca
73
+ elif _obhzghgyiiqyxs.operator == b'"':
74
+ _obhzghgyiiqyxs.operands[2] = _ozvxtrglfaamca
75
+ if replacement_codec is not _obhzghgyiiqyxs.state.codec:
76
+ end_index = max((item.index for item in show))
77
+ switches.append(FontSwitch(start_index=_obhzghgyiiqyxs.index, end_index=end_index, original_font=_obhzghgyiiqyxs.state.codec.resource_name, fallback_font=replacement_codec.resource_name, font_size=_obhzghgyiiqyxs.state.font_size))
78
+ codec_overrides[_obhzghgyiiqyxs.index] = replacement_codec
79
+ for _kysulfzczktzyg in show[1:]:
80
+ if _kysulfzczktzyg.operator == b'TJ':
81
+ operations[_kysulfzczktzyg.index] = ([ArrayObject()], b'TJ')
82
+ elif _kysulfzczktzyg.operator == b'Tj':
83
+ _kysulfzczktzyg.operands[0] = ByteStringObject(b'')
84
+
85
+ def _rewrite_operations(operations: list[tuple[list[Any], bytes]], codecs: Mapping[str, FontCodec], replacements: Sequence[Replacement], preserve_advance: bool, report: ReplacementReport, matched: set[str], fallback_resolver: FallbackResolver | None=None) -> int:
86
+ current: FontCodec | None = None
87
+ font_size = 0.0
88
+ character_spacing = 0.0
89
+ word_spacing = 0.0
90
+ graphics_stack: list[tuple[FontCodec | None, float, float, float]] = []
91
+ pending: list[ShowOperation] = []
92
+ changed = 0
93
+ switches: list[FontSwitch] = []
94
+ codec_overrides: dict[int, Any] = {}
95
+
96
+ def flush() -> None:
97
+ nonlocal changed
98
+ if not pending:
99
+ return
100
+ _thxewrahfdqqak = ''.join((item.text for item in pending))
101
+ _oryzaiymhakdqi, count, _ktpnukflgendej = _apply_replacements(_thxewrahfdqqak, replacements)
102
+ if count:
103
+ try:
104
+ _qsjxyawyahxgsf, _uhscrkgeqkxgpq = _encode_replacement(pending[0].state.codec, _oryzaiymhakdqi, pending[0].state.font_size, fallback_resolver)
105
+ except FontEncodingError as _zhahwhyvmjkpmb:
106
+ _aloxvjwjdheyxi = str(_zhahwhyvmjkpmb)
107
+ if _aloxvjwjdheyxi not in report.encoding_failures:
108
+ report.encoding_failures.append(_aloxvjwjdheyxi)
109
+ print(f'Warning: {_aloxvjwjdheyxi}', file=sys.stderr)
110
+ else:
111
+ _set_show_text(operations, pending, _qsjxyawyahxgsf, _thxewrahfdqqak, _oryzaiymhakdqi, preserve_advance, _uhscrkgeqkxgpq, switches, codec_overrides)
112
+ report.replacements += count
113
+ matched.update(_ktpnukflgendej)
114
+ changed += count
115
+ pending.clear()
116
+ for index, (operands, operator) in enumerate(operations):
117
+ if operator == b'Tf':
118
+ flush()
119
+ if len(operands) >= 2:
120
+ current = codecs.get(str(operands[0]))
121
+ try:
122
+ font_size = float(operands[1])
123
+ except (TypeError, ValueError):
124
+ font_size = 0.0
125
+ continue
126
+ if operator == b'Tc':
127
+ flush()
128
+ character_spacing = float(operands[0]) if operands else 0.0
129
+ continue
130
+ if operator == b'Tw':
131
+ flush()
132
+ word_spacing = float(operands[0]) if operands else 0.0
133
+ continue
134
+ if operator == b'q':
135
+ flush()
136
+ graphics_stack.append((current, font_size, character_spacing, word_spacing))
137
+ continue
138
+ if operator == b'Q':
139
+ flush()
140
+ if graphics_stack:
141
+ current, font_size, character_spacing, word_spacing = graphics_stack.pop()
142
+ continue
143
+ if operator in TEXT_SHOW and current is not None:
144
+ state = TextState(current, font_size, character_spacing, word_spacing)
145
+ item = _show_payload(index, operator, operands, state)
146
+ if item is None:
147
+ flush()
148
+ continue
149
+ if operator not in (b'Tj', b'TJ'):
150
+ flush()
151
+ pending.append(item)
152
+ flush()
153
+ elif pending and pending[0].state != state:
154
+ flush()
155
+ pending.append(item)
156
+ else:
157
+ pending.append(item)
158
+ continue
159
+ flush()
160
+ flush()
161
+ changed += _rewrite_positioned_fragments(operations, codecs, replacements, report, matched, fallback_resolver, switches, codec_overrides)
162
+ _apply_font_switches(operations, switches)
163
+ return changed
164
+
165
+ def _apply_font_switches(operations: list[tuple[list[Any], bytes]], switches: Sequence[FontSwitch]) -> None:
166
+ for switch in sorted(switches, key=lambda item: item.start_index, reverse=True):
167
+ restore = ([NameObject(switch.original_font), FloatObject(switch.font_size)], b'Tf')
168
+ select = ([NameObject(switch.fallback_font), FloatObject(switch.font_size)], b'Tf')
169
+ operations.insert(switch.end_index + 1, restore)
170
+ operations.insert(switch.start_index, select)
171
+
172
+ def _matrix_multiply(left: list[float], right: list[float]) -> list[float]:
173
+ return [left[0] * right[0] + left[1] * right[2], left[0] * right[1] + left[1] * right[3], left[2] * right[0] + left[3] * right[2], left[2] * right[1] + left[3] * right[3], left[4] * right[0] + left[5] * right[2] + right[4], left[4] * right[1] + left[5] * right[3] + right[5]]
174
+
175
+ def _positioned_shows(operations: list[tuple[list[Any], bytes]], codecs: Mapping[str, FontCodec], codec_overrides: Mapping[int, Any] | None=None) -> list[PositionedShow]:
176
+ _dvmmrmusrwxjpp: FontCodec | None = None
177
+ font_size = 0.0
178
+ character_spacing = 0.0
179
+ word_spacing = 0.0
180
+ _brcwlgpcudqsme = 0.0
181
+ _nukgknummdhzuh = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
182
+ _wjlguzphrrqktg = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
183
+ _xgejqljueoksef: list[tuple[Any, ...]] = []
184
+ _oqvqqtgeclndyt: list[PositionedShow] = []
185
+ for index, (operands, operator) in enumerate(operations):
186
+ if operator == b'q':
187
+ _xgejqljueoksef.append((_dvmmrmusrwxjpp, font_size, character_spacing, word_spacing, _brcwlgpcudqsme, _nukgknummdhzuh.copy(), _wjlguzphrrqktg.copy()))
188
+ elif operator == b'Q':
189
+ if _xgejqljueoksef:
190
+ _dvmmrmusrwxjpp, font_size, character_spacing, word_spacing, _brcwlgpcudqsme, _nukgknummdhzuh, _wjlguzphrrqktg = _xgejqljueoksef.pop()
191
+ elif operator == b'cm' and len(operands) >= 6:
192
+ _lpjxvpispdavam = [float(value) for value in operands[:6]]
193
+ _wjlguzphrrqktg = _matrix_multiply(_lpjxvpispdavam, _wjlguzphrrqktg)
194
+ elif operator == b'BT':
195
+ _nukgknummdhzuh = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
196
+ elif operator == b'Tf' and len(operands) >= 2:
197
+ _dvmmrmusrwxjpp = codecs.get(str(operands[0]))
198
+ font_size = float(operands[1])
199
+ elif operator == b'Tc':
200
+ character_spacing = float(operands[0]) if operands else 0.0
201
+ elif operator == b'Tw':
202
+ word_spacing = float(operands[0]) if operands else 0.0
203
+ elif operator == b'TL':
204
+ _brcwlgpcudqsme = float(operands[0]) if operands else 0.0
205
+ elif operator == b'Tm' and len(operands) >= 6:
206
+ _nukgknummdhzuh = [float(value) for value in operands[:6]]
207
+ elif operator in (b'Td', b'TD') and len(operands) >= 2:
208
+ _oqqajzclmlhmtd, _nqqvamqfdhtesh = (float(operands[0]), float(operands[1]))
209
+ _nukgknummdhzuh[4] += _oqqajzclmlhmtd
210
+ _nukgknummdhzuh[5] += _nqqvamqfdhtesh
211
+ if operator == b'TD':
212
+ _brcwlgpcudqsme = -_nqqvamqfdhtesh
213
+ elif operator == b'T*':
214
+ _nukgknummdhzuh[5] -= _brcwlgpcudqsme
215
+ elif operator in TEXT_SHOW and _dvmmrmusrwxjpp is not None:
216
+ if operator in (b"'", b'"'):
217
+ _nukgknummdhzuh[5] -= _brcwlgpcudqsme
218
+ _jychlhollysmce = codec_overrides.get(index, _dvmmrmusrwxjpp) if codec_overrides is not None else _dvmmrmusrwxjpp
219
+ state = TextState(_jychlhollysmce, font_size, character_spacing, word_spacing)
220
+ show = _show_payload(index, operator, operands, state)
221
+ if show is None:
222
+ continue
223
+ x = _nukgknummdhzuh[4] * _wjlguzphrrqktg[0] + _nukgknummdhzuh[5] * _wjlguzphrrqktg[2] + _wjlguzphrrqktg[4]
224
+ y = _nukgknummdhzuh[4] * _wjlguzphrrqktg[1] + _nukgknummdhzuh[5] * _wjlguzphrrqktg[3] + _wjlguzphrrqktg[5]
225
+ _oqvqqtgeclndyt.append(PositionedShow(show, x, y))
226
+ _ibhhpbqhsskkym = (_jychlhollysmce.width(show.text) - show.adjustment) / 1000.0 * font_size
227
+ _ibhhpbqhsskkym += len(show.text) * character_spacing
228
+ _ibhhpbqhsskkym += show.text.count(' ') * word_spacing
229
+ _nukgknummdhzuh[4] += _ibhhpbqhsskkym
230
+ return _oqvqqtgeclndyt
231
+
232
+ def _clear_show(operations: list[tuple[list[Any], bytes]], positioned: PositionedShow) -> None:
233
+ show = positioned.show
234
+ _dfjqytvrjjlioo = ByteStringObject(b'')
235
+ if show.operator in (b'Tj', b'TJ'):
236
+ operations[show.index] = ([ArrayObject([_dfjqytvrjjlioo])], b'TJ')
237
+ elif show.operator == b"'":
238
+ show.operands[0] = _dfjqytvrjjlioo
239
+ elif show.operator == b'"':
240
+ show.operands[2] = _dfjqytvrjjlioo
241
+
242
+ def _set_anchored_show(operations: list[tuple[list[Any], bytes]], positioned: PositionedShow, encoded: bytes, replacement_codec: Any, switches: list[FontSwitch], codec_overrides: dict[int, Any]) -> None:
243
+ show = positioned.show
244
+ _dsnfeciqyilosk = ByteStringObject(encoded)
245
+ if show.operator in (b'Tj', b'TJ'):
246
+ operations[show.index] = ([ArrayObject([_dsnfeciqyilosk])], b'TJ')
247
+ elif show.operator == b"'":
248
+ show.operands[0] = _dsnfeciqyilosk
249
+ elif show.operator == b'"':
250
+ show.operands[2] = _dsnfeciqyilosk
251
+ if replacement_codec is not show.state.codec:
252
+ switches.append(FontSwitch(start_index=show.index, end_index=show.index, original_font=show.state.codec.resource_name, fallback_font=replacement_codec.resource_name, font_size=show.state.font_size))
253
+ codec_overrides[show.index] = replacement_codec
254
+
255
+ def _reflow_relative_text_moves(operations: list[tuple[list[Any], bytes]], first_show_index: int, last_show_index: int, replacement_advance: float) -> None:
256
+ _wjefgccagqzskr: list[int] = []
257
+ for index in range(first_show_index + 1, last_show_index):
258
+ operands, operator = operations[index]
259
+ if operator in (b'Td', b'TD') and len(operands) >= 2 and (abs(float(operands[1])) < 1e-06):
260
+ _wjefgccagqzskr.append(index)
261
+ if not _wjefgccagqzskr:
262
+ return
263
+ for index in _wjefgccagqzskr:
264
+ operations[index][0][0] = FloatObject(0.0)
265
+ for index in range(last_show_index + 1, len(operations)):
266
+ operands, operator = operations[index]
267
+ if operator in TEXT_SHOW or operator in (b'ET', b'BT', b'Tm', b'T*'):
268
+ break
269
+ if operator in (b'Td', b'TD') and len(operands) >= 2 and (abs(float(operands[1])) < 1e-06):
270
+ operands[0] = FloatObject(round(replacement_advance, 6))
271
+ break
272
+
273
+ def _rewrite_positioned_fragments(operations: list[tuple[list[Any], bytes]], codecs: Mapping[str, FontCodec], replacements: Sequence[Replacement], report: ReplacementReport, matched: set[str], fallback_resolver: FallbackResolver | None, switches: list[FontSwitch], codec_overrides: dict[int, Any]) -> int:
274
+ positioned = [item for item in _positioned_shows(operations, codecs, codec_overrides) if item.show.text]
275
+ lines: list[list[PositionedShow]] = []
276
+ for item in positioned:
277
+ tolerance = max(0.5, abs(item.show.state.font_size) * 0.2)
278
+ for line in lines:
279
+ anchor = line[0]
280
+ if anchor.show.state.codec is item.show.state.codec and abs(anchor.y - item.y) <= tolerance:
281
+ line.append(item)
282
+ break
283
+ else:
284
+ lines.append([item])
285
+ line_chains: list[list[PositionedShow]] = []
286
+ for line in lines:
287
+ line.sort(key=lambda item: item.x)
288
+ chains: list[list[PositionedShow]] = []
289
+ for item in line:
290
+ if not chains:
291
+ chains.append([item])
292
+ continue
293
+ previous = chains[-1][-1]
294
+ codec = item.show.state.codec
295
+ size = abs(item.show.state.font_size) or 1.0
296
+ previous_end = previous.x + codec.width(previous.show.text) / 1000.0 * size
297
+ space = max(codec.width(' ') / 1000.0 * size, size * 0.2)
298
+ gap = item.x - previous_end
299
+ if -space <= gap <= 2.25 * space:
300
+ chains[-1].append(item)
301
+ else:
302
+ chains.append([item])
303
+ line_chains.extend(chains)
304
+ changed = _replace_fragment_chains(operations, line_chains, replacements, report, matched, fallback_resolver, switches, codec_overrides)
305
+ refreshed = [item for item in _positioned_shows(operations, codecs, codec_overrides) if item.show.text]
306
+ sequential: list[list[PositionedShow]] = []
307
+ for item in refreshed:
308
+ if not sequential:
309
+ sequential.append([item])
310
+ continue
311
+ previous = sequential[-1][-1]
312
+ size = max(abs(item.show.state.font_size), 1.0)
313
+ nearby = abs(item.y - previous.y) <= 1.5 * size
314
+ close_in_stream = item.show.index - previous.show.index <= 18
315
+ if previous.show.state.codec is item.show.state.codec and nearby and close_in_stream:
316
+ sequential[-1].append(item)
317
+ else:
318
+ sequential.append([item])
319
+ changed += _replace_fragment_chains(operations, sequential, replacements, report, matched, fallback_resolver, switches, codec_overrides, use_content_order=True)
320
+ return changed
321
+
322
+ def _replace_fragment_chains(operations: list[tuple[list[Any], bytes]], chains: Sequence[Sequence[PositionedShow]], replacements: Sequence[Replacement], report: ReplacementReport, matched: set[str], fallback_resolver: FallbackResolver | None, switches: list[FontSwitch], codec_overrides: dict[int, Any], *, use_content_order: bool=False) -> int:
323
+ _ipuzkbubtxohlx = 0
324
+ for _xgltulrsgubkby in chains:
325
+ if len(_xgltulrsgubkby) < 2:
326
+ continue
327
+ _vhwdgqlspbzbye: list[str] = []
328
+ _bmbvmgzgbuhybe: list[tuple[int, int] | None] = []
329
+ for _sobnyqibpybvpw, _lqftcyyrwbzrco in enumerate(_xgltulrsgubkby):
330
+ if _sobnyqibpybvpw:
331
+ _qkslmjfcsszwso = _xgltulrsgubkby[_sobnyqibpybvpw - 1]
332
+ codec = _lqftcyyrwbzrco.show.state.codec
333
+ _sxorndgwjbhpyi = abs(_lqftcyyrwbzrco.show.state.font_size) or 1.0
334
+ _vbksykdgyoovnx = _qkslmjfcsszwso.x + codec.width(_qkslmjfcsszwso.show.text) / 1000.0 * _sxorndgwjbhpyi
335
+ _pstrlmimrvczvn = max(codec.width(' ') / 1000.0 * _sxorndgwjbhpyi, _sxorndgwjbhpyi * 0.2)
336
+ _xerzilnauysewq = abs(_lqftcyyrwbzrco.y - _qkslmjfcsszwso.y) > _sxorndgwjbhpyi * 0.2
337
+ _onhqjureaaymkt = _lqftcyyrwbzrco.x - _vbksykdgyoovnx > 0.15 * _pstrlmimrvczvn
338
+ if not _qkslmjfcsszwso.show.text.endswith((' ', '\t', '\r', '\n')) and (not _lqftcyyrwbzrco.show.text.startswith((' ', '\t', '\r', '\n'))) and (_xerzilnauysewq if use_content_order else _onhqjureaaymkt):
339
+ _vhwdgqlspbzbye.append(' ')
340
+ _bmbvmgzgbuhybe.append(None)
341
+ for _vstqsyvurnrbzq, _jivjejahdqniew in enumerate(_lqftcyyrwbzrco.show.text):
342
+ _vhwdgqlspbzbye.append(_jivjejahdqniew)
343
+ _bmbvmgzgbuhybe.append((_sobnyqibpybvpw, _vstqsyvurnrbzq))
344
+ _jargclyilokrdx = ''.join(_vhwdgqlspbzbye)
345
+ _kzvwyiknixihah: list[tuple[int, int, Replacement]] = []
346
+ _kzdvvmtbseqatx: set[int] = set()
347
+ for _kkdvmnwmoodeok in replacements:
348
+ start = 0
349
+ while True:
350
+ _tbcratdkwytvbd = _jargclyilokrdx.find(_kkdvmnwmoodeok.old, start)
351
+ if _tbcratdkwytvbd < 0:
352
+ break
353
+ _pqqcvfknomsycc = _tbcratdkwytvbd + len(_kkdvmnwmoodeok.old)
354
+ _mevatselfkzobf = {origin[0] for origin in _bmbvmgzgbuhybe[_tbcratdkwytvbd:_pqqcvfknomsycc] if origin is not None}
355
+ if len(_mevatselfkzobf) >= 2 and (not any((i in _kzdvvmtbseqatx for i in range(_tbcratdkwytvbd, _pqqcvfknomsycc)))):
356
+ _kzvwyiknixihah.append((_tbcratdkwytvbd, _pqqcvfknomsycc, _kkdvmnwmoodeok))
357
+ _kzdvvmtbseqatx.update(range(_tbcratdkwytvbd, _pqqcvfknomsycc))
358
+ start = max(_pqqcvfknomsycc, _tbcratdkwytvbd + 1)
359
+ for start, _pqqcvfknomsycc, _kkdvmnwmoodeok in sorted(_kzvwyiknixihah, reverse=True):
360
+ _fverphwioeeich = [origin for origin in _bmbvmgzgbuhybe[start:_pqqcvfknomsycc] if origin is not None]
361
+ if not _fverphwioeeich:
362
+ continue
363
+ _guetbtadgixmwx, _gvccrdrlhesubo = _fverphwioeeich[0]
364
+ _nriusfjiuxxdxx, _czxijlblnyajrl = _fverphwioeeich[-1]
365
+ _zzegxxeiouuwci = _xgltulrsgubkby[_guetbtadgixmwx]
366
+ _kvyxybezejkihj = _xgltulrsgubkby[_nriusfjiuxxdxx]
367
+ _vmcrznhtrtoquw = _zzegxxeiouuwci.show.text[:_gvccrdrlhesubo] + _kkdvmnwmoodeok.new + _kvyxybezejkihj.show.text[_czxijlblnyajrl + 1:]
368
+ try:
369
+ _xufnnmiraddwpb, _tzugszpyhhqdeq = _encode_replacement(_zzegxxeiouuwci.show.state.codec, _vmcrznhtrtoquw, _zzegxxeiouuwci.show.state.font_size, fallback_resolver)
370
+ except FontEncodingError as _kdwzjcxqeaqhjg:
371
+ _hnkcanibxexsqp = str(_kdwzjcxqeaqhjg)
372
+ if _hnkcanibxexsqp not in report.encoding_failures:
373
+ report.encoding_failures.append(_hnkcanibxexsqp)
374
+ print(f'Warning: {_hnkcanibxexsqp}', file=sys.stderr)
375
+ continue
376
+ _set_anchored_show(operations, _zzegxxeiouuwci, _xufnnmiraddwpb, _tzugszpyhhqdeq, switches, codec_overrides)
377
+ if _guetbtadgixmwx < _nriusfjiuxxdxx:
378
+ _reflow_relative_text_moves(operations, _zzegxxeiouuwci.show.index, _kvyxybezejkihj.show.index, _tzugszpyhhqdeq.width(_vmcrznhtrtoquw) / 1000.0 * _zzegxxeiouuwci.show.state.font_size)
379
+ for _wkksyolnhajvhs in _xgltulrsgubkby[_guetbtadgixmwx + 1:_nriusfjiuxxdxx + 1]:
380
+ _clear_show(operations, _wkksyolnhajvhs)
381
+ report.replacements += 1
382
+ matched.add(_kkdvmnwmoodeok.old)
383
+ _ipuzkbubtxohlx += 1
384
+ return _ipuzkbubtxohlx
apdf/utils.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import sys
4
+ from typing import Any
5
+ from pypdf.generic import ByteStringObject, TextStringObject
6
+ _RESET = '\x1b[0m'
7
+ _BOLD = '\x1b[1m'
8
+ _DIM = '\x1b[2m'
9
+ _ITALIC = '\x1b[3m'
10
+ _WHITE = '\x1b[97m'
11
+ _CYAN = '\x1b[96m'
12
+ _BLUE = '\x1b[94m'
13
+ _GREEN = '\x1b[92m'
14
+ _YELLOW = '\x1b[93m'
15
+ _RED = '\x1b[91m'
16
+ _MAGENTA = '\x1b[95m'
17
+ _GRAY = '\x1b[90m'
18
+
19
+ def _supports_color() -> bool:
20
+ return sys.stderr.isatty() and 'NO_COLOR' not in os.environ
21
+
22
+ def _c(text: str, *styles: str) -> str:
23
+ if not _supports_color():
24
+ return text
25
+ return f"{''.join(styles)}{text}{_RESET}"
26
+
27
+ def _deref(value: Any) -> Any:
28
+ try:
29
+ return value.get_object()
30
+ except Exception:
31
+ return value
32
+
33
+ def _object_key(value: Any) -> tuple[str, int, int] | tuple[str, int]:
34
+ ref = getattr(value, 'indirect_reference', None)
35
+ if ref is not None:
36
+ return ('ref', int(ref.idnum), int(ref.generation))
37
+ return ('direct', id(value))
38
+
39
+ def _raw_bytes(value: Any) -> bytes:
40
+ if isinstance(value, ByteStringObject):
41
+ return bytes(value)
42
+ if isinstance(value, TextStringObject):
43
+ return value.original_bytes
44
+ if isinstance(value, bytes):
45
+ return value
46
+ if isinstance(value, str):
47
+ return value.encode('latin-1', errors='surrogatepass')
48
+ raise TypeError(f'Not a PDF string operand: {type(value).__name__}')
49
+
50
+ def _is_pdf_string(value: Any) -> bool:
51
+ return isinstance(value, (str, bytes, TextStringObject, ByteStringObject))
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.5
2
+ Name: apdf
3
+ Version: 1.0
4
+ Summary: Meet another powerful PDF editing tool, designed to modify raw PDF data in hexadecimal format as well as Unicode text.
5
+ Author: Abodx9
6
+ License-Expression: MIT
7
+ Keywords: Hex,PDFs,Raw,Text,Tools
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: fonttools==4.63.0
18
+ Requires-Dist: pypdf==6.16.1
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Another PDF editor
22
+
23
+ **A-PDF** is a fast, command-line tool designed to replace Unicode and Hex text directly within PDF content streams.
24
+
25
+ ## Features
26
+ - **Direct Stream Replacement**: Modifies the underlying PDF streams directly for fast and precise text replacement.
27
+ - **Unicode & Hex Support**: Replace text using standard string replacements that are automatically encoded to the document's fonts.
28
+ - **Spacing Compensation**: Automatically compensates for `TJ` (Text Justification) width adjustments after text replacements to preserve document layout (can be disabled).
29
+ - **Strict Mode**: Optionally fail replacements if text is missing or the font cannot encode the requested characters.
30
+ - **Fallback Font Support**: Embed a custom TrueType font when the PDF's subset font lacks a glyph, or disable fallback entirely.
31
+
32
+ ## Installation
33
+
34
+ You can run the tool directly using [uv](https://github.com/astral-sh/uv), which will automatically handle dependencies:
35
+ ```bash
36
+ uv add apdf # or As a tool: uv tool install apdf
37
+ # then Run it
38
+ uv run apdf --help
39
+ ```
40
+ Or simply use it in your standard Python environment.
41
+ ```bash
42
+ pip install apdf
43
+
44
+ # Run
45
+ apdf --help
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ apdf [-h] [-r OLD=NEW] [-nopa] [-s] [-ff FONT.ttf] [-no-ff] input [output]
52
+ ```
53
+
54
+ ### Examples
55
+
56
+ **Basic replacement:**
57
+ ```bash
58
+ apdf input.pdf output.pdf -r "OldText=NewText"
59
+ ```
60
+
61
+ **Multiple replacements:**
62
+ ```bash
63
+ apdf document.pdf updated_document.pdf \
64
+ -r "Draft=Final" \
65
+ -r "2023=2024"
66
+ ```
67
+
68
+ **Disable width compensation** (if characters are bunching up or spacing is incorrect):
69
+ ```bash
70
+ apdf input.pdf output.pdf -r "foo=bar" -nopa
71
+ ```
72
+
73
+ **Strict mode** (fails if the replacement target is not found):
74
+ ```bash
75
+ apdf input.pdf output.pdf -r "foo=bar" -s
76
+ ```
77
+
78
+ ### Arguments
79
+
80
+ * `input`: The source PDF file.
81
+ * `output`: (Optional) The destination PDF file. Defaults to `output.pdf`.
82
+ * `-r OLD=NEW`: Replace. The text replacement pair. You can supply this argument multiple times.
83
+ * `-nopa`: No preserve advance. Do not compensate `TJ` text widths after making replacements.
84
+ * `-s`: Strict. Fail if the original text is missing or the selected font cannot encode the new text.
85
+ * `-ff FONT.ttf`: Fallback font. TrueType font to embed when the PDF's subset font lacks a glyph.
86
+ * `-no-ff`: No font fallback. Reject missing glyphs instead of embedding a fallback font.
@@ -0,0 +1,14 @@
1
+ apdf/__init__.py,sha256=D8C3qEhghuNGqbTlp-eK_FR4fTfX-NWteDBk4X2JrIE,364
2
+ apdf/__main__.py,sha256=SzcAN5jLCfR7FbezjiJ9A21X-9LB2iiL5Ym2ds3b-tE,51
3
+ apdf/cli.py,sha256=Gq0GQ4gK4nX98BPJuwFdGrKrdWU2lORMds06DRKnfvk,4885
4
+ apdf/codec.py,sha256=2w5-ESvCZCFiMTsrKiiabaXxut4BzR-giFe6UxjAI-o,8415
5
+ apdf/engine.py,sha256=RcnY3SFyBV7ydVpCQ5x1nRX6Jr-z_D2wrf0RHAkFd2E,7628
6
+ apdf/exceptions.py,sha256=WkhmBFqleKBlETTQCUSPO7gqDF4bwptm7dm1htBG9Zc,390
7
+ apdf/fallback.py,sha256=OXyfHMk1zvJt92a5Ryo9jffGtIC83idcjvHi_sS3z-c,12740
8
+ apdf/models.py,sha256=i-Za3r5X8kiXcIxvI0r4I_tp6_1fkCi_ZczUQHlUH_s,1063
9
+ apdf/operations.py,sha256=Xg7U9geHb7M4QyoenSsDPHTvEIfHpYfz_9BvaMtSawo,21964
10
+ apdf/utils.py,sha256=7iohE2i4aImyht1RmnLfAoVsG8dVTPoS_CsKW2CqXo8,1496
11
+ apdf-1.0.dist-info/METADATA,sha256=hfSjkcCnKMt0_Fdaon__hZeK-5xwPgS3sFMBbKGKwRA,3100
12
+ apdf-1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
13
+ apdf-1.0.dist-info/entry_points.txt,sha256=P_30JbdytaW1iXrO3bCidw01coWmfnvqss5zmixGcaM,44
14
+ apdf-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
+ apdf = apdf.__main__:main