159 lines
7.0 KiB
Python
159 lines
7.0 KiB
Python
"""Render a model with render_model.py and compose it into a bottle label:
|
|
the render on the left, title and subtitle on the right.
|
|
|
|
Takes the same arguments as render_model.py (the model path first, then
|
|
--texture, --project, --azimuth, ...), plus the label options below.
|
|
--output is an A4 sheet with the label repeated --labels-per-sheet times
|
|
down the page (PDF or PNG by extension, at --dpi). The render itself is made
|
|
with a transparent background, trimmed to its visible pixels and scaled to fit
|
|
the left panel of each label.
|
|
|
|
Usage:
|
|
python make_label.py MODEL.blend --texture TEX --output labels.pdf \
|
|
[render_model.py options] [--title jider] \
|
|
[--subtitle "alc 5.5% · mostly maggot-free"] [--labels-per-sheet 4]
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
RENDER_MODEL = os.path.join(HERE, "render_model.py")
|
|
A4_MM = (210, 297)
|
|
|
|
|
|
def parse_args():
|
|
"""Split argv into label options and the arguments passed on to render_model.py."""
|
|
p = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="All other options are passed to render_model.py "
|
|
f"(see: python {os.path.basename(RENDER_MODEL)} "
|
|
"MODEL.blend --help).")
|
|
p.add_argument("model", help="the .blend file to render")
|
|
p.add_argument("--output", required=True, help="output A4 sheet (.pdf or .png)")
|
|
p.add_argument("--save-render", help="also keep the apple render at this path")
|
|
p.add_argument("--title", default="jider")
|
|
p.add_argument("--subtitle", default="alc 5.5% · mostly maggot-free")
|
|
p.add_argument("--labels-per-sheet", type=int, default=4,
|
|
help="labels stacked vertically on the A4 page")
|
|
p.add_argument("--page-margin", type=float, default=10.0,
|
|
help="blank margin around the page in mm")
|
|
p.add_argument("--label-gap", type=float, default=4.0, help="space between labels in mm")
|
|
p.add_argument("--dpi", type=int, default=300)
|
|
p.add_argument("--label-split", type=float, default=0.42,
|
|
help="fraction of the width given to the apple panel")
|
|
p.add_argument("--label-background", default="#ffffff", help="label colour")
|
|
p.add_argument("--label-ink", default="#7a1f12", help="text and border colour")
|
|
p.add_argument("--label-font", default=os.path.join(HERE, "assets/fonts/comic.ttf"),
|
|
help="subtitle font")
|
|
p.add_argument("--label-title-font", default=os.path.join(HERE, "assets/fonts/comicbd.ttf"),
|
|
help="title font")
|
|
return p.parse_known_args()
|
|
|
|
|
|
def render_apple(model, render_args, out_path):
|
|
# --resolution 1500 is a default the caller's own --resolution overrides (last one wins).
|
|
cmd = [sys.executable, RENDER_MODEL, model, "--resolution", "1500", *render_args,
|
|
"--transparent", "--output", out_path]
|
|
print("Running:", " ".join(cmd))
|
|
result = subprocess.run(cmd)
|
|
if result.returncode != 0 or not os.path.exists(out_path):
|
|
sys.exit(f"render_model.py failed (exit code {result.returncode})")
|
|
|
|
|
|
def fit_font(path, text, max_w, max_h):
|
|
"""Largest font size at which `text` fits inside max_w x max_h."""
|
|
lo, hi = 4, 2000
|
|
while lo < hi:
|
|
size = (lo + hi + 1) // 2
|
|
left, top, right, bottom = ImageFont.truetype(path, size).getbbox(text)
|
|
if right - left <= max_w and bottom - top <= max_h:
|
|
lo = size
|
|
else:
|
|
hi = size - 1
|
|
return ImageFont.truetype(path, lo)
|
|
|
|
|
|
def draw_centered(draw, font, text, cx, top, fill):
|
|
"""Draw text horizontally centred on cx with its ink starting at y = top."""
|
|
left, t, right, _ = font.getbbox(text)
|
|
draw.text((cx - (left + right) / 2, top - t), text, font=font, fill=fill)
|
|
|
|
|
|
def main():
|
|
args, render_args = parse_args()
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
apple_path = args.save_render or os.path.join(tmp, "apple.png")
|
|
render_apple(args.model, render_args, apple_path)
|
|
apple = Image.open(apple_path).convert("RGBA")
|
|
|
|
# A4 page with the labels stacked vertically.
|
|
def px(mm):
|
|
return round(mm / 25.4 * args.dpi)
|
|
|
|
page_w, page_h = px(A4_MM[0]), px(A4_MM[1])
|
|
n = args.labels_per_sheet
|
|
margin, gap = px(args.page_margin), px(args.label_gap)
|
|
label_w = page_w - 2 * margin
|
|
label_h = (page_h - 2 * margin - (n - 1) * gap) // n
|
|
label = compose_label(args, apple, label_w, label_h)
|
|
|
|
page = Image.new("RGB", (page_w, page_h), "white")
|
|
for i in range(n):
|
|
page.paste(label, (margin, margin + i * (label_h + gap)))
|
|
page.save(args.output, dpi=(args.dpi, args.dpi), resolution=args.dpi)
|
|
print(f"Wrote {args.output}: {n} labels of "
|
|
f"{label_w / args.dpi * 25.4:.0f} x {label_h / args.dpi * 25.4:.1f} mm on A4")
|
|
|
|
|
|
def compose_label(args, apple, W, H):
|
|
pad = round(H * 0.07)
|
|
label = Image.new("RGBA", (W, H), args.label_background)
|
|
draw = ImageDraw.Draw(label)
|
|
ink = args.label_ink
|
|
|
|
# Double border.
|
|
for inset, width in [(pad * 0.35, max(2, H // 120)), (pad * 0.55, max(1, H // 300))]:
|
|
draw.rounded_rectangle((inset, inset, W - inset, H - inset), radius=pad * 0.6,
|
|
outline=ink, width=round(width))
|
|
|
|
# Left panel: apple trimmed to its pixels, scaled to fit, with a soft contact shadow.
|
|
apple = apple.crop(apple.getchannel("A").getbbox())
|
|
panel_w = round(W * args.label_split)
|
|
box_w, box_h = panel_w - 2 * pad, H - 2.6 * pad
|
|
scale = min(box_w / apple.width, box_h / apple.height)
|
|
apple = apple.resize((round(apple.width * scale), round(apple.height * scale)), Image.LANCZOS)
|
|
ax = pad + (box_w - apple.width) // 2
|
|
ay = round(1.1 * pad + (box_h - apple.height) / 2)
|
|
|
|
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
|
sw, sh = apple.width * 0.75, apple.height * 0.08
|
|
scx, scy = ax + apple.width / 2, ay + apple.height - sh * 0.3
|
|
ImageDraw.Draw(shadow).ellipse((scx - sw / 2, scy - sh / 2, scx + sw / 2, scy + sh / 2),
|
|
fill=(60, 30, 10, 110))
|
|
label.alpha_composite(shadow.filter(ImageFilter.GaussianBlur(H / 60)))
|
|
label.alpha_composite(apple, (ax, ay))
|
|
|
|
# Right panel: big title over the subtitle, the pair centred vertically.
|
|
text_l, text_r = panel_w, W - 1.5 * pad
|
|
text_w, cx = text_r - text_l, (text_l + text_r) / 2
|
|
title_font = fit_font(args.label_title_font, args.title, text_w, H * 0.5)
|
|
sub_font = fit_font(args.label_font, args.subtitle, text_w, H * 0.09)
|
|
_, tt, _, tb = title_font.getbbox(args.title)
|
|
_, st, _, sb = sub_font.getbbox(args.subtitle)
|
|
gap = H * 0.06
|
|
top = (H - ((tb - tt) + gap + (sb - st))) / 2
|
|
draw_centered(draw, title_font, args.title, cx, top, ink)
|
|
draw_centered(draw, sub_font, args.subtitle, cx, top + (tb - tt) + gap, ink)
|
|
|
|
return label.convert("RGB")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|