commit ea5b20b3ceda3cd6516486f6691e98493ffced6b Author: Teppy Date: Mon May 4 15:23:38 2026 -0400 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..c59439b --- /dev/null +++ b/USAGE.md @@ -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") +``` diff --git a/spork.py b/spork.py new file mode 100644 index 0000000..1e321c1 --- /dev/null +++ b/spork.py @@ -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")