apdf 1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apdf-1.0/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .python-version
12
+ ob.py
apdf-1.0/PKG-INFO ADDED
@@ -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.
apdf-1.0/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Another PDF editor
2
+
3
+ **A-PDF** is a fast, command-line tool designed to replace Unicode and Hex text directly within PDF content streams.
4
+
5
+ ## Features
6
+ - **Direct Stream Replacement**: Modifies the underlying PDF streams directly for fast and precise text replacement.
7
+ - **Unicode & Hex Support**: Replace text using standard string replacements that are automatically encoded to the document's fonts.
8
+ - **Spacing Compensation**: Automatically compensates for `TJ` (Text Justification) width adjustments after text replacements to preserve document layout (can be disabled).
9
+ - **Strict Mode**: Optionally fail replacements if text is missing or the font cannot encode the requested characters.
10
+ - **Fallback Font Support**: Embed a custom TrueType font when the PDF's subset font lacks a glyph, or disable fallback entirely.
11
+
12
+ ## Installation
13
+
14
+ You can run the tool directly using [uv](https://github.com/astral-sh/uv), which will automatically handle dependencies:
15
+ ```bash
16
+ uv add apdf # or As a tool: uv tool install apdf
17
+ # then Run it
18
+ uv run apdf --help
19
+ ```
20
+ Or simply use it in your standard Python environment.
21
+ ```bash
22
+ pip install apdf
23
+
24
+ # Run
25
+ apdf --help
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ apdf [-h] [-r OLD=NEW] [-nopa] [-s] [-ff FONT.ttf] [-no-ff] input [output]
32
+ ```
33
+
34
+ ### Examples
35
+
36
+ **Basic replacement:**
37
+ ```bash
38
+ apdf input.pdf output.pdf -r "OldText=NewText"
39
+ ```
40
+
41
+ **Multiple replacements:**
42
+ ```bash
43
+ apdf document.pdf updated_document.pdf \
44
+ -r "Draft=Final" \
45
+ -r "2023=2024"
46
+ ```
47
+
48
+ **Disable width compensation** (if characters are bunching up or spacing is incorrect):
49
+ ```bash
50
+ apdf input.pdf output.pdf -r "foo=bar" -nopa
51
+ ```
52
+
53
+ **Strict mode** (fails if the replacement target is not found):
54
+ ```bash
55
+ apdf input.pdf output.pdf -r "foo=bar" -s
56
+ ```
57
+
58
+ ### Arguments
59
+
60
+ * `input`: The source PDF file.
61
+ * `output`: (Optional) The destination PDF file. Defaults to `output.pdf`.
62
+ * `-r OLD=NEW`: Replace. The text replacement pair. You can supply this argument multiple times.
63
+ * `-nopa`: No preserve advance. Do not compensate `TJ` text widths after making replacements.
64
+ * `-s`: Strict. Fail if the original text is missing or the selected font cannot encode the new text.
65
+ * `-ff FONT.ttf`: Fallback font. TrueType font to embed when the PDF's subset font lacks a glyph.
66
+ * `-no-ff`: No font fallback. Reject missing glyphs instead of embedding a fallback font.
@@ -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']
@@ -0,0 +1,2 @@
1
+ from apdf.cli import main
2
+ raise SystemExit(main())
apdf-1.0/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-1.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
@@ -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
@@ -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