Initial commit: Spork card PDF compositor
Python library for building print-ready PDFs by layering transparent PNGs and JPGs. Supports image cropping, page fills, lines for crop marks, and region copying for tiling cards across a sheet. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
206
USAGE.md
Normal file
206
USAGE.md
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
# Spork — Usage Guide
|
||||||
|
|
||||||
|
Spork is a Python library for building print-ready PDFs by layering images together. It was designed for compositing trading cards and tiling them onto pages for printing.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install reportlab Pillow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
from spork import *
|
||||||
|
|
||||||
|
outfile = NewPDF(8.5, 11)
|
||||||
|
art = ReadPNG("mushroom.png")
|
||||||
|
frame = ReadPNG("PictureFrame.png")
|
||||||
|
outfile.layer(art, 0.5, 0.5, 3.5, 3.5)
|
||||||
|
outfile.layer(frame, 0.5, 0.5, 3.5, 3.5)
|
||||||
|
outfile.save("output.pdf")
|
||||||
|
```
|
||||||
|
|
||||||
|
All coordinates and dimensions are in **inches**. The origin `(0, 0)` is the **top-left** corner of the page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Loading Images
|
||||||
|
|
||||||
|
```python
|
||||||
|
img = ReadPNG("card_art.png")
|
||||||
|
img = ReadJPG("photo.jpg")
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return a `LoadedImage` that can be cropped before layering.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cropping Images
|
||||||
|
|
||||||
|
All crop methods modify the image in place and return `self`, so they can be chained.
|
||||||
|
|
||||||
|
### Remove pixels from an edge
|
||||||
|
|
||||||
|
```python
|
||||||
|
img.CropPixelsL(100) # remove 100px from the left
|
||||||
|
img.CropPixelsR(50) # remove 50px from the right
|
||||||
|
img.CropPixelsT(80) # remove 80px from the top
|
||||||
|
img.CropPixelsB(30) # remove 30px from the bottom
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keep only pixels from an edge
|
||||||
|
|
||||||
|
```python
|
||||||
|
img.KeepPixelsT(500) # keep the top 500px, discard the rest
|
||||||
|
img.KeepPixelsB(500) # keep the bottom 500px
|
||||||
|
img.KeepPixelsL(500) # keep the left 500px
|
||||||
|
img.KeepPixelsR(500) # keep the right 500px
|
||||||
|
```
|
||||||
|
|
||||||
|
### Make it square
|
||||||
|
|
||||||
|
```python
|
||||||
|
img.CropSquare() # trim the longer dimension equally from both sides
|
||||||
|
img.KeepSquare(400) # keep only the center 400x400 pixels
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chaining
|
||||||
|
|
||||||
|
```python
|
||||||
|
art = ReadPNG("wide_photo.png").CropPixelsL(100).CropPixelsR(100).CropSquare()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a PDF
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile = NewPDF(8.5, 11) # width, height in inches (this is US Letter)
|
||||||
|
```
|
||||||
|
|
||||||
|
### layer(img, x, y, w, h)
|
||||||
|
|
||||||
|
Place an image on the page. Images are scaled to fit the given width and height. Layers stack in the order they are added — later layers draw on top of earlier ones.
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile.layer(background, 0, 0, 8.5, 11) # full-page background
|
||||||
|
outfile.layer(art, 0.25, 0.25, 2.0, 2.8) # card art
|
||||||
|
outfile.layer(frame, 0.25, 0.25, 2.0, 2.8) # transparent frame on top
|
||||||
|
```
|
||||||
|
|
||||||
|
PNG transparency is preserved — this is how you composite a card from separate art and frame layers.
|
||||||
|
|
||||||
|
### fill(color)
|
||||||
|
|
||||||
|
Fill the entire page with a solid color. Typically used first, as a background.
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile.fill(BLACK)
|
||||||
|
```
|
||||||
|
|
||||||
|
### line(color, x0, y0, x1, y1, width=1)
|
||||||
|
|
||||||
|
Draw a straight line between two points. The `width` parameter is in pixels (at 300 DPI, so 1px is a hairline). Useful for crop marks.
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile.line(BLACK, 0.25, 0.0, 0.25, 0.15) # vertical crop mark
|
||||||
|
outfile.line(BLACK, 0.0, 0.25, 0.15, 0.25) # horizontal crop mark
|
||||||
|
outfile.line(RED, 0, 4.0, 8.5, 4.0, width=3) # thicker red guide line
|
||||||
|
```
|
||||||
|
|
||||||
|
### copy(x0, y0, x1, y1, x2, y2)
|
||||||
|
|
||||||
|
Copy a rectangular region of the page and paste it at a new position. The rectangle from `(x0, y0)` to `(x1, y1)` is pasted with its top-left corner at `(x2, y2)`.
|
||||||
|
|
||||||
|
This is how you tile a card across a page — compose it once, then copy it to fill the sheet.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Compose a 2.5 x 3.5 inch card at (0.25, 0.25)
|
||||||
|
outfile.layer(art, 0.25, 0.25, 2.5, 3.5)
|
||||||
|
outfile.layer(frame, 0.25, 0.25, 2.5, 3.5)
|
||||||
|
|
||||||
|
# Tile it: 3 columns x 3 rows
|
||||||
|
cw = 2.5 # card width
|
||||||
|
ch = 3.5 # card height
|
||||||
|
for row in range(3):
|
||||||
|
for col in range(3):
|
||||||
|
if row == 0 and col == 0:
|
||||||
|
continue # skip the original
|
||||||
|
outfile.copy(0.25, 0.25, 0.25 + cw, 0.25 + ch,
|
||||||
|
0.25 + col * cw, 0.25 + row * ch)
|
||||||
|
```
|
||||||
|
|
||||||
|
### save(filename)
|
||||||
|
|
||||||
|
Render everything and write the PDF. Output is 300 DPI.
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile.save("cards.pdf")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
Built-in color constants (all are `(R, G, B)` tuples):
|
||||||
|
|
||||||
|
| Name | Value |
|
||||||
|
|--------------|-------------------|
|
||||||
|
| `BLACK` | `(0, 0, 0)` |
|
||||||
|
| `WHITE` | `(255, 255, 255)` |
|
||||||
|
| `RED` | `(255, 0, 0)` |
|
||||||
|
| `GREEN` | `(0, 128, 0)` |
|
||||||
|
| `BLUE` | `(0, 0, 255)` |
|
||||||
|
| `YELLOW` | `(255, 255, 0)` |
|
||||||
|
| `CYAN` | `(0, 255, 255)` |
|
||||||
|
| `MAGENTA` | `(255, 0, 255)` |
|
||||||
|
| `GRAY` | `(128, 128, 128)` |
|
||||||
|
| `LIGHT_GRAY` | `(192, 192, 192)` |
|
||||||
|
| `DARK_GRAY` | `(64, 64, 64)` |
|
||||||
|
|
||||||
|
You can also pass any `(R, G, B)` tuple directly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
outfile.fill((30, 30, 30))
|
||||||
|
outfile.line((255, 128, 0), 0, 0, 8.5, 11) # orange diagonal
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Example: Trading Card Sheet
|
||||||
|
|
||||||
|
```python
|
||||||
|
from spork import *
|
||||||
|
|
||||||
|
# Load and prep the art
|
||||||
|
art = ReadJPG("dragon.jpg").CropSquare()
|
||||||
|
frame = ReadPNG("card_frame.png")
|
||||||
|
|
||||||
|
# Create a letter-size page
|
||||||
|
page = NewPDF(8.5, 11)
|
||||||
|
page.fill(WHITE)
|
||||||
|
|
||||||
|
# Compose one card at top-left (standard poker size: 2.5 x 3.5)
|
||||||
|
page.layer(art, 0.25, 0.25, 2.5, 3.5)
|
||||||
|
page.layer(frame, 0.25, 0.25, 2.5, 3.5)
|
||||||
|
|
||||||
|
# Tile into a 3x3 grid
|
||||||
|
cw, ch = 2.5, 3.5
|
||||||
|
for row in range(3):
|
||||||
|
for col in range(3):
|
||||||
|
if row == 0 and col == 0:
|
||||||
|
continue
|
||||||
|
page.copy(0.25, 0.25, 0.25 + cw, 0.25 + ch,
|
||||||
|
0.25 + col * cw, 0.25 + row * ch)
|
||||||
|
|
||||||
|
# Add crop marks at each card corner
|
||||||
|
for row in range(4):
|
||||||
|
for col in range(4):
|
||||||
|
x = 0.25 + col * cw
|
||||||
|
y = 0.25 + row * ch
|
||||||
|
page.line(BLACK, x, y - 0.15, x, y + 0.15)
|
||||||
|
page.line(BLACK, x - 0.15, y, x + 0.15, y)
|
||||||
|
|
||||||
|
page.save("dragon_sheet.pdf")
|
||||||
|
```
|
||||||
175
spork.py
Normal file
175
spork.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from reportlab.lib.units import inch
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Colors (R, G, B)
|
||||||
|
BLACK = (0, 0, 0)
|
||||||
|
WHITE = (255, 255, 255)
|
||||||
|
RED = (255, 0, 0)
|
||||||
|
GREEN = (0, 128, 0)
|
||||||
|
BLUE = (0, 0, 255)
|
||||||
|
YELLOW = (255, 255, 0)
|
||||||
|
CYAN = (0, 255, 255)
|
||||||
|
MAGENTA = (255, 0, 255)
|
||||||
|
GRAY = (128, 128, 128)
|
||||||
|
LIGHT_GRAY = (192, 192, 192)
|
||||||
|
DARK_GRAY = (64, 64, 64)
|
||||||
|
|
||||||
|
|
||||||
|
class LoadedImage:
|
||||||
|
def __init__(self, filepath):
|
||||||
|
if not os.path.exists(filepath):
|
||||||
|
raise FileNotFoundError(f"Image not found: {filepath}")
|
||||||
|
self.filepath = filepath
|
||||||
|
self.image = Image.open(filepath)
|
||||||
|
|
||||||
|
def CropPixelsL(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((pixels, 0, w, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def CropPixelsR(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, 0, w - pixels, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def CropPixelsT(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, pixels, w, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def CropPixelsB(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, 0, w, h - pixels))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def CropSquare(self):
|
||||||
|
w, h = self.image.size
|
||||||
|
if w > h:
|
||||||
|
excess = w - h
|
||||||
|
left = excess // 2
|
||||||
|
self.image = self.image.crop((left, 0, left + h, h))
|
||||||
|
elif h > w:
|
||||||
|
excess = h - w
|
||||||
|
top = excess // 2
|
||||||
|
self.image = self.image.crop((0, top, w, top + w))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def KeepPixelsT(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, 0, w, pixels))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def KeepPixelsB(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, h - pixels, w, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def KeepPixelsL(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((0, 0, pixels, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def KeepPixelsR(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
self.image = self.image.crop((w - pixels, 0, w, h))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def KeepSquare(self, pixels):
|
||||||
|
w, h = self.image.size
|
||||||
|
cx, cy = w // 2, h // 2
|
||||||
|
half = pixels // 2
|
||||||
|
self.image = self.image.crop((cx - half, cy - half, cx - half + pixels, cy - half + pixels))
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def ReadPNG(filepath):
|
||||||
|
return LoadedImage(filepath)
|
||||||
|
|
||||||
|
|
||||||
|
def ReadJPG(filepath):
|
||||||
|
return LoadedImage(filepath)
|
||||||
|
|
||||||
|
|
||||||
|
DPI = 300
|
||||||
|
|
||||||
|
|
||||||
|
class NewPDF:
|
||||||
|
def __init__(self, width_inches, height_inches):
|
||||||
|
self.width_inches = width_inches
|
||||||
|
self.height_inches = height_inches
|
||||||
|
self.width = width_inches * inch
|
||||||
|
self.height = height_inches * inch
|
||||||
|
self._ops = []
|
||||||
|
|
||||||
|
def layer(self, img, x_inches, y_inches, w_inches, h_inches):
|
||||||
|
self._ops.append(("layer", img, x_inches, y_inches, w_inches, h_inches))
|
||||||
|
|
||||||
|
def copy(self, x0, y0, x1, y1, x2, y2):
|
||||||
|
self._ops.append(("copy", x0, y0, x1, y1, x2, y2))
|
||||||
|
|
||||||
|
def fill(self, color):
|
||||||
|
self._ops.append(("fill", color))
|
||||||
|
|
||||||
|
def line(self, color, x0, y0, x1, y1, width=1):
|
||||||
|
self._ops.append(("line", color, x0, y0, x1, y1, width))
|
||||||
|
|
||||||
|
def save(self, filename):
|
||||||
|
pw = int(self.width_inches * DPI)
|
||||||
|
ph = int(self.height_inches * DPI)
|
||||||
|
page = Image.new("RGBA", (pw, ph), (255, 255, 255, 255))
|
||||||
|
|
||||||
|
for op in self._ops:
|
||||||
|
if op[0] == "layer":
|
||||||
|
_, img, x, y, w, h = op
|
||||||
|
xp = int(x * DPI)
|
||||||
|
yp = int(y * DPI)
|
||||||
|
wp = int(w * DPI)
|
||||||
|
hp = int(h * DPI)
|
||||||
|
resized = img.image.resize((wp, hp), Image.LANCZOS)
|
||||||
|
if resized.mode == "RGBA":
|
||||||
|
page.paste(resized, (xp, yp), resized)
|
||||||
|
else:
|
||||||
|
page.paste(resized, (xp, yp))
|
||||||
|
elif op[0] == "copy":
|
||||||
|
_, x0, y0, x1, y1, x2, y2 = op
|
||||||
|
region = page.crop((
|
||||||
|
int(x0 * DPI), int(y0 * DPI),
|
||||||
|
int(x1 * DPI), int(y1 * DPI),
|
||||||
|
))
|
||||||
|
page.paste(region, (int(x2 * DPI), int(y2 * DPI)))
|
||||||
|
elif op[0] == "fill":
|
||||||
|
_, color = op
|
||||||
|
page.paste(color + (255,), (0, 0, pw, ph))
|
||||||
|
elif op[0] == "line":
|
||||||
|
_, color, x0, y0, x1, y1, width = op
|
||||||
|
draw = ImageDraw.Draw(page)
|
||||||
|
draw.line(
|
||||||
|
[(int(x0 * DPI), int(y0 * DPI)),
|
||||||
|
(int(x1 * DPI), int(y1 * DPI))],
|
||||||
|
fill=color + (255,), width=width,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write composited image to PDF
|
||||||
|
rgb_page = page.convert("RGB")
|
||||||
|
c = canvas.Canvas(filename, pagesize=(self.width, self.height))
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||||
|
try:
|
||||||
|
rgb_page.save(tmp.name, "PNG")
|
||||||
|
c.drawImage(tmp.name, 0, 0, self.width, self.height)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
c.save()
|
||||||
|
print(f"Saved: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Example usage ──
|
||||||
|
if __name__ == "__main__":
|
||||||
|
outfile = NewPDF(8.5, 11)
|
||||||
|
a = ReadPNG("mushroom.png")
|
||||||
|
b = ReadPNG("PictureFrame.png")
|
||||||
|
outfile.layer(a, 0.5, 0.5, 3.5, 3.5)
|
||||||
|
outfile.layer(b, 0.5, 0.5, 3.5, 3.5)
|
||||||
|
outfile.save("output.pdf")
|
||||||
Reference in New Issue
Block a user