spotpdf 0.7.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.
Files changed (60) hide show
  1. spotpdf/__init__.py +54 -0
  2. spotpdf/__main__.py +3 -0
  3. spotpdf/alternate.py +146 -0
  4. spotpdf/alternate_plan.py +286 -0
  5. spotpdf/alternate_validation.py +272 -0
  6. spotpdf/budget_content.py +123 -0
  7. spotpdf/budget_graph.py +83 -0
  8. spotpdf/budget_preflight.py +60 -0
  9. spotpdf/cli.py +128 -0
  10. spotpdf/cli_dry_run.py +31 -0
  11. spotpdf/cli_limits.py +90 -0
  12. spotpdf/cli_output.py +477 -0
  13. spotpdf/cli_parser.py +254 -0
  14. spotpdf/cmyk.py +144 -0
  15. spotpdf/colors.py +148 -0
  16. spotpdf/content.py +401 -0
  17. spotpdf/content_support.py +105 -0
  18. spotpdf/convert.py +143 -0
  19. spotpdf/convert_aliases.py +303 -0
  20. spotpdf/convert_content.py +583 -0
  21. spotpdf/convert_operators.py +67 -0
  22. spotpdf/convert_plan.py +128 -0
  23. spotpdf/convert_resource_contexts.py +307 -0
  24. spotpdf/convert_resources.py +121 -0
  25. spotpdf/convert_state.py +64 -0
  26. spotpdf/convert_stream_owners.py +243 -0
  27. spotpdf/convert_streams.py +426 -0
  28. spotpdf/document.py +543 -0
  29. spotpdf/inspection.py +44 -0
  30. spotpdf/inventory.py +527 -0
  31. spotpdf/inventory_content.py +600 -0
  32. spotpdf/inventory_graph.py +159 -0
  33. spotpdf/inventory_hazards.py +359 -0
  34. spotpdf/inventory_prepress.py +168 -0
  35. spotpdf/inventory_usage.py +65 -0
  36. spotpdf/inventory_values.py +128 -0
  37. spotpdf/limits.py +103 -0
  38. spotpdf/metadata_fingerprint.py +165 -0
  39. spotpdf/model.py +264 -0
  40. spotpdf/mutation_verification.py +139 -0
  41. spotpdf/objects.py +47 -0
  42. spotpdf/publication.py +206 -0
  43. spotpdf/py.typed +0 -0
  44. spotpdf/removal_resources.py +228 -0
  45. spotpdf/rename.py +236 -0
  46. spotpdf/rename_hazards.py +261 -0
  47. spotpdf/rename_plan.py +595 -0
  48. spotpdf/rename_request.py +74 -0
  49. spotpdf/rename_slots.py +412 -0
  50. spotpdf/rename_structures.py +320 -0
  51. spotpdf/resource_owners.py +62 -0
  52. spotpdf/scan.py +324 -0
  53. spotpdf/separation_targets.py +233 -0
  54. spotpdf/trailer_semantics.py +30 -0
  55. spotpdf-0.7.0.dist-info/METADATA +525 -0
  56. spotpdf-0.7.0.dist-info/RECORD +60 -0
  57. spotpdf-0.7.0.dist-info/WHEEL +5 -0
  58. spotpdf-0.7.0.dist-info/entry_points.txt +2 -0
  59. spotpdf-0.7.0.dist-info/licenses/LICENSE +21 -0
  60. spotpdf-0.7.0.dist-info/top_level.txt +1 -0
