summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile17
-rw-r--r--img/cat01.jpegbin0 -> 180093 bytes
-rw-r--r--img/cat02.jpegbin0 -> 217939 bytes
-rw-r--r--img/cat03.jpegbin0 -> 242942 bytes
-rw-r--r--img/cat04.jpegbin0 -> 1165608 bytes
-rw-r--r--img/cat05.jpegbin0 -> 212034 bytes
-rw-r--r--img/cat06.jpegbin0 -> 251014 bytes
-rwxr-xr-xmake-cards228
9 files changed, 247 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..48c2893
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.d
+*.pdf
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..c7c6321
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,17 @@
+.DELETE_ON_ERROR:
+
+PDFS := cards.pdf
+DEP_FILES := $(PDFS:.pdf=.d)
+
+.PHONY: all
+all: $(PDFS)
+ @
+
+.PHONY: clean
+clean:
+ $(RM) -- $(PDFS) $(DEP_FILES)
+
+cards.pdf: make-cards
+ ./make-cards
+
+-include: $(DEP_FILES)
diff --git a/img/cat01.jpeg b/img/cat01.jpeg
new file mode 100644
index 0000000..068bce5
--- /dev/null
+++ b/img/cat01.jpeg
Binary files differ
diff --git a/img/cat02.jpeg b/img/cat02.jpeg
new file mode 100644
index 0000000..95ea33c
--- /dev/null
+++ b/img/cat02.jpeg
Binary files differ
diff --git a/img/cat03.jpeg b/img/cat03.jpeg
new file mode 100644
index 0000000..3b46ef6
--- /dev/null
+++ b/img/cat03.jpeg
Binary files differ
diff --git a/img/cat04.jpeg b/img/cat04.jpeg
new file mode 100644
index 0000000..3241de7
--- /dev/null
+++ b/img/cat04.jpeg
Binary files differ
diff --git a/img/cat05.jpeg b/img/cat05.jpeg
new file mode 100644
index 0000000..385f388
--- /dev/null
+++ b/img/cat05.jpeg
Binary files differ
diff --git a/img/cat06.jpeg b/img/cat06.jpeg
new file mode 100644
index 0000000..86fa535
--- /dev/null
+++ b/img/cat06.jpeg
Binary files differ
diff --git a/make-cards b/make-cards
new file mode 100755
index 0000000..c5b47f5
--- /dev/null
+++ b/make-cards
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+# stdlib
+import argparse
+import sys
+from dataclasses import dataclass
+from math import ceil, floor, sqrt
+from pathlib import Path
+from typing import List
+
+# external
+from fpdf import FPDF
+from PIL import Image
+
+DEBUG = False
+SCRIPT_DIR = Path(__file__).parent
+PDF_PATH = SCRIPT_DIR / "cards.pdf"
+
+# Constants for fpdf
+LN_NEXT_LINE = 1
+
+# Notes on layout:
+# - All units are inches, unless the variable has an explicit unit suffix
+# (such as foo_w_px).
+# - Rectangle variables are named: foo_x, foo_y, foo_w, foo_h.
+# - Bounding box variables are named: foo_bb_x, foo_bb_y, foo_bb_w, foo_bb_h.
+# - Each bounding box contains the padding of its content (unless the code
+# makes a mistake).
+
+# Global padding for everything.
+# TODO: Customize the padding for each page entity.
+pad = 1/8
+
+# Page size is US Letter.
+page_w = 8.5
+page_h = 11
+
+@dataclass
+class Card:
+ text: str
+ images: List[Path]
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("-d", "--debug", action="store_true")
+ args = ap.parse_args()
+
+ global DEBUG
+ DEBUG = args.debug
+
+ def cat_path(i):
+ return SCRIPT_DIR / "img" / f"cat{i:02d}.jpeg"
+
+ cards = [
+ Card("meow01", [cat_path(1)]),
+ Card("meow02", [cat_path(2)]),
+ Card("meow03", 3 * [cat_path(3)]), # 3 copies of same image
+ Card("meow04", 4 * [cat_path(4)]), # 4 copies of same image
+ Card("meow05", [cat_path(5)]),
+ Card("meow06", [cat_path(6)]),
+ ]
+
+ make_pdf(cards)
+ make_depfile(cards)
+
+def make_pdf(cards):
+ pdf = FPDF(orientation="portrait", unit="in", format="letter")
+
+ # The font family must be set before the pdf's first page begins.
+ pdf.set_font(family="courier", style="B", size=14)
+
+ for i, card in enumerate(cards):
+ if i % 2 == 0:
+ # page's top card
+ pdf.add_page()
+ pdf.set_margins(left=0, top=0)
+ pdf.set_y(0)
+ else:
+ # page's bottom card
+ pdf.dashed_line(x1 = 0, y1 = page_h / 2,
+ x2 = page_w, y2 = page_h / 2,
+ dash_length = 1/8, space_length = 1/8)
+ pdf.set_y(page_h / 2)
+
+ emit_card(pdf, cards, i)
+
+ pdf.close()
+ pdf.output(name=PDF_PATH, dest="F") # F => file
+
+def emit_card(pdf, cards, card_index):
+ # TODO: Cleanup the padding details.
+
+ card_w = page_w
+ card_h = page_h / 2
+ card_x = 0
+ card_y = (card_index % 2) * page_h / 2
+
+ logd_rect(f"card[{card_index}]", card_x, card_y, card_w, card_h)
+
+ grid_w = card_w
+ grid_h = card_h - 1 # allow room for caption
+ grid_x = card_x
+ grid_y = card_y
+
+ logd_rect(f"grid[{card_index}]", grid_x, grid_y, grid_w, grid_h)
+
+ max_rows = 4
+ max_cols = 4
+ max_images = max_rows * max_cols
+
+ card = cards[card_index]
+
+ if len(card.images) == 0:
+ raise Exception("card has no images")
+
+ if len(card.images) > max_images:
+ raise Exception(f"card has more than {max_images} images")
+
+ # Render the images in a square layout. Each row contains the same number
+ # of images except possibly the last row, which may contain fewer.
+ s = sqrt(len(card.images))
+ rows = int(ceil(s))
+ cols_full = rows
+
+ logd(f"grid[{card_index}]: rows={rows}, cols_full={cols_full}")
+
+ for i in range(rows):
+ # Within a card, the extent of each row is invariant.
+ row_w = grid_w
+ row_h = grid_h / rows
+ row_x = grid_x
+ row_y = grid_y + i * row_h
+
+ logd_rect(f"row[{card_index},{i}]", row_x, row_y, row_w, row_h)
+
+ cols = cols_full
+ if i == rows - 1:
+ # last row
+ cols = len(card.images) - i * cols_full
+ assert 1 <= cols and cols <= cols_full
+
+ for j in range(cols):
+ logd(f"cell[{card_index},{i},{j}]")
+
+ col_w = row_w / cols
+ logd(f"col_w: {col_w}")
+
+ # Within a card, the extent of each image's bounding box is
+ # invariant. This ensures that all images receive similar scaling,
+ # even when the last row is not full.
+ image_bb_w = row_w / cols_full
+ image_bb_h = row_h
+ image_bb_x = (row_w - cols * image_bb_w) / 2 + j * image_bb_w
+ image_bb_y = row_y
+ image_bb_aspect = image_bb_w / image_bb_h
+
+ logd_rect(f"image_bb[{i},{j}]", image_bb_x, image_bb_y, image_bb_w, image_bb_h)
+ if DEBUG:
+ pdf.set_draw_color(255, 0, 0)
+ pdf.rect(image_bb_x, image_bb_y, image_bb_w - 0.02, image_bb_h - 0.02)
+
+ image_path = card.images[i * cols_full + j]
+ image = Image.open(image_path)
+ image_w_px, image_h_px = image.size
+ image_aspect = image_w_px / image_h_px
+
+ if image_aspect > image_bb_aspect:
+ image_w = image_bb_w - pad
+ image_h = image_w / image_aspect
+ else:
+ image_h = image_bb_h - pad
+ image_w = image_h * image_aspect
+
+ image_x = image_bb_x + (image_bb_w - image_w) / 2
+ image_y = image_bb_y + (image_bb_h - image_h) / 2
+
+ logd_rect(f"image[{card_index},{i},{j}]", image_x, image_y, image_w, image_h)
+
+ pdf.set_xy(image_x, image_y)
+ pdf.image(name = image_path,
+ w = image_w,
+ h = image_h,
+ type = image.format)
+
+ if DEBUG:
+ pdf.set_draw_color(0, 0, 255)
+ pdf.rect(image_x, image_y, image_w - 0.02, image_h - 0.02)
+
+ text_w = pdf.get_string_width(card.text)
+ text_x = (page_w - text_w) / 2
+ text_y = grid_y + grid_h + pad
+
+ if len(card.text.splitlines()) > 1:
+ raise Exception("caption must be a single line")
+
+ # Text must be single line.
+ if text_w > page_w - 2 * pad:
+ raise Exception("caption is too long")
+
+ pdf.set_xy(text_x, text_y)
+ pdf.cell(txt = card.text,
+ w = 0, h = 0,
+ ln=LN_NEXT_LINE)
+
+# Write the dependency file used by the Makefile.
+def make_depfile(cards):
+ with open(PDF_PATH.with_suffix(".d"), "w") as f:
+ dep_path = PDF_PATH.relative_to(SCRIPT_DIR).with_suffix(".d")
+
+ f.write(PDF_PATH.name)
+ f.write(":")
+
+ for c in cards:
+ for img_path in c.images:
+ f.write(" ")
+ f.write(str(img_path.relative_to(SCRIPT_DIR)))
+
+ f.write("\n")
+
+def logd(msg):
+ if DEBUG:
+ print(f"debug: {msg}", file=sys.stderr)
+
+def logd_rect(name, x, y, w, h):
+ logd(f"{name}: {x:0.2f} {y:0.2f} {w:0.2f} {h:0.2f}")
+
+if __name__ == "__main__":
+ main()