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.
@@ -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
+ [![BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](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
+ [![BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](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,2 @@
1
+ [console_scripts]
2
+ imagetyler = imagetyler.cli:app
@@ -0,0 +1,3 @@
1
+ typer
2
+ reportlab
3
+ pillow
@@ -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)