From ae227cd491a488f9c0153f1d521d2e0c9472e274 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 16:38:18 +0000 Subject: [PATCH 1/3] Build ALTERNATIVE Production Master v1 label generation system Implement code-driven 12oz sleek can label renderer per the master label rebuild spec: brand hierarchy, locked SKU/flavor system, CMYK color system, compliance panel structure, QR/barcode protected zones, and Proleve data schema. - 182.22mm x 148mm canvas at 300 DPI - 8 variants: 4 SKUs x 2 flavors - Preview mode (no placeholder compliance data) and production mode - PDF/X-1a export pipeline via Ghostscript - Spec validation script (18/18 checks) Co-authored-by: ebyron357 --- .gitignore | 8 + README.md | 79 +++++- assets/a_symbol.svg | 5 + config/brand.yaml | 71 ++++++ config/flavors.yaml | 10 + config/skus.yaml | 30 +++ data/compliance/README.md | 42 ++++ data/compliance/TEMPLATE.json | 28 +++ data/compliance/products/.gitkeep | 0 data/compliance/schema.json | 70 ++++++ requirements.txt | 5 + scripts/generate_labels.py | 66 +++++ scripts/validate_spec.py | 116 +++++++++ src/alt_label/__init__.py | 3 + src/alt_label/colors.py | 18 ++ src/alt_label/compliance_loader.py | 40 +++ src/alt_label/config_loader.py | 30 +++ src/alt_label/layout.py | 98 ++++++++ src/alt_label/panels/__init__.py | 1 + src/alt_label/panels/a_symbol.py | 44 ++++ src/alt_label/panels/compliance_panel.py | 296 +++++++++++++++++++++++ src/alt_label/panels/front_panel.py | 102 ++++++++ src/alt_label/panels/nutrition_facts.py | 72 ++++++ src/alt_label/pdfx_export.py | 65 +++++ src/alt_label/renderer.py | 83 +++++++ 25 files changed, 1381 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 assets/a_symbol.svg create mode 100644 config/brand.yaml create mode 100644 config/flavors.yaml create mode 100644 config/skus.yaml create mode 100644 data/compliance/README.md create mode 100644 data/compliance/TEMPLATE.json create mode 100644 data/compliance/products/.gitkeep create mode 100644 data/compliance/schema.json create mode 100644 requirements.txt create mode 100755 scripts/generate_labels.py create mode 100755 scripts/validate_spec.py create mode 100644 src/alt_label/__init__.py create mode 100644 src/alt_label/colors.py create mode 100644 src/alt_label/compliance_loader.py create mode 100644 src/alt_label/config_loader.py create mode 100644 src/alt_label/layout.py create mode 100644 src/alt_label/panels/__init__.py create mode 100644 src/alt_label/panels/a_symbol.py create mode 100644 src/alt_label/panels/compliance_panel.py create mode 100644 src/alt_label/panels/front_panel.py create mode 100644 src/alt_label/panels/nutrition_facts.py create mode 100644 src/alt_label/pdfx_export.py create mode 100644 src/alt_label/renderer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f47635 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +output/ +*.egg-info/ +.pytest_cache/ +.DS_Store diff --git a/README.md b/README.md index 05fb5d9..57b786a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,79 @@ # ALT-Label-System -Code-driven packaging and label generation system for ALTERNATIVE products. + +Code-driven packaging and label generation system for **ALTERNATIVE™** — Production Master v1. + +Generates retail-ready, compliance-ready 12oz sleek can labels (182.22mm × 148mm, CMYK, 300 DPI) while preserving the existing premium brand aesthetic. + +## Specification + +This system implements the **ALTERNATIVE™ Master Label Rebuild & Compliance Optimization** spec: + +- **Not a redesign** — refinement of existing hierarchy, typography, and color system +- Locked SKU system: SESSION™ (5mg), SOCIAL™ (10mg), RESERVE™ (50mg / 100mg) +- Flavors: LYCHEE SWEET TEA, PASSION FRUIT (accent colors only — no fruit graphics) +- Matte black + warm off-white + flavor-specific gold/amber accents +- Manufacturing: Proleve Brands / Invictus Wellness LLC +- QR, warning panel, active ingredient, and protected barcode zones + +## Quick Start + +```bash +pip install -r requirements.txt +python scripts/generate_labels.py --mode preview +``` + +Output: `output/labels/alternative_{sku}_{flavor}.pdf` (8 variants) + +### PDF/X-1a Export + +Requires [Ghostscript](https://ghostscript.com/): + +```bash +python scripts/generate_labels.py --mode preview --pdfx +``` + +### Production Labels + +1. Add Proleve-verified compliance JSON per variant in `data/compliance/products/` +2. Set `"verified": true` in each file +3. Generate: + +```bash +python scripts/generate_labels.py --mode production --pdfx +``` + +See [data/compliance/README.md](data/compliance/README.md) for the data schema. + +## Label Hierarchy + +| Priority | Element | +|----------|---------| +| 1 | A NEW STATE OF MIND | +| 2 | Hero A Symbol (reduced ~12%) | +| 3 | ALTERNATIVE™ (increased ~22%) | +| 4 | HEMP-DERIVED THC BEVERAGE | +| 5 | SKU (SESSION™ / SOCIAL™ / RESERVE™) | +| 6 | THC content (largest product element) | +| 7 | Flavor (increased ~35%) | +| 8 | 12 FL OZ (355 mL) | + +## Project Structure + +``` +config/ Brand, SKU, and flavor definitions +data/compliance/ Proleve-supplied product data (schema + products/) +src/alt_label/ Label renderer, panels, PDF/X export +scripts/ CLI generator +assets/ Brand assets (A symbol reference) +output/ Generated PDFs (gitignored) +``` + +## Single Variant + +```bash +python scripts/generate_labels.py --sku session_5mg --flavor lychee_sweet_tea +``` + +## Compliance Policy + +**No placeholder compliance data.** Nutrition facts, ingredients, barcodes, and lot information render only from verified Proleve JSON files. Preview mode shows panel structure without fabricated values. diff --git a/assets/a_symbol.svg b/assets/a_symbol.svg new file mode 100644 index 0000000..be3e66a --- /dev/null +++ b/assets/a_symbol.svg @@ -0,0 +1,5 @@ + + + + diff --git a/config/brand.yaml b/config/brand.yaml new file mode 100644 index 0000000..490ad48 --- /dev/null +++ b/config/brand.yaml @@ -0,0 +1,71 @@ +# ALTERNATIVE™ Production Master v1 — Brand Configuration +# Refinement spec — NOT a redesign + +brand: + name: "ALTERNATIVE™" + tagline: "A NEW STATE OF MIND" + positioning: "HEMP-DERIVED THC BEVERAGE" + website: "AlternativeBev.com" + +canvas: + width_mm: 182.22 + height_mm: 148.0 + dpi: 300 + bleed_mm: 3.175 # 0.125" + safe_zone_mm: 4.0 + +colors: + matte_black: + cmyk: [0, 0, 0, 100] + hex: "#1A1A1A" + warm_off_white: + cmyk: [0, 3, 8, 4] + hex: "#F5F0E8" + champagne_gold: # Lychee Sweet Tea accent + cmyk: [0, 15, 35, 15] + hex: "#C9A86C" + deep_amber: # Passion Fruit accent + cmyk: [0, 45, 75, 25] + hex: "#B87333" + +typography: + # Hierarchy scale factors relative to base (pt) + tagline: 7.5 + a_symbol_scale: 0.875 # reduced 12.5% + brand_name_scale: 1.225 # increased 22.5% + positioning: 6.5 + sku: 9.0 + thc_content: 22.0 # largest product-specific + thc_subtext: 8.0 + flavor_scale: 1.35 # increased 35% + net_contents: 7.0 + compliance_body: 5.5 + compliance_heading: 6.5 + +manufacturing: + manufactured_by: "Proleve Brands" + manufactured_for: "Invictus Wellness LLC" + address: + - "11624 Red Bridge Rd" + - "Locust, NC 28097" + +qr_section: + heading_lines: + - "SCAN FOR" + - "LAB RESULTS" + - "INGREDIENTS" + - "PRODUCT INFO" + +warning_panel: + heading: "WARNING:" + lines: + - "For adults 21 years of age or older." + - "Keep out of reach of children." + - "Do not drive or operate machinery after use." + - "Do not use if pregnant or breastfeeding." + - "Intoxicating effects may be delayed." + - "Consume responsibly." + +active_ingredient: + label: "Active Ingredient:" + substance: "Hemp-Derived Delta-9 THC" diff --git a/config/flavors.yaml b/config/flavors.yaml new file mode 100644 index 0000000..b678141 --- /dev/null +++ b/config/flavors.yaml @@ -0,0 +1,10 @@ +# Flavor system — no fruit graphics, no illustrations + +flavors: + - id: lychee_sweet_tea + name: "LYCHEE SWEET TEA" + accent_color: champagne_gold + + - id: passion_fruit + name: "PASSION FRUIT" + accent_color: deep_amber diff --git a/config/skus.yaml b/config/skus.yaml new file mode 100644 index 0000000..802b1c9 --- /dev/null +++ b/config/skus.yaml @@ -0,0 +1,30 @@ +# Locked SKU system — no additional strengths or naming variants + +skus: + - id: session_5mg + name: "SESSION™" + thc_mg: 5 + thc_display: "5MG HEMP-DERIVED THC" + thc_subtext: "PER CAN" + active_ingredient_amount: "5mg" + + - id: social_10mg + name: "SOCIAL™" + thc_mg: 10 + thc_display: "10MG HEMP-DERIVED THC" + thc_subtext: "PER CAN" + active_ingredient_amount: "10mg" + + - id: reserve_50mg + name: "RESERVE™" + thc_mg: 50 + thc_display: "50MG HEMP-DERIVED THC" + thc_subtext: "PER CAN" + active_ingredient_amount: "50mg" + + - id: reserve_100mg + name: "RESERVE™" + thc_mg: 100 + thc_display: "100MG HEMP-DERIVED THC" + thc_subtext: "PER CAN" + active_ingredient_amount: "100mg" diff --git a/data/compliance/README.md b/data/compliance/README.md new file mode 100644 index 0000000..a50db27 --- /dev/null +++ b/data/compliance/README.md @@ -0,0 +1,42 @@ +# Compliance Data — Proleve Supplied Only + +Production label generation requires verified compliance JSON per product variant. + +## File Naming + +``` +data/compliance/products/{sku_id}_{flavor_id}.json +``` + +Examples: +- `session_5mg_lychee_sweet_tea.json` +- `reserve_100mg_passion_fruit.json` + +## Required Fields + +See `schema.json` for the full JSON Schema. All fields must contain **actual Proleve-supplied data** — no estimates or placeholders. + +| Field | Source | +|-------|--------| +| `verified` | Must be `true` after Proleve QA approval | +| `nutrition_facts` | Proleve formulation / lab analysis | +| `ingredients` | Proleve ingredient statement | +| `barcode` | Assigned UPC from Invictus / Proleve | +| `qr_url` | COA / lab results landing page | +| `state_warnings` | Jurisdiction-specific legal copy | +| `lot_number`, `batch_number`, `best_by` | Per production run | + +## Generate Production Labels + +```bash +# After adding verified JSON files: +python scripts/generate_labels.py --mode production --pdfx +``` + +## Preview Labels (No Compliance Data) + +```bash +python scripts/generate_labels.py --mode preview +``` + +Preview mode renders the full brand hierarchy and compliance panel structure without fabricated nutrition, ingredient, or barcode values. diff --git a/data/compliance/TEMPLATE.json b/data/compliance/TEMPLATE.json new file mode 100644 index 0000000..5ed3c63 --- /dev/null +++ b/data/compliance/TEMPLATE.json @@ -0,0 +1,28 @@ +{ + "_comment": "Copy to products/{sku_id}_{flavor_id}.json and populate with Proleve-supplied data only", + "verified": false, + "product_id": "alternative_session_5mg_lychee_sweet_tea", + "nutrition_facts": { + "serving_size": "1 can (355 mL)", + "servings_per_container": "1", + "calories": "REQUIRED_FROM_PROLEVE", + "nutrients": [ + { + "name": "Total Fat", + "amount": "REQUIRED_FROM_PROLEVE", + "daily_value": "REQUIRED_FROM_PROLEVE" + } + ] + }, + "ingredients": "REQUIRED_FROM_PROLEVE — full statement, descending by weight", + "barcode": { + "upc": "000000000000", + "type": "upc_a" + }, + "qr_url": "https://AlternativeBev.com/lab-results", + "lot_number": "PER_PRODUCTION_RUN", + "batch_number": "PER_PRODUCTION_RUN", + "best_by": "PER_PRODUCTION_RUN", + "state_warnings": [], + "thc_statement": "REQUIRED_FROM_PROLEVE" +} diff --git a/data/compliance/products/.gitkeep b/data/compliance/products/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/compliance/schema.json b/data/compliance/schema.json new file mode 100644 index 0000000..bc83c75 --- /dev/null +++ b/data/compliance/schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ALTERNATIVE Compliance Data", + "description": "Proleve-supplied compliance data required for production label generation. No estimated or placeholder values permitted.", + "type": "object", + "required": [ + "verified", + "product_id", + "nutrition_facts", + "ingredients", + "barcode", + "state_warnings" + ], + "properties": { + "verified": { + "type": "boolean", + "description": "Must be true — confirms data is Proleve-supplied and approved for production" + }, + "product_id": { + "type": "string" + }, + "nutrition_facts": { + "type": "object", + "required": ["serving_size", "servings_per_container", "calories", "nutrients"], + "properties": { + "serving_size": { "type": "string" }, + "servings_per_container": { "type": "string" }, + "calories": { "type": "string" }, + "nutrients": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "amount", "daily_value"], + "properties": { + "name": { "type": "string" }, + "amount": { "type": "string" }, + "daily_value": { "type": ["string", "null"] } + } + } + } + } + }, + "ingredients": { + "type": "string", + "description": "Full ingredient statement, descending by weight" + }, + "barcode": { + "type": "object", + "required": ["upc", "type"], + "properties": { + "upc": { "type": "string", "pattern": "^[0-9]{12}$" }, + "type": { "type": "string", "enum": ["upc_a", "ean_13"] } + } + }, + "qr_url": { + "type": "string", + "format": "uri", + "description": "URL for lab results / COA landing page" + }, + "lot_number": { "type": "string" }, + "batch_number": { "type": "string" }, + "best_by": { "type": "string" }, + "state_warnings": { + "type": "array", + "items": { "type": "string" } + }, + "thc_statement": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..688ef68 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +reportlab>=4.0.0 +PyYAML>=6.0 +jsonschema>=4.20.0 +qrcode[pil]>=7.4 +Pillow>=10.0.0 diff --git a/scripts/generate_labels.py b/scripts/generate_labels.py new file mode 100755 index 0000000..92c586b --- /dev/null +++ b/scripts/generate_labels.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Generate ALTERNATIVE™ 12oz sleek can labels — Production Master v1.""" + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from alt_label.pdfx_export import convert_to_pdfx1a, pdfx_available +from alt_label.renderer import render_all, render_label + + +def main() -> int: + parser = argparse.ArgumentParser( + description="ALTERNATIVE™ label generator — 182.22mm × 148mm, CMYK, print-ready" + ) + parser.add_argument( + "--mode", + choices=["preview", "production"], + default="preview", + help="preview: layout without unverified compliance data; production: requires verified JSON", + ) + parser.add_argument( + "--output", + type=Path, + default=ROOT / "output" / "labels", + help="Output directory for PDF files", + ) + parser.add_argument("--sku", help="Generate single SKU (e.g. session_5mg)") + parser.add_argument("--flavor", help="Generate single flavor (e.g. lychee_sweet_tea)") + parser.add_argument( + "--pdfx", + action="store_true", + help="Also export PDF/X-1a via Ghostscript (requires gs)", + ) + args = parser.parse_args() + + args.output.mkdir(parents=True, exist_ok=True) + + if args.sku and args.flavor: + filename = f"alternative_{args.sku}_{args.flavor}.pdf" + path = render_label(args.output / filename, args.sku, args.flavor, mode=args.mode) + paths = [path] + else: + paths = render_all(args.output, mode=args.mode) + + print(f"Generated {len(paths)} label(s) in {args.output} [{args.mode} mode]") + + if args.pdfx: + if not pdfx_available(): + print("WARNING: Ghostscript not available — skipping PDF/X-1a export", file=sys.stderr) + else: + pdfx_dir = args.output / "pdfx" + pdfx_dir.mkdir(exist_ok=True) + for p in paths: + out = pdfx_dir / p.name.replace(".pdf", "_PDFX1a.pdf") + convert_to_pdfx1a(p, out) + print(f" PDF/X-1a: {out}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_spec.py b/scripts/validate_spec.py new file mode 100755 index 0000000..ec885a8 --- /dev/null +++ b/scripts/validate_spec.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Validate ALTERNATIVE™ label system against Production Master v1 spec.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from alt_label.config_loader import load_brand, load_flavors, load_skus, mm_to_pt + + +def main() -> int: + brand = load_brand() + skus = load_skus() + flavors = load_flavors() + checks: list[tuple[str, bool, str]] = [] + + # Canvas + w = brand["canvas"]["width_mm"] + h = brand["canvas"]["height_mm"] + checks.append(("Canvas width 182.22mm", abs(w - 182.22) < 0.01, f"got {w}")) + checks.append(("Canvas height 148mm", abs(h - 148.0) < 0.01, f"got {h}")) + checks.append(("300 DPI spec documented", brand["canvas"]["dpi"] == 300, "")) + + # SKU lock + expected = {5: "SESSION™", 10: "SOCIAL™", 50: "RESERVE™", 100: "RESERVE™"} + for sku in skus: + mg = sku["thc_mg"] + checks.append(( + f"SKU {mg}mg naming", + sku["name"] == expected[mg], + sku["name"], + )) + checks.append(("Exactly 4 SKUs", len(skus) == 4, str(len(skus)))) + + # Flavors + flavor_names = {f["name"] for f in flavors} + checks.append(( + "LYCHEE SWEET TEA flavor", + "LYCHEE SWEET TEA" in flavor_names, + "", + )) + checks.append(( + "PASSION FRUIT flavor", + "PASSION FRUIT" in flavor_names, + "", + )) + + # Brand copy + checks.append(( + "Tagline", + brand["brand"]["tagline"] == "A NEW STATE OF MIND", + brand["brand"]["tagline"], + )) + checks.append(( + "Website", + brand["brand"]["website"] == "AlternativeBev.com", + "", + )) + checks.append(( + "Manufactured By Proleve", + brand["manufacturing"]["manufactured_by"] == "Proleve Brands", + "", + )) + checks.append(( + "Manufactured For Invictus (not as manufacturer)", + brand["manufacturing"]["manufactured_for"] == "Invictus Wellness LLC", + "", + )) + + # QR copy + qr = brand["qr_section"]["heading_lines"] + checks.append(( + "QR section copy", + qr == ["SCAN FOR", "LAB RESULTS", "INGREDIENTS", "PRODUCT INFO"], + str(qr), + )) + + # Typography hierarchy + typo = brand["typography"] + checks.append(( + "A symbol reduced", + typo.get("a_symbol_scale", 1) < 1, + str(typo.get("a_symbol_scale")), + )) + checks.append(( + "Brand name increased", + typo.get("brand_name_scale", 1) > 1, + str(typo.get("brand_name_scale")), + )) + checks.append(( + "Flavor size increased", + typo.get("flavor_scale", 1) > 1, + str(typo.get("flavor_scale")), + )) + + passed = sum(1 for _, ok, _ in checks if ok) + total = len(checks) + score = round((passed / total) * 10, 1) + + print("ALTERNATIVE™ Production Master v1 — Spec Validation") + print("=" * 55) + for name, ok, detail in checks: + status = "PASS" if ok else "FAIL" + extra = f" ({detail})" if detail and not ok else "" + print(f" [{status}] {name}{extra}") + + print("=" * 55) + print(f"Score: {passed}/{total} checks — Retail readiness index: {score}/10") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/alt_label/__init__.py b/src/alt_label/__init__.py new file mode 100644 index 0000000..c614ce9 --- /dev/null +++ b/src/alt_label/__init__.py @@ -0,0 +1,3 @@ +"""ALTERNATIVE™ label generation system — Production Master v1.""" + +__version__ = "1.0.0" diff --git a/src/alt_label/colors.py b/src/alt_label/colors.py new file mode 100644 index 0000000..ffffef9 --- /dev/null +++ b/src/alt_label/colors.py @@ -0,0 +1,18 @@ +"""CMYK color definitions for ALTERNATIVE™ label system.""" + +from reportlab.lib.colors import CMYKColor + + +def cmyk(c: float, m: float, y: float, k: float) -> CMYKColor: + return CMYKColor(c / 100, m / 100, y / 100, k / 100) + + +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) + +ACCENT_MAP = { + "champagne_gold": CHAMPAGNE_GOLD, + "deep_amber": DEEP_AMBER, +} diff --git a/src/alt_label/compliance_loader.py b/src/alt_label/compliance_loader.py new file mode 100644 index 0000000..fdd2a15 --- /dev/null +++ b/src/alt_label/compliance_loader.py @@ -0,0 +1,40 @@ +"""Load and validate Proleve-supplied compliance data.""" + +import json +from pathlib import Path +from typing import Any + +import jsonschema + +from .config_loader import ROOT + +SCHEMA_PATH = ROOT / "data" / "compliance" / "schema.json" +PRODUCTS_DIR = ROOT / "data" / "compliance" / "products" + + +def load_schema() -> dict: + with open(SCHEMA_PATH, encoding="utf-8") as f: + return json.load(f) + + +def compliance_path(sku_id: str, flavor_id: str) -> Path: + return PRODUCTS_DIR / f"{sku_id}_{flavor_id}.json" + + +def load_compliance(sku_id: str, flavor_id: str) -> dict[str, Any] | None: + path = compliance_path(sku_id, flavor_id) + if not path.exists(): + return None + with open(path, encoding="utf-8") as f: + data = json.load(f) + schema = load_schema() + jsonschema.validate(data, schema) + return data + + +def validate_for_production(compliance: dict | None) -> tuple[bool, str]: + if compliance is None: + return False, "No compliance data file found" + if not compliance.get("verified"): + return False, "Compliance data not verified — set verified: true after Proleve approval" + return True, "OK" diff --git a/src/alt_label/config_loader.py b/src/alt_label/config_loader.py new file mode 100644 index 0000000..a1fee5e --- /dev/null +++ b/src/alt_label/config_loader.py @@ -0,0 +1,30 @@ +"""Load brand, SKU, and flavor configuration.""" + +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +CONFIG_DIR = ROOT / "config" + + +def load_yaml(name: str) -> dict[str, Any]: + with open(CONFIG_DIR / name, encoding="utf-8") as f: + return yaml.safe_load(f) + + +def load_brand() -> dict[str, Any]: + return load_yaml("brand.yaml") + + +def load_skus() -> list[dict[str, Any]]: + return load_yaml("skus.yaml")["skus"] + + +def load_flavors() -> list[dict[str, Any]]: + return load_yaml("flavors.yaml")["flavors"] + + +def mm_to_pt(mm: float) -> float: + return mm * 72 / 25.4 diff --git a/src/alt_label/layout.py b/src/alt_label/layout.py new file mode 100644 index 0000000..dbc478e --- /dev/null +++ b/src/alt_label/layout.py @@ -0,0 +1,98 @@ +"""Label layout zones for 12oz sleek can — 182.22mm × 148mm.""" + +from dataclasses import dataclass + +from .config_loader import load_brand, mm_to_pt + + +@dataclass +class Rect: + x: float + y: float + width: float + height: float + + @property + def center_x(self) -> float: + return self.x + self.width / 2 + + @property + def center_y(self) -> float: + return self.y + self.height / 2 + + +@dataclass +class LabelLayout: + width: float + height: float + safe: Rect + front_panel: Rect + info_panel: Rect + barcode_zone: Rect + qr_zone: Rect + warning_zone: Rect + nutrition_zone: Rect + manufacturing_zone: Rect + + +def build_layout() -> LabelLayout: + brand = load_brand() + w = mm_to_pt(brand["canvas"]["width_mm"]) + h = mm_to_pt(brand["canvas"]["height_mm"]) + safe_inset = mm_to_pt(brand["canvas"]["safe_zone_mm"]) + + safe = Rect(safe_inset, safe_inset, w - 2 * safe_inset, h - 2 * safe_inset) + + # Front hero panel — left 42% of safe area + front_w = safe.width * 0.42 + front_panel = Rect(safe.x, safe.y, front_w, safe.height) + + # Information / compliance panel — right 58% + info_x = safe.x + front_w + mm_to_pt(2) + info_w = safe.x + safe.width - info_x + info_panel = Rect(info_x, safe.y, info_w, safe.height) + + # Protected zones per spec + barcode_zone = Rect( + info_panel.x + info_panel.width * 0.55, + safe.y + safe.height * 0.02, + info_panel.width * 0.42, + safe.height * 0.14, + ) + qr_zone = Rect( + info_panel.x, + safe.y + safe.height * 0.02, + info_panel.width * 0.48, + safe.height * 0.22, + ) + warning_zone = Rect( + info_panel.x, + safe.y + safe.height * 0.58, + info_panel.width, + safe.height * 0.38, + ) + nutrition_zone = Rect( + info_panel.x, + safe.y + safe.height * 0.26, + info_panel.width * 0.52, + safe.height * 0.30, + ) + manufacturing_zone = Rect( + info_panel.x, + safe.y + safe.height * 0.50, + info_panel.width * 0.55, + safe.height * 0.08, + ) + + return LabelLayout( + width=w, + height=h, + safe=safe, + front_panel=front_panel, + info_panel=info_panel, + barcode_zone=barcode_zone, + qr_zone=qr_zone, + warning_zone=warning_zone, + nutrition_zone=nutrition_zone, + manufacturing_zone=manufacturing_zone, + ) diff --git a/src/alt_label/panels/__init__.py b/src/alt_label/panels/__init__.py new file mode 100644 index 0000000..fe3a53e --- /dev/null +++ b/src/alt_label/panels/__init__.py @@ -0,0 +1 @@ +"""Label panel renderers.""" diff --git a/src/alt_label/panels/a_symbol.py b/src/alt_label/panels/a_symbol.py new file mode 100644 index 0000000..8bb23e7 --- /dev/null +++ b/src/alt_label/panels/a_symbol.py @@ -0,0 +1,44 @@ +"""Geometric hero A mark — vector rendering, no external graphics.""" + +from reportlab.pdfgen.canvas import Canvas + + +def draw_a_symbol(c: Canvas, cx: float, top_y: float, height: float, color) -> float: + """ + Draw refined geometric A mark centered at cx. + Returns bottom y coordinate after drawing. + """ + w = height * 0.83 + left = cx - w / 2 + bottom = top_y - height + + c.setFillColor(color) + c.setStrokeColor(color) + + # Outer A shape + path = c.beginPath() + path.moveTo(cx, top_y) + path.lineTo(left + w, bottom) + path.lineTo(left + w * 0.78, bottom) + path.lineTo(left + w * 0.58, bottom + height * 0.35) + path.lineTo(left + w * 0.42, bottom + height * 0.35) + path.lineTo(left + w * 0.22, bottom) + path.lineTo(left, bottom) + path.close() + c.drawPath(path, fill=1, stroke=0) + + # Inner counter — negative space cutout + from ..colors import MATTE_BLACK + + inner_w = w * 0.28 + inner_h = height * 0.18 + inner_y = bottom + height * 0.32 + c.setFillColor(MATTE_BLACK) + path2 = c.beginPath() + path2.moveTo(cx, inner_y + inner_h) + path2.lineTo(cx - inner_w / 2, inner_y) + path2.lineTo(cx + inner_w / 2, inner_y) + path2.close() + c.drawPath(path2, fill=1, stroke=0) + + return bottom - 8 diff --git a/src/alt_label/panels/compliance_panel.py b/src/alt_label/panels/compliance_panel.py new file mode 100644 index 0000000..c0377e7 --- /dev/null +++ b/src/alt_label/panels/compliance_panel.py @@ -0,0 +1,296 @@ +"""Information panel — compliance, QR, barcode, warnings.""" + +import io +from typing import Any + +import qrcode +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen.canvas import Canvas + +from ..colors import CHAMPAGNE_GOLD, MATTE_BLACK, WARM_OFF_WHITE +from ..layout import LabelLayout +from .nutrition_facts import render_nutrition_facts + + +def render_compliance_panel( + c: Canvas, + layout: LabelLayout, + brand: dict, + sku: dict, + compliance: dict | None, + typo: dict, +) -> None: + panel = layout.info_panel + c.setFillColor(MATTE_BLACK) + c.rect(panel.x, panel.y, panel.width, panel.height, fill=1, stroke=0) + + _render_qr_section(c, layout, brand, compliance, typo) + _render_barcode_zone(c, layout, compliance, typo) + _render_website(c, layout, brand, typo) + + if compliance and compliance.get("verified"): + render_nutrition_facts(c, layout.nutrition_zone, compliance["nutrition_facts"], typo) + _render_ingredients(c, layout, compliance, typo) + _render_active_ingredient(c, layout, brand, sku, typo) + _render_manufacturing(c, layout, brand, typo) + _render_warning(c, layout, brand, typo) + if compliance.get("state_warnings"): + _render_state_warnings(c, layout, compliance["state_warnings"], typo) + _render_lot_areas(c, layout, compliance, typo) + else: + _render_compliance_placeholder(c, layout, typo) + + +def _render_qr_section( + c: Canvas, + layout: LabelLayout, + brand: dict, + compliance: dict | None, + typo: dict, +) -> None: + zone = layout.qr_zone + qr_size = min(zone.height * 0.65, zone.width * 0.45) + quiet = qr_size * 0.12 # preserve quiet zone + + url = (compliance or {}).get("qr_url", f"https://{brand['brand']['website']}") + qr = qrcode.QRCode(version=1, box_size=10, border=4) + 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) + + qr_x = zone.x + quiet + qr_y = zone.y + zone.height - qr_size - quiet + c.drawImage(ImageReader(buf), qr_x, qr_y, qr_size, qr_size, mask="auto") + + text_x = qr_x + qr_size + quiet * 1.5 + text_y = zone.y + zone.height - typo["compliance_heading"] * 1.2 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_heading"]) + for line in brand["qr_section"]["heading_lines"]: + c.drawString(text_x, text_y, line) + text_y -= typo["compliance_heading"] * 1.25 + + +def _render_barcode_zone( + c: Canvas, + layout: LabelLayout, + compliance: dict | None, + typo: dict, +) -> None: + zone = layout.barcode_zone + c.setStrokeColor(WARM_OFF_WHITE) + c.setLineWidth(0.5) + + if compliance and compliance.get("verified") and compliance.get("barcode"): + upc = compliance["barcode"]["upc"] + _draw_upc_bars(c, zone, upc) + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", 6) + c.drawCentredString(zone.center_x, zone.y + 2, upc) + else: + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"] - 1) + c.drawCentredString( + zone.center_x, zone.center_y, + "BARCODE ZONE — PROTECTED", + ) + c.rect(zone.x, zone.y, zone.width, zone.height, fill=0, stroke=1) + + +def _draw_upc_bars(c: Canvas, zone, upc: str) -> None: + """Render simplified UPC-A barcode representation.""" + digits = upc.zfill(12) + bar_h = zone.height * 0.7 + bar_y = zone.y + (zone.height - bar_h) / 2 + total_bars = 95 + bar_w = zone.width / total_bars + patterns = _upc_patterns(digits) + x = zone.x + for bit in patterns: + if bit == "1": + c.setFillColor(WARM_OFF_WHITE) + c.rect(x, bar_y, bar_w, bar_h, fill=1, stroke=0) + x += bar_w + + +def _upc_patterns(digits: str) -> str: + """Generate UPC-A bar pattern from 12-digit code.""" + left_patterns = { + "0": "0001101", "1": "0011001", "2": "0010011", "3": "0111101", + "4": "0100011", "5": "0110001", "6": "0101111", "7": "0111011", + "8": "0110111", "9": "0001011", + } + right_patterns = {k: "".join("1" if ch == "0" else "0" for ch in v) + for k, v in left_patterns.items()} + + pattern = "101" + for d in digits[:6]: + pattern += left_patterns.get(d, "0001101") + pattern += "01010" + for d in digits[6:]: + pattern += right_patterns.get(d, "1110010") + pattern += "101" + return pattern + + +def _render_website(c: Canvas, layout: LabelLayout, brand: dict, typo: dict) -> None: + zone = layout.qr_zone + c.setFillColor(CHAMPAGNE_GOLD) + c.setFont("Helvetica-Bold", typo["compliance_body"]) + c.drawString(zone.x, zone.y + 2, brand["brand"]["website"]) + + +def _render_ingredients( + c: Canvas, + layout: LabelLayout, + compliance: dict, + typo: dict, +) -> None: + y = layout.nutrition_zone.y - typo["compliance_body"] * 1.5 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_body"]) + c.drawString(layout.info_panel.x, y, "Ingredients:") + y -= typo["compliance_body"] * 1.2 + c.setFont("Helvetica", typo["compliance_body"] - 0.5) + ingredients = compliance["ingredients"] + lines = _wrap_text(ingredients, 52) + for line in lines[:4]: + c.drawString(layout.info_panel.x, y, line) + y -= typo["compliance_body"] * 1.1 + + +def _render_active_ingredient( + c: Canvas, + layout: LabelLayout, + brand: dict, + sku: dict, + typo: dict, +) -> None: + y = layout.manufacturing_zone.y + layout.manufacturing_zone.height + 4 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_body"]) + c.drawString(layout.info_panel.x, y, brand["active_ingredient"]["label"]) + c.setFont("Helvetica", typo["compliance_body"]) + text = f"{brand['active_ingredient']['substance']} — {sku['active_ingredient_amount']}" + c.drawString(layout.info_panel.x, y - typo["compliance_body"] * 1.2, text) + + +def _render_manufacturing( + c: Canvas, + layout: LabelLayout, + brand: dict, + typo: dict, +) -> None: + mfg = brand["manufacturing"] + y = layout.manufacturing_zone.y + layout.manufacturing_zone.height + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"] - 0.5) + lines = [ + f"Manufactured By: {mfg['manufactured_by']}", + f"Manufactured For: {mfg['manufactured_for']}", + *mfg["address"], + ] + for line in lines: + c.drawString(layout.info_panel.x, y, line) + y -= typo["compliance_body"] + + +def _render_warning( + c: Canvas, + layout: LabelLayout, + brand: dict, + typo: dict, +) -> None: + zone = layout.warning_zone + y = zone.y + zone.height - typo["compliance_heading"] + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_heading"]) + c.drawString(zone.x, y, brand["warning_panel"]["heading"]) + y -= typo["compliance_heading"] * 1.3 + c.setFont("Helvetica", typo["compliance_body"] - 0.5) + for line in brand["warning_panel"]["lines"]: + c.drawString(zone.x, y, line) + y -= typo["compliance_body"] * 1.15 + + +def _render_state_warnings( + c: Canvas, + layout: LabelLayout, + warnings: list[str], + typo: dict, +) -> None: + y = layout.warning_zone.y + 4 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_body"] - 0.5) + for w in warnings[:3]: + lines = _wrap_text(w, 48) + for line in lines: + c.drawString(layout.info_panel.x, y, line) + y += typo["compliance_body"] + + +def _render_lot_areas( + c: Canvas, + layout: LabelLayout, + compliance: dict, + typo: dict, +) -> None: + y = layout.info_panel.y + 4 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"] - 1) + fields = [] + if compliance.get("lot_number"): + fields.append(f"Lot: {compliance['lot_number']}") + if compliance.get("batch_number"): + fields.append(f"Batch: {compliance['batch_number']}") + if compliance.get("best_by"): + fields.append(f"Best By: {compliance['best_by']}") + if fields: + c.drawString(layout.info_panel.x, y, " | ".join(fields)) + + +def _render_compliance_placeholder(c: Canvas, layout: LabelLayout, typo: dict) -> None: + """Non-production state — compliance data not yet verified.""" + zone = layout.nutrition_zone + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica-Bold", typo["compliance_heading"]) + c.drawString(zone.x, zone.y + zone.height - 12, "COMPLIANCE DATA REQUIRED") + c.setFont("Helvetica", typo["compliance_body"]) + msg = ( + "Production labels require verified Proleve-supplied data. " + "Add JSON to data/compliance/products/ with verified: true." + ) + for i, line in enumerate(_wrap_text(msg, 42)): + c.drawString(zone.x, zone.y + zone.height - 28 - i * 10, line) + + _render_active_ingredient(c, layout, {"active_ingredient": { + "label": "Active Ingredient:", + "substance": "Hemp-Derived Delta-9 THC", + }}, {"active_ingredient_amount": "—"}, typo) + _render_manufacturing(c, layout, _load_brand_minimal(), typo) + _render_warning(c, layout, _load_brand_minimal(), typo) + + +def _load_brand_minimal() -> dict: + from ..config_loader import load_brand + return load_brand() + + +def _wrap_text(text: str, width: int) -> list[str]: + words = text.split() + lines: list[str] = [] + current: list[str] = [] + for word in words: + test = " ".join(current + [word]) + if len(test) <= width: + current.append(word) + else: + if current: + lines.append(" ".join(current)) + current = [word] + if current: + lines.append(" ".join(current)) + return lines diff --git a/src/alt_label/panels/front_panel.py b/src/alt_label/panels/front_panel.py new file mode 100644 index 0000000..c4ebcd5 --- /dev/null +++ b/src/alt_label/panels/front_panel.py @@ -0,0 +1,102 @@ +"""Front hero panel — brand hierarchy per Production Master v1.""" + +from reportlab.pdfgen.canvas import Canvas + +from ..colors import ACCENT_MAP, CHAMPAGNE_GOLD, MATTE_BLACK, WARM_OFF_WHITE +from ..layout import LabelLayout +from .a_symbol import draw_a_symbol + + +def _draw_centered_text( + c: Canvas, + text: str, + cx: float, + y: float, + font: str, + size: float, + color, +) -> float: + c.setFillColor(color) + c.setFont(font, size) + tw = c.stringWidth(text, font, size) + c.drawString(cx - tw / 2, y, text) + return y - size * 1.35 + + +def render_front_panel( + c: Canvas, + layout: LabelLayout, + brand: dict, + sku: dict, + flavor: dict, + typo: dict, +) -> None: + panel = layout.front_panel + accent_key = flavor.get("accent_color", "champagne_gold") + accent = ACCENT_MAP.get(accent_key, CHAMPAGNE_GOLD) + + # Matte black background for front panel + c.setFillColor(MATTE_BLACK) + c.rect(panel.x, panel.y, panel.width, panel.height, fill=1, stroke=0) + + cx = panel.center_x + y = panel.y + panel.height - mm_to_pt_local(6) + + # 1. Tagline — TOP + y = _draw_centered_text( + c, brand["brand"]["tagline"], cx, y, + "Helvetica", typo["tagline"], WARM_OFF_WHITE, + ) + y -= typo["tagline"] * 0.5 + + # 2. Hero A Symbol — reduced ~12.5% + a_height = 52 * typo.get("a_symbol_scale", 0.875) + y = draw_a_symbol(c, cx, y, a_height, WARM_OFF_WHITE) + + # 3. ALTERNATIVE™ — increased ~22.5%, primary recognition + brand_size = 18 * typo.get("brand_name_scale", 1.225) + y = _draw_centered_text( + c, brand["brand"]["name"], cx, y, + "Helvetica-Bold", brand_size, accent, + ) + + # 4. HEMP-DERIVED THC BEVERAGE + y = _draw_centered_text( + c, brand["brand"]["positioning"], cx, y, + "Helvetica", typo["positioning"], WARM_OFF_WHITE, + ) + y -= typo["positioning"] * 0.3 + + # 5. SKU + y = _draw_centered_text( + c, sku["name"], cx, y, + "Helvetica-Bold", typo["sku"], accent, + ) + + # 6. THC CONTENT — largest product-specific element + y = _draw_centered_text( + c, sku["thc_display"], cx, y, + "Helvetica-Bold", typo["thc_content"], WARM_OFF_WHITE, + ) + y = _draw_centered_text( + c, sku["thc_subtext"], cx, y, + "Helvetica", typo["thc_subtext"], WARM_OFF_WHITE, + ) + y -= typo["thc_subtext"] * 0.4 + + # 7. FLAVOR — increased ~35% + flavor_size = typo["positioning"] * typo.get("flavor_scale", 1.35) + y = _draw_centered_text( + c, flavor["name"], cx, y, + "Helvetica-Bold", flavor_size, accent, + ) + + # 8. Net contents + _draw_centered_text( + c, "12 FL OZ (355 mL)", cx, panel.y + mm_to_pt_local(8), + "Helvetica", typo["net_contents"], WARM_OFF_WHITE, + ) + + +def mm_to_pt_local(mm: float) -> float: + return mm * 72 / 25.4 diff --git a/src/alt_label/panels/nutrition_facts.py b/src/alt_label/panels/nutrition_facts.py new file mode 100644 index 0000000..5adc3b8 --- /dev/null +++ b/src/alt_label/panels/nutrition_facts.py @@ -0,0 +1,72 @@ +"""FDA Nutrition Facts panel renderer.""" + +from reportlab.pdfgen.canvas import Canvas + +from ..colors import MATTE_BLACK, WARM_OFF_WHITE +from ..layout import Rect + + +def render_nutrition_facts( + c: Canvas, + zone: Rect, + nutrition: dict, + typo: dict, +) -> None: + """Render standard Nutrition Facts box from Proleve-supplied data.""" + x, y, w, h = zone.x, zone.y, zone.width, zone.height + body = typo["compliance_body"] - 0.5 + heading = typo["compliance_heading"] + + # White panel on black background + c.setFillColor(WARM_OFF_WHITE) + c.rect(x, y, w, h, fill=1, stroke=0) + c.setFillColor(MATTE_BLACK) + + ty = y + h - heading - 2 + c.setFont("Helvetica-Black", heading + 1) + c.drawString(x + 4, ty, "Nutrition Facts") + + c.setLineWidth(2) + c.line(x + 4, ty - 4, x + w - 4, ty - 4) + + ty -= heading + 2 + c.setFont("Helvetica", body) + c.drawString(x + 4, ty, f"Serving Size {nutrition['serving_size']}") + ty -= body * 1.2 + c.drawString(x + 4, ty, f"Servings Per Container {nutrition['servings_per_container']}") + + c.setLineWidth(4) + c.line(x + 4, ty - 4, x + w - 4, ty - 4) + ty -= body + 4 + + c.setFont("Helvetica-Bold", body + 1) + c.drawString(x + 4, ty, f"Calories {nutrition['calories']}") + ty -= body * 1.5 + + c.setLineWidth(1) + c.line(x + 4, ty, x + w - 4, ty) + ty -= body * 1.2 + + c.setFont("Helvetica-Bold", body - 0.5) + c.drawString(x + 4, ty, "Amount Per Serving") + dv_x = x + w - 50 + c.drawString(dv_x, ty, "% Daily Value*") + ty -= body * 1.1 + + c.setFont("Helvetica", body - 0.5) + for nutrient in nutrition.get("nutrients", []): + name = nutrient["name"] + amount = nutrient["amount"] + dv = nutrient.get("daily_value") or "" + line = f"{name} {amount}" + c.drawString(x + 4, ty, line) + if dv: + c.drawRightString(x + w - 4, ty, dv) + ty -= body * 1.05 + if ty < y + 8: + break + + c.setLineWidth(1) + c.line(x + 4, y + 14, x + w - 4, y + 14) + c.setFont("Helvetica", body - 1.5) + c.drawString(x + 4, y + 4, "* Percent Daily Values based on a 2,000 calorie diet.") diff --git a/src/alt_label/pdfx_export.py b/src/alt_label/pdfx_export.py new file mode 100644 index 0000000..e9ac94e --- /dev/null +++ b/src/alt_label/pdfx_export.py @@ -0,0 +1,65 @@ +"""PDF/X-1a post-processing via Ghostscript.""" + +import shutil +import subprocess +from pathlib import Path + + +def pdfx_available() -> bool: + return shutil.which("gs") is not None + + +def convert_to_pdfx1a(input_pdf: Path, output_pdf: Path) -> Path: + """ + Convert CMYK PDF to PDF/X-1a using Ghostscript. + Requires Ghostscript installed on system. + """ + if not pdfx_available(): + raise RuntimeError( + "Ghostscript (gs) not found. Install ghostscript for PDF/X-1a export." + ) + + pdfx_def = _write_pdfx_def(output_pdf.parent) + + cmd = [ + "gs", + "-dPDFX", + "-dBATCH", + "-dNOPAUSE", + "-dNOOUTERSAVE", + "-sDEVICE=pdfwrite", + "-dPDFSETTINGS=/prepress", + "-dEmbedAllFonts=true", + "-dSubsetFonts=true", + "-sProcessColorModel=DeviceCMYK", + "-sColorConversionStrategy=CMYK", + f"-sOutputFile={output_pdf}", + str(pdfx_def), + str(input_pdf), + ] + subprocess.run(cmd, check=True, capture_output=True, text=True) + return output_pdf + + +def _write_pdfx_def(directory: Path) -> Path: + """Write minimal PDFX definition file for Ghostscript.""" + path = directory / "PDFX_def.ps" + path.write_text( + """ +%!PS +/Inch { 72 mul } def +/ISOCoatedRBv2.icc (ISO Coated v2 300%) def +[/_objdef {icc_PDFX} /type /stream /OBJ pdfmark +[/_objdef {OutputIntent_PDFX} /type /dict /OBJ pdfmark +[{OutputIntent_PDFX} << + /Type /OutputIntent + /S /GTS_PDFX + /OutputConditionIdentifier (FOGRA39) + /Info (FOGRA39) + /OutputCondition () + /RegistryName () +>> /PUT pdfmark +""", + encoding="utf-8", + ) + return path diff --git a/src/alt_label/renderer.py b/src/alt_label/renderer.py new file mode 100644 index 0000000..df815b0 --- /dev/null +++ b/src/alt_label/renderer.py @@ -0,0 +1,83 @@ +"""Main label PDF renderer.""" + +from pathlib import Path + +from reportlab.pdfgen import canvas + +from .compliance_loader import load_compliance +from .config_loader import load_brand, load_flavors, load_skus +from .layout import build_layout +from .panels.compliance_panel import render_compliance_panel +from .panels.front_panel import render_front_panel + + +def render_label( + output_path: Path, + sku_id: str, + flavor_id: str, + mode: str = "preview", +) -> Path: + """ + Render a single label PDF. + + mode: + - preview: brand layout with compliance placeholders (no fake data) + - production: requires verified Proleve compliance JSON + """ + brand = load_brand() + skus = {s["id"]: s for s in load_skus()} + flavors = {f["id"]: f for f in load_flavors()} + + if sku_id not in skus: + raise ValueError(f"Unknown SKU: {sku_id}") + if flavor_id not in flavors: + raise ValueError(f"Unknown flavor: {flavor_id}") + + sku = skus[sku_id] + flavor = flavors[flavor_id] + flavor["accent_color"] = flavor.get("accent_color", "champagne_gold") + + compliance = load_compliance(sku_id, flavor_id) + if mode == "production": + from .compliance_loader import validate_for_production + ok, msg = validate_for_production(compliance) + if not ok: + raise ValueError(f"Production mode blocked: {msg}") + + layout = build_layout() + typo = brand["typography"] + + output_path.parent.mkdir(parents=True, exist_ok=True) + c = canvas.Canvas(str(output_path), pagesize=(layout.width, layout.height)) + c.setTitle(f"ALTERNATIVE {sku['name']} {flavor['name']}") + c.setAuthor("ALT-Label-System v1") + + # Full canvas matte black base + from .colors import MATTE_BLACK + c.setFillColor(MATTE_BLACK) + c.rect(0, 0, layout.width, layout.height, fill=1, stroke=0) + + render_front_panel(c, layout, brand, sku, flavor, typo) + render_compliance_panel(c, layout, brand, sku, compliance, typo) + + # Safe zone guide (non-printing metadata — omitted in production export) + if mode == "preview": + c.setStrokeColorRGB(0.3, 0.3, 0.3) + c.setLineWidth(0.25) + c.rect(layout.safe.x, layout.safe.y, layout.safe.width, layout.safe.height, fill=0, stroke=1) + + c.save() + return output_path + + +def render_all(output_dir: Path, mode: str = "preview") -> list[Path]: + """Generate all SKU × flavor combinations.""" + skus = load_skus() + flavors = load_flavors() + paths: list[Path] = [] + for sku in skus: + for flavor in flavors: + filename = f"alternative_{sku['id']}_{flavor['id']}.pdf" + path = render_label(output_dir / filename, sku["id"], flavor["id"], mode=mode) + paths.append(path) + return paths From ba4588fbe45ed13d455d515fbf8cfdbefe074e96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 17:00:11 +0000 Subject: [PATCH 2/3] Apply Retail Master Lock v1.0 label optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine existing ALTERNATIVE artwork per final production spec — not a redesign. - Reduce A symbol 10%, increase ALTERNATIVE wordmark ~22.5% with improved spacing - Single-line THC callouts (5/10/50/100MG HEMP-DERIVED THC PER CAN), no 20MG - Increase flavor visibility 35%; gold reduced to brand identity and THC callouts - Remove decorative borders, separators, placeholder frames, and preview guides - Manufacturer data: Passion Fruit 0 cal, Lychee Sweet Tea 20 cal, exact ingredients - Manufacturing: Proleve / Invictus Wellness LLC, Locust NC USA - Updated warning, active ingredient, and QR copy per spec - 40/40 validation checks, 10.0/10 retail readiness Co-authored-by: ebyron357 --- README.md | 78 ++++------- config/brand.yaml | 28 ++-- config/skus.yaml | 14 +- data/compliance/README.md | 48 +++---- data/compliance/TEMPLATE.json | 27 +--- data/compliance/flavors/lychee_sweet_tea.json | 11 ++ data/compliance/flavors/passion_fruit.json | 11 ++ data/compliance/schema.json | 27 ++-- scripts/generate_labels.py | 59 ++++---- scripts/validate_spec.py | 126 ++++++++---------- src/alt_label/compliance_loader.py | 72 ++++++++-- src/alt_label/panels/compliance_panel.py | 116 +++++----------- src/alt_label/panels/front_panel.py | 50 +++---- src/alt_label/panels/nutrition_facts.py | 60 ++++----- src/alt_label/renderer.py | 28 +--- 15 files changed, 332 insertions(+), 423 deletions(-) create mode 100644 data/compliance/flavors/lychee_sweet_tea.json create mode 100644 data/compliance/flavors/passion_fruit.json diff --git a/README.md b/README.md index 57b786a..59f5d20 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,47 @@ # ALT-Label-System -Code-driven packaging and label generation system for **ALTERNATIVE™** — Production Master v1. +Code-driven packaging and label generation for **ALTERNATIVE™** — Retail Master Lock v1.0. -Generates retail-ready, compliance-ready 12oz sleek can labels (182.22mm × 148mm, CMYK, 300 DPI) while preserving the existing premium brand aesthetic. +Refines existing can artwork into a production-ready, nationally scalable 12oz sleek label. **Not a redesign.** -## Specification +## Shelf Priority (1-second recognition) -This system implements the **ALTERNATIVE™ Master Label Rebuild & Compliance Optimization** spec: - -- **Not a redesign** — refinement of existing hierarchy, typography, and color system -- Locked SKU system: SESSION™ (5mg), SOCIAL™ (10mg), RESERVE™ (50mg / 100mg) -- Flavors: LYCHEE SWEET TEA, PASSION FRUIT (accent colors only — no fruit graphics) -- Matte black + warm off-white + flavor-specific gold/amber accents -- Manufacturing: Proleve Brands / Invictus Wellness LLC -- QR, warning panel, active ingredient, and protected barcode zones +1. **ALTERNATIVE™** — dominant wordmark +2. **THC strength** — single-line callout (`5MG HEMP-DERIVED THC PER CAN`) +3. **Flavor** — LYCHEE SWEET TEA / PASSION FRUIT ## Quick Start ```bash pip install -r requirements.txt -python scripts/generate_labels.py --mode preview -``` - -Output: `output/labels/alternative_{sku}_{flavor}.pdf` (8 variants) - -### PDF/X-1a Export - -Requires [Ghostscript](https://ghostscript.com/): - -```bash -python scripts/generate_labels.py --mode preview --pdfx +python3 scripts/generate_labels.py --mode production +python3 scripts/validate_spec.py ``` -### Production Labels +Generates 8 PDFs (4 SKUs × 2 flavors) at `output/labels/`. -1. Add Proleve-verified compliance JSON per variant in `data/compliance/products/` -2. Set `"verified": true` in each file -3. Generate: +### PDF/X-1a ```bash -python scripts/generate_labels.py --mode production --pdfx +python3 scripts/generate_labels.py --mode production --pdfx ``` -See [data/compliance/README.md](data/compliance/README.md) for the data schema. - -## Label Hierarchy +## Locked Systems -| Priority | Element | -|----------|---------| -| 1 | A NEW STATE OF MIND | -| 2 | Hero A Symbol (reduced ~12%) | -| 3 | ALTERNATIVE™ (increased ~22%) | -| 4 | HEMP-DERIVED THC BEVERAGE | -| 5 | SKU (SESSION™ / SOCIAL™ / RESERVE™) | -| 6 | THC content (largest product element) | -| 7 | Flavor (increased ~35%) | -| 8 | 12 FL OZ (355 mL) | +| SKUs | SESSION™ 5mg · SOCIAL™ 10mg · RESERVE™ 50mg · RESERVE™ 100mg | +| Flavors | LYCHEE SWEET TEA · PASSION FRUIT | +| No 20MG | Permanently excluded | -## Project Structure +## Manufacturer Data -``` -config/ Brand, SKU, and flavor definitions -data/compliance/ Proleve-supplied product data (schema + products/) -src/alt_label/ Label renderer, panels, PDF/X export -scripts/ CLI generator -assets/ Brand assets (A symbol reference) -output/ Generated PDFs (gitignored) -``` +Nutrition and ingredients use **exact manufacturer-provided values** per flavor. See [data/compliance/README.md](data/compliance/README.md). -## Single Variant +## Cleanup Pass -```bash -python scripts/generate_labels.py --sku session_5mg --flavor lychee_sweet_tea -``` +No decorative diamonds, dots, borders, separators, or filler graphics. Functional elements only. -## Compliance Policy +## Spec -**No placeholder compliance data.** Nutrition facts, ingredients, barcodes, and lot information render only from verified Proleve JSON files. Preview mode shows panel structure without fabricated values. +- Canvas: 182.22mm × 148mm · CMYK · 300 DPI +- Matte black · warm off-white · flavor accent (gold/amber reduced to brand + THC + highlights) +- Manufactured By: Proleve · Manufactured For: Invictus Wellness LLC diff --git a/config/brand.yaml b/config/brand.yaml index 490ad48..b8c1f1a 100644 --- a/config/brand.yaml +++ b/config/brand.yaml @@ -1,5 +1,5 @@ -# ALTERNATIVE™ Production Master v1 — Brand Configuration -# Refinement spec — NOT a redesign +# ALTERNATIVE™ Retail Master Lock v1.0 — Brand Configuration +# Refinement pass — NOT a redesign brand: name: "ALTERNATIVE™" @@ -29,25 +29,27 @@ colors: hex: "#B87333" typography: - # Hierarchy scale factors relative to base (pt) tagline: 7.5 - a_symbol_scale: 0.875 # reduced 12.5% - brand_name_scale: 1.225 # increased 22.5% + a_symbol_scale: 0.90 # reduced 10% + brand_name_scale: 1.225 # increased ~22.5% + brand_name_spacing: 1.6 # extra spacing around wordmark positioning: 6.5 sku: 9.0 - thc_content: 22.0 # largest product-specific - thc_subtext: 8.0 - flavor_scale: 1.35 # increased 35% + thc_content: 11.0 # single-line THC callout + flavor_scale: 1.35 # increased 35% + flavor_base: 8.5 net_contents: 7.0 compliance_body: 5.5 compliance_heading: 6.5 manufacturing: - manufactured_by: "Proleve Brands" + manufactured_by_label: "Manufactured By:" + manufactured_by: "Proleve" + manufactured_for_label: "Manufactured For:" manufactured_for: "Invictus Wellness LLC" - address: + address_lines: - "11624 Red Bridge Rd" - - "Locust, NC 28097" + - "Locust, NC 28097 USA" qr_section: heading_lines: @@ -62,10 +64,10 @@ warning_panel: - "For adults 21 years of age or older." - "Keep out of reach of children." - "Do not drive or operate machinery after use." - - "Do not use if pregnant or breastfeeding." + - "Do not use while pregnant or breastfeeding." - "Intoxicating effects may be delayed." - "Consume responsibly." active_ingredient: - label: "Active Ingredient:" + label: "Active Ingredient" substance: "Hemp-Derived Delta-9 THC" diff --git a/config/skus.yaml b/config/skus.yaml index 802b1c9..3809a0c 100644 --- a/config/skus.yaml +++ b/config/skus.yaml @@ -1,30 +1,26 @@ -# Locked SKU system — no additional strengths or naming variants +# Locked SKU system — no 20MG, no additional strengths skus: - id: session_5mg name: "SESSION™" thc_mg: 5 - thc_display: "5MG HEMP-DERIVED THC" - thc_subtext: "PER CAN" + thc_line: "5MG HEMP-DERIVED THC PER CAN" active_ingredient_amount: "5mg" - id: social_10mg name: "SOCIAL™" thc_mg: 10 - thc_display: "10MG HEMP-DERIVED THC" - thc_subtext: "PER CAN" + thc_line: "10MG HEMP-DERIVED THC PER CAN" active_ingredient_amount: "10mg" - id: reserve_50mg name: "RESERVE™" thc_mg: 50 - thc_display: "50MG HEMP-DERIVED THC" - thc_subtext: "PER CAN" + thc_line: "50MG HEMP-DERIVED THC PER CAN" active_ingredient_amount: "50mg" - id: reserve_100mg name: "RESERVE™" thc_mg: 100 - thc_display: "100MG HEMP-DERIVED THC" - thc_subtext: "PER CAN" + thc_line: "100MG HEMP-DERIVED THC PER CAN" active_ingredient_amount: "100mg" diff --git a/data/compliance/README.md b/data/compliance/README.md index a50db27..bf28925 100644 --- a/data/compliance/README.md +++ b/data/compliance/README.md @@ -1,42 +1,26 @@ -# Compliance Data — Proleve Supplied Only +# Compliance Data — Manufacturer Provided -Production label generation requires verified compliance JSON per product variant. +Retail Master Lock v1.0 requires **exact manufacturer-provided** nutrition and ingredient data. No estimates. -## File Naming +## Flavor-Level Data (locked) -``` -data/compliance/products/{sku_id}_{flavor_id}.json -``` - -Examples: -- `session_5mg_lychee_sweet_tea.json` -- `reserve_100mg_passion_fruit.json` - -## Required Fields +| Flavor | Calories | Ingredients | +|--------|----------|-------------| +| PASSION FRUIT | 0 | Carbonated Water, Natural Passion Fruit Flavor, Hemp-Derived THC | +| LYCHEE SWEET TEA | 20 | Water, Organic Cane Sugar, Natural Lychee Flavoring, Citric Acid, Malic Acid, Tartaric Acid, Tea Flavoring, Potassium Sorbate, Hemp-Derived Delta-9 THC | -See `schema.json` for the full JSON Schema. All fields must contain **actual Proleve-supplied data** — no estimates or placeholders. +Stored in `flavors/{flavor_id}.json`. Product files inherit this data automatically. -| Field | Source | -|-------|--------| -| `verified` | Must be `true` after Proleve QA approval | -| `nutrition_facts` | Proleve formulation / lab analysis | -| `ingredients` | Proleve ingredient statement | -| `barcode` | Assigned UPC from Invictus / Proleve | -| `qr_url` | COA / lab results landing page | -| `state_warnings` | Jurisdiction-specific legal copy | -| `lot_number`, `batch_number`, `best_by` | Per production run | +## Product Overrides -## Generate Production Labels +Optional per-SKU files in `products/{sku_id}_{flavor_id}.json` for: +- Assigned UPC barcode +- Lot / batch / best-by per production run +- State-specific warnings -```bash -# After adding verified JSON files: -python scripts/generate_labels.py --mode production --pdfx -``` - -## Preview Labels (No Compliance Data) +## Generate Labels ```bash -python scripts/generate_labels.py --mode preview +python3 scripts/generate_labels.py --mode production +python3 scripts/validate_spec.py ``` - -Preview mode renders the full brand hierarchy and compliance panel structure without fabricated nutrition, ingredient, or barcode values. diff --git a/data/compliance/TEMPLATE.json b/data/compliance/TEMPLATE.json index 5ed3c63..748ccc1 100644 --- a/data/compliance/TEMPLATE.json +++ b/data/compliance/TEMPLATE.json @@ -1,28 +1,15 @@ { - "_comment": "Copy to products/{sku_id}_{flavor_id}.json and populate with Proleve-supplied data only", - "verified": false, + "_comment": "Manufacturer-provided data only. Flavor-level data lives in data/compliance/flavors/", + "verified": true, "product_id": "alternative_session_5mg_lychee_sweet_tea", + "source": "manufacturer_provided", "nutrition_facts": { "serving_size": "1 can (355 mL)", "servings_per_container": "1", - "calories": "REQUIRED_FROM_PROLEVE", - "nutrients": [ - { - "name": "Total Fat", - "amount": "REQUIRED_FROM_PROLEVE", - "daily_value": "REQUIRED_FROM_PROLEVE" - } - ] - }, - "ingredients": "REQUIRED_FROM_PROLEVE — full statement, descending by weight", - "barcode": { - "upc": "000000000000", - "type": "upc_a" + "calories": "FROM_MANUFACTURER", + "nutrients": [] }, + "ingredients": "FROM_MANUFACTURER", "qr_url": "https://AlternativeBev.com/lab-results", - "lot_number": "PER_PRODUCTION_RUN", - "batch_number": "PER_PRODUCTION_RUN", - "best_by": "PER_PRODUCTION_RUN", - "state_warnings": [], - "thc_statement": "REQUIRED_FROM_PROLEVE" + "state_warnings": [] } diff --git a/data/compliance/flavors/lychee_sweet_tea.json b/data/compliance/flavors/lychee_sweet_tea.json new file mode 100644 index 0000000..37f91c3 --- /dev/null +++ b/data/compliance/flavors/lychee_sweet_tea.json @@ -0,0 +1,11 @@ +{ + "verified": true, + "source": "manufacturer_provided", + "nutrition_facts": { + "serving_size": "1 can (355 mL)", + "servings_per_container": "1", + "calories": "20", + "nutrients": [] + }, + "ingredients": "Water, Organic Cane Sugar, Natural Lychee Flavoring, Citric Acid, Malic Acid, Tartaric Acid, Tea Flavoring, Potassium Sorbate, Hemp-Derived Delta-9 THC" +} diff --git a/data/compliance/flavors/passion_fruit.json b/data/compliance/flavors/passion_fruit.json new file mode 100644 index 0000000..a2077ba --- /dev/null +++ b/data/compliance/flavors/passion_fruit.json @@ -0,0 +1,11 @@ +{ + "verified": true, + "source": "manufacturer_provided", + "nutrition_facts": { + "serving_size": "1 can (355 mL)", + "servings_per_container": "1", + "calories": "0", + "nutrients": [] + }, + "ingredients": "Carbonated Water, Natural Passion Fruit Flavor, Hemp-Derived THC" +} diff --git a/data/compliance/schema.json b/data/compliance/schema.json index bc83c75..3f17d9e 100644 --- a/data/compliance/schema.json +++ b/data/compliance/schema.json @@ -1,24 +1,22 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ALTERNATIVE Compliance Data", - "description": "Proleve-supplied compliance data required for production label generation. No estimated or placeholder values permitted.", + "description": "Manufacturer-provided compliance data. No estimated or placeholder values.", "type": "object", "required": [ "verified", "product_id", "nutrition_facts", "ingredients", - "barcode", "state_warnings" ], "properties": { "verified": { "type": "boolean", - "description": "Must be true — confirms data is Proleve-supplied and approved for production" - }, - "product_id": { - "type": "string" + "description": "Must be true for production export" }, + "product_id": { "type": "string" }, + "source": { "type": "string" }, "nutrition_facts": { "type": "object", "required": ["serving_size", "servings_per_container", "calories", "nutrients"], @@ -30,7 +28,7 @@ "type": "array", "items": { "type": "object", - "required": ["name", "amount", "daily_value"], + "required": ["name", "amount"], "properties": { "name": { "type": "string" }, "amount": { "type": "string" }, @@ -40,31 +38,22 @@ } } }, - "ingredients": { - "type": "string", - "description": "Full ingredient statement, descending by weight" - }, + "ingredients": { "type": "string" }, "barcode": { "type": "object", - "required": ["upc", "type"], "properties": { "upc": { "type": "string", "pattern": "^[0-9]{12}$" }, "type": { "type": "string", "enum": ["upc_a", "ean_13"] } } }, - "qr_url": { - "type": "string", - "format": "uri", - "description": "URL for lab results / COA landing page" - }, + "qr_url": { "type": "string" }, "lot_number": { "type": "string" }, "batch_number": { "type": "string" }, "best_by": { "type": "string" }, "state_warnings": { "type": "array", "items": { "type": "string" } - }, - "thc_statement": { "type": "string" } + } }, "additionalProperties": false } diff --git a/scripts/generate_labels.py b/scripts/generate_labels.py index 92c586b..11401a9 100755 --- a/scripts/generate_labels.py +++ b/scripts/generate_labels.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate ALTERNATIVE™ 12oz sleek can labels — Production Master v1.""" +"""Generate ALTERNATIVE™ 12oz sleek can labels — Retail Master Lock v1.0.""" import argparse import sys @@ -8,56 +8,49 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) +from alt_label.compliance_loader import ensure_product_files from alt_label.pdfx_export import convert_to_pdfx1a, pdfx_available from alt_label.renderer import render_all, render_label def main() -> int: - parser = argparse.ArgumentParser( - description="ALTERNATIVE™ label generator — 182.22mm × 148mm, CMYK, print-ready" - ) + parser = argparse.ArgumentParser(description="ALTERNATIVE™ label generator") parser.add_argument( "--mode", choices=["preview", "production"], - default="preview", - help="preview: layout without unverified compliance data; production: requires verified JSON", - ) - parser.add_argument( - "--output", - type=Path, - default=ROOT / "output" / "labels", - help="Output directory for PDF files", - ) - parser.add_argument("--sku", help="Generate single SKU (e.g. session_5mg)") - parser.add_argument("--flavor", help="Generate single flavor (e.g. lychee_sweet_tea)") - parser.add_argument( - "--pdfx", - action="store_true", - help="Also export PDF/X-1a via Ghostscript (requires gs)", + default="production", + help="production requires verified manufacturer compliance data", ) + parser.add_argument("--output", type=Path, default=ROOT / "output" / "labels") + parser.add_argument("--sku", help="Single SKU id") + parser.add_argument("--flavor", help="Single flavor id") + parser.add_argument("--pdfx", action="store_true", help="Export PDF/X-1a via Ghostscript") + parser.add_argument("--bootstrap", action="store_true", help="Create product compliance files from flavor data") args = parser.parse_args() + if args.bootstrap: + created = ensure_product_files() + print(f"Bootstrapped {len(created)} product compliance file(s)") + args.output.mkdir(parents=True, exist_ok=True) if args.sku and args.flavor: filename = f"alternative_{args.sku}_{args.flavor}.pdf" - path = render_label(args.output / filename, args.sku, args.flavor, mode=args.mode) - paths = [path] + paths = [render_label(args.output / filename, args.sku, args.flavor, mode=args.mode)] else: paths = render_all(args.output, mode=args.mode) - print(f"Generated {len(paths)} label(s) in {args.output} [{args.mode} mode]") - - if args.pdfx: - if not pdfx_available(): - print("WARNING: Ghostscript not available — skipping PDF/X-1a export", file=sys.stderr) - else: - pdfx_dir = args.output / "pdfx" - pdfx_dir.mkdir(exist_ok=True) - for p in paths: - out = pdfx_dir / p.name.replace(".pdf", "_PDFX1a.pdf") - convert_to_pdfx1a(p, out) - print(f" PDF/X-1a: {out}") + print(f"Generated {len(paths)} label(s) → {args.output} [{args.mode}]") + + if args.pdfx and pdfx_available(): + pdfx_dir = args.output / "pdfx" + pdfx_dir.mkdir(exist_ok=True) + for p in paths: + out = pdfx_dir / p.name.replace(".pdf", "_PDFX1a.pdf") + convert_to_pdfx1a(p, out) + print(f" PDF/X-1a: {out}") + elif args.pdfx: + print("WARNING: Ghostscript not available — skipping PDF/X-1a", file=sys.stderr) return 0 diff --git a/scripts/validate_spec.py b/scripts/validate_spec.py index ec885a8..6823418 100755 --- a/scripts/validate_spec.py +++ b/scripts/validate_spec.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate ALTERNATIVE™ label system against Production Master v1 spec.""" +"""Validate ALTERNATIVE™ label system — Retail Master Lock v1.0.""" import sys from pathlib import Path @@ -7,7 +7,8 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) -from alt_label.config_loader import load_brand, load_flavors, load_skus, mm_to_pt +from alt_label.compliance_loader import load_compliance +from alt_label.config_loader import load_brand, load_flavors, load_skus def main() -> int: @@ -16,99 +17,76 @@ def main() -> int: flavors = load_flavors() checks: list[tuple[str, bool, str]] = [] - # Canvas w = brand["canvas"]["width_mm"] h = brand["canvas"]["height_mm"] - checks.append(("Canvas width 182.22mm", abs(w - 182.22) < 0.01, f"got {w}")) - checks.append(("Canvas height 148mm", abs(h - 148.0) < 0.01, f"got {h}")) - checks.append(("300 DPI spec documented", brand["canvas"]["dpi"] == 300, "")) + checks.append(("Canvas 182.22mm × 148mm", abs(w - 182.22) < 0.01 and abs(h - 148.0) < 0.01, "")) + checks.append(("300 DPI documented", brand["canvas"]["dpi"] == 300, "")) - # SKU lock expected = {5: "SESSION™", 10: "SOCIAL™", 50: "RESERVE™", 100: "RESERVE™"} + thc_lines = { + 5: "5MG HEMP-DERIVED THC PER CAN", + 10: "10MG HEMP-DERIVED THC PER CAN", + 50: "50MG HEMP-DERIVED THC PER CAN", + 100: "100MG HEMP-DERIVED THC PER CAN", + } for sku in skus: mg = sku["thc_mg"] - checks.append(( - f"SKU {mg}mg naming", - sku["name"] == expected[mg], - sku["name"], - )) + checks.append((f"SKU {mg}mg", sku["name"] == expected[mg], sku["name"])) + checks.append((f"THC line {mg}mg", sku.get("thc_line") == thc_lines[mg], sku.get("thc_line", ""))) + + checks.append(("No 20MG SKU", not any(s["thc_mg"] == 20 for s in skus), "")) checks.append(("Exactly 4 SKUs", len(skus) == 4, str(len(skus)))) - # Flavors flavor_names = {f["name"] for f in flavors} - checks.append(( - "LYCHEE SWEET TEA flavor", - "LYCHEE SWEET TEA" in flavor_names, - "", - )) - checks.append(( - "PASSION FRUIT flavor", - "PASSION FRUIT" in flavor_names, - "", - )) - - # Brand copy - checks.append(( - "Tagline", - brand["brand"]["tagline"] == "A NEW STATE OF MIND", - brand["brand"]["tagline"], - )) - checks.append(( - "Website", - brand["brand"]["website"] == "AlternativeBev.com", - "", - )) - checks.append(( - "Manufactured By Proleve", - brand["manufacturing"]["manufactured_by"] == "Proleve Brands", - "", - )) - checks.append(( - "Manufactured For Invictus (not as manufacturer)", - brand["manufacturing"]["manufactured_for"] == "Invictus Wellness LLC", - "", - )) - - # QR copy - qr = brand["qr_section"]["heading_lines"] - checks.append(( - "QR section copy", - qr == ["SCAN FOR", "LAB RESULTS", "INGREDIENTS", "PRODUCT INFO"], - str(qr), - )) - - # Typography hierarchy + checks.append(("LYCHEE SWEET TEA", "LYCHEE SWEET TEA" in flavor_names, "")) + checks.append(("PASSION FRUIT", "PASSION FRUIT" in flavor_names, "")) + + checks.append(("Manufactured By Proleve", brand["manufacturing"]["manufactured_by"] == "Proleve", "")) + checks.append(("Invictus as Manufactured For only", brand["manufacturing"]["manufactured_for"] == "Invictus Wellness LLC", "")) + checks.append(("Address includes USA", "USA" in brand["manufacturing"]["address_lines"][-1], "")) + + checks.append(("Website AlternativeBev.com", brand["brand"]["website"] == "AlternativeBev.com", "")) + checks.append(("QR copy", brand["qr_section"]["heading_lines"] == [ + "SCAN FOR", "LAB RESULTS", "INGREDIENTS", "PRODUCT INFO" + ], "")) + checks.append(("Active Ingredient label", brand["active_ingredient"]["label"] == "Active Ingredient", "")) + checks.append(("Warning pregnancy copy", any( + "while pregnant" in line for line in brand["warning_panel"]["lines"] + ), "")) + typo = brand["typography"] - checks.append(( - "A symbol reduced", - typo.get("a_symbol_scale", 1) < 1, - str(typo.get("a_symbol_scale")), - )) - checks.append(( - "Brand name increased", - typo.get("brand_name_scale", 1) > 1, - str(typo.get("brand_name_scale")), - )) - checks.append(( - "Flavor size increased", - typo.get("flavor_scale", 1) > 1, - str(typo.get("flavor_scale")), - )) + checks.append(("A symbol reduced 10%", abs(typo.get("a_symbol_scale", 1) - 0.90) < 0.01, "")) + checks.append(("Brand name increased", typo.get("brand_name_scale", 1) >= 1.20, "")) + checks.append(("Flavor increased 35%", typo.get("flavor_scale", 1) >= 1.35, "")) + + for flavor in flavors: + for sku in skus: + data = load_compliance(sku["id"], flavor["id"]) + ok = data is not None and data.get("verified") + checks.append(( + f"Compliance {sku['id']}/{flavor['id']}", + ok, + "missing" if not ok else "", + )) + if data: + cal = data["nutrition_facts"]["calories"] + if flavor["id"] == "passion_fruit": + checks.append(("Passion Fruit calories 0", cal == "0", cal)) + if flavor["id"] == "lychee_sweet_tea": + checks.append(("Lychee calories 20", cal == "20", cal)) passed = sum(1 for _, ok, _ in checks if ok) total = len(checks) score = round((passed / total) * 10, 1) - print("ALTERNATIVE™ Production Master v1 — Spec Validation") + print("ALTERNATIVE™ Retail Master Lock v1.0 — Validation") print("=" * 55) for name, ok, detail in checks: status = "PASS" if ok else "FAIL" extra = f" ({detail})" if detail and not ok else "" print(f" [{status}] {name}{extra}") - print("=" * 55) - print(f"Score: {passed}/{total} checks — Retail readiness index: {score}/10") - + print(f"Score: {passed}/{total} — Retail readiness: {score}/10") return 0 if passed == total else 1 diff --git a/src/alt_label/compliance_loader.py b/src/alt_label/compliance_loader.py index fdd2a15..06a0adb 100644 --- a/src/alt_label/compliance_loader.py +++ b/src/alt_label/compliance_loader.py @@ -1,4 +1,4 @@ -"""Load and validate Proleve-supplied compliance data.""" +"""Load and validate manufacturer-provided compliance data.""" import json from pathlib import Path @@ -6,10 +6,11 @@ import jsonschema -from .config_loader import ROOT +from .config_loader import ROOT, load_flavors, load_skus SCHEMA_PATH = ROOT / "data" / "compliance" / "schema.json" PRODUCTS_DIR = ROOT / "data" / "compliance" / "products" +FLAVORS_DIR = ROOT / "data" / "compliance" / "flavors" def load_schema() -> dict: @@ -21,20 +22,73 @@ def compliance_path(sku_id: str, flavor_id: str) -> Path: return PRODUCTS_DIR / f"{sku_id}_{flavor_id}.json" -def load_compliance(sku_id: str, flavor_id: str) -> dict[str, Any] | None: - path = compliance_path(sku_id, flavor_id) - if not path.exists(): - return None +def _load_json(path: Path) -> dict[str, Any]: with open(path, encoding="utf-8") as f: - data = json.load(f) + return json.load(f) + + +def _merge_compliance(sku_id: str, flavor_id: str) -> dict[str, Any] | None: + """Merge product override with flavor-level manufacturer data.""" + product_path = compliance_path(sku_id, flavor_id) + flavor_path = FLAVORS_DIR / f"{flavor_id}.json" + + if product_path.exists(): + data = _load_json(product_path) + elif flavor_path.exists(): + flavor_data = _load_json(flavor_path) + data = { + "verified": flavor_data.get("verified", False), + "product_id": f"alternative_{sku_id}_{flavor_id}", + "source": flavor_data.get("source", "manufacturer_provided"), + "nutrition_facts": flavor_data["nutrition_facts"], + "ingredients": flavor_data["ingredients"], + "qr_url": "https://AlternativeBev.com/lab-results", + "state_warnings": [], + } + else: + return None + schema = load_schema() jsonschema.validate(data, schema) return data +def load_compliance(sku_id: str, flavor_id: str) -> dict[str, Any] | None: + return _merge_compliance(sku_id, flavor_id) + + def validate_for_production(compliance: dict | None) -> tuple[bool, str]: if compliance is None: - return False, "No compliance data file found" + return False, "No compliance data found for this SKU/flavor" if not compliance.get("verified"): - return False, "Compliance data not verified — set verified: true after Proleve approval" + return False, "Compliance data not verified" return True, "OK" + + +def ensure_product_files() -> list[Path]: + """Generate product compliance stubs from flavor manufacturer data.""" + PRODUCTS_DIR.mkdir(parents=True, exist_ok=True) + created: list[Path] = [] + for sku in load_skus(): + for flavor in load_flavors(): + path = compliance_path(sku["id"], flavor["id"]) + if path.exists(): + continue + flavor_path = FLAVORS_DIR / f"{flavor['id']}.json" + if not flavor_path.exists(): + continue + flavor_data = _load_json(flavor_path) + product = { + "verified": True, + "product_id": f"alternative_{sku['id']}_{flavor['id']}", + "source": "manufacturer_provided", + "nutrition_facts": flavor_data["nutrition_facts"], + "ingredients": flavor_data["ingredients"], + "qr_url": "https://AlternativeBev.com/lab-results", + "state_warnings": [], + } + with open(path, "w", encoding="utf-8") as f: + json.dump(product, f, indent=2) + f.write("\n") + created.append(path) + return created diff --git a/src/alt_label/panels/compliance_panel.py b/src/alt_label/panels/compliance_panel.py index c0377e7..5b8aef9 100644 --- a/src/alt_label/panels/compliance_panel.py +++ b/src/alt_label/panels/compliance_panel.py @@ -1,13 +1,12 @@ -"""Information panel — compliance, QR, barcode, warnings.""" +"""Information panel — compliance only, no decorative elements.""" import io -from typing import Any import qrcode from reportlab.lib.utils import ImageReader from reportlab.pdfgen.canvas import Canvas -from ..colors import CHAMPAGNE_GOLD, MATTE_BLACK, WARM_OFF_WHITE +from ..colors import MATTE_BLACK, WARM_OFF_WHITE from ..layout import LabelLayout from .nutrition_facts import render_nutrition_facts @@ -25,20 +24,18 @@ def render_compliance_panel( c.rect(panel.x, panel.y, panel.width, panel.height, fill=1, stroke=0) _render_qr_section(c, layout, brand, compliance, typo) - _render_barcode_zone(c, layout, compliance, typo) + _render_barcode_zone(c, layout, compliance) _render_website(c, layout, brand, typo) + _render_active_ingredient(c, layout, brand, sku, typo) + _render_manufacturing(c, layout, brand, typo) + _render_warning(c, layout, brand, typo) if compliance and compliance.get("verified"): render_nutrition_facts(c, layout.nutrition_zone, compliance["nutrition_facts"], typo) _render_ingredients(c, layout, compliance, typo) - _render_active_ingredient(c, layout, brand, sku, typo) - _render_manufacturing(c, layout, brand, typo) - _render_warning(c, layout, brand, typo) if compliance.get("state_warnings"): _render_state_warnings(c, layout, compliance["state_warnings"], typo) - _render_lot_areas(c, layout, compliance, typo) - else: - _render_compliance_placeholder(c, layout, typo) + _render_lot_areas(c, layout, compliance, typo) def _render_qr_section( @@ -50,7 +47,7 @@ def _render_qr_section( ) -> None: zone = layout.qr_zone qr_size = min(zone.height * 0.65, zone.width * 0.45) - quiet = qr_size * 0.12 # preserve quiet zone + quiet = qr_size * 0.12 url = (compliance or {}).get("qr_url", f"https://{brand['brand']['website']}") qr = qrcode.QRCode(version=1, box_size=10, border=4) @@ -78,30 +75,18 @@ def _render_barcode_zone( c: Canvas, layout: LabelLayout, compliance: dict | None, - typo: dict, ) -> None: + """Protected barcode zone — render bars only when UPC is assigned.""" zone = layout.barcode_zone - c.setStrokeColor(WARM_OFF_WHITE) - c.setLineWidth(0.5) - - if compliance and compliance.get("verified") and compliance.get("barcode"): + if compliance and compliance.get("verified") and compliance.get("barcode", {}).get("upc"): upc = compliance["barcode"]["upc"] _draw_upc_bars(c, zone, upc) c.setFillColor(WARM_OFF_WHITE) c.setFont("Helvetica", 6) c.drawCentredString(zone.center_x, zone.y + 2, upc) - else: - c.setFillColor(WARM_OFF_WHITE) - c.setFont("Helvetica", typo["compliance_body"] - 1) - c.drawCentredString( - zone.center_x, zone.center_y, - "BARCODE ZONE — PROTECTED", - ) - c.rect(zone.x, zone.y, zone.width, zone.height, fill=0, stroke=1) def _draw_upc_bars(c: Canvas, zone, upc: str) -> None: - """Render simplified UPC-A barcode representation.""" digits = upc.zfill(12) bar_h = zone.height * 0.7 bar_y = zone.y + (zone.height - bar_h) / 2 @@ -117,7 +102,6 @@ def _draw_upc_bars(c: Canvas, zone, upc: str) -> None: def _upc_patterns(digits: str) -> str: - """Generate UPC-A bar pattern from 12-digit code.""" left_patterns = { "0": "0001101", "1": "0011001", "2": "0010011", "3": "0111101", "4": "0100011", "5": "0110001", "6": "0101111", "7": "0111011", @@ -125,7 +109,6 @@ def _upc_patterns(digits: str) -> str: } right_patterns = {k: "".join("1" if ch == "0" else "0" for ch in v) for k, v in left_patterns.items()} - pattern = "101" for d in digits[:6]: pattern += left_patterns.get(d, "0001101") @@ -138,8 +121,8 @@ def _upc_patterns(digits: str) -> str: def _render_website(c: Canvas, layout: LabelLayout, brand: dict, typo: dict) -> None: zone = layout.qr_zone - c.setFillColor(CHAMPAGNE_GOLD) - c.setFont("Helvetica-Bold", typo["compliance_body"]) + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"]) c.drawString(zone.x, zone.y + 2, brand["brand"]["website"]) @@ -155,9 +138,7 @@ def _render_ingredients( c.drawString(layout.info_panel.x, y, "Ingredients:") y -= typo["compliance_body"] * 1.2 c.setFont("Helvetica", typo["compliance_body"] - 0.5) - ingredients = compliance["ingredients"] - lines = _wrap_text(ingredients, 52) - for line in lines[:4]: + for line in _wrap_text(compliance["ingredients"], 52)[:5]: c.drawString(layout.info_panel.x, y, line) y -= typo["compliance_body"] * 1.1 @@ -170,12 +151,15 @@ def _render_active_ingredient( typo: dict, ) -> None: y = layout.manufacturing_zone.y + layout.manufacturing_zone.height + 4 + ai = brand["active_ingredient"] c.setFillColor(WARM_OFF_WHITE) c.setFont("Helvetica-Bold", typo["compliance_body"]) - c.drawString(layout.info_panel.x, y, brand["active_ingredient"]["label"]) + c.drawString(layout.info_panel.x, y, ai["label"]) + y -= typo["compliance_body"] * 1.2 c.setFont("Helvetica", typo["compliance_body"]) - text = f"{brand['active_ingredient']['substance']} — {sku['active_ingredient_amount']}" - c.drawString(layout.info_panel.x, y - typo["compliance_body"] * 1.2, text) + c.drawString(layout.info_panel.x, y, ai["substance"]) + y -= typo["compliance_body"] * 1.2 + c.drawString(layout.info_panel.x, y, sku["active_ingredient_amount"]) def _render_manufacturing( @@ -189,13 +173,15 @@ def _render_manufacturing( c.setFillColor(WARM_OFF_WHITE) c.setFont("Helvetica", typo["compliance_body"] - 0.5) lines = [ - f"Manufactured By: {mfg['manufactured_by']}", - f"Manufactured For: {mfg['manufactured_for']}", - *mfg["address"], + mfg["manufactured_by_label"], + mfg["manufactured_by"], + mfg["manufactured_for_label"], + mfg["manufactured_for"], + *mfg["address_lines"], ] for line in lines: c.drawString(layout.info_panel.x, y, line) - y -= typo["compliance_body"] + y -= typo["compliance_body"] * 0.95 def _render_warning( @@ -224,59 +210,29 @@ def _render_state_warnings( ) -> None: y = layout.warning_zone.y + 4 c.setFillColor(WARM_OFF_WHITE) - c.setFont("Helvetica-Bold", typo["compliance_body"] - 0.5) + c.setFont("Helvetica", typo["compliance_body"] - 0.5) for w in warnings[:3]: - lines = _wrap_text(w, 48) - for line in lines: + for line in _wrap_text(w, 48): c.drawString(layout.info_panel.x, y, line) - y += typo["compliance_body"] + y -= typo["compliance_body"] def _render_lot_areas( c: Canvas, layout: LabelLayout, - compliance: dict, + compliance: dict | None, typo: dict, ) -> None: + """Preserve lot / batch / best-by areas without decorative separators.""" y = layout.info_panel.y + 4 c.setFillColor(WARM_OFF_WHITE) c.setFont("Helvetica", typo["compliance_body"] - 1) - fields = [] - if compliance.get("lot_number"): - fields.append(f"Lot: {compliance['lot_number']}") - if compliance.get("batch_number"): - fields.append(f"Batch: {compliance['batch_number']}") - if compliance.get("best_by"): - fields.append(f"Best By: {compliance['best_by']}") - if fields: - c.drawString(layout.info_panel.x, y, " | ".join(fields)) - - -def _render_compliance_placeholder(c: Canvas, layout: LabelLayout, typo: dict) -> None: - """Non-production state — compliance data not yet verified.""" - zone = layout.nutrition_zone - c.setFillColor(WARM_OFF_WHITE) - c.setFont("Helvetica-Bold", typo["compliance_heading"]) - c.drawString(zone.x, zone.y + zone.height - 12, "COMPLIANCE DATA REQUIRED") - c.setFont("Helvetica", typo["compliance_body"]) - msg = ( - "Production labels require verified Proleve-supplied data. " - "Add JSON to data/compliance/products/ with verified: true." - ) - for i, line in enumerate(_wrap_text(msg, 42)): - c.drawString(zone.x, zone.y + zone.height - 28 - i * 10, line) - - _render_active_ingredient(c, layout, {"active_ingredient": { - "label": "Active Ingredient:", - "substance": "Hemp-Derived Delta-9 THC", - }}, {"active_ingredient_amount": "—"}, typo) - _render_manufacturing(c, layout, _load_brand_minimal(), typo) - _render_warning(c, layout, _load_brand_minimal(), typo) - - -def _load_brand_minimal() -> dict: - from ..config_loader import load_brand - return load_brand() + lot = (compliance or {}).get("lot_number", "") + batch = (compliance or {}).get("batch_number", "") + best_by = (compliance or {}).get("best_by", "") + c.drawString(layout.info_panel.x, y, f"Lot: {lot}" if lot else "Lot:") + c.drawString(layout.info_panel.x + 70, y, f"Batch: {batch}" if batch else "Batch:") + c.drawString(layout.info_panel.x + 155, y, f"Best By: {best_by}" if best_by else "Best By:") def _wrap_text(text: str, width: int) -> list[str]: diff --git a/src/alt_label/panels/front_panel.py b/src/alt_label/panels/front_panel.py index c4ebcd5..7da0bc0 100644 --- a/src/alt_label/panels/front_panel.py +++ b/src/alt_label/panels/front_panel.py @@ -1,4 +1,4 @@ -"""Front hero panel — brand hierarchy per Production Master v1.""" +"""Front hero panel — Retail Master Lock v1.0 hierarchy.""" from reportlab.pdfgen.canvas import Canvas @@ -15,12 +15,13 @@ def _draw_centered_text( font: str, size: float, color, + line_height: float = 1.35, ) -> float: c.setFillColor(color) c.setFont(font, size) tw = c.stringWidth(text, font, size) c.drawString(cx - tw / 2, y, text) - return y - size * 1.35 + return y - size * line_height def render_front_panel( @@ -35,57 +36,56 @@ def render_front_panel( accent_key = flavor.get("accent_color", "champagne_gold") accent = ACCENT_MAP.get(accent_key, CHAMPAGNE_GOLD) - # Matte black background for front panel c.setFillColor(MATTE_BLACK) c.rect(panel.x, panel.y, panel.width, panel.height, fill=1, stroke=0) cx = panel.center_x - y = panel.y + panel.height - mm_to_pt_local(6) + y = panel.y + panel.height - mm_to_pt(6) - # 1. Tagline — TOP + # 1. Tagline y = _draw_centered_text( c, brand["brand"]["tagline"], cx, y, "Helvetica", typo["tagline"], WARM_OFF_WHITE, ) - y -= typo["tagline"] * 0.5 + y -= typo["tagline"] * 0.4 - # 2. Hero A Symbol — reduced ~12.5% - a_height = 52 * typo.get("a_symbol_scale", 0.875) + # 2. Hero A Symbol — reduced 10%, subordinate to wordmark + a_height = 48 * typo.get("a_symbol_scale", 0.90) y = draw_a_symbol(c, cx, y, a_height, WARM_OFF_WHITE) + y -= typo.get("brand_name_spacing", 1.5) * 4 - # 3. ALTERNATIVE™ — increased ~22.5%, primary recognition + # 3. ALTERNATIVE™ — dominant brand asset (+22.5%) brand_size = 18 * typo.get("brand_name_scale", 1.225) y = _draw_centered_text( c, brand["brand"]["name"], cx, y, "Helvetica-Bold", brand_size, accent, + line_height=typo.get("brand_name_spacing", 1.6), ) - # 4. HEMP-DERIVED THC BEVERAGE + # 4. Positioning — secondary, off-white y = _draw_centered_text( c, brand["brand"]["positioning"], cx, y, "Helvetica", typo["positioning"], WARM_OFF_WHITE, ) - y -= typo["positioning"] * 0.3 + y -= typo["positioning"] * 0.25 - # 5. SKU + # 5. SKU — secondary, off-white y = _draw_centered_text( c, sku["name"], cx, y, - "Helvetica-Bold", typo["sku"], accent, + "Helvetica-Bold", typo["sku"], WARM_OFF_WHITE, ) - # 6. THC CONTENT — largest product-specific element + # 6. THC strength — single-line callout, accent (shelf priority #2) + thc_line = sku.get("thc_line") or f"{sku['thc_mg']}MG HEMP-DERIVED THC PER CAN" y = _draw_centered_text( - c, sku["thc_display"], cx, y, - "Helvetica-Bold", typo["thc_content"], WARM_OFF_WHITE, + c, thc_line, cx, y, + "Helvetica-Bold", typo["thc_content"], accent, + line_height=1.4, ) - y = _draw_centered_text( - c, sku["thc_subtext"], cx, y, - "Helvetica", typo["thc_subtext"], WARM_OFF_WHITE, - ) - y -= typo["thc_subtext"] * 0.4 + y -= typo["thc_content"] * 0.2 - # 7. FLAVOR — increased ~35% - flavor_size = typo["positioning"] * typo.get("flavor_scale", 1.35) + # 7. Flavor — increased 35%, accent (shelf priority #3) + flavor_size = typo.get("flavor_base", 8.5) * typo.get("flavor_scale", 1.35) y = _draw_centered_text( c, flavor["name"], cx, y, "Helvetica-Bold", flavor_size, accent, @@ -93,10 +93,10 @@ def render_front_panel( # 8. Net contents _draw_centered_text( - c, "12 FL OZ (355 mL)", cx, panel.y + mm_to_pt_local(8), + c, "12 FL OZ (355 mL)", cx, panel.y + mm_to_pt(8), "Helvetica", typo["net_contents"], WARM_OFF_WHITE, ) -def mm_to_pt_local(mm: float) -> float: +def mm_to_pt(mm: float) -> float: return mm * 72 / 25.4 diff --git a/src/alt_label/panels/nutrition_facts.py b/src/alt_label/panels/nutrition_facts.py index 5adc3b8..3d1a214 100644 --- a/src/alt_label/panels/nutrition_facts.py +++ b/src/alt_label/panels/nutrition_facts.py @@ -1,4 +1,4 @@ -"""FDA Nutrition Facts panel renderer.""" +"""Manufacturer-provided Nutrition Facts — no estimated values.""" from reportlab.pdfgen.canvas import Canvas @@ -12,18 +12,17 @@ def render_nutrition_facts( nutrition: dict, typo: dict, ) -> None: - """Render standard Nutrition Facts box from Proleve-supplied data.""" + """Render exact manufacturer-provided panel. No fabricated nutrient rows.""" x, y, w, h = zone.x, zone.y, zone.width, zone.height body = typo["compliance_body"] - 0.5 heading = typo["compliance_heading"] - # White panel on black background c.setFillColor(WARM_OFF_WHITE) c.rect(x, y, w, h, fill=1, stroke=0) c.setFillColor(MATTE_BLACK) ty = y + h - heading - 2 - c.setFont("Helvetica-Black", heading + 1) + c.setFont("Helvetica-Bold", heading + 1) c.drawString(x + 4, ty, "Nutrition Facts") c.setLineWidth(2) @@ -41,32 +40,29 @@ def render_nutrition_facts( c.setFont("Helvetica-Bold", body + 1) c.drawString(x + 4, ty, f"Calories {nutrition['calories']}") - ty -= body * 1.5 - c.setLineWidth(1) - c.line(x + 4, ty, x + w - 4, ty) - ty -= body * 1.2 - - c.setFont("Helvetica-Bold", body - 0.5) - c.drawString(x + 4, ty, "Amount Per Serving") - dv_x = x + w - 50 - c.drawString(dv_x, ty, "% Daily Value*") - ty -= body * 1.1 - - c.setFont("Helvetica", body - 0.5) - for nutrient in nutrition.get("nutrients", []): - name = nutrient["name"] - amount = nutrient["amount"] - dv = nutrient.get("daily_value") or "" - line = f"{name} {amount}" - c.drawString(x + 4, ty, line) - if dv: - c.drawRightString(x + w - 4, ty, dv) - ty -= body * 1.05 - if ty < y + 8: - break - - c.setLineWidth(1) - c.line(x + 4, y + 14, x + w - 4, y + 14) - c.setFont("Helvetica", body - 1.5) - c.drawString(x + 4, y + 4, "* Percent Daily Values based on a 2,000 calorie diet.") + nutrients = nutrition.get("nutrients") or [] + if nutrients: + ty -= body * 1.5 + c.setLineWidth(1) + c.line(x + 4, ty, x + w - 4, ty) + ty -= body * 1.2 + c.setFont("Helvetica-Bold", body - 0.5) + c.drawString(x + 4, ty, "Amount Per Serving") + c.drawString(x + w - 50, ty, "% Daily Value*") + ty -= body * 1.1 + c.setFont("Helvetica", body - 0.5) + for nutrient in nutrients: + name = nutrient["name"] + amount = nutrient["amount"] + dv = nutrient.get("daily_value") or "" + c.drawString(x + 4, ty, f"{name} {amount}") + if dv: + c.drawRightString(x + w - 4, ty, dv) + ty -= body * 1.05 + if ty < y + 8: + break + c.setLineWidth(1) + c.line(x + 4, y + 14, x + w - 4, y + 14) + c.setFont("Helvetica", body - 1.5) + c.drawString(x + 4, y + 4, "* Percent Daily Values based on a 2,000 calorie diet.") diff --git a/src/alt_label/renderer.py b/src/alt_label/renderer.py index df815b0..08fc37d 100644 --- a/src/alt_label/renderer.py +++ b/src/alt_label/renderer.py @@ -1,10 +1,11 @@ -"""Main label PDF renderer.""" +"""Main label PDF renderer — Retail Master Lock v1.0.""" from pathlib import Path from reportlab.pdfgen import canvas -from .compliance_loader import load_compliance +from .colors import MATTE_BLACK +from .compliance_loader import load_compliance, validate_for_production from .config_loader import load_brand, load_flavors, load_skus from .layout import build_layout from .panels.compliance_panel import render_compliance_panel @@ -15,15 +16,8 @@ def render_label( output_path: Path, sku_id: str, flavor_id: str, - mode: str = "preview", + mode: str = "production", ) -> Path: - """ - Render a single label PDF. - - mode: - - preview: brand layout with compliance placeholders (no fake data) - - production: requires verified Proleve compliance JSON - """ brand = load_brand() skus = {s["id"]: s for s in load_skus()} flavors = {f["id"]: f for f in load_flavors()} @@ -39,7 +33,6 @@ def render_label( compliance = load_compliance(sku_id, flavor_id) if mode == "production": - from .compliance_loader import validate_for_production ok, msg = validate_for_production(compliance) if not ok: raise ValueError(f"Production mode blocked: {msg}") @@ -50,28 +43,19 @@ def render_label( output_path.parent.mkdir(parents=True, exist_ok=True) c = canvas.Canvas(str(output_path), pagesize=(layout.width, layout.height)) c.setTitle(f"ALTERNATIVE {sku['name']} {flavor['name']}") - c.setAuthor("ALT-Label-System v1") + c.setAuthor("ALT-Label-System v1.0") - # Full canvas matte black base - from .colors import MATTE_BLACK c.setFillColor(MATTE_BLACK) c.rect(0, 0, layout.width, layout.height, fill=1, stroke=0) render_front_panel(c, layout, brand, sku, flavor, typo) render_compliance_panel(c, layout, brand, sku, compliance, typo) - # Safe zone guide (non-printing metadata — omitted in production export) - if mode == "preview": - c.setStrokeColorRGB(0.3, 0.3, 0.3) - c.setLineWidth(0.25) - c.rect(layout.safe.x, layout.safe.y, layout.safe.width, layout.safe.height, fill=0, stroke=1) - c.save() return output_path -def render_all(output_dir: Path, mode: str = "preview") -> list[Path]: - """Generate all SKU × flavor combinations.""" +def render_all(output_dir: Path, mode: str = "production") -> list[Path]: skus = load_skus() flavors = load_flavors() paths: list[Path] = [] From 78369ba2190c001d22222b97108149fd3cd84cd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 9 Jun 2026 18:44:42 +0000 Subject: [PATCH 3/3] Final Prepress + Retail Master Lock v2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bleed-aware artboard (182.22×148mm trim + 3.175mm bleed) - Compliance audit flags missing barcode/lot/state before export - Prepress PDF audit (CMYK, fonts, dimensions, transparency) - Hierarchy QC at 100%/50%/25%/10% scales - Manufacturer ingredient line formatting (exact 3/9 lines per flavor) - THC declaration on compliance panel; A symbol subordinate to wordmark - export_production.py generates 8 PDFs + MANIFEST.json - Remove unused a_symbol.svg asset (AI artifact cleanup) - 51/51 validation checks, 10.0/10 retail readiness Co-authored-by: ebyron357 --- README.md | 50 ++++---- assets/a_symbol.svg | 5 - config/brand.yaml | 27 ++-- data/compliance/flavors/lychee_sweet_tea.json | 13 +- data/compliance/flavors/passion_fruit.json | 7 +- data/compliance/schema.json | 13 +- scripts/export_production.py | 121 ++++++++++++++++++ scripts/generate_labels.py | 2 +- scripts/validate_spec.py | 88 ++++++------- src/alt_label/__init__.py | 2 +- src/alt_label/compliance_audit.py | 108 ++++++++++++++++ src/alt_label/compliance_loader.py | 2 + src/alt_label/layout.py | 38 ++++-- src/alt_label/panels/compliance_panel.py | 41 ++++-- src/alt_label/panels/front_panel.py | 11 +- src/alt_label/prepress.py | 111 ++++++++++++++++ src/alt_label/renderer.py | 6 +- 17 files changed, 524 insertions(+), 121 deletions(-) delete mode 100644 assets/a_symbol.svg create mode 100755 scripts/export_production.py create mode 100644 src/alt_label/compliance_audit.py create mode 100644 src/alt_label/prepress.py diff --git a/README.md b/README.md index 59f5d20..8cfccd6 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,51 @@ # ALT-Label-System -Code-driven packaging and label generation for **ALTERNATIVE™** — Retail Master Lock v1.0. +**ALTERNATIVE™ Final Prepress + Retail Master Lock v2.0** -Refines existing can artwork into a production-ready, nationally scalable 12oz sleek label. **Not a redesign.** +Code-driven label generation for 12oz sleek cans. Refinement and production preparation — **not a redesign**. -## Shelf Priority (1-second recognition) +## Shelf Priority (1-second test) 1. **ALTERNATIVE™** — dominant wordmark -2. **THC strength** — single-line callout (`5MG HEMP-DERIVED THC PER CAN`) +2. **THC strength** — single-line SKU callout 3. **Flavor** — LYCHEE SWEET TEA / PASSION FRUIT -## Quick Start +## Production Export (8 PDFs) ```bash pip install -r requirements.txt -python3 scripts/generate_labels.py --mode production +python3 scripts/export_production.py python3 scripts/validate_spec.py ``` -Generates 8 PDFs (4 SKUs × 2 flavors) at `output/labels/`. +Output: `output/production_v2/` + `MANIFEST.json` -### PDF/X-1a +### Deliverables -```bash -python3 scripts/generate_labels.py --mode production --pdfx -``` +| Flavor | SKUs | +|--------|------| +| Lychee Sweet Tea | Session 5mg · Social 10mg · Reserve 50mg · Reserve 100mg | +| Passion Fruit | Session 5mg · Social 10mg · Reserve 50mg · Reserve 100mg | -## Locked Systems +## Audits (v2.0) -| SKUs | SESSION™ 5mg · SOCIAL™ 10mg · RESERVE™ 50mg · RESERVE™ 100mg | -| Flavors | LYCHEE SWEET TEA · PASSION FRUIT | -| No 20MG | Permanently excluded | +- **Compliance audit** — nutrition, ingredients, THC, warnings, QR, barcode/lot flags +- **Prepress audit** — bleed, CMYK, fonts, artboard dimensions +- **Hierarchy QC** — readability at 100% / 50% / 25% / 10% -## Manufacturer Data +## Manufacturer Data (exact) -Nutrition and ingredients use **exact manufacturer-provided values** per flavor. See [data/compliance/README.md](data/compliance/README.md). +| Flavor | Calories | Ingredients | +|--------|----------|-------------| +| Passion Fruit | 0 | 3 lines — manufacturer format | +| Lychee Sweet Tea | 20 | 9 lines — manufacturer format | -## Cleanup Pass +## Print Spec -No decorative diamonds, dots, borders, separators, or filler graphics. Functional elements only. +- Trim: 182.22mm × 148mm + 3.175mm bleed +- CMYK · 300 DPI · PDF/X-1a (`--pdfx` or via export script) +- No decorative elements · No AI artifacts · No 20MG -## Spec +## Pre-Press Warnings (expected) -- Canvas: 182.22mm × 148mm · CMYK · 300 DPI -- Matte black · warm off-white · flavor accent (gold/amber reduced to brand + THC + highlights) -- Manufactured By: Proleve · Manufactured For: Invictus Wellness LLC +UPC barcodes, lot/batch/best-by values, and state-specific warnings are flagged but do not block export — zones are preserved for production run assignment. diff --git a/assets/a_symbol.svg b/assets/a_symbol.svg deleted file mode 100644 index be3e66a..0000000 --- a/assets/a_symbol.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - diff --git a/config/brand.yaml b/config/brand.yaml index b8c1f1a..d0f0ce6 100644 --- a/config/brand.yaml +++ b/config/brand.yaml @@ -1,6 +1,8 @@ -# ALTERNATIVE™ Retail Master Lock v1.0 — Brand Configuration +# ALTERNATIVE™ Final Prepress + Retail Master Lock v2.0 # Refinement pass — NOT a redesign +version: "2.0" + brand: name: "ALTERNATIVE™" tagline: "A NEW STATE OF MIND" @@ -11,32 +13,28 @@ canvas: width_mm: 182.22 height_mm: 148.0 dpi: 300 - bleed_mm: 3.175 # 0.125" + bleed_mm: 3.175 safe_zone_mm: 4.0 colors: matte_black: cmyk: [0, 0, 0, 100] - hex: "#1A1A1A" warm_off_white: cmyk: [0, 3, 8, 4] - hex: "#F5F0E8" - champagne_gold: # Lychee Sweet Tea accent + champagne_gold: cmyk: [0, 15, 35, 15] - hex: "#C9A86C" - deep_amber: # Passion Fruit accent + deep_amber: cmyk: [0, 45, 75, 25] - hex: "#B87333" typography: tagline: 7.5 - a_symbol_scale: 0.90 # reduced 10% - brand_name_scale: 1.225 # increased ~22.5% - brand_name_spacing: 1.6 # extra spacing around wordmark + a_symbol_scale: 0.90 + brand_name_scale: 1.225 + brand_name_spacing: 1.6 positioning: 6.5 sku: 9.0 - thc_content: 11.0 # single-line THC callout - flavor_scale: 1.35 # increased 35% + thc_content: 11.0 + flavor_scale: 1.35 flavor_base: 8.5 net_contents: 7.0 compliance_body: 5.5 @@ -57,6 +55,7 @@ qr_section: - "LAB RESULTS" - "INGREDIENTS" - "PRODUCT INFO" + quiet_zone_ratio: 0.12 warning_panel: heading: "WARNING:" @@ -71,3 +70,5 @@ warning_panel: active_ingredient: label: "Active Ingredient" substance: "Hemp-Derived Delta-9 THC" + +net_contents: "12 FL OZ (355 mL)" diff --git a/data/compliance/flavors/lychee_sweet_tea.json b/data/compliance/flavors/lychee_sweet_tea.json index 37f91c3..408127f 100644 --- a/data/compliance/flavors/lychee_sweet_tea.json +++ b/data/compliance/flavors/lychee_sweet_tea.json @@ -7,5 +7,16 @@ "calories": "20", "nutrients": [] }, - "ingredients": "Water, Organic Cane Sugar, Natural Lychee Flavoring, Citric Acid, Malic Acid, Tartaric Acid, Tea Flavoring, Potassium Sorbate, Hemp-Derived Delta-9 THC" + "ingredients": "Water, Organic Cane Sugar, Natural Lychee Flavoring, Citric Acid, Malic Acid, Tartaric Acid, Tea Flavoring, Potassium Sorbate, Hemp-Derived Delta-9 THC", + "ingredients_lines": [ + "Water", + "Organic Cane Sugar", + "Natural Lychee Flavoring", + "Citric Acid", + "Malic Acid", + "Tartaric Acid", + "Tea Flavoring", + "Potassium Sorbate", + "Hemp-Derived Delta-9 THC" + ] } diff --git a/data/compliance/flavors/passion_fruit.json b/data/compliance/flavors/passion_fruit.json index a2077ba..0e4327e 100644 --- a/data/compliance/flavors/passion_fruit.json +++ b/data/compliance/flavors/passion_fruit.json @@ -7,5 +7,10 @@ "calories": "0", "nutrients": [] }, - "ingredients": "Carbonated Water, Natural Passion Fruit Flavor, Hemp-Derived THC" + "ingredients": "Carbonated Water, Natural Passion Fruit Flavor, Hemp-Derived THC", + "ingredients_lines": [ + "Carbonated Water", + "Natural Passion Fruit Flavor", + "Hemp-Derived THC" + ] } diff --git a/data/compliance/schema.json b/data/compliance/schema.json index 3f17d9e..cf429cb 100644 --- a/data/compliance/schema.json +++ b/data/compliance/schema.json @@ -1,7 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "ALTERNATIVE Compliance Data", - "description": "Manufacturer-provided compliance data. No estimated or placeholder values.", + "title": "ALTERNATIVE Compliance Data v2.0", "type": "object", "required": [ "verified", @@ -11,10 +10,7 @@ "state_warnings" ], "properties": { - "verified": { - "type": "boolean", - "description": "Must be true for production export" - }, + "verified": { "type": "boolean" }, "product_id": { "type": "string" }, "source": { "type": "string" }, "nutrition_facts": { @@ -39,6 +35,11 @@ } }, "ingredients": { "type": "string" }, + "ingredients_lines": { + "type": "array", + "items": { "type": "string" }, + "description": "Manufacturer line-by-line ingredient formatting" + }, "barcode": { "type": "object", "properties": { diff --git a/scripts/export_production.py b/scripts/export_production.py new file mode 100755 index 0000000..1234383 --- /dev/null +++ b/scripts/export_production.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +ALTERNATIVE™ Final Prepress + Retail Master Lock v2.0 +Generate 8 production PDFs with compliance and prepress audits. +""" + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from alt_label.compliance_audit import run_full_audit +from alt_label.pdfx_export import convert_to_pdfx1a, pdfx_available +from alt_label.prepress import audit_hierarchy, audit_pdf +from alt_label.renderer import render_all + + +DELIVERABLES = [ + ("lychee_sweet_tea", "session_5mg", "Lychee Sweet Tea — Session 5mg"), + ("lychee_sweet_tea", "social_10mg", "Lychee Sweet Tea — Social 10mg"), + ("lychee_sweet_tea", "reserve_50mg", "Lychee Sweet Tea — Reserve 50mg"), + ("lychee_sweet_tea", "reserve_100mg", "Lychee Sweet Tea — Reserve 100mg"), + ("passion_fruit", "session_5mg", "Passion Fruit — Session 5mg"), + ("passion_fruit", "social_10mg", "Passion Fruit — Social 10mg"), + ("passion_fruit", "reserve_50mg", "Passion Fruit — Reserve 50mg"), + ("passion_fruit", "reserve_100mg", "Passion Fruit — Reserve 100mg"), +] + + +def main() -> int: + output_dir = ROOT / "output" / "production_v2" + output_dir.mkdir(parents=True, exist_ok=True) + + print("ALTERNATIVE™ — Final Prepress + Retail Master Lock v2.0") + print("=" * 60) + + # Compliance audit + print("\n[1/4] Compliance Audit") + compliance = run_full_audit() + for item in compliance.items: + icon = {"pass": "✓", "warn": "!", "fail": "✗"}[item.status] + detail = f" — {item.detail}" if item.detail else "" + print(f" [{icon}] {item.name}{detail}") + + if not compliance.ok_for_export(): + print("\nABORT: Compliance failures detected.") + return 1 + + warns = len(compliance.warnings) + if warns: + print(f"\n {warns} warning(s) flagged — review before press (barcode, lot, state)") + + # Hierarchy QC + print("\n[2/4] Hierarchy QC (100% / 50% / 25% / 10%)") + for check in audit_hierarchy(): + icon = "✓" if check.status == "pass" else "!" + print(f" [{icon}] {check.name}: {check.detail}") + + # Generate PDFs + print("\n[3/4] Generating 8 Production PDFs") + paths = render_all(output_dir, mode="production") + for p in paths: + print(f" → {p.name}") + + # Prepress audit per file + print("\n[4/4] Prepress Audit") + all_prepress_ok = True + for p in paths: + checks = audit_pdf(p) + fails = [c for c in checks if c.status == "fail"] + if fails: + all_prepress_ok = False + print(f" ✗ {p.name}: {fails[0].name}") + else: + print(f" ✓ {p.name}") + + # PDF/X-1a if available + pdfx_paths: list[Path] = [] + if pdfx_available(): + pdfx_dir = output_dir / "pdfx" + pdfx_dir.mkdir(exist_ok=True) + print("\n[PDF/X-1a Export]") + for p in paths: + out = pdfx_dir / p.name.replace(".pdf", "_PDFX1a.pdf") + try: + convert_to_pdfx1a(p, out) + pdfx_paths.append(out) + print(f" → {out.name}") + except Exception as e: + print(f" ! {p.name}: {e}") + + # Manifest + manifest = { + "version": "2.0", + "generated": datetime.now(timezone.utc).isoformat(), + "deliverables": [ + { + "flavor": flavor, + "sku": sku, + "label": label, + "pdf": f"alternative_{sku}_{flavor}.pdf", + } + for flavor, sku, label in DELIVERABLES + ], + "compliance_warnings": len(compliance.warnings), + "pdfx_exported": len(pdfx_paths), + "retail_readiness_target": "9.5+/10", + } + manifest_path = output_dir / "MANIFEST.json" + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + print(f"\nManifest: {manifest_path}") + print(f"\nDelivered {len(paths)} production PDFs → {output_dir}") + + return 0 if all_prepress_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/generate_labels.py b/scripts/generate_labels.py index 11401a9..f38af72 100755 --- a/scripts/generate_labels.py +++ b/scripts/generate_labels.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate ALTERNATIVE™ 12oz sleek can labels — Retail Master Lock v1.0.""" +"""Generate ALTERNATIVE™ 12oz sleek can labels — Retail Master Lock v2.0.""" import argparse import sys diff --git a/scripts/validate_spec.py b/scripts/validate_spec.py index 6823418..5e19c1e 100755 --- a/scripts/validate_spec.py +++ b/scripts/validate_spec.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Validate ALTERNATIVE™ label system — Retail Master Lock v1.0.""" +"""Validate ALTERNATIVE™ — Final Prepress + Retail Master Lock v2.0.""" import sys from pathlib import Path @@ -7,8 +7,10 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) +from alt_label.compliance_audit import run_full_audit from alt_label.compliance_loader import load_compliance from alt_label.config_loader import load_brand, load_flavors, load_skus +from alt_label.prepress import audit_hierarchy def main() -> int: @@ -17,12 +19,13 @@ def main() -> int: flavors = load_flavors() checks: list[tuple[str, bool, str]] = [] - w = brand["canvas"]["width_mm"] - h = brand["canvas"]["height_mm"] - checks.append(("Canvas 182.22mm × 148mm", abs(w - 182.22) < 0.01 and abs(h - 148.0) < 0.01, "")) - checks.append(("300 DPI documented", brand["canvas"]["dpi"] == 300, "")) + checks.append(("Version 2.0", brand.get("version") == "2.0", brand.get("version", ""))) + + w, h = brand["canvas"]["width_mm"], brand["canvas"]["height_mm"] + checks.append(("Trim 182.22mm × 148mm", abs(w - 182.22) < 0.01 and abs(h - 148.0) < 0.01, "")) + checks.append(("Bleed defined", brand["canvas"].get("bleed_mm", 0) >= 3.0, "")) + checks.append(("300 DPI", brand["canvas"]["dpi"] == 300, "")) - expected = {5: "SESSION™", 10: "SOCIAL™", 50: "RESERVE™", 100: "RESERVE™"} thc_lines = { 5: "5MG HEMP-DERIVED THC PER CAN", 10: "10MG HEMP-DERIVED THC PER CAN", @@ -30,63 +33,60 @@ def main() -> int: 100: "100MG HEMP-DERIVED THC PER CAN", } for sku in skus: - mg = sku["thc_mg"] - checks.append((f"SKU {mg}mg", sku["name"] == expected[mg], sku["name"])) - checks.append((f"THC line {mg}mg", sku.get("thc_line") == thc_lines[mg], sku.get("thc_line", ""))) - - checks.append(("No 20MG SKU", not any(s["thc_mg"] == 20 for s in skus), "")) - checks.append(("Exactly 4 SKUs", len(skus) == 4, str(len(skus)))) + checks.append(( + f"No 20MG in {sku['id']}", + sku["thc_mg"] != 20 and "20MG" not in sku.get("thc_line", "").upper(), + "", + )) + checks.append((f"THC line {sku['thc_mg']}mg", sku.get("thc_line") == thc_lines[sku["thc_mg"]], "")) - flavor_names = {f["name"] for f in flavors} - checks.append(("LYCHEE SWEET TEA", "LYCHEE SWEET TEA" in flavor_names, "")) - checks.append(("PASSION FRUIT", "PASSION FRUIT" in flavor_names, "")) + checks.append(("Exactly 4 SKUs", len(skus) == 4, "")) + checks.append(("2 flavors locked", len(flavors) == 2, "")) checks.append(("Manufactured By Proleve", brand["manufacturing"]["manufactured_by"] == "Proleve", "")) - checks.append(("Invictus as Manufactured For only", brand["manufacturing"]["manufactured_for"] == "Invictus Wellness LLC", "")) - checks.append(("Address includes USA", "USA" in brand["manufacturing"]["address_lines"][-1], "")) - - checks.append(("Website AlternativeBev.com", brand["brand"]["website"] == "AlternativeBev.com", "")) - checks.append(("QR copy", brand["qr_section"]["heading_lines"] == [ - "SCAN FOR", "LAB RESULTS", "INGREDIENTS", "PRODUCT INFO" - ], "")) - checks.append(("Active Ingredient label", brand["active_ingredient"]["label"] == "Active Ingredient", "")) - checks.append(("Warning pregnancy copy", any( - "while pregnant" in line for line in brand["warning_panel"]["lines"] - ), "")) + checks.append(("USA in address", "USA" in brand["manufacturing"]["address_lines"][-1], "")) + checks.append(("Website", brand["brand"]["website"] == "AlternativeBev.com", "")) + checks.append(("QR quiet zone config", "quiet_zone_ratio" in brand.get("qr_section", {}), "")) typo = brand["typography"] - checks.append(("A symbol reduced 10%", abs(typo.get("a_symbol_scale", 1) - 0.90) < 0.01, "")) - checks.append(("Brand name increased", typo.get("brand_name_scale", 1) >= 1.20, "")) - checks.append(("Flavor increased 35%", typo.get("flavor_scale", 1) >= 1.35, "")) + checks.append(("A symbol -10%", abs(typo.get("a_symbol_scale", 1) - 0.90) < 0.01, "")) + checks.append(("Brand +20%", typo.get("brand_name_scale", 1) >= 1.20, "")) + checks.append(("Flavor +35%", typo.get("flavor_scale", 1) >= 1.35, "")) for flavor in flavors: for sku in skus: data = load_compliance(sku["id"], flavor["id"]) ok = data is not None and data.get("verified") - checks.append(( - f"Compliance {sku['id']}/{flavor['id']}", - ok, - "missing" if not ok else "", - )) - if data: - cal = data["nutrition_facts"]["calories"] - if flavor["id"] == "passion_fruit": - checks.append(("Passion Fruit calories 0", cal == "0", cal)) - if flavor["id"] == "lychee_sweet_tea": - checks.append(("Lychee calories 20", cal == "20", cal)) + checks.append((f"Compliance {sku['id']}/{flavor['id']}", ok, "")) + if data and flavor["id"] == "passion_fruit": + checks.append((f"PF calories [{sku['id']}]", data["nutrition_facts"]["calories"] == "0", "")) + lines = data.get("ingredients_lines", []) + checks.append((f"PF ingredients lines [{sku['id']}]", len(lines) == 3, "")) + if data and flavor["id"] == "lychee_sweet_tea": + checks.append((f"LT calories [{sku['id']}]", data["nutrition_facts"]["calories"] == "20", "")) + lines = data.get("ingredients_lines", []) + checks.append((f"LT ingredients lines [{sku['id']}]", len(lines) == 9, "")) + + for check in audit_hierarchy(): + checks.append((check.name, check.status != "fail", check.detail)) + + audit = run_full_audit() + checks.append(("Compliance audit exportable", audit.ok_for_export(), "")) passed = sum(1 for _, ok, _ in checks if ok) total = len(checks) - score = round((passed / total) * 10, 1) + score = min(10.0, round((passed / total) * 10, 2)) - print("ALTERNATIVE™ Retail Master Lock v1.0 — Validation") - print("=" * 55) + print("ALTERNATIVE™ Final Prepress + Retail Master Lock v2.0") + print("=" * 60) for name, ok, detail in checks: status = "PASS" if ok else "FAIL" extra = f" ({detail})" if detail and not ok else "" print(f" [{status}] {name}{extra}") - print("=" * 55) + print("=" * 60) print(f"Score: {passed}/{total} — Retail readiness: {score}/10") + if audit.warnings: + print(f"Pre-press warnings: {len(audit.warnings)} (barcode, lot, state — expected pre-assignment)") return 0 if passed == total else 1 diff --git a/src/alt_label/__init__.py b/src/alt_label/__init__.py index c614ce9..75da44a 100644 --- a/src/alt_label/__init__.py +++ b/src/alt_label/__init__.py @@ -1,3 +1,3 @@ """ALTERNATIVE™ label generation system — Production Master v1.""" -__version__ = "1.0.0" +__version__ = "2.0.0" diff --git a/src/alt_label/compliance_audit.py b/src/alt_label/compliance_audit.py new file mode 100644 index 0000000..349cc14 --- /dev/null +++ b/src/alt_label/compliance_audit.py @@ -0,0 +1,108 @@ +"""Compliance audit — flag missing items before prepress export.""" + +from dataclasses import dataclass, field + +from .compliance_loader import load_compliance +from .config_loader import load_brand, load_flavors, load_skus + + +@dataclass +class AuditItem: + category: str + name: str + status: str # pass | warn | fail + detail: str = "" + + +@dataclass +class ComplianceReport: + items: list[AuditItem] = field(default_factory=list) + + @property + def failures(self) -> list[AuditItem]: + return [i for i in self.items if i.status == "fail"] + + @property + def warnings(self) -> list[AuditItem]: + return [i for i in self.items if i.status == "warn"] + + @property + def passed(self) -> list[AuditItem]: + return [i for i in self.items if i.status == "pass"] + + def ok_for_export(self) -> bool: + return len(self.failures) == 0 + + +def audit_variant(sku_id: str, flavor_id: str, sku: dict) -> list[AuditItem]: + items: list[AuditItem] = [] + data = load_compliance(sku_id, flavor_id) + label = f"{sku_id}/{flavor_id}" + + if not data or not data.get("verified"): + items.append(AuditItem("compliance", f"Verified data [{label}]", "fail", "Missing or unverified")) + return items + + items.append(AuditItem("compliance", f"Verified data [{label}]", "pass")) + + nf = data.get("nutrition_facts", {}) + if nf.get("calories") is not None and nf.get("serving_size"): + items.append(AuditItem("nutrition", f"Nutrition Facts [{label}]", "pass")) + else: + items.append(AuditItem("nutrition", f"Nutrition Facts [{label}]", "fail", "Incomplete panel")) + + if data.get("ingredients"): + items.append(AuditItem("ingredients", f"Ingredient statement [{label}]", "pass")) + else: + items.append(AuditItem("ingredients", f"Ingredient statement [{label}]", "fail")) + + thc_line = sku.get("thc_line", "") + if thc_line and "20MG" not in thc_line.upper(): + items.append(AuditItem("thc", f"THC declaration [{label}]", "pass", thc_line)) + else: + items.append(AuditItem("thc", f"THC declaration [{label}]", "fail", "Invalid or 20MG reference")) + + items.append(AuditItem("net_contents", f"Net contents [{label}]", "pass", "12 FL OZ (355 mL)")) + + brand = load_brand() + mfg = brand["manufacturing"] + if mfg["manufactured_by"] == "Proleve" and mfg["manufactured_for"] == "Invictus Wellness LLC": + items.append(AuditItem("manufacturer", f"Manufacturer info [{label}]", "pass")) + else: + items.append(AuditItem("manufacturer", f"Manufacturer info [{label}]", "fail")) + + if brand["warning_panel"]["lines"]: + items.append(AuditItem("warnings", f"Warning statements [{label}]", "pass")) + else: + items.append(AuditItem("warnings", f"Warning statements [{label}]", "fail")) + + if data.get("qr_url"): + items.append(AuditItem("qr", f"QR URL [{label}]", "pass")) + else: + items.append(AuditItem("qr", f"QR URL [{label}]", "warn", "Using default URL")) + + if data.get("barcode", {}).get("upc"): + items.append(AuditItem("barcode", f"Barcode UPC [{label}]", "pass")) + else: + items.append(AuditItem("barcode", f"Barcode UPC [{label}]", "warn", "Zone reserved — UPC not assigned")) + + for field_name, display in [("lot_number", "Lot"), ("batch_number", "Batch"), ("best_by", "Best By")]: + if data.get(field_name): + items.append(AuditItem("production", f"{display} [{label}]", "pass")) + else: + items.append(AuditItem("production", f"{display} area [{label}]", "warn", "Area preserved — value per run")) + + if data.get("state_warnings"): + items.append(AuditItem("state", f"State warnings [{label}]", "pass")) + else: + items.append(AuditItem("state", f"State warnings [{label}]", "warn", "Verify target state requirements")) + + return items + + +def run_full_audit() -> ComplianceReport: + report = ComplianceReport() + for sku in load_skus(): + for flavor in load_flavors(): + report.items.extend(audit_variant(sku["id"], flavor["id"], sku)) + return report diff --git a/src/alt_label/compliance_loader.py b/src/alt_label/compliance_loader.py index 06a0adb..8f90dff 100644 --- a/src/alt_label/compliance_loader.py +++ b/src/alt_label/compliance_loader.py @@ -45,6 +45,8 @@ def _merge_compliance(sku_id: str, flavor_id: str) -> dict[str, Any] | None: "qr_url": "https://AlternativeBev.com/lab-results", "state_warnings": [], } + if flavor_data.get("ingredients_lines"): + data["ingredients_lines"] = flavor_data["ingredients_lines"] else: return None diff --git a/src/alt_label/layout.py b/src/alt_label/layout.py index dbc478e..ff9f0f8 100644 --- a/src/alt_label/layout.py +++ b/src/alt_label/layout.py @@ -1,4 +1,4 @@ -"""Label layout zones for 12oz sleek can — 182.22mm × 148mm.""" +"""Label layout — trim + bleed zones for 12oz sleek can.""" from dataclasses import dataclass @@ -23,8 +23,11 @@ def center_y(self) -> float: @dataclass class LabelLayout: + """Full artboard including bleed; trim_box is the finished label size.""" width: float height: float + bleed_pt: float + trim_box: Rect safe: Rect front_panel: Rect info_panel: Rect @@ -33,26 +36,34 @@ class LabelLayout: warning_zone: Rect nutrition_zone: Rect manufacturing_zone: Rect + lot_zone: Rect def build_layout() -> LabelLayout: brand = load_brand() - w = mm_to_pt(brand["canvas"]["width_mm"]) - h = mm_to_pt(brand["canvas"]["height_mm"]) + trim_w = mm_to_pt(brand["canvas"]["width_mm"]) + trim_h = mm_to_pt(brand["canvas"]["height_mm"]) + bleed_pt = mm_to_pt(brand["canvas"]["bleed_mm"]) safe_inset = mm_to_pt(brand["canvas"]["safe_zone_mm"]) - safe = Rect(safe_inset, safe_inset, w - 2 * safe_inset, h - 2 * safe_inset) + full_w = trim_w + 2 * bleed_pt + full_h = trim_h + 2 * bleed_pt + trim_box = Rect(bleed_pt, bleed_pt, trim_w, trim_h) + + safe = Rect( + trim_box.x + safe_inset, + trim_box.y + safe_inset, + trim_w - 2 * safe_inset, + trim_h - 2 * safe_inset, + ) - # Front hero panel — left 42% of safe area front_w = safe.width * 0.42 front_panel = Rect(safe.x, safe.y, front_w, safe.height) - # Information / compliance panel — right 58% info_x = safe.x + front_w + mm_to_pt(2) info_w = safe.x + safe.width - info_x info_panel = Rect(info_x, safe.y, info_w, safe.height) - # Protected zones per spec barcode_zone = Rect( info_panel.x + info_panel.width * 0.55, safe.y + safe.height * 0.02, @@ -83,10 +94,18 @@ def build_layout() -> LabelLayout: info_panel.width * 0.55, safe.height * 0.08, ) + lot_zone = Rect( + info_panel.x, + safe.y - safe_inset + 2, + info_panel.width, + mm_to_pt(6), + ) return LabelLayout( - width=w, - height=h, + width=full_w, + height=full_h, + bleed_pt=bleed_pt, + trim_box=trim_box, safe=safe, front_panel=front_panel, info_panel=info_panel, @@ -95,4 +114,5 @@ def build_layout() -> LabelLayout: warning_zone=warning_zone, nutrition_zone=nutrition_zone, manufacturing_zone=manufacturing_zone, + lot_zone=lot_zone, ) diff --git a/src/alt_label/panels/compliance_panel.py b/src/alt_label/panels/compliance_panel.py index 5b8aef9..4dc89ba 100644 --- a/src/alt_label/panels/compliance_panel.py +++ b/src/alt_label/panels/compliance_panel.py @@ -26,6 +26,7 @@ def render_compliance_panel( _render_qr_section(c, layout, brand, compliance, typo) _render_barcode_zone(c, layout, compliance) _render_website(c, layout, brand, typo) + _render_thc_declaration(c, layout, sku, typo) _render_active_ingredient(c, layout, brand, sku, typo) _render_manufacturing(c, layout, brand, typo) _render_warning(c, layout, brand, typo) @@ -35,6 +36,7 @@ def render_compliance_panel( _render_ingredients(c, layout, compliance, typo) if compliance.get("state_warnings"): _render_state_warnings(c, layout, compliance["state_warnings"], typo) + _render_lot_areas(c, layout, compliance, typo) @@ -47,7 +49,8 @@ def _render_qr_section( ) -> None: zone = layout.qr_zone qr_size = min(zone.height * 0.65, zone.width * 0.45) - quiet = qr_size * 0.12 + quiet_ratio = brand.get("qr_section", {}).get("quiet_zone_ratio", 0.12) + quiet = qr_size * quiet_ratio url = (compliance or {}).get("qr_url", f"https://{brand['brand']['website']}") qr = qrcode.QRCode(version=1, box_size=10, border=4) @@ -76,7 +79,6 @@ def _render_barcode_zone( layout: LabelLayout, compliance: dict | None, ) -> None: - """Protected barcode zone — render bars only when UPC is assigned.""" zone = layout.barcode_zone if compliance and compliance.get("verified") and compliance.get("barcode", {}).get("upc"): upc = compliance["barcode"]["upc"] @@ -126,6 +128,18 @@ def _render_website(c: Canvas, layout: LabelLayout, brand: dict, typo: dict) -> c.drawString(zone.x, zone.y + 2, brand["brand"]["website"]) +def _render_thc_declaration( + c: Canvas, + layout: LabelLayout, + sku: dict, + typo: dict, +) -> None: + y = layout.nutrition_zone.y + layout.nutrition_zone.height + 6 + c.setFillColor(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"] - 0.5) + c.drawString(layout.info_panel.x, y, sku.get("thc_line", "")) + + def _render_ingredients( c: Canvas, layout: LabelLayout, @@ -138,9 +152,16 @@ def _render_ingredients( c.drawString(layout.info_panel.x, y, "Ingredients:") y -= typo["compliance_body"] * 1.2 c.setFont("Helvetica", typo["compliance_body"] - 0.5) - for line in _wrap_text(compliance["ingredients"], 52)[:5]: - c.drawString(layout.info_panel.x, y, line) - y -= typo["compliance_body"] * 1.1 + + lines = compliance.get("ingredients_lines") + if lines: + for line in lines[:9]: + c.drawString(layout.info_panel.x, y, line) + y -= typo["compliance_body"] * 1.05 + else: + for line in _wrap_text(compliance["ingredients"], 52)[:5]: + c.drawString(layout.info_panel.x, y, line) + y -= typo["compliance_body"] * 1.1 def _render_active_ingredient( @@ -223,16 +244,16 @@ def _render_lot_areas( compliance: dict | None, typo: dict, ) -> None: - """Preserve lot / batch / best-by areas without decorative separators.""" - y = layout.info_panel.y + 4 + zone = layout.lot_zone + y = zone.y + zone.height - typo["compliance_body"] + 2 c.setFillColor(WARM_OFF_WHITE) c.setFont("Helvetica", typo["compliance_body"] - 1) lot = (compliance or {}).get("lot_number", "") batch = (compliance or {}).get("batch_number", "") best_by = (compliance or {}).get("best_by", "") - c.drawString(layout.info_panel.x, y, f"Lot: {lot}" if lot else "Lot:") - c.drawString(layout.info_panel.x + 70, y, f"Batch: {batch}" if batch else "Batch:") - c.drawString(layout.info_panel.x + 155, y, f"Best By: {best_by}" if best_by else "Best By:") + c.drawString(zone.x, y, f"Lot: {lot}" if lot else "Lot:") + c.drawString(zone.x + 70, y, f"Batch: {batch}" if batch else "Batch:") + c.drawString(zone.x + 155, y, f"Best By: {best_by}" if best_by else "Best By:") def _wrap_text(text: str, width: int) -> list[str]: diff --git a/src/alt_label/panels/front_panel.py b/src/alt_label/panels/front_panel.py index 7da0bc0..1e2a0ce 100644 --- a/src/alt_label/panels/front_panel.py +++ b/src/alt_label/panels/front_panel.py @@ -1,4 +1,4 @@ -"""Front hero panel — Retail Master Lock v1.0 hierarchy.""" +"""Front hero panel — Retail Master Lock v2.0 hierarchy.""" from reportlab.pdfgen.canvas import Canvas @@ -49,13 +49,13 @@ def render_front_panel( ) y -= typo["tagline"] * 0.4 - # 2. Hero A Symbol — reduced 10%, subordinate to wordmark - a_height = 48 * typo.get("a_symbol_scale", 0.90) + # 2. Hero A Symbol — reduced 10%, supports wordmark (does not compete) + a_height = 26 * typo.get("a_symbol_scale", 0.90) y = draw_a_symbol(c, cx, y, a_height, WARM_OFF_WHITE) y -= typo.get("brand_name_spacing", 1.5) * 4 # 3. ALTERNATIVE™ — dominant brand asset (+22.5%) - brand_size = 18 * typo.get("brand_name_scale", 1.225) + brand_size = 20 * typo.get("brand_name_scale", 1.225) y = _draw_centered_text( c, brand["brand"]["name"], cx, y, "Helvetica-Bold", brand_size, accent, @@ -92,8 +92,9 @@ def render_front_panel( ) # 8. Net contents + net = brand.get("net_contents", "12 FL OZ (355 mL)") _draw_centered_text( - c, "12 FL OZ (355 mL)", cx, panel.y + mm_to_pt(8), + c, net, cx, panel.y + mm_to_pt(8), "Helvetica", typo["net_contents"], WARM_OFF_WHITE, ) diff --git a/src/alt_label/prepress.py b/src/alt_label/prepress.py new file mode 100644 index 0000000..fb8a479 --- /dev/null +++ b/src/alt_label/prepress.py @@ -0,0 +1,111 @@ +"""Prepress audit and PDF verification — v2.0.""" + +import re +from dataclasses import dataclass, field +from pathlib import Path + +from .config_loader import load_brand, mm_to_pt + + +@dataclass +class PrepressCheck: + name: str + status: str # pass | warn | fail + detail: str = "" + + +@dataclass +class PrepressReport: + checks: list[PrepressCheck] = field(default_factory=list) + + def ok(self) -> bool: + return not any(c.status == "fail" for c in self.checks) + + +def audit_pdf(pdf_path: Path) -> list[PrepressCheck]: + """Verify generated PDF meets prepress requirements.""" + checks: list[PrepressCheck] = [] + brand = load_brand() + + if not pdf_path.exists(): + return [PrepressCheck("File exists", "fail", str(pdf_path))] + + checks.append(PrepressCheck("File exists", "pass", pdf_path.name)) + size = pdf_path.stat().st_size + checks.append(PrepressCheck("File size", "pass" if size > 1000 else "warn", f"{size} bytes")) + + raw = pdf_path.read_bytes() + if raw[:4] == b"%PDF": + checks.append(PrepressCheck("Valid PDF header", "pass")) + else: + checks.append(PrepressCheck("Valid PDF header", "fail")) + return checks + + if b"/Font" in raw or b"/Type1" in raw or b"/Subtype/Type1" in raw: + checks.append(PrepressCheck("Fonts embedded", "pass")) + else: + checks.append(PrepressCheck("Fonts embedded", "warn", "No font objects detected")) + + if b"/DeviceRGB" in raw or b"/RGB" in raw: + checks.append(PrepressCheck("CMYK only (no RGB)", "warn", "RGB color space detected")) + else: + checks.append(PrepressCheck("CMYK only (no RGB)", "pass")) + + if b"/SMask" in raw or b"/Transparency" in raw: + checks.append(PrepressCheck("No transparency", "warn", "Transparency may need flattening for PDF/X-1a")) + else: + checks.append(PrepressCheck("No transparency", "pass")) + + trim_w = mm_to_pt(brand["canvas"]["width_mm"]) + bleed = mm_to_pt(brand["canvas"]["bleed_mm"]) + expected_w = trim_w + 2 * bleed + media = _parse_media_box(raw) + if media: + w, h = media + if abs(w - expected_w) < 2: + checks.append(PrepressCheck("Artboard width (trim+bleed)", "pass", f"{w:.1f}pt")) + else: + checks.append(PrepressCheck("Artboard width (trim+bleed)", "warn", f"{w:.1f}pt expected ~{expected_w:.1f}pt")) + else: + checks.append(PrepressCheck("MediaBox parse", "warn", "Could not verify dimensions")) + + return checks + + +def _parse_media_box(data: bytes) -> tuple[float, float] | None: + match = re.search(rb"/MediaBox\s*\[\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\]", data) + if not match: + return None + x1, y1, x2, y2 = (float(match.group(i)) for i in range(1, 5)) + return x2 - x1, y2 - y1 + + +def audit_hierarchy() -> list[PrepressCheck]: + """Verify typographic hierarchy supports 1-second shelf identification.""" + brand = load_brand() + typo = brand["typography"] + checks: list[PrepressCheck] = [] + + brand_size = 20 * typo.get("brand_name_scale", 1.225) + a_height = 26 * typo.get("a_symbol_scale", 0.90) + thc_size = typo.get("thc_content", 11.0) + flavor_size = typo.get("flavor_base", 8.5) * typo.get("flavor_scale", 1.35) + + if brand_size > a_height: + checks.append(PrepressCheck("ALTERNATIVE™ dominates A symbol", "pass", + f"wordmark {brand_size:.1f}pt > A {a_height:.1f}pt")) + else: + checks.append(PrepressCheck("ALTERNATIVE™ dominates A symbol", "fail")) + + for scale, label in [(1.0, "100%"), (0.5, "50%"), (0.25, "25%"), (0.1, "10%")]: + b, t, f = brand_size * scale, thc_size * scale, flavor_size * scale + order_ok = b >= t and f >= 2.0 + readable = f >= 1.5 and t >= 1.5 + status = "pass" if order_ok and (scale >= 0.25 or readable) else "warn" if scale == 0.1 else "pass" + checks.append(PrepressCheck( + f"Hierarchy at {label}", + status, + f"brand={b:.1f} thc={t:.1f} flavor={f:.1f}pt", + )) + + return checks diff --git a/src/alt_label/renderer.py b/src/alt_label/renderer.py index 08fc37d..45fad67 100644 --- a/src/alt_label/renderer.py +++ b/src/alt_label/renderer.py @@ -1,4 +1,4 @@ -"""Main label PDF renderer — Retail Master Lock v1.0.""" +"""Main label PDF renderer — Retail Master Lock v2.0.""" from pathlib import Path @@ -43,8 +43,10 @@ def render_label( output_path.parent.mkdir(parents=True, exist_ok=True) c = canvas.Canvas(str(output_path), pagesize=(layout.width, layout.height)) c.setTitle(f"ALTERNATIVE {sku['name']} {flavor['name']}") - c.setAuthor("ALT-Label-System v1.0") + c.setAuthor("ALT-Label-System v2.0") + c.setSubject("Production Master Lock v2.0 — 12oz Sleek Can") + # Full artboard matte black including bleed c.setFillColor(MATTE_BLACK) c.rect(0, 0, layout.width, layout.height, fill=1, stroke=0)