spotpdf/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """Inspect and safely mutate named spot colors in PDF content."""
2
+
3
+ from .alternate import set_alternate_cmyk
4
+ from .convert import convert_spot_to_cmyk
5
+ from .document import check_spot, inspect_pdf, remove_all_spots, remove_spot
6
+ from .limits import (
7
+ DEFAULT_PROCESSING_LIMITS,
8
+ ProcessingBudgetExceeded,
9
+ ProcessingLimits,
10
+ )
11
+ from .model import (
12
+ AlternateResult,
13
+ BatchRemovalResult,
14
+ ColorantRole,
15
+ ConversionResult,
16
+ InspectionReport,
17
+ InvalidPdfError,
18
+ NestingLimitExceededError,
19
+ RemovalStats,
20
+ RenameResult,
21
+ SpotKind,
22
+ SpotPdfError,
23
+ SpotSummary,
24
+ UnsupportedSpotUseError,
25
+ __version__,
26
+ )
27
+ from .rename import rename_spot
28
+
29
+ __all__ = [
30
+ "DEFAULT_PROCESSING_LIMITS",
31
+ "AlternateResult",
32
+ "BatchRemovalResult",
33
+ "ColorantRole",
34
+ "ConversionResult",
35
+ "InspectionReport",
36
+ "InvalidPdfError",
37
+ "NestingLimitExceededError",
38
+ "ProcessingBudgetExceeded",
39
+ "ProcessingLimits",
40
+ "RemovalStats",
41
+ "RenameResult",
42
+ "SpotKind",
43
+ "SpotPdfError",
44
+ "SpotSummary",
45
+ "UnsupportedSpotUseError",
46
+ "__version__",
47
+ "check_spot",
48
+ "convert_spot_to_cmyk",
49
+ "inspect_pdf",
50
+ "remove_all_spots",
51
+ "remove_spot",
52
+ "rename_spot",
53
+ "set_alternate_cmyk",
54
+ ]
spotpdf/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
spotpdf/alternate.py ADDED
@@ -0,0 +1,146 @@
1
+ """Atomic orchestration for changing Separation alternate CMYK previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from os import PathLike
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pikepdf
11
+
12
+ from .alternate_plan import (
13
+ AlternatePlan,
14
+ NormalizedCmyk,
15
+ build_alternate_plan,
16
+ )
17
+ from .cmyk import (
18
+ PercentageCmyk,
19
+ normalized_cmyk,
20
+ parse_cmyk_percentages,
21
+ validate_cmyk_percentages,
22
+ )
23
+ from .inventory import discover_spot_declarations
24
+ from .limits import DEFAULT_PROCESSING_LIMITS, ProcessingLimits, require_processing_limits
25
+ from .model import AlternateResult, SpotPdfError
26
+ from .mutation_verification import (
27
+ ContentFingerprint,
28
+ InventoryFingerprint,
29
+ content_fingerprint,
30
+ inventory_fingerprint,
31
+ parse_content_streams,
32
+ )
33
+ from .publication import atomic_pdf_output, open_strict, save_pdf
34
+ from .rename_slots import semantic_pdf_fingerprint
35
+ from .scan import validate_document_for_mutation
36
+
37
+
38
+ def set_alternate_cmyk(
39
+ input_path: str | PathLike[str],
40
+ output_path: str | PathLike[str],
41
+ spot: str,
42
+ cmyk: Sequence[object],
43
+ *,
44
+ force: bool = False,
45
+ limits: ProcessingLimits = DEFAULT_PROCESSING_LIMITS,
46
+ ) -> AlternateResult:
47
+ """Replace every matching Separation preview with one linear CMYK fallback."""
48
+
49
+ input_path = Path(input_path)
50
+ output_path = Path(output_path)
51
+ limits = require_processing_limits(limits)
52
+ percentages = validate_cmyk_percentages(cmyk)
53
+ normalized = normalized_cmyk(percentages)
54
+ stored_percentages: PercentageCmyk = (
55
+ normalized[0] * 100,
56
+ normalized[1] * 100,
57
+ normalized[2] * 100,
58
+ normalized[3] * 100,
59
+ )
60
+ result: AlternateResult | None = None
61
+ with atomic_pdf_output(input_path, output_path, force=force, limits=limits) as output:
62
+ with open_strict(output.input_path, limits=limits) as pdf:
63
+ validate_document_for_mutation(pdf)
64
+ before = discover_spot_declarations(pdf)
65
+ plan = build_alternate_plan(pdf, before, spot, normalized)
66
+ expected_inventory = inventory_fingerprint(before)
67
+ expected_content = content_fingerprint(pdf)
68
+ expected_masked_document = plan.normalized_document_fingerprint()
69
+
70
+ plan.apply()
71
+ _verify_in_memory(
72
+ pdf,
73
+ plan,
74
+ expected_inventory,
75
+ expected_content,
76
+ expected_masked_document,
77
+ )
78
+ expected_saved_document = semantic_pdf_fingerprint(pdf)
79
+ save_pdf(pdf, output.temp_path)
80
+ result = AlternateResult(
81
+ spot=spot,
82
+ cmyk_percentages=stored_percentages,
83
+ definitions_changed=plan.definitions_changed,
84
+ )
85
+
86
+ _verify_saved_pdf(
87
+ output.temp_path,
88
+ spot,
89
+ normalized,
90
+ expected_inventory,
91
+ expected_content,
92
+ expected_saved_document,
93
+ result.definitions_changed,
94
+ )
95
+
96
+ if result is None: # pragma: no cover - guarded by the transaction above
97
+ raise SpotPdfError("alternate preview change did not produce a result")
98
+ return result
99
+
100
+
101
+ def _verify_in_memory(
102
+ pdf: pikepdf.Pdf,
103
+ plan: AlternatePlan,
104
+ expected_inventory: InventoryFingerprint,
105
+ expected_content: ContentFingerprint,
106
+ expected_masked_document: tuple[Any, ...],
107
+ ) -> None:
108
+ plan.verify_requested()
109
+ if inventory_fingerprint(discover_spot_declarations(pdf)) != expected_inventory:
110
+ raise SpotPdfError("spot inventory changed while setting the alternate preview")
111
+ if content_fingerprint(pdf) != expected_content:
112
+ raise SpotPdfError("content streams changed while setting the alternate preview")
113
+ if plan.normalized_document_fingerprint() != expected_masked_document:
114
+ raise SpotPdfError("PDF semantics changed beyond the planned alternate preview slots")
115
+
116
+
117
+ def _verify_saved_pdf(
118
+ path: Path,
119
+ spot: str,
120
+ cmyk: NormalizedCmyk,
121
+ expected_inventory: InventoryFingerprint,
122
+ expected_content: ContentFingerprint,
123
+ expected_document: tuple[Any, ...],
124
+ expected_definitions: int,
125
+ ) -> None:
126
+ with open_strict(path, limits=None) as pdf:
127
+ report = discover_spot_declarations(pdf)
128
+ if inventory_fingerprint(report) != expected_inventory:
129
+ raise SpotPdfError("saved PDF spot inventory changed unexpectedly")
130
+ plan = build_alternate_plan(pdf, report, spot, cmyk)
131
+ if plan.definitions_changed != expected_definitions:
132
+ raise SpotPdfError("saved PDF has a different number of target Separations")
133
+ plan.verify_requested()
134
+ if content_fingerprint(pdf) != expected_content:
135
+ raise SpotPdfError("saved PDF content streams changed unexpectedly")
136
+ parse_content_streams(pdf)
137
+ if semantic_pdf_fingerprint(pdf) != expected_document:
138
+ raise SpotPdfError("saved PDF object semantics changed during rewrite")
139
+
140
+
141
+ __all__ = [
142
+ "PercentageCmyk",
143
+ "parse_cmyk_percentages",
144
+ "set_alternate_cmyk",
145
+ "validate_cmyk_percentages",
146
+ ]
@@ -0,0 +1,286 @@
1
+ """Validated mutation plans for Separation alternate CMYK previews."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ import pikepdf
11
+
12
+ from .alternate_validation import reject_inline_target_definitions, validate_existing_preview
13
+ from .cmyk import NormalizedCmyk, canonical_pdf_number, canonicalize_normalized_cmyk
14
+ from .colors import PROCESS_COLORANTS, SPECIAL_COLORANTS
15
+ from .inventory_graph import walk_reachable
16
+ from .inventory_values import name_or_string, name_value
17
+ from .model import (
18
+ ColorantRole,
19
+ InspectionReport,
20
+ InvalidPdfError,
21
+ SpotKind,
22
+ SpotPdfError,
23
+ UnsupportedSpotUseError,
24
+ )
25
+ from .rename_hazards import devicen_target_mentions, name_field_mentions
26
+ from .rename_slots import semantic_object_fingerprint, semantic_pdf_fingerprint
27
+
28
+
29
+ @dataclass
30
+ class AlternateSlot:
31
+ """One complete Separation definition whose preview may be replaced."""
32
+
33
+ separation: pikepdf.Array
34
+ spot: str
35
+ original_alternate: tuple[Any, ...]
36
+ original_tint: tuple[Any, ...]
37
+ requested_tint: tuple[Any, ...]
38
+ locations: set[str] = field(default_factory=set)
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ return min(self.locations)
43
+
44
+ def verify_original(self) -> None:
45
+ self._verify_identity()
46
+ if semantic_object_fingerprint(self.separation[2]) != self.original_alternate:
47
+ raise SpotPdfError(f"alternate color space changed before apply at {self.label}")
48
+ if semantic_object_fingerprint(self.separation[3]) != self.original_tint:
49
+ raise SpotPdfError(f"tint transform changed before apply at {self.label}")
50
+
51
+ def verify_requested(self) -> None:
52
+ self._verify_identity()
53
+ if self.separation[2] != pikepdf.Name.DeviceCMYK:
54
+ raise SpotPdfError(f"requested /DeviceCMYK alternate is absent at {self.label}")
55
+ if semantic_object_fingerprint(self.separation[3]) != self.requested_tint:
56
+ raise SpotPdfError(f"requested linear CMYK tint transform is absent at {self.label}")
57
+
58
+ def _verify_identity(self) -> None:
59
+ if (
60
+ len(self.separation) != 4
61
+ or name_value(self.separation[0]) != "Separation"
62
+ or name_value(self.separation[1]) != self.spot
63
+ ):
64
+ raise SpotPdfError(f"Separation identity changed unexpectedly at {self.label}")
65
+
66
+
67
+ @dataclass
68
+ class AlternatePlan:
69
+ """A complete set of preview-only mutations for one spot plate."""
70
+
71
+ pdf: pikepdf.Pdf
72
+ spot: str
73
+ cmyk: NormalizedCmyk
74
+ slots: tuple[AlternateSlot, ...]
75
+ _applied: bool = False
76
+
77
+ @property
78
+ def definitions_changed(self) -> int:
79
+ return len(self.slots)
80
+
81
+ def apply(self) -> None:
82
+ """Replace only alternate-space and tint-transform array members."""
83
+
84
+ if self._applied:
85
+ raise SpotPdfError("alternate preview plan has already been applied")
86
+ for slot in self.slots:
87
+ slot.verify_original()
88
+ tint_transform = self.pdf.make_indirect(_linear_cmyk_function(self.cmyk))
89
+ for slot in self.slots:
90
+ slot.separation[2] = pikepdf.Name.DeviceCMYK
91
+ slot.separation[3] = tint_transform
92
+ self._applied = True
93
+ self.verify_requested()
94
+
95
+ def verify_requested(self) -> None:
96
+ """Require every planned definition to contain the exact requested preview."""
97
+
98
+ for slot in self.slots:
99
+ slot.verify_requested()
100
+
101
+ def normalized_document_fingerprint(self) -> tuple[Any, ...]:
102
+ """Fingerprint the document while masking only the two preview slots."""
103
+
104
+ with self._masked_preview_slots():
105
+ return semantic_pdf_fingerprint(self.pdf)
106
+
107
+ @contextmanager
108
+ def _masked_preview_slots(self) -> Iterator[None]:
109
+ originals: list[tuple[AlternateSlot, Any, Any]] = []
110
+ try:
111
+ for slot in self.slots:
112
+ slot.verify_requested() if self._applied else slot.verify_original()
113
+ originals.append((slot, slot.separation[2], slot.separation[3]))
114
+ slot.separation[2] = pikepdf.Name("/__spotpdf_alternate_space_slot__")
115
+ slot.separation[3] = pikepdf.Name("/__spotpdf_tint_transform_slot__")
116
+ yield
117
+ finally:
118
+ for slot, alternate, tint in reversed(originals):
119
+ slot.separation[2] = alternate
120
+ slot.separation[3] = tint
121
+
122
+
123
+ class _PlanBuilder:
124
+ """Find every matching Separation and reject DeviceN or malformed uses."""
125
+
126
+ def __init__(
127
+ self,
128
+ pdf: pikepdf.Pdf,
129
+ report: InspectionReport,
130
+ spot: str,
131
+ cmyk: NormalizedCmyk,
132
+ ) -> None:
133
+ self.pdf = pdf
134
+ self.report = report
135
+ self.spot = spot
136
+ self.cmyk = cmyk
137
+ self.requested_tint = semantic_object_fingerprint(_linear_cmyk_function(cmyk))
138
+ self.slots: dict[str, AlternateSlot] = {}
139
+
140
+ def build(self) -> AlternatePlan:
141
+ self._validate_request()
142
+ for visit in walk_reachable(self.pdf):
143
+ value = visit.value
144
+ if not isinstance(value, pikepdf.Array) or not value:
145
+ continue
146
+ family = name_or_string(value[0])
147
+ if family == "Separation":
148
+ self._inspect_separation(value, visit.locations)
149
+ elif family == "DeviceN" and devicen_target_mentions(value, frozenset({self.spot})):
150
+ raise UnsupportedSpotUseError(
151
+ f"{min(visit.locations)}: DeviceN use of {self.spot!r} is not supported"
152
+ )
153
+ reject_inline_target_definitions(self.pdf, self.spot)
154
+ self._validate_coverage()
155
+ return AlternatePlan(
156
+ pdf=self.pdf,
157
+ spot=self.spot,
158
+ cmyk=self.cmyk,
159
+ slots=tuple(sorted(self.slots.values(), key=lambda slot: slot.label)),
160
+ )
161
+
162
+ def _validate_request(self) -> None:
163
+ if self.spot in SPECIAL_COLORANTS:
164
+ raise InvalidPdfError("reserved /All and /None separation previews cannot be changed")
165
+ if self.spot in PROCESS_COLORANTS:
166
+ raise InvalidPdfError("canonical process-color previews cannot be changed")
167
+ summary = self.report.colorants.get(self.spot)
168
+ if summary is None:
169
+ raise InvalidPdfError(f"spot color is absent: {self.spot!r}")
170
+ if summary.roles != {ColorantRole.SPOT}:
171
+ roles = ", ".join(sorted(role.value for role in summary.roles)) or "unknown"
172
+ raise InvalidPdfError(
173
+ f"colorant {self.spot!r} is not an unambiguous spot (roles: {roles})"
174
+ )
175
+ if SpotKind.DEVICEN in summary.kinds:
176
+ raise UnsupportedSpotUseError(
177
+ f"DeviceN use of {self.spot!r} is not supported by set-alternate"
178
+ )
179
+ if not self._expected_definition_ids():
180
+ raise InvalidPdfError(
181
+ f"spot color {self.spot!r} has no reachable Separation definition"
182
+ )
183
+
184
+ def _inspect_separation(
185
+ self,
186
+ value: pikepdf.Array,
187
+ locations: tuple[str, ...],
188
+ ) -> None:
189
+ raw_name = value[1] if len(value) >= 2 else None
190
+ if name_or_string(raw_name) != self.spot:
191
+ if name_field_mentions(raw_name, frozenset({self.spot})):
192
+ raise UnsupportedSpotUseError(
193
+ f"{min(locations)}: malformed Separation name field mentions {self.spot!r}"
194
+ )
195
+ return
196
+ location = min(locations)
197
+ if (
198
+ not isinstance(value[0], pikepdf.Name)
199
+ or not isinstance(raw_name, pikepdf.Name)
200
+ or len(value) != 4
201
+ or not isinstance(value[2], (pikepdf.Name, pikepdf.Array))
202
+ or not isinstance(value[3], (pikepdf.Dictionary, pikepdf.Stream))
203
+ ):
204
+ raise UnsupportedSpotUseError(
205
+ f"{location}: malformed Separation array cannot be changed safely"
206
+ )
207
+ validate_existing_preview(value[2], value[3], location)
208
+ definition_id = self._definition_id(locations)
209
+ proposed = AlternateSlot(
210
+ separation=value,
211
+ spot=self.spot,
212
+ original_alternate=semantic_object_fingerprint(value[2]),
213
+ original_tint=semantic_object_fingerprint(value[3]),
214
+ requested_tint=self.requested_tint,
215
+ )
216
+ slot = self.slots.setdefault(definition_id, proposed)
217
+ if slot.separation is not value and (
218
+ semantic_object_fingerprint(slot.separation) != semantic_object_fingerprint(value)
219
+ ):
220
+ raise UnsupportedSpotUseError(
221
+ f"{location}: inventory identity maps to conflicting Separation arrays"
222
+ )
223
+ slot.locations.update(locations)
224
+
225
+ def _definition_id(self, locations: tuple[str, ...]) -> str:
226
+ location_set = set(locations)
227
+ matches = [
228
+ definition.object_id
229
+ for definition in self.report.definitions.values()
230
+ if definition.kind is SpotKind.SEPARATION
231
+ and any(component.name == self.spot for component in definition.components)
232
+ and location_set.intersection(definition.locations)
233
+ ]
234
+ if len(set(matches)) != 1:
235
+ raise UnsupportedSpotUseError(
236
+ f"{min(locations)}: Separation has no unique inventory identity"
237
+ )
238
+ return matches[0]
239
+
240
+ def _expected_definition_ids(self) -> set[str]:
241
+ return {
242
+ definition.object_id
243
+ for definition in self.report.definitions.values()
244
+ if definition.kind is SpotKind.SEPARATION
245
+ and any(component.name == self.spot for component in definition.components)
246
+ }
247
+
248
+ def _validate_coverage(self) -> None:
249
+ expected = self._expected_definition_ids()
250
+ actual = set(self.slots)
251
+ if actual != expected:
252
+ raise UnsupportedSpotUseError(
253
+ "inventory Separation definitions could not be mapped one-to-one "
254
+ f"(expected {len(expected)}, planned {len(actual)})"
255
+ )
256
+
257
+
258
+ def _linear_cmyk_function(cmyk: NormalizedCmyk) -> pikepdf.Dictionary:
259
+ full_tone = tuple(canonical_pdf_number(value) for value in cmyk)
260
+ return pikepdf.Dictionary(
261
+ FunctionType=2,
262
+ Domain=pikepdf.Array([0, 1]),
263
+ Range=pikepdf.Array([0, 1, 0, 1, 0, 1, 0, 1]),
264
+ C0=pikepdf.Array([0, 0, 0, 0]),
265
+ C1=pikepdf.Array(full_tone),
266
+ N=1,
267
+ )
268
+
269
+
270
+ def build_alternate_plan(
271
+ pdf: pikepdf.Pdf,
272
+ report: InspectionReport,
273
+ spot: str,
274
+ cmyk: NormalizedCmyk,
275
+ ) -> AlternatePlan:
276
+ """Build a complete preview mutation plan without changing the PDF."""
277
+
278
+ return _PlanBuilder(pdf, report, spot, canonicalize_normalized_cmyk(cmyk)).build()
279
+
280
+
281
+ __all__ = [
282
+ "AlternatePlan",
283
+ "NormalizedCmyk",
284
+ "build_alternate_plan",
285
+ "canonicalize_normalized_cmyk",
286
+ ]