Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Output: `output/production_v2/`

## Syrup Labels (Master Compliance + Production Rebuild)

4 PDFs — Original · Grape · Strawberry · Mango
4 PDFs — Original · Mango · Strawberry · Grape

```bash
python3 scripts/export_syrup_production.py
Expand Down
13 changes: 9 additions & 4 deletions config/syrup/brand.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ product_line: syrup
brand:
name: "ALTERNATIVE™"
website: "AlternativeBev.com"
age_gate: "21+"
statement_of_identity: "Hemp-Derived Delta-9 THC Syrup"

canvas:
Expand All @@ -26,9 +27,11 @@ colors:
champagne_gold:
cmyk: [0, 15, 35, 15]
deep_amber:
cmyk: [0, 45, 75, 25]
cmyk: [0, 38, 62, 28]
berry_accent:
cmyk: [25, 55, 30, 10]
strawberry_accent:
cmyk: [18, 72, 48, 22]
citrus_accent:
cmyk: [0, 25, 55, 10]

Expand All @@ -39,10 +42,12 @@ typography:
thc_per_serving: 8.0
servings: 7.5
net_contents: 7.0
statement_of_identity: 6.6
age_gate: 5.0
panel_heading: 6.5
panel_body: 5.5
panel_body: 6.0
supplement_heading: 6.0
supplement_body: 5.0
supplement_body: 5.5

product:
total_thc_mg: 420
Expand Down Expand Up @@ -74,7 +79,7 @@ qr_section:
- "LAB RESULTS"
- "INGREDIENTS"
- "PRODUCT INFO"
quiet_zone_ratio: 0.12
quiet_zone_ratio: 0.14

warning_panel:
heading: "WARNING:"
Expand Down
18 changes: 9 additions & 9 deletions config/syrup/flavors.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,17 @@ flavors:
display_name: "Original"
accent_color: champagne_gold

- id: grape
name: "GRAPE"
display_name: "Grape"
accent_color: berry_accent
- id: mango
name: "MANGO"
display_name: "Mango"
accent_color: deep_amber

- id: strawberry
name: "STRAWBERRY"
display_name: "Strawberry"
accent_color: berry_accent
accent_color: strawberry_accent

- id: mango
name: "MANGO"
display_name: "Mango"
accent_color: citrus_accent
- id: grape
name: "GRAPE"
display_name: "Grape"
accent_color: berry_accent
5 changes: 4 additions & 1 deletion src/alt_syrup/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ def cmyk(c: float, m: float, y: float, k: float) -> CMYKColor:
MATTE_BLACK = cmyk(0, 0, 0, 100)
WARM_OFF_WHITE = cmyk(0, 3, 8, 4)
CHAMPAGNE_GOLD = cmyk(0, 15, 35, 15)
DEEP_AMBER = cmyk(0, 45, 75, 25)
DEEP_AMBER = cmyk(0, 38, 62, 28)
BERRY_ACCENT = cmyk(25, 55, 30, 10)
STRAWBERRY_ACCENT = cmyk(18, 72, 48, 22)
CITRUS_ACCENT = cmyk(0, 25, 55, 10)
DIVIDER = cmyk(0, 2, 5, 35)

ACCENT_MAP = {
"champagne_gold": CHAMPAGNE_GOLD,
"deep_amber": DEEP_AMBER,
"berry_accent": BERRY_ACCENT,
"strawberry_accent": STRAWBERRY_ACCENT,
"citrus_accent": CITRUS_ACCENT,
}
45 changes: 13 additions & 32 deletions src/alt_syrup/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,38 +58,19 @@ def build_layout() -> SyrupLayout:
back.width - 2 * safe_inset, back.height - 2 * safe_inset,
)

supplement_zone = Rect(
safe_back.x, safe_back.y + safe_back.height * 0.42,
safe_back.width * 0.48, safe_back.height * 0.38,
)
directions_zone = Rect(
safe_back.x, safe_back.y + safe_back.height * 0.72,
safe_back.width, safe_back.height * 0.26,
)
ingredients_zone = Rect(
safe_back.x + safe_back.width * 0.50, safe_back.y + safe_back.height * 0.42,
safe_back.width * 0.50, safe_back.height * 0.28,
)
warning_zone = Rect(
safe_back.x, safe_back.y + safe_back.height * 0.08,
safe_back.width, safe_back.height * 0.32,
)
responsible_zone = Rect(
safe_back.x, safe_back.y + safe_back.height * 0.80,
safe_back.width * 0.55, safe_back.height * 0.18,
)
qr_zone = Rect(
safe_back.x, safe_back.y,
safe_back.width * 0.42, safe_back.height * 0.10,
)
barcode_zone = Rect(
safe_back.x + safe_back.width * 0.55, safe_back.y,
safe_back.width * 0.45, safe_back.height * 0.10,
)
lot_zone = Rect(
safe_back.x, safe_back.y + safe_back.height * 0.94,
safe_back.width, safe_back.height * 0.06,
)
# Non-overlapping vertical bands (percentages of safe_back.height)
h = safe_back.height
x0 = safe_back.x
w = safe_back.width

qr_zone = Rect(x0, safe_back.y, w * 0.62, h * 0.18)
barcode_zone = Rect(x0 + w * 0.62, safe_back.y, w * 0.38, h * 0.18)
warning_zone = Rect(x0, safe_back.y + h * 0.18, w, h * 0.22)
supplement_zone = Rect(x0, safe_back.y + h * 0.40, w * 0.48, h * 0.23)
ingredients_zone = Rect(x0 + w * 0.50, safe_back.y + h * 0.40, w * 0.50, h * 0.23)
directions_zone = Rect(x0, safe_back.y + h * 0.63, w, h * 0.14)
responsible_zone = Rect(x0, safe_back.y + h * 0.77, w, h * 0.14)
lot_zone = Rect(x0, safe_back.y + h * 0.91, w, h * 0.09)

return SyrupLayout(
width=full_w,
Expand Down
145 changes: 111 additions & 34 deletions src/alt_syrup/panels/back_panel.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,37 @@
"""Back panel — standardized compliance sections, no filler."""
"""Back panel — standardized compliance sections, master alignment."""

import io

import qrcode
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas

from ..colors import MATTE_BLACK, WARM_OFF_WHITE
from ..layout import SyrupLayout
from ..colors import DIVIDER, MATTE_BLACK, WARM_OFF_WHITE
from ..layout import Rect, SyrupLayout
from .supplement_facts import render_supplement_facts


def _body_size(typo: dict) -> float:
return max(typo.get("panel_body", 6.0), 6.0)


def _lot_size(typo: dict) -> float:
return max(typo.get("panel_body", 6.0) - 0.5, 5.5)


def _clip_zone(c: Canvas, zone: Rect) -> None:
c.saveState()
p = c.beginPath()
p.rect(zone.x, zone.y, zone.width, zone.height)
c.clipPath(p, stroke=0, fill=0)


def _section_divider(c: Canvas, zone: Rect) -> None:
c.setStrokeColor(DIVIDER)
c.setLineWidth(0.35)
c.line(zone.x, zone.y + zone.height, zone.x + zone.width, zone.y + zone.height)


def render_back_panel(
c: Canvas,
layout: SyrupLayout,
Expand All @@ -24,36 +45,50 @@ def render_back_panel(

_render_qr(c, layout, brand, compliance, typo)
_render_barcode(c, layout, compliance)
_render_directions(c, layout, brand, typo)
_render_ingredients(c, layout, compliance, typo)
render_supplement_facts(c, layout.supplement_zone, compliance["supplement_facts"], typo)
_section_divider(c, layout.warning_zone)
_render_warnings(c, layout, brand, compliance, typo)
render_supplement_facts(c, layout.supplement_zone, compliance["supplement_facts"], typo)
_render_ingredients(c, layout, compliance, typo)
_section_divider(c, layout.directions_zone)
_render_directions(c, layout, brand, typo)
_section_divider(c, layout.responsible_zone)
_render_responsible_party(c, layout, brand, typo)
_section_divider(c, layout.lot_zone)
_render_lot(c, layout, compliance, typo)


def _render_qr(c: Canvas, layout: SyrupLayout, brand: dict, compliance: dict, typo: dict) -> None:
zone = layout.qr_zone
qr_size = min(zone.height * 0.85, zone.width * 0.55)
quiet = qr_size * brand["qr_section"].get("quiet_zone_ratio", 0.12)
body = _body_size(typo)
website_band = body * 1.75
content_h = zone.height - website_band
quiet_ratio = brand["qr_section"].get("quiet_zone_ratio", 0.14)
qr_size = min(content_h * 0.90, zone.width * 0.50)
quiet = max(qr_size * quiet_ratio, 2)
qr_x = zone.x + quiet
qr_y = zone.y + website_band + max((content_h - qr_size) / 2, quiet * 0.5)

url = compliance.get("qr_url", f"https://{brand['brand']['website']}")
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr = qrcode.QRCode(version=1, box_size=12, border=4, error_correction=qrcode.constants.ERROR_CORRECT_M)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
c.drawImage(ImageReader(buf), zone.x + quiet, zone.y + quiet, qr_size, qr_size, mask="auto")
tx = zone.x + quiet + qr_size + quiet
ty = zone.y + zone.height - typo["panel_heading"]
c.drawImage(ImageReader(buf), qr_x, qr_y, qr_size, qr_size)

tx = qr_x + qr_size + quiet
ty = zone.y + zone.height - website_band - 1
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica-Bold", typo["panel_heading"] - 1)
c.setFont("Helvetica-Bold", typo["panel_heading"] - 0.5)
for line in brand["qr_section"]["heading_lines"]:
c.drawString(tx, ty, line)
ty -= typo["panel_heading"]
c.setFont("Helvetica", typo["panel_body"] - 0.5)
c.drawString(zone.x, zone.y, brand["brand"]["website"])
ty -= typo["panel_heading"] * 0.92

c.setFont("Helvetica", body)
site = brand["brand"]["website"]
c.drawString(zone.x, zone.y + 2, site)


def _render_barcode(c: Canvas, layout: SyrupLayout, compliance: dict) -> None:
Expand All @@ -71,7 +106,7 @@ def _render_barcode(c: Canvas, layout: SyrupLayout, compliance: dict) -> None:
c.setFillColor(WARM_OFF_WHITE)
c.rect(x, bar_y, bar_w, bar_h, fill=1, stroke=0)
x += bar_w
c.setFont("Helvetica", 5)
c.setFont("Helvetica", 5.5)
c.drawCentredString(zone.x + zone.width / 2, zone.y + 1, upc)


Expand All @@ -88,70 +123,97 @@ def _upc_pattern(digits: str) -> str:

def _render_directions(c: Canvas, layout: SyrupLayout, brand: dict, typo: dict) -> None:
zone = layout.directions_zone
body = _body_size(typo)
_clip_zone(c, zone)
y = zone.y + zone.height - typo["panel_heading"]
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica-Bold", typo["panel_heading"])
c.drawString(zone.x, y, brand["directions"]["heading"])
y -= typo["panel_heading"] * 1.2
c.setFont("Helvetica", typo["panel_body"] - 0.5)
y -= typo["panel_heading"] * 1.12
c.setFont("Helvetica", body)
for line in brand["directions"]["lines"]:
if y < zone.y + body:
break
c.drawString(zone.x, y, line)
y -= typo["panel_body"] * 1.1
y -= body * 1.04
c.restoreState()


def _render_ingredients(c: Canvas, layout: SyrupLayout, compliance: dict, typo: dict) -> None:
zone = layout.ingredients_zone
body = _body_size(typo)
_clip_zone(c, zone)
y = zone.y + zone.height - typo["panel_heading"]
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica-Bold", typo["panel_heading"])
c.drawString(zone.x, y, "INGREDIENTS:")
y -= typo["panel_heading"] * 1.1
c.setFont("Helvetica", typo["panel_body"] - 0.5)
for line in compliance.get("ingredients_lines", []):
y -= typo["panel_heading"] * 1.02
c.setFont("Helvetica", body)
text = compliance.get("ingredients", "")
if not text:
text = ", ".join(compliance.get("ingredients_lines", []))
for line in _wrap_to_width(c, text, "Helvetica", body, zone.width - 4):
if y < zone.y + body:
break
c.drawString(zone.x, y, line)
y -= typo["panel_body"] * 1.05
y -= body * 1.06
c.restoreState()


def _render_warnings(c: Canvas, layout: SyrupLayout, brand: dict, compliance: dict, typo: dict) -> None:
zone = layout.warning_zone
body = _body_size(typo)
_clip_zone(c, zone)
y = zone.y + zone.height - typo["panel_heading"]
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica-Bold", typo["panel_heading"])
c.drawString(zone.x, y, brand["warning_panel"]["heading"])
y -= typo["panel_heading"] * 1.2
c.setFont("Helvetica", typo["panel_body"] - 0.6)
y -= typo["panel_heading"] * 1.08
c.setFont("Helvetica", body)
for line in brand["warning_panel"]["lines"]:
if y < zone.y + body:
break
c.drawString(zone.x, y, line)
y -= typo["panel_body"] * 1.05
y -= body * 1.0
for sw in compliance.get("state_warnings", [])[:2]:
for part in _wrap(sw, 38):
for part in _wrap(sw, 34):
if y < zone.y + body:
break
c.drawString(zone.x, y, part)
y -= typo["panel_body"]
y -= body * 1.0
c.restoreState()


def _render_responsible_party(c: Canvas, layout: SyrupLayout, brand: dict, typo: dict) -> None:
zone = layout.responsible_zone
body = _body_size(typo)
_clip_zone(c, zone)
rp = brand["responsible_party"]
y = zone.y + zone.height - typo["panel_body"]
y = zone.y + zone.height - body
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica", typo["panel_body"] - 0.6)
c.setFont("Helvetica", body)
for line in [
rp["manufactured_by_label"], rp["manufactured_by"],
rp["manufactured_for_label"], rp["manufactured_for"],
*rp["address_lines"],
]:
if y < zone.y + body:
break
c.drawString(zone.x, y, line)
y -= typo["panel_body"] * 0.95
y -= body * 0.90
c.restoreState()


def _render_lot(c: Canvas, layout: SyrupLayout, compliance: dict, typo: dict) -> None:
zone = layout.lot_zone
size = _lot_size(typo)
c.setFillColor(WARM_OFF_WHITE)
c.setFont("Helvetica", typo["panel_body"] - 1)
c.setFont("Helvetica", size)
lot = compliance.get("lot_number", "")
best = compliance.get("best_by", "")
c.drawString(zone.x, zone.y + 2, f"Lot: {lot}" if lot else "Lot:")
c.drawString(zone.x + 80, zone.y + 2, f"Best By: {best}" if best else "Best By:")
best_x = zone.x + zone.width * 0.52
c.drawString(best_x, zone.y + 2, f"Best By: {best}" if best else "Best By:")


def _wrap(text: str, width: int) -> list[str]:
Expand All @@ -167,3 +229,18 @@ def _wrap(text: str, width: int) -> list[str]:
if cur:
lines.append(" ".join(cur))
return lines


def _wrap_to_width(c: Canvas, text: str, font: str, size: float, max_width: float) -> list[str]:
words, lines, cur = text.split(), [], []
for word in words:
test = " ".join(cur + [word]) if cur else word
if c.stringWidth(test, font, size) <= max_width:
cur.append(word)
else:
if cur:
lines.append(" ".join(cur))
cur = [word]
if cur:
lines.append(" ".join(cur))
return lines
Loading