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 +5 -0
- apdf/__main__.py +2 -0
- apdf/cli.py +73 -0
- apdf/codec.py +169 -0
- apdf/engine.py +125 -0
- apdf/exceptions.py +20 -0
- apdf/fallback.py +221 -0
- apdf/models.py +51 -0
- apdf/operations.py +384 -0
- apdf/utils.py +51 -0
- apdf-1.0.dist-info/METADATA +86 -0
- apdf-1.0.dist-info/RECORD +14 -0
- apdf-1.0.dist-info/WHEEL +4 -0
- apdf-1.0.dist-info/entry_points.txt +2 -0
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
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,,
|
apdf-1.0.dist-info/WHEEL
ADDED