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 +12 -0
- apdf-1.0/PKG-INFO +86 -0
- apdf-1.0/README.md +66 -0
- apdf-1.0/apdf/__init__.py +5 -0
- apdf-1.0/apdf/__main__.py +2 -0
- apdf-1.0/apdf/cli.py +73 -0
- apdf-1.0/apdf/codec.py +169 -0
- apdf-1.0/apdf/engine.py +125 -0
- apdf-1.0/apdf/exceptions.py +20 -0
- apdf-1.0/apdf/fallback.py +221 -0
- apdf-1.0/apdf/models.py +51 -0
- apdf-1.0/apdf/operations.py +384 -0
- apdf-1.0/apdf/utils.py +51 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/__init__.py +16 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/__main__.py +5 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/cli.py +147 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/codec.py +221 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/engine.py +252 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/exceptions.py +30 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/fallback.py +354 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/models.py +61 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/operations.py +674 -0
- apdf-1.0/apdf.obfuscation-backup-20260820-110606/utils.py +65 -0
- apdf-1.0/pyproject.toml +33 -0
- apdf-1.0/uv.lock +76 -0
apdf-1.0/.gitignore
ADDED
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']
|
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
|
apdf-1.0/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
|
|
@@ -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
|