compile-pdf-impose 0.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.
- compile_pdf_impose-0.1.0/.github/workflows/publish-pypi.yml +48 -0
- compile_pdf_impose-0.1.0/PKG-INFO +52 -0
- compile_pdf_impose-0.1.0/README.md +19 -0
- compile_pdf_impose-0.1.0/pyproject.toml +70 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/__init__.py +28 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/api.py +159 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/cli.py +89 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/engine.py +373 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/layout_schema.py +132 -0
- compile_pdf_impose-0.1.0/src/compile_pdf_impose/verify.py +250 -0
- compile_pdf_impose-0.1.0/tests/__init__.py +0 -0
- compile_pdf_impose-0.1.0/tests/conftest.py +191 -0
- compile_pdf_impose-0.1.0/tests/test_impose_api.py +101 -0
- compile_pdf_impose-0.1.0/tests/test_impose_cli.py +93 -0
- compile_pdf_impose-0.1.0/tests/test_impose_determinism.py +41 -0
- compile_pdf_impose-0.1.0/tests/test_impose_engine.py +133 -0
- compile_pdf_impose-0.1.0/tests/test_impose_layout_schema.py +105 -0
- compile_pdf_impose-0.1.0/tests/test_impose_surface.py +22 -0
- compile_pdf_impose-0.1.0/tests/test_impose_verify.py +120 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: publish-pypi
|
|
2
|
+
|
|
3
|
+
# Publishes compile-pdf-impose to PyPI when a v*.*.* tag is pushed.
|
|
4
|
+
#
|
|
5
|
+
# Auth: PyPI Trusted Publishers (OIDC) — no API token in secrets.
|
|
6
|
+
# Configure once at https://pypi.org/manage/account/publishing/ with:
|
|
7
|
+
# - PyPI project: compile-pdf-impose
|
|
8
|
+
# - Owner: printwithsynergy
|
|
9
|
+
# - Repository: compile-pdf-impose
|
|
10
|
+
# - Workflow: publish-pypi.yml
|
|
11
|
+
# - Environment: release
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
tags:
|
|
16
|
+
- "v*.*.*"
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
build:
|
|
20
|
+
name: build sdist + wheel
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
- uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
- run: pip install --upgrade pip build
|
|
28
|
+
- run: python -m build --sdist --wheel --outdir dist/
|
|
29
|
+
- uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/*
|
|
33
|
+
|
|
34
|
+
publish:
|
|
35
|
+
name: publish to PyPI
|
|
36
|
+
needs: build
|
|
37
|
+
runs-on: ubuntu-latest
|
|
38
|
+
environment:
|
|
39
|
+
name: release
|
|
40
|
+
url: https://pypi.org/project/compile-pdf-impose/
|
|
41
|
+
permissions:
|
|
42
|
+
id-token: write
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/download-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: compile-pdf-impose
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CompilePDF impose producer.
|
|
5
|
+
Project-URL: Homepage, https://compilepdf.com
|
|
6
|
+
Project-URL: Repository, https://github.com/printwithsynergy/compile-pdf-impose
|
|
7
|
+
Project-URL: Issues, https://github.com/printwithsynergy/compile-pdf-impose/issues
|
|
8
|
+
Author-email: Print With Synergy <iam@quincy.codes>
|
|
9
|
+
License: AGPL-3.0-or-later
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: click>=8.1
|
|
19
|
+
Requires-Dist: codex-pdf<2.0,>=1.15.0
|
|
20
|
+
Requires-Dist: compile-pdf-core<1.0,>=0.1.0
|
|
21
|
+
Requires-Dist: fastapi>=0.110
|
|
22
|
+
Requires-Dist: pikepdf>=8.13
|
|
23
|
+
Requires-Dist: pillow>=10.2
|
|
24
|
+
Requires-Dist: pydantic>=2.6
|
|
25
|
+
Requires-Dist: structlog>=24.1
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.9; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# compile-pdf-impose
|
|
35
|
+
|
|
36
|
+
Sheet-level step-and-repeat layout for CompilePDF.
|
|
37
|
+
|
|
38
|
+
Layout solved by `codex_pdf.geom.tile_grid`; this package drops cells via `pikepdf`. Configurable sheet, gutter, cell rotation, page mapping. Back-side modes: work-and-turn, work-and-tumble. Cell-extract round-trip verifier (Layer 5) confirms every cell matches its source page SHA-256.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
pip install compile-pdf-impose
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Position in the stack
|
|
47
|
+
|
|
48
|
+
One of four [CompilePDF](https://compilepdf.com) producers (trap, impose, marks, rewrite). Each lives in its own repo and PyPI package so you install only what you need. Producers depend on `compile-pdf-core`, never on each other.
|
|
49
|
+
|
|
50
|
+
- Repo: https://github.com/printwithsynergy/compile-pdf-impose
|
|
51
|
+
- Deployment host: https://github.com/printwithsynergy/compile-pdf
|
|
52
|
+
- License: AGPL-3.0-or-later
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# compile-pdf-impose
|
|
2
|
+
|
|
3
|
+
Sheet-level step-and-repeat layout for CompilePDF.
|
|
4
|
+
|
|
5
|
+
Layout solved by `codex_pdf.geom.tile_grid`; this package drops cells via `pikepdf`. Configurable sheet, gutter, cell rotation, page mapping. Back-side modes: work-and-turn, work-and-tumble. Cell-extract round-trip verifier (Layer 5) confirms every cell matches its source page SHA-256.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pip install compile-pdf-impose
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Position in the stack
|
|
14
|
+
|
|
15
|
+
One of four [CompilePDF](https://compilepdf.com) producers (trap, impose, marks, rewrite). Each lives in its own repo and PyPI package so you install only what you need. Producers depend on `compile-pdf-core`, never on each other.
|
|
16
|
+
|
|
17
|
+
- Repo: https://github.com/printwithsynergy/compile-pdf-impose
|
|
18
|
+
- Deployment host: https://github.com/printwithsynergy/compile-pdf
|
|
19
|
+
- License: AGPL-3.0-or-later
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "compile-pdf-impose"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CompilePDF impose producer."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "AGPL-3.0-or-later" }
|
|
7
|
+
authors = [{ name = "Print With Synergy", email = "iam@quincy.codes" }]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Topic :: Multimedia :: Graphics",
|
|
16
|
+
"Topic :: Software Development :: Libraries",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
dependencies = [
|
|
20
|
+
"compile-pdf-core>=0.1.0,<1.0",
|
|
21
|
+
"codex-pdf>=1.15.0,<2.0",
|
|
22
|
+
"fastapi>=0.110",
|
|
23
|
+
"pikepdf>=8.13",
|
|
24
|
+
"pydantic>=2.6",
|
|
25
|
+
"click>=8.1",
|
|
26
|
+
"structlog>=24.1",
|
|
27
|
+
"Pillow>=10.2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=8.0",
|
|
33
|
+
"pytest-asyncio>=0.23",
|
|
34
|
+
"pytest-cov>=4.1",
|
|
35
|
+
"ruff>=0.4",
|
|
36
|
+
"mypy>=1.9",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
compile-pdf-impose = "compile_pdf_impose.cli:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://compilepdf.com"
|
|
44
|
+
Repository = "https://github.com/printwithsynergy/compile-pdf-impose"
|
|
45
|
+
Issues = "https://github.com/printwithsynergy/compile-pdf-impose/issues"
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["hatchling"]
|
|
49
|
+
build-backend = "hatchling.build"
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["src/compile_pdf_impose"]
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py312"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "C4", "RET"]
|
|
60
|
+
ignore = ["E501"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
python_files = ["test_*.py"]
|
|
65
|
+
addopts = ["-ra", "--strict-markers", "--strict-config"]
|
|
66
|
+
asyncio_mode = "auto"
|
|
67
|
+
|
|
68
|
+
[tool.coverage.run]
|
|
69
|
+
source = ["src/compile_pdf_impose"]
|
|
70
|
+
branch = true
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Impose producer — sheet-level step-and-repeat layout.
|
|
2
|
+
|
|
3
|
+
Per spec §4.1 — consumes ``codex_pdf.geom.tile_grid`` (with the
|
|
4
|
+
GEOM_SCHEMA_VERSION 1.1.0 extension for ``cell_rotation``,
|
|
5
|
+
``flip_per_row``, ``bleed_handling``, ``CellPlacement``) as the
|
|
6
|
+
canonical layout primitive. No Compile-side layout math.
|
|
7
|
+
|
|
8
|
+
Codex surface consumed:
|
|
9
|
+
|
|
10
|
+
- :func:`codex_pdf.geom.tile_grid` — the canonical step-and-repeat solver.
|
|
11
|
+
- :class:`codex_pdf.geom.TileGrid` — input shape.
|
|
12
|
+
- :class:`codex_pdf.geom.TileResult` — output container.
|
|
13
|
+
- :class:`codex_pdf.geom.CellPlacement` — per-cell anchor + transform.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from codex_pdf.geom import CellPlacement, TileGrid, TileResult, tile_grid
|
|
19
|
+
|
|
20
|
+
from compile_pdf_core.version import IMPOSE_SCHEMA_VERSION
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"CellPlacement",
|
|
24
|
+
"IMPOSE_SCHEMA_VERSION",
|
|
25
|
+
"TileGrid",
|
|
26
|
+
"TileResult",
|
|
27
|
+
"tile_grid",
|
|
28
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""FastAPI router for the impose producer.
|
|
2
|
+
|
|
3
|
+
Mounts under ``/v1/impose`` from :mod:`compile_pdf.api.main`. Single
|
|
4
|
+
endpoint today: ``POST /v1/impose/apply``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
|
|
12
|
+
import structlog
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
from compile_pdf_core.cache import compute_cache_key, hash_canonical_plan
|
|
17
|
+
from compile_pdf_impose.engine import ImposePlanError, apply_plan
|
|
18
|
+
from compile_pdf_impose.layout_schema import ImposePlan
|
|
19
|
+
from compile_pdf_impose.verify import verify_impose
|
|
20
|
+
from compile_pdf_core.retention import (
|
|
21
|
+
parse_consent,
|
|
22
|
+
persist_if_opted_in,
|
|
23
|
+
resolve_tenant,
|
|
24
|
+
)
|
|
25
|
+
from compile_pdf_core.version import (
|
|
26
|
+
CODEX_DOCUMENT_SCHEMA_VERSION_PIN,
|
|
27
|
+
IMPOSE_SCHEMA_VERSION,
|
|
28
|
+
VERSION,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
router = APIRouter()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ImposeApplyRequest(BaseModel):
|
|
37
|
+
"""Request envelope: an inline base64-encoded PDF + a plan."""
|
|
38
|
+
|
|
39
|
+
model_config = {"extra": "forbid"}
|
|
40
|
+
|
|
41
|
+
input_pdf_b64: str = Field(min_length=1)
|
|
42
|
+
plan: ImposePlan
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ImposeApplyResponse(BaseModel):
|
|
46
|
+
model_config = {"extra": "forbid"}
|
|
47
|
+
|
|
48
|
+
output_pdf_b64: str
|
|
49
|
+
pdf_sha256: str
|
|
50
|
+
input_sha256: str
|
|
51
|
+
plan_sha256: str
|
|
52
|
+
cache_key: str
|
|
53
|
+
cache_hit: bool = False
|
|
54
|
+
sheets_written: int
|
|
55
|
+
cells_per_sheet: int
|
|
56
|
+
input_pages: int
|
|
57
|
+
schema_version: str = IMPOSE_SCHEMA_VERSION
|
|
58
|
+
compile_version: str = VERSION
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.post("/apply", response_model=ImposeApplyResponse, status_code=status.HTTP_200_OK)
|
|
62
|
+
async def impose_apply(payload: ImposeApplyRequest, request: Request) -> ImposeApplyResponse:
|
|
63
|
+
"""Impose an inline base64-encoded PDF onto sheets per the plan."""
|
|
64
|
+
try:
|
|
65
|
+
input_bytes = base64.b64decode(payload.input_pdf_b64, validate=True)
|
|
66
|
+
except (ValueError, TypeError) as exc:
|
|
67
|
+
raise HTTPException(
|
|
68
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
69
|
+
detail=f"input_pdf_b64 is not valid base64: {exc}",
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
if not input_bytes:
|
|
73
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="input is empty")
|
|
74
|
+
|
|
75
|
+
input_sha256 = hashlib.sha256(input_bytes).hexdigest()
|
|
76
|
+
plan_sha256 = hash_canonical_plan(payload.plan.model_dump(mode="json"))
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
from codex_pdf.color import COLOR_SCHEMA_VERSION
|
|
80
|
+
from codex_pdf.geom import GEOM_SCHEMA_VERSION
|
|
81
|
+
except ImportError as exc: # pragma: no cover — codex-pdf is a hard dep
|
|
82
|
+
raise HTTPException(
|
|
83
|
+
status_code=500, detail=f"codex-pdf surface unavailable: {exc}"
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
cache_key = compute_cache_key(
|
|
87
|
+
producer="impose",
|
|
88
|
+
input_sha256=input_sha256,
|
|
89
|
+
canonical_plan_sha256=plan_sha256,
|
|
90
|
+
codex_pdf_package_version=_resolve_codex_pdf_version(),
|
|
91
|
+
color_schema_version=COLOR_SCHEMA_VERSION,
|
|
92
|
+
geom_schema_version=GEOM_SCHEMA_VERSION,
|
|
93
|
+
codex_document_schema_version=CODEX_DOCUMENT_SCHEMA_VERSION_PIN,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
logger.info(
|
|
97
|
+
"impose.apply.start",
|
|
98
|
+
input_sha256=input_sha256[:16],
|
|
99
|
+
plan_sha256=plan_sha256[:16],
|
|
100
|
+
cache_key=cache_key[:16],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
result = apply_plan(input_bytes, payload.plan)
|
|
105
|
+
except ImposePlanError as exc:
|
|
106
|
+
raise HTTPException(status_code=422, detail=f"plan rejected: {exc}") from exc
|
|
107
|
+
|
|
108
|
+
verify = verify_impose(
|
|
109
|
+
input_bytes=input_bytes,
|
|
110
|
+
output_bytes=result.output_bytes,
|
|
111
|
+
plan=payload.plan,
|
|
112
|
+
expected_sheets=result.sheets_written,
|
|
113
|
+
determinism_replay=False,
|
|
114
|
+
)
|
|
115
|
+
if not (verify.layer1_schema and verify.layer3_unchanged and verify.layer5_cell_extract):
|
|
116
|
+
logger.error("impose.apply.verify_failed", failures=verify.failures)
|
|
117
|
+
raise HTTPException(
|
|
118
|
+
status_code=500,
|
|
119
|
+
detail={"error": "verify failed", "failures": verify.failures},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
consent = parse_consent(request)
|
|
123
|
+
response = ImposeApplyResponse(
|
|
124
|
+
output_pdf_b64=base64.b64encode(result.output_bytes).decode("ascii"),
|
|
125
|
+
pdf_sha256=result.pdf_sha256,
|
|
126
|
+
input_sha256=input_sha256,
|
|
127
|
+
plan_sha256=plan_sha256,
|
|
128
|
+
cache_key=cache_key,
|
|
129
|
+
cache_hit=False,
|
|
130
|
+
sheets_written=result.sheets_written,
|
|
131
|
+
cells_per_sheet=result.cells_per_sheet,
|
|
132
|
+
input_pages=result.input_pages,
|
|
133
|
+
)
|
|
134
|
+
retained = persist_if_opted_in(
|
|
135
|
+
consent=consent,
|
|
136
|
+
producer="impose",
|
|
137
|
+
tenant=resolve_tenant(request),
|
|
138
|
+
input_bytes=input_bytes,
|
|
139
|
+
output_bytes=result.output_bytes,
|
|
140
|
+
result=response.model_dump(mode="json"),
|
|
141
|
+
input_sha256=input_sha256,
|
|
142
|
+
)
|
|
143
|
+
logger.info(
|
|
144
|
+
"impose.apply.ok",
|
|
145
|
+
output_sha256=result.pdf_sha256[:16],
|
|
146
|
+
sheets_written=result.sheets_written,
|
|
147
|
+
consent=consent,
|
|
148
|
+
retained=retained,
|
|
149
|
+
)
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_codex_pdf_version() -> str:
|
|
154
|
+
"""Read codex_pdf wheel version Compile was deployed against."""
|
|
155
|
+
try:
|
|
156
|
+
from codex_pdf import __version__ as codex_version
|
|
157
|
+
except ImportError:
|
|
158
|
+
return "unknown"
|
|
159
|
+
return str(codex_version)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Click subcommand registration for ``compile-pdf impose``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from compile_pdf_impose.engine import ImposePlanError, apply_plan
|
|
12
|
+
from compile_pdf_impose.layout_schema import ImposePlan, impose_plan_json_schema
|
|
13
|
+
from compile_pdf_impose.verify import verify_impose
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(group: click.Group) -> None:
|
|
17
|
+
"""Attach the ``impose`` subcommand to the top-level CLI group."""
|
|
18
|
+
|
|
19
|
+
@group.command("impose", help="Impose a 1-up PDF onto sheets.")
|
|
20
|
+
@click.option(
|
|
21
|
+
"--layout",
|
|
22
|
+
"layout_path",
|
|
23
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
24
|
+
required=True,
|
|
25
|
+
help="JSON impose-plan document.",
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--verify/--no-verify",
|
|
29
|
+
default=True,
|
|
30
|
+
help="Run four-layer post-condition checks before writing output.",
|
|
31
|
+
)
|
|
32
|
+
@click.argument(
|
|
33
|
+
"input_path",
|
|
34
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
35
|
+
)
|
|
36
|
+
@click.argument(
|
|
37
|
+
"output_path",
|
|
38
|
+
type=click.Path(dir_okay=False, path_type=Path),
|
|
39
|
+
)
|
|
40
|
+
def impose_cmd(
|
|
41
|
+
layout_path: Path,
|
|
42
|
+
input_path: Path,
|
|
43
|
+
output_path: Path,
|
|
44
|
+
verify: bool,
|
|
45
|
+
) -> None:
|
|
46
|
+
plan_dict = json.loads(layout_path.read_text(encoding="utf-8"))
|
|
47
|
+
try:
|
|
48
|
+
plan = ImposePlan.model_validate(plan_dict)
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
click.echo(f"plan validation failed: {exc}", err=True)
|
|
51
|
+
sys.exit(3)
|
|
52
|
+
|
|
53
|
+
input_bytes = input_path.read_bytes()
|
|
54
|
+
try:
|
|
55
|
+
result = apply_plan(input_bytes, plan)
|
|
56
|
+
except ImposePlanError as exc:
|
|
57
|
+
click.echo(f"plan rejected: {exc}", err=True)
|
|
58
|
+
sys.exit(4)
|
|
59
|
+
|
|
60
|
+
if verify:
|
|
61
|
+
check = verify_impose(
|
|
62
|
+
input_bytes=input_bytes,
|
|
63
|
+
output_bytes=result.output_bytes,
|
|
64
|
+
plan=plan,
|
|
65
|
+
expected_sheets=result.sheets_written,
|
|
66
|
+
)
|
|
67
|
+
if not check.passed:
|
|
68
|
+
click.echo("verify failed:", err=True)
|
|
69
|
+
for failure in check.failures:
|
|
70
|
+
click.echo(f" - {failure}", err=True)
|
|
71
|
+
sys.exit(4)
|
|
72
|
+
|
|
73
|
+
output_path.write_bytes(result.output_bytes)
|
|
74
|
+
click.echo(
|
|
75
|
+
json.dumps(
|
|
76
|
+
{
|
|
77
|
+
"sheets_written": result.sheets_written,
|
|
78
|
+
"cells_per_sheet": result.cells_per_sheet,
|
|
79
|
+
"input_pages": result.input_pages,
|
|
80
|
+
"pdf_sha256": result.pdf_sha256,
|
|
81
|
+
"output": str(output_path),
|
|
82
|
+
},
|
|
83
|
+
indent=2,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@group.command("impose-schema", hidden=True, help="Dump the impose-plan JSON Schema.")
|
|
88
|
+
def impose_schema_cmd() -> None:
|
|
89
|
+
click.echo(json.dumps(impose_plan_json_schema(), indent=2))
|