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.
@@ -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))