diff options
Diffstat (limited to 'make-cards')
-rwxr-xr-x | make-cards | 228 |
1 files changed, 228 insertions, 0 deletions
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() |