# Compose the die-cut Halloween stickers: a pose SVG, "MyBazaar" as free-floating letters in the scene, the tagline, # a white border and a cut line. Text is converted to outlines (Fredoka, SIL OFL) so the printer needs no fonts. # Units are pixels at 300 dpi. Each sticker sits centred on a page the size of its print slot (3x3 in or 2x3 in); # the border is 35 px (~3 mm) and the bleed 24 px (2 mm), and both stay inside the slot. # usage: python compose-stickers.py [cut size in inches for all, e.g. 2] import io, math, os, re, subprocess, sys, tempfile import numpy as np, cairosvg, uharfbuzz as hb from PIL import Image from scipy import ndimage from fontTools.ttLib import TTFont from fontTools.varLib.instancer import instantiateVariableFont from fontTools.pens.svgPathPen import SVGPathPen from fontTools.pens.transformPen import TransformPen GALLERY, FONT, OUT, BAT_FLAT = sys.argv[1:5] # With a cut size, each sticker's longer side at the cut line is exactly that, and the page grows to hold the bleed. CUT = float(sys.argv[5]) if len(sys.argv) > 5 else None BORDER, BLEED = 35, 24 # Spare page around the bleed so the smoothed outline never runs into the slot edge. MARGIN = 8 INK, OUTLINE, LETTER, GOLD, WHITE = "#0C2B27", "#123235", "#D7ECE8", "#F0C64B", "#FFFFFF" # The "MyBazaar" letters: pine.60, one step lighter than the logo (#01796F) so they stand apart from Mybo's body. PINE = "#27A897" _fonts = {} def font(weight): if weight not in _fonts: f = instantiateVariableFont(TTFont(FONT), {"wght": weight, "wdth": 100}) buf = io.BytesIO(); f.save(buf) _fonts[weight] = (f, hb.Font(hb.Face(buf.getvalue()))) return _fonts[weight] def glyphs(s, size, weight): """Shape a string; returns [(path d drawn at the glyph's own origin, x of origin, advance)] and the total width.""" tt, hbf = font(weight) k = size / tt["head"].unitsPerEm buf = hb.Buffer(); buf.add_str(s); buf.guess_segment_properties() hb.shape(hbf, buf, {"kern": True, "liga": True}) gs, names, out, x = tt.getGlyphSet(), tt.getGlyphOrder(), [], 0.0 for info, pos in zip(buf.glyph_infos, buf.glyph_positions): pen = SVGPathPen(gs) gs[names[info.codepoint]].draw(TransformPen(pen, (k, 0, 0, -k, pos.x_offset * k, -pos.y_offset * k))) out.append((pen.getCommands(), x, pos.x_advance * k)) x += pos.x_advance * k return out, x def metric(weight, name): tt, _ = font(weight) upem = tt["head"].unitsPerEm return {"cap": tt["OS/2"].sCapHeight, "desc": -tt["hhea"].descent}[name] / upem def text(s, size, weight, fill, cx, baseline, tilt=0): """Centred outlined text; tilt (degrees) turns the whole line about its centre for a hand-placed sticker feel.""" gl, w = glyphs(s, size, weight) parts = "".join(f'' for p, x, _ in gl if p) return f'{parts}', w def word(s, size, cx, baseline, arc_radius=None, outline=6, fill=PINE): """'MyBazaar' as chunky letters drawn like Mybo: pine fill, dark outline. With arc_radius they bend over like a marquee.""" gl, w = glyphs(s, size, 700) placed = [] for p, x, adv in gl: if not p: continue mid = x + adv / 2 - w / 2 if arc_radius: a = mid / arc_radius px, py, deg = cx + arc_radius * math.sin(a), baseline + arc_radius * (1 - math.cos(a)), math.degrees(a) else: px, py, deg = cx + mid, baseline, 0 placed.append(f'') body = "".join(placed) svg = (f'{body}' f'{body}') return svg, w def sparkle(cx, cy, r, fill=GOLD): """A four-point sparkle like the one on the witch's wand.""" q = r * 0.28 d = (f"M{cx},{cy-r} Q{cx+q},{cy-q} {cx+r},{cy} Q{cx+q},{cy+q} {cx},{cy+r} " f"Q{cx-q},{cy+q} {cx-r},{cy} Q{cx-q},{cy-q} {cx},{cy-r} Z") return f'' def alpha_of(svg, scale=1): png = cairosvg.svg2png(bytestring=svg.encode(), scale=scale) return np.array(Image.open(io.BytesIO(png)).convert("RGBA"))[:, :, 3] def pose(name, keep_shadow): svg = open(f"{GALLERY}/05-svg/{name}.svg").read() W, H = map(float, re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg).groups()) body = svg[svg.index(">") + 1: svg.rindex("")] if not keep_shadow: body = re.sub(r']*fill-opacity="[^"]*"[^>]*>.*?', "", body, count=1, flags=re.S) a = alpha_of(f'{body}') ys, xs = np.nonzero(a > 20) return body, (xs.min() * 4, ys.min() * 4, (xs.max() + 1) * 4, (ys.max() + 1) * 4) def place(body, bbox, x, y, height): """Draw a pose so its tight bbox top-left lands at (x, y) at the given height. Returns svg and a pose->page mapper.""" s = height / (bbox[3] - bbox[1]) tx, ty = x - bbox[0] * s, y - bbox[1] * s return f'{body}', (lambda px, py: (tx + px * s, ty + py * s)), s def contour(mask, offset_px, scale): """Grow the artwork's silhouette by offset_px, fill its holes, smooth it, and trace one outline with potrace.""" grown = ndimage.distance_transform_edt(~mask) <= offset_px * scale grown = ndimage.binary_fill_holes(grown) grown = ndimage.gaussian_filter(grown.astype(float), 9 * scale) > 0.5 with tempfile.TemporaryDirectory() as d: Image.fromarray(np.where(grown, 0, 255).astype(np.uint8)).convert("1").save(f"{d}/m.pbm") subprocess.run(["potrace", f"{d}/m.pbm", "-s", "-o", f"{d}/m.svg", "--turdsize", "200", "--alphamax", "1.2", "--opttolerance", "0.4"], check=True) s = open(f"{d}/m.svg").read() tr = re.search(r'' + "".join(f'' for p in re.findall(r'" def finish(name, art, w, h, slot): """Fit the artwork as large as its slot allows, then add the white border, bleed and cut line.""" a = alpha_of(f'{art}') ys, xs = np.nonzero(a > 10) x0, y0, x1, y1 = xs.min(), ys.min(), xs.max() + 1, ys.max() + 1 if CUT: slot = (round(CUT + 2 * (BLEED + MARGIN) / 300, 3),) * 2 # +2 px makes up for the corner rounding the outline smoothing shaves off. fit = (CUT * 300 - 2 * BORDER + 2) / max(x1 - x0, y1 - y0) W, H = round(slot[0] * 300), round(slot[1] * 300) if not CUT: room = 2 * (BORDER + BLEED + MARGIN) fit = min((W - room) / (x1 - x0), (H - room) / (y1 - y0)) cw, ch = (x1 - x0) * fit + 2 * BORDER, (y1 - y0) * fit + 2 * BORDER ox, oy = (W - (x1 - x0) * fit) / 2 - x0 * fit, (H - (y1 - y0) * fit) / 2 - y0 * fit moved = f'{art}' S = 2 mask = alpha_of(f'{moved}', S) > 10 svg = f''' {name} - MyBazaar Halloween sticker, {slot[0]}x{slot[1]} in slot (AI-generated art) {contour(mask, BORDER + BLEED, S)} {moved} {contour(mask, BORDER, S)} ''' os.makedirs(OUT, exist_ok=True) open(f"{OUT}/{name}.svg", "w").write(svg) print(f"{name}: {slot[0]}x{slot[1]} in slot, sticker about {cw/300:.2f} x {ch/300:.2f} in at the cut line") TAG = 600 # --- Witch: Mybo sits on the MyBazaar letters; sparkles from his wand drift down onto them. body, bb = pose("witch-casting", keep_shadow=False) mybo_h = 520 g, at, s = place(body, bb, 260, 40, mybo_h) body_l, body_r = at(bb[0], 0)[0], at(bb[2], 0)[0] cx = (body_l + body_r) / 2 - 10 size = 88 letters_top = 40 + mybo_h - 8 base = letters_top + metric(700, "cap") * size wd, _ = word("MyBazaar", size, cx, base) art = [wd, g] wx, wy = at(1000, 330) art += [sparkle(wx + 30, wy + 140, 13), sparkle(wx + 5, wy + 215, 9), sparkle(wx + 40, wy + 290, 16)] tag_base = base + metric(700, "desc") * size + 26 + metric(TAG, "cap") * 72 art.append(text("Hex-cellent si(gh)tes", 72, TAG, INK, cx, tag_base, tilt=-3)[0]) finish("witch-hex-cellent", "\n".join(art), 1100, 1100, (3, 3)) # --- Bat: Mybo hangs by his claws from the bottom of the MyBazaar letters. body, bb = pose("bat-hanging-nosign", keep_shadow=False) mybo_h = 480 g, at, s = place(body, bb, 250, 190, mybo_h) claw_l, claw_r = at(450, 432), at(880, 432) cx = (claw_l[0] + claw_r[0]) / 2 size = 84 base = claw_l[1] + 12 wd, _ = word("MyBazaar", size, cx, base) art = [g, wd] ty = 190 + mybo_h + 30 + metric(TAG, "cap") * 66 art.append(text("Going batty", 66, TAG, INK, cx, ty, tilt=-3)[0]) art.append(text("for your brand.", 66, TAG, INK, cx + 4, ty + 80, tilt=-3)[0]) finish("bat-going-batty", "\n".join(art), 1100, 1100, (2, 3)) # --- Vampire: MyBazaar arcs over him like a theatre marquee under a string of bulbs; Mybo says the line. body, bb = pose("vampire-ticket", keep_shadow=True) mybo_h = 500 x_left, top = 360, 240 g, at, s = place(body, bb, x_left, top, mybo_h) pw = (bb[2] - bb[0]) * s cx = x_left + pw * 0.48 size, R = 76, 480 base = top - 22 wd, wwidth = word("MyBazaar", size, cx, base, arc_radius=R) cap = metric(700, "cap") * size string_r = R + cap + 26 half = (wwidth / 2 + 40) / string_r pts = [(cx + string_r * math.sin(a), base - cap - 26 + string_r * (1 - math.cos(a))) for a in np.linspace(-half, half, 40)] art = [f''] for a in np.linspace(-half, half, 9): bx, by = cx + string_r * math.sin(a), base - cap - 26 + string_r * (1 - math.cos(a)) art.append(f'') art += [wd, g] mouth_x, mouth_y = at(0, 0)[0] + 0.47 * (bb[2]) * s, top + mybo_h * 0.36 bw, bh = 300, 160 bx0, by0 = x_left - bw + 70, top + 40 tail = f'M{bx0+bw-80:.1f},{by0+bh-34:.1f} Q{bx0+bw+10:.1f},{by0+bh+10:.1f} {mouth_x-40:.1f},{mouth_y+12:.1f} Q{bx0+bw-20:.1f},{by0+bh-40:.1f} {bx0+bw-40:.1f},{by0+bh-70:.1f} Z' art.append(f'') art.append(f'') cy = by0 + bh / 2 art.append(text("Fang-cy", 60, TAG, INK, bx0 + bw / 2, cy - 8)[0]) art.append(text("a site?", 60, TAG, INK, bx0 + bw / 2, cy + 8 + metric(TAG, "cap") * 60)[0]) finish("vampire-fang-cy", "\n".join(art), 1200, 1100, (3, 3)) # --- Ghost, scared: a little ghost pops up from behind the MyBazaar letters and startles Mybo. body, bb = pose("ghost-scared", keep_shadow=False) mybo_h = 600 g, at, s = place(body, bb, 200, 40, mybo_h) size = 112 cx = at((bb[0] + bb[2]) / 2, 0)[0] base = at(0, 1240)[1] + metric(700, "cap") * size wd, _ = word("MyBazaar", size, cx, base) art = [g, wd] tag_base = base + metric(700, "desc") * size + 30 + metric(TAG, "cap") * 72 art.append(text("Boo!", 72, TAG, INK, cx, tag_base, tilt=-3)[0]) art.append(text("Always online!", 72, TAG, INK, cx + 4, tag_base + 86, tilt=-3)[0]) finish("ghost-boo-always-online", "\n".join(art), 1200, 1200, (3, 3)) # --- Ghost, caught: Mybo sits on the MyBazaar letters, the ghost's empty sheet left lying beside them. body, bb = pose("ghost-caught", keep_shadow=False) mybo_h = 560 g, at, s = place(body, bb, 160, 40, mybo_h) size = 84 cx = at(1170, 0)[0] base = at(0, 1575)[1] + metric(700, "cap") * size wd, _ = word("MyBazaar", size, cx, base) art = [wd, g] tag_base = max(base + metric(700, "desc") * size, at(0, bb[3])[1]) + 30 + metric(TAG, "cap") * 68 art.append(text("no. ghost. customers.", 68, TAG, INK, at((bb[0] + bb[2]) / 2, 0)[0], tag_base, tilt=-3)[0]) finish("ghost-no-ghost-customers", "\n".join(art), 1200, 1200, (3, 3))