imagetyler 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.
- imagetyler-0.1.0/LICENSE +29 -0
- imagetyler-0.1.0/PKG-INFO +103 -0
- imagetyler-0.1.0/README.md +61 -0
- imagetyler-0.1.0/pyproject.toml +34 -0
- imagetyler-0.1.0/setup.cfg +4 -0
- imagetyler-0.1.0/src/imagetyler/__init__.py +31 -0
- imagetyler-0.1.0/src/imagetyler/cli.py +150 -0
- imagetyler-0.1.0/src/imagetyler/core.py +48 -0
- imagetyler-0.1.0/src/imagetyler/layout.py +60 -0
- imagetyler-0.1.0/src/imagetyler/parser.py +81 -0
- imagetyler-0.1.0/src/imagetyler/pdf.py +42 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/PKG-INFO +103 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/SOURCES.txt +17 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/dependency_links.txt +1 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/entry_points.txt +2 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/requires.txt +3 -0
- imagetyler-0.1.0/src/imagetyler.egg-info/top_level.txt +1 -0
- imagetyler-0.1.0/tests/test_core.py +21 -0
- imagetyler-0.1.0/tests/test_parser.py +28 -0
imagetyler-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Adityarup Laha
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagetyler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tile an image across a PDF page for print-ready output.
|
|
5
|
+
License: BSD 3-Clause License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026, Adityarup Laha
|
|
8
|
+
All rights reserved.
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: typer
|
|
39
|
+
Requires-Dist: reportlab
|
|
40
|
+
Requires-Dist: pillow
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# ImageTyler
|
|
44
|
+
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
|
|
47
|
+
ImageTyler tiles a source image across a PDF page for print-ready output. Entirely vibe-coded, but reviewed and tested by humans. It supports mixed-unit dimensions, padding, and cutting guides.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install as a standalone tool with `uv`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install imagetyler
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add it as a dependency in your project:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add imagetyler
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CLI Usage
|
|
64
|
+
|
|
65
|
+
Basic usage:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
imagetyler photo.jpg --size 35x45mm --paper A4
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
ImageTyler accepts mixed-unit dimensions and inherits missing units from either side of the `WxH` expression. These all work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
imagetyler photo.jpg --size 35x45mm --paper 210x297mm
|
|
75
|
+
imagetyler photo.jpg --size 35mmx45 --paper Letter
|
|
76
|
+
imagetyler photo.jpg --padding 2mm --landscape --border
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Useful flags include `--padding` for spacing between tiles, `--landscape` for rotating the target page, and `--border` with `--border-width` for cutting guides.
|
|
80
|
+
|
|
81
|
+
Use `--list-units` to inspect supported measurement units and `--list-paper` to list recognized paper aliases.
|
|
82
|
+
|
|
83
|
+
## API Usage
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
|
|
88
|
+
from imagetyler import generate_tiled_pdf
|
|
89
|
+
|
|
90
|
+
output = generate_tiled_pdf(
|
|
91
|
+
image_path=Path("photo.jpg"),
|
|
92
|
+
output_pdf=Path("tiled_output.pdf"),
|
|
93
|
+
paper="A4",
|
|
94
|
+
image_size="35x45mm",
|
|
95
|
+
padding="2mm",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
print(output)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
ImageTyler is distributed under the BSD-3-Clause license.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# ImageTyler
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
ImageTyler tiles a source image across a PDF page for print-ready output. Entirely vibe-coded, but reviewed and tested by humans. It supports mixed-unit dimensions, padding, and cutting guides.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Install as a standalone tool with `uv`:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv tool install imagetyler
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add it as a dependency in your project:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv add imagetyler
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## CLI Usage
|
|
22
|
+
|
|
23
|
+
Basic usage:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
imagetyler photo.jpg --size 35x45mm --paper A4
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
ImageTyler accepts mixed-unit dimensions and inherits missing units from either side of the `WxH` expression. These all work:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
imagetyler photo.jpg --size 35x45mm --paper 210x297mm
|
|
33
|
+
imagetyler photo.jpg --size 35mmx45 --paper Letter
|
|
34
|
+
imagetyler photo.jpg --padding 2mm --landscape --border
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Useful flags include `--padding` for spacing between tiles, `--landscape` for rotating the target page, and `--border` with `--border-width` for cutting guides.
|
|
38
|
+
|
|
39
|
+
Use `--list-units` to inspect supported measurement units and `--list-paper` to list recognized paper aliases.
|
|
40
|
+
|
|
41
|
+
## API Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from imagetyler import generate_tiled_pdf
|
|
47
|
+
|
|
48
|
+
output = generate_tiled_pdf(
|
|
49
|
+
image_path=Path("photo.jpg"),
|
|
50
|
+
output_pdf=Path("tiled_output.pdf"),
|
|
51
|
+
paper="A4",
|
|
52
|
+
image_size="35x45mm",
|
|
53
|
+
padding="2mm",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
print(output)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
ImageTyler is distributed under the BSD-3-Clause license.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "imagetyler"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tile an image across a PDF page for print-ready output."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
dependencies = ["typer", "reportlab", "pillow"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
imagetyler = "imagetyler.cli:app"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools]
|
|
18
|
+
package-dir = { "" = "src" }
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = ["pytest", "ruff"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
target-version = "py310"
|
|
31
|
+
line-length = 100
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint]
|
|
34
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Public API for ImageTyler."""
|
|
2
|
+
|
|
3
|
+
from .core import generate_tiled_pdf
|
|
4
|
+
from .layout import ASPECT_RATIO_TOLERANCE, calculate_grid_layout, validate_aspect_ratio
|
|
5
|
+
from .parser import (
|
|
6
|
+
AVAILABLE_PAGESIZES,
|
|
7
|
+
AVAILABLE_UNITS,
|
|
8
|
+
DEFAULT_BORDER_WIDTH_STR,
|
|
9
|
+
DEFAULT_IMAGE_SIZE_STR,
|
|
10
|
+
DEFAULT_PAPER_STR,
|
|
11
|
+
DEFAULT_PADDING_STR,
|
|
12
|
+
DEFAULT_UNIT_STR,
|
|
13
|
+
parse_dimensions,
|
|
14
|
+
parse_length,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ASPECT_RATIO_TOLERANCE",
|
|
19
|
+
"AVAILABLE_PAGESIZES",
|
|
20
|
+
"AVAILABLE_UNITS",
|
|
21
|
+
"DEFAULT_BORDER_WIDTH_STR",
|
|
22
|
+
"DEFAULT_IMAGE_SIZE_STR",
|
|
23
|
+
"DEFAULT_PAPER_STR",
|
|
24
|
+
"DEFAULT_PADDING_STR",
|
|
25
|
+
"DEFAULT_UNIT_STR",
|
|
26
|
+
"calculate_grid_layout",
|
|
27
|
+
"generate_tiled_pdf",
|
|
28
|
+
"parse_dimensions",
|
|
29
|
+
"parse_length",
|
|
30
|
+
"validate_aspect_ratio",
|
|
31
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated, Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .core import generate_tiled_pdf
|
|
10
|
+
from .parser import AVAILABLE_PAGESIZES, AVAILABLE_UNITS
|
|
11
|
+
|
|
12
|
+
LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(message)s"
|
|
13
|
+
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="Automagically tile images onto a PDF page using regex dimension parsing.",
|
|
17
|
+
add_completion=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def setup_logger(verbose: bool) -> logging.Logger:
|
|
22
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
23
|
+
logging.basicConfig(level=level, format=LOG_FORMAT, datefmt=DATE_FORMAT)
|
|
24
|
+
return logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def list_units_callback(value: bool) -> None:
|
|
28
|
+
if value:
|
|
29
|
+
typer.echo("Supported measurement units (case-insensitive):")
|
|
30
|
+
typer.echo(f" {', '.join(sorted(AVAILABLE_UNITS.keys()))}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_paper_callback(value: bool) -> None:
|
|
35
|
+
if value:
|
|
36
|
+
typer.echo("Supported paper size aliases (case-insensitive):")
|
|
37
|
+
typer.echo(f" {', '.join(sorted(AVAILABLE_PAGESIZES.keys()))}")
|
|
38
|
+
raise typer.Exit()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command()
|
|
42
|
+
def tile_image(
|
|
43
|
+
image_path: Annotated[
|
|
44
|
+
Optional[Path],
|
|
45
|
+
typer.Argument(
|
|
46
|
+
help="Path to the input image file (JPEG, PNG, etc).",
|
|
47
|
+
exists=True,
|
|
48
|
+
file_okay=True,
|
|
49
|
+
dir_okay=False,
|
|
50
|
+
readable=True,
|
|
51
|
+
resolve_path=True,
|
|
52
|
+
),
|
|
53
|
+
] = None,
|
|
54
|
+
output_pdf: Annotated[
|
|
55
|
+
Path,
|
|
56
|
+
typer.Option("--output", "-o", help="Path to save the generated PDF file.", writable=True),
|
|
57
|
+
] = Path("tiled_output.pdf"),
|
|
58
|
+
paper: Annotated[
|
|
59
|
+
str,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--paper",
|
|
62
|
+
"-p",
|
|
63
|
+
help="Paper size alias (e.g., A4) or explicit dimensions (e.g., 210x297mm).",
|
|
64
|
+
),
|
|
65
|
+
] = "A4",
|
|
66
|
+
landscape: Annotated[
|
|
67
|
+
bool,
|
|
68
|
+
typer.Option(
|
|
69
|
+
"--landscape",
|
|
70
|
+
"-l",
|
|
71
|
+
help="Rotate the target paper orientation to landscape.",
|
|
72
|
+
),
|
|
73
|
+
] = False,
|
|
74
|
+
image_size: Annotated[
|
|
75
|
+
str,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--size",
|
|
78
|
+
"-s",
|
|
79
|
+
help="Target image size formatted as WxH with optional mixed units.",
|
|
80
|
+
),
|
|
81
|
+
] = "35x45mm",
|
|
82
|
+
padding_str: Annotated[
|
|
83
|
+
str, typer.Option("--padding", help="Minimum padding between images.")
|
|
84
|
+
] = "2mm",
|
|
85
|
+
draw_border: Annotated[
|
|
86
|
+
bool,
|
|
87
|
+
typer.Option(
|
|
88
|
+
"--border",
|
|
89
|
+
"-b",
|
|
90
|
+
help="Draw a solid black border around each image for ease of cutting.",
|
|
91
|
+
),
|
|
92
|
+
] = False,
|
|
93
|
+
border_width_str: Annotated[
|
|
94
|
+
str,
|
|
95
|
+
typer.Option(
|
|
96
|
+
"--border-width",
|
|
97
|
+
help="Width of the border if enabled, supporting standard units.",
|
|
98
|
+
),
|
|
99
|
+
] = "2pt",
|
|
100
|
+
verbose: Annotated[
|
|
101
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose debug logging.")
|
|
102
|
+
] = False,
|
|
103
|
+
list_units: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
typer.Option(
|
|
106
|
+
"--list-units",
|
|
107
|
+
help="List all supported measurement units (case-insensitive) and exit.",
|
|
108
|
+
callback=list_units_callback,
|
|
109
|
+
is_eager=True,
|
|
110
|
+
),
|
|
111
|
+
] = False,
|
|
112
|
+
list_paper: Annotated[
|
|
113
|
+
bool,
|
|
114
|
+
typer.Option(
|
|
115
|
+
"--list-paper",
|
|
116
|
+
help="List all supported paper size aliases (case-insensitive) and exit.",
|
|
117
|
+
callback=list_paper_callback,
|
|
118
|
+
is_eager=True,
|
|
119
|
+
),
|
|
120
|
+
] = False,
|
|
121
|
+
) -> None:
|
|
122
|
+
if not image_path:
|
|
123
|
+
typer.echo(
|
|
124
|
+
"Error: Missing argument 'IMAGE_PATH'. Use --help for usage details.",
|
|
125
|
+
err=True,
|
|
126
|
+
)
|
|
127
|
+
raise typer.Exit(code=1)
|
|
128
|
+
|
|
129
|
+
setup_logger(verbose)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
generate_tiled_pdf(
|
|
133
|
+
image_path=image_path,
|
|
134
|
+
output_pdf=output_pdf,
|
|
135
|
+
paper=paper,
|
|
136
|
+
landscape=landscape,
|
|
137
|
+
image_size=image_size,
|
|
138
|
+
padding=padding_str,
|
|
139
|
+
draw_border=draw_border,
|
|
140
|
+
border_width=border_width_str,
|
|
141
|
+
)
|
|
142
|
+
except (ValueError, RuntimeError) as exc:
|
|
143
|
+
logging.getLogger(__name__).error(str(exc))
|
|
144
|
+
raise typer.Exit(code=1) from exc
|
|
145
|
+
|
|
146
|
+
logging.getLogger(__name__).info("Successfully generated PDF: '%s'", output_pdf)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
app()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .layout import calculate_grid_layout, validate_aspect_ratio
|
|
7
|
+
from .pdf import generate_pdf
|
|
8
|
+
from .parser import parse_dimensions, parse_length
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def generate_tiled_pdf(
|
|
14
|
+
image_path: Path | str,
|
|
15
|
+
output_pdf: Path | str = Path("tiled_output.pdf"),
|
|
16
|
+
paper: str = "A4",
|
|
17
|
+
landscape: bool = False,
|
|
18
|
+
image_size: str = "35x45mm",
|
|
19
|
+
padding: str = "2mm",
|
|
20
|
+
draw_border: bool = False,
|
|
21
|
+
border_width: str = "2pt",
|
|
22
|
+
) -> Path:
|
|
23
|
+
image_path = Path(image_path)
|
|
24
|
+
output_pdf = Path(output_pdf)
|
|
25
|
+
|
|
26
|
+
paper_width, paper_height = parse_dimensions(paper)
|
|
27
|
+
if landscape:
|
|
28
|
+
paper_width, paper_height = paper_height, paper_width
|
|
29
|
+
|
|
30
|
+
img_width, img_height = parse_dimensions(image_size)
|
|
31
|
+
padding_value = parse_length(padding)
|
|
32
|
+
border_width_value = parse_length(border_width) if draw_border else 0.0
|
|
33
|
+
|
|
34
|
+
validate_aspect_ratio(image_path, img_width, img_height, logger)
|
|
35
|
+
|
|
36
|
+
grid = calculate_grid_layout(paper_width, paper_height, img_width, img_height, padding_value)
|
|
37
|
+
generate_pdf(
|
|
38
|
+
image_path=image_path,
|
|
39
|
+
output_pdf=output_pdf,
|
|
40
|
+
paper_size=(paper_width, paper_height),
|
|
41
|
+
grid=grid,
|
|
42
|
+
image_size=(img_width, img_height),
|
|
43
|
+
padding=padding_value,
|
|
44
|
+
draw_border=draw_border,
|
|
45
|
+
border_width=border_width_value,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return output_pdf
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
ASPECT_RATIO_TOLERANCE: float = 0.05
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculate_grid_layout(
|
|
12
|
+
paper_width: float,
|
|
13
|
+
paper_height: float,
|
|
14
|
+
img_width: float,
|
|
15
|
+
img_height: float,
|
|
16
|
+
padding: float,
|
|
17
|
+
) -> tuple[int, int, float, float]:
|
|
18
|
+
cols = int((paper_width + padding) / (img_width + padding))
|
|
19
|
+
rows = int((paper_height + padding) / (img_height + padding))
|
|
20
|
+
|
|
21
|
+
if cols <= 0 or rows <= 0:
|
|
22
|
+
raise ValueError("The specified image dimensions and padding exceed the paper size.")
|
|
23
|
+
|
|
24
|
+
total_grid_width = (cols * img_width) + ((cols - 1) * padding)
|
|
25
|
+
total_grid_height = (rows * img_height) + ((rows - 1) * padding)
|
|
26
|
+
|
|
27
|
+
margin_x = (paper_width - total_grid_width) / 2.0
|
|
28
|
+
margin_y = (paper_height - total_grid_height) / 2.0
|
|
29
|
+
|
|
30
|
+
return cols, rows, margin_x, margin_y
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate_aspect_ratio(
|
|
34
|
+
image_path: Path,
|
|
35
|
+
target_width: float,
|
|
36
|
+
target_height: float,
|
|
37
|
+
logger: logging.Logger,
|
|
38
|
+
tolerance: float = ASPECT_RATIO_TOLERANCE,
|
|
39
|
+
) -> None:
|
|
40
|
+
try:
|
|
41
|
+
with Image.open(image_path) as img:
|
|
42
|
+
native_width_px, native_height_px = img.size
|
|
43
|
+
native_ratio = native_width_px / native_height_px
|
|
44
|
+
target_ratio = target_width / target_height
|
|
45
|
+
|
|
46
|
+
difference = abs((native_ratio / target_ratio) - 1.0)
|
|
47
|
+
logger.debug(
|
|
48
|
+
"Native aspect ratio: %.3f, Target aspect ratio: %.3f",
|
|
49
|
+
native_ratio,
|
|
50
|
+
target_ratio,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if difference > tolerance:
|
|
54
|
+
logger.warning(
|
|
55
|
+
"ASPECT RATIO MISMATCH: The input image differs from the target dimensions by %.1f%%. "
|
|
56
|
+
"The image will be visibly stretched or squashed.",
|
|
57
|
+
difference * 100,
|
|
58
|
+
)
|
|
59
|
+
except Exception as exc: # pragma: no cover - surface-level file/image failure
|
|
60
|
+
raise ValueError(f"Failed to read image '{image_path}': {exc}") from exc
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from reportlab.lib import pagesizes, units
|
|
7
|
+
|
|
8
|
+
AVAILABLE_UNITS: dict[str, float] = {
|
|
9
|
+
name.lower(): value
|
|
10
|
+
for name, value in inspect.getmembers(units)
|
|
11
|
+
if not name.startswith("_") and isinstance(value, (int, float))
|
|
12
|
+
}
|
|
13
|
+
AVAILABLE_UNITS["pt"] = 1.0
|
|
14
|
+
|
|
15
|
+
AVAILABLE_PAGESIZES: dict[str, tuple[float, float]] = {
|
|
16
|
+
name.lower(): value
|
|
17
|
+
for name, value in inspect.getmembers(pagesizes)
|
|
18
|
+
if not name.startswith("_") and isinstance(value, tuple) and len(value) == 2
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
DEFAULT_PAPER_STR: str = "A4"
|
|
22
|
+
DEFAULT_IMAGE_SIZE_STR: str = "35x45mm"
|
|
23
|
+
DEFAULT_PADDING_STR: str = "2mm"
|
|
24
|
+
DEFAULT_UNIT_STR: str = "mm"
|
|
25
|
+
DEFAULT_BORDER_WIDTH_STR: str = "2pt"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_dimensions(val: str) -> tuple[float, float]:
|
|
29
|
+
"""Parse a `WxH` string or paper alias into points."""
|
|
30
|
+
|
|
31
|
+
clean_val = re.sub(r"\s+", "", val.lower())
|
|
32
|
+
|
|
33
|
+
if clean_val in AVAILABLE_PAGESIZES:
|
|
34
|
+
return AVAILABLE_PAGESIZES[clean_val]
|
|
35
|
+
|
|
36
|
+
match = re.match(r"^([\d.]+)([a-z]*)x([\d.]+)([a-z]*)$", clean_val)
|
|
37
|
+
if not match:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Invalid dimension format: '{val}'. Expected 'WxH' or a standard paper alias."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
w_num_str, w_unit_str, h_num_str, h_unit_str = match.groups()
|
|
43
|
+
|
|
44
|
+
if not w_unit_str and not h_unit_str:
|
|
45
|
+
resolved_w_unit = DEFAULT_UNIT_STR
|
|
46
|
+
resolved_h_unit = DEFAULT_UNIT_STR
|
|
47
|
+
elif not w_unit_str:
|
|
48
|
+
resolved_w_unit = h_unit_str
|
|
49
|
+
resolved_h_unit = h_unit_str
|
|
50
|
+
elif not h_unit_str:
|
|
51
|
+
resolved_w_unit = w_unit_str
|
|
52
|
+
resolved_h_unit = w_unit_str
|
|
53
|
+
else:
|
|
54
|
+
resolved_w_unit = w_unit_str
|
|
55
|
+
resolved_h_unit = h_unit_str
|
|
56
|
+
|
|
57
|
+
if resolved_w_unit not in AVAILABLE_UNITS:
|
|
58
|
+
raise ValueError(f"Unknown unit for width: '{resolved_w_unit}'")
|
|
59
|
+
if resolved_h_unit not in AVAILABLE_UNITS:
|
|
60
|
+
raise ValueError(f"Unknown unit for height: '{resolved_h_unit}'")
|
|
61
|
+
|
|
62
|
+
return float(w_num_str) * AVAILABLE_UNITS[resolved_w_unit], float(h_num_str) * AVAILABLE_UNITS[
|
|
63
|
+
resolved_h_unit
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_length(val: str) -> float:
|
|
68
|
+
"""Parse a single length string into points."""
|
|
69
|
+
|
|
70
|
+
clean_val = re.sub(r"\s+", "", val.lower())
|
|
71
|
+
match = re.match(r"^([\d.]+)([a-z]*)$", clean_val)
|
|
72
|
+
if not match:
|
|
73
|
+
raise ValueError(f"Cannot parse length value: '{val}'")
|
|
74
|
+
|
|
75
|
+
num_str, unit_str = match.groups()
|
|
76
|
+
resolved_unit = unit_str if unit_str else DEFAULT_UNIT_STR
|
|
77
|
+
|
|
78
|
+
if resolved_unit not in AVAILABLE_UNITS:
|
|
79
|
+
raise ValueError(f"Unknown unit: '{resolved_unit}'")
|
|
80
|
+
|
|
81
|
+
return float(num_str) * AVAILABLE_UNITS[resolved_unit]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from reportlab.pdfgen import canvas
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def generate_pdf(
|
|
10
|
+
image_path: Path,
|
|
11
|
+
output_pdf: Path,
|
|
12
|
+
paper_size: tuple[float, float],
|
|
13
|
+
grid: tuple[int, int, float, float],
|
|
14
|
+
image_size: tuple[float, float],
|
|
15
|
+
padding: float,
|
|
16
|
+
draw_border: bool,
|
|
17
|
+
border_width: float,
|
|
18
|
+
) -> None:
|
|
19
|
+
cols, rows, margin_x, margin_y = grid
|
|
20
|
+
img_width, img_height = image_size
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
pdf_canvas = canvas.Canvas(str(output_pdf), pagesize=paper_size)
|
|
24
|
+
|
|
25
|
+
for row in range(rows):
|
|
26
|
+
for col in range(cols):
|
|
27
|
+
x_pos = margin_x + (col * (img_width + padding))
|
|
28
|
+
y_pos = margin_y + (row * (img_height + padding))
|
|
29
|
+
|
|
30
|
+
pdf_canvas.drawImage(
|
|
31
|
+
str(image_path), x_pos, y_pos, width=img_width, height=img_height
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if draw_border:
|
|
35
|
+
pdf_canvas.setLineWidth(border_width)
|
|
36
|
+
pdf_canvas.setStrokeColorRGB(0, 0, 0)
|
|
37
|
+
pdf_canvas.rect(x_pos, y_pos, img_width, img_height, stroke=1, fill=0)
|
|
38
|
+
|
|
39
|
+
pdf_canvas.showPage()
|
|
40
|
+
pdf_canvas.save()
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise RuntimeError(f"An error occurred while generating the PDF: {exc}") from exc
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: imagetyler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tile an image across a PDF page for print-ready output.
|
|
5
|
+
License: BSD 3-Clause License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026, Adityarup Laha
|
|
8
|
+
All rights reserved.
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
34
|
+
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
License-File: LICENSE
|
|
38
|
+
Requires-Dist: typer
|
|
39
|
+
Requires-Dist: reportlab
|
|
40
|
+
Requires-Dist: pillow
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# ImageTyler
|
|
44
|
+
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
|
|
47
|
+
ImageTyler tiles a source image across a PDF page for print-ready output. Entirely vibe-coded, but reviewed and tested by humans. It supports mixed-unit dimensions, padding, and cutting guides.
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install as a standalone tool with `uv`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install imagetyler
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add it as a dependency in your project:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv add imagetyler
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## CLI Usage
|
|
64
|
+
|
|
65
|
+
Basic usage:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
imagetyler photo.jpg --size 35x45mm --paper A4
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
ImageTyler accepts mixed-unit dimensions and inherits missing units from either side of the `WxH` expression. These all work:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
imagetyler photo.jpg --size 35x45mm --paper 210x297mm
|
|
75
|
+
imagetyler photo.jpg --size 35mmx45 --paper Letter
|
|
76
|
+
imagetyler photo.jpg --padding 2mm --landscape --border
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Useful flags include `--padding` for spacing between tiles, `--landscape` for rotating the target page, and `--border` with `--border-width` for cutting guides.
|
|
80
|
+
|
|
81
|
+
Use `--list-units` to inspect supported measurement units and `--list-paper` to list recognized paper aliases.
|
|
82
|
+
|
|
83
|
+
## API Usage
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
|
|
88
|
+
from imagetyler import generate_tiled_pdf
|
|
89
|
+
|
|
90
|
+
output = generate_tiled_pdf(
|
|
91
|
+
image_path=Path("photo.jpg"),
|
|
92
|
+
output_pdf=Path("tiled_output.pdf"),
|
|
93
|
+
paper="A4",
|
|
94
|
+
image_size="35x45mm",
|
|
95
|
+
padding="2mm",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
print(output)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
ImageTyler is distributed under the BSD-3-Clause license.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/imagetyler/__init__.py
|
|
5
|
+
src/imagetyler/cli.py
|
|
6
|
+
src/imagetyler/core.py
|
|
7
|
+
src/imagetyler/layout.py
|
|
8
|
+
src/imagetyler/parser.py
|
|
9
|
+
src/imagetyler/pdf.py
|
|
10
|
+
src/imagetyler.egg-info/PKG-INFO
|
|
11
|
+
src/imagetyler.egg-info/SOURCES.txt
|
|
12
|
+
src/imagetyler.egg-info/dependency_links.txt
|
|
13
|
+
src/imagetyler.egg-info/entry_points.txt
|
|
14
|
+
src/imagetyler.egg-info/requires.txt
|
|
15
|
+
src/imagetyler.egg-info/top_level.txt
|
|
16
|
+
tests/test_core.py
|
|
17
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
imagetyler
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from PIL import Image
|
|
6
|
+
|
|
7
|
+
from imagetyler.core import generate_tiled_pdf
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_generate_tiled_pdf_creates_output(tmp_path: Path):
|
|
11
|
+
image_path = tmp_path / "sample.png"
|
|
12
|
+
output_pdf = tmp_path / "output.pdf"
|
|
13
|
+
|
|
14
|
+
image = Image.new("RGB", (350, 450), color="white")
|
|
15
|
+
image.save(image_path)
|
|
16
|
+
|
|
17
|
+
result = generate_tiled_pdf(image_path=image_path, output_pdf=output_pdf)
|
|
18
|
+
|
|
19
|
+
assert result == output_pdf
|
|
20
|
+
assert output_pdf.exists()
|
|
21
|
+
assert output_pdf.stat().st_size > 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from reportlab.lib import pagesizes, units
|
|
6
|
+
|
|
7
|
+
from imagetyler.parser import parse_dimensions, parse_length
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_parse_dimensions_inherits_units_from_right():
|
|
11
|
+
width, height = parse_dimensions("35x45mm")
|
|
12
|
+
assert math.isclose(width, 35 * units.mm)
|
|
13
|
+
assert math.isclose(height, 45 * units.mm)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_parse_dimensions_inherits_units_from_left():
|
|
17
|
+
width, height = parse_dimensions("35mmx45")
|
|
18
|
+
assert math.isclose(width, 35 * units.mm)
|
|
19
|
+
assert math.isclose(height, 45 * units.mm)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_parse_dimensions_accepts_pagesize_alias():
|
|
23
|
+
width, height = parse_dimensions("A4")
|
|
24
|
+
assert (width, height) == pagesizes.A4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_parse_length_defaults_to_millimeters():
|
|
28
|
+
assert math.isclose(parse_length("2"), 2 * units.mm)
|