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..8cfccd6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ # ALT-Label-System -Code-driven packaging and label generation system for ALTERNATIVE products. + +**ALTERNATIVE™ Final Prepress + Retail Master Lock v2.0** + +Code-driven label generation for 12oz sleek cans. Refinement and production preparation — **not a redesign**. + +## Shelf Priority (1-second test) + +1. **ALTERNATIVE™** — dominant wordmark +2. **THC strength** — single-line SKU callout +3. **Flavor** — LYCHEE SWEET TEA / PASSION FRUIT + +## Production Export (8 PDFs) + +```bash +pip install -r requirements.txt +python3 scripts/export_production.py +python3 scripts/validate_spec.py +``` + +Output: `output/production_v2/` + `MANIFEST.json` + +### Deliverables + +| Flavor | SKUs | +|--------|------| +| Lychee Sweet Tea | Session 5mg · Social 10mg · Reserve 50mg · Reserve 100mg | +| Passion Fruit | Session 5mg · Social 10mg · Reserve 50mg · Reserve 100mg | + +## Audits (v2.0) + +- **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 (exact) + +| Flavor | Calories | Ingredients | +|--------|----------|-------------| +| Passion Fruit | 0 | 3 lines — manufacturer format | +| Lychee Sweet Tea | 20 | 9 lines — manufacturer format | + +## Print Spec + +- 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 + +## Pre-Press Warnings (expected) + +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/config/brand.yaml b/config/brand.yaml new file mode 100644 index 0000000..d0f0ce6 --- /dev/null +++ b/config/brand.yaml @@ -0,0 +1,74 @@ +# 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" + positioning: "HEMP-DERIVED THC BEVERAGE" + website: "AlternativeBev.com" + +canvas: + width_mm: 182.22 + height_mm: 148.0 + dpi: 300 + bleed_mm: 3.175 + safe_zone_mm: 4.0 + +colors: + 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] + +typography: + tagline: 7.5 + 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 + flavor_scale: 1.35 + flavor_base: 8.5 + net_contents: 7.0 + compliance_body: 5.5 + compliance_heading: 6.5 + +manufacturing: + manufactured_by_label: "Manufactured By:" + manufactured_by: "Proleve" + manufactured_for_label: "Manufactured For:" + manufactured_for: "Invictus Wellness LLC" + address_lines: + - "11624 Red Bridge Rd" + - "Locust, NC 28097 USA" + +qr_section: + heading_lines: + - "SCAN FOR" + - "LAB RESULTS" + - "INGREDIENTS" + - "PRODUCT INFO" + quiet_zone_ratio: 0.12 + +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 while pregnant or breastfeeding." + - "Intoxicating effects may be delayed." + - "Consume responsibly." + +active_ingredient: + label: "Active Ingredient" + substance: "Hemp-Derived Delta-9 THC" + +net_contents: "12 FL OZ (355 mL)" 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..3809a0c --- /dev/null +++ b/config/skus.yaml @@ -0,0 +1,26 @@ +# Locked SKU system — no 20MG, no additional strengths + +skus: + - id: session_5mg + name: "SESSION™" + thc_mg: 5 + thc_line: "5MG HEMP-DERIVED THC PER CAN" + active_ingredient_amount: "5mg" + + - id: social_10mg + name: "SOCIAL™" + thc_mg: 10 + thc_line: "10MG HEMP-DERIVED THC PER CAN" + active_ingredient_amount: "10mg" + + - id: reserve_50mg + name: "RESERVE™" + thc_mg: 50 + thc_line: "50MG HEMP-DERIVED THC PER CAN" + active_ingredient_amount: "50mg" + + - id: reserve_100mg + name: "RESERVE™" + thc_mg: 100 + thc_line: "100MG HEMP-DERIVED THC PER CAN" + active_ingredient_amount: "100mg" diff --git a/data/compliance/README.md b/data/compliance/README.md new file mode 100644 index 0000000..bf28925 --- /dev/null +++ b/data/compliance/README.md @@ -0,0 +1,26 @@ +# Compliance Data — Manufacturer Provided + +Retail Master Lock v1.0 requires **exact manufacturer-provided** nutrition and ingredient data. No estimates. + +## Flavor-Level Data (locked) + +| 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 | + +Stored in `flavors/{flavor_id}.json`. Product files inherit this data automatically. + +## Product Overrides + +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 + +## Generate Labels + +```bash +python3 scripts/generate_labels.py --mode production +python3 scripts/validate_spec.py +``` diff --git a/data/compliance/TEMPLATE.json b/data/compliance/TEMPLATE.json new file mode 100644 index 0000000..748ccc1 --- /dev/null +++ b/data/compliance/TEMPLATE.json @@ -0,0 +1,15 @@ +{ + "_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": "FROM_MANUFACTURER", + "nutrients": [] + }, + "ingredients": "FROM_MANUFACTURER", + "qr_url": "https://AlternativeBev.com/lab-results", + "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..408127f --- /dev/null +++ b/data/compliance/flavors/lychee_sweet_tea.json @@ -0,0 +1,22 @@ +{ + "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", + "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 new file mode 100644 index 0000000..0e4327e --- /dev/null +++ b/data/compliance/flavors/passion_fruit.json @@ -0,0 +1,16 @@ +{ + "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", + "ingredients_lines": [ + "Carbonated Water", + "Natural Passion Fruit Flavor", + "Hemp-Derived THC" + ] +} 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..cf429cb --- /dev/null +++ b/data/compliance/schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ALTERNATIVE Compliance Data v2.0", + "type": "object", + "required": [ + "verified", + "product_id", + "nutrition_facts", + "ingredients", + "state_warnings" + ], + "properties": { + "verified": { "type": "boolean" }, + "product_id": { "type": "string" }, + "source": { "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"], + "properties": { + "name": { "type": "string" }, + "amount": { "type": "string" }, + "daily_value": { "type": ["string", "null"] } + } + } + } + } + }, + "ingredients": { "type": "string" }, + "ingredients_lines": { + "type": "array", + "items": { "type": "string" }, + "description": "Manufacturer line-by-line ingredient formatting" + }, + "barcode": { + "type": "object", + "properties": { + "upc": { "type": "string", "pattern": "^[0-9]{12}$" }, + "type": { "type": "string", "enum": ["upc_a", "ean_13"] } + } + }, + "qr_url": { "type": "string" }, + "lot_number": { "type": "string" }, + "batch_number": { "type": "string" }, + "best_by": { "type": "string" }, + "state_warnings": { + "type": "array", + "items": { "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/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 new file mode 100755 index 0000000..f38af72 --- /dev/null +++ b/scripts/generate_labels.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Generate ALTERNATIVE™ 12oz sleek can labels — Retail Master Lock v2.0.""" + +import argparse +import sys +from pathlib import Path + +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") + parser.add_argument( + "--mode", + choices=["preview", "production"], + 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" + 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) → {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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_spec.py b/scripts/validate_spec.py new file mode 100755 index 0000000..5e19c1e --- /dev/null +++ b/scripts/validate_spec.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Validate ALTERNATIVE™ — Final Prepress + Retail Master Lock v2.0.""" + +import sys +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.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: + brand = load_brand() + skus = load_skus() + flavors = load_flavors() + checks: list[tuple[str, bool, str]] = [] + + 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, "")) + + 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: + 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"]], "")) + + 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(("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 -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, "")) + 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 = min(10.0, round((passed / total) * 10, 2)) + + 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("=" * 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 + + +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..75da44a --- /dev/null +++ b/src/alt_label/__init__.py @@ -0,0 +1,3 @@ +"""ALTERNATIVE™ label generation system — Production Master v1.""" + +__version__ = "2.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_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 new file mode 100644 index 0000000..8f90dff --- /dev/null +++ b/src/alt_label/compliance_loader.py @@ -0,0 +1,96 @@ +"""Load and validate manufacturer-provided compliance data.""" + +import json +from pathlib import Path +from typing import Any + +import jsonschema + +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: + 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_json(path: Path) -> dict[str, Any]: + with open(path, encoding="utf-8") as 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": [], + } + if flavor_data.get("ingredients_lines"): + data["ingredients_lines"] = flavor_data["ingredients_lines"] + 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 found for this SKU/flavor" + if not compliance.get("verified"): + 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/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..ff9f0f8 --- /dev/null +++ b/src/alt_label/layout.py @@ -0,0 +1,118 @@ +"""Label layout — trim + bleed zones for 12oz sleek can.""" + +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: + """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 + barcode_zone: Rect + qr_zone: Rect + warning_zone: Rect + nutrition_zone: Rect + manufacturing_zone: Rect + lot_zone: Rect + + +def build_layout() -> LabelLayout: + brand = load_brand() + 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"]) + + 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_w = safe.width * 0.42 + front_panel = Rect(safe.x, safe.y, front_w, safe.height) + + 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) + + 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, + ) + lot_zone = Rect( + info_panel.x, + safe.y - safe_inset + 2, + info_panel.width, + mm_to_pt(6), + ) + + return LabelLayout( + width=full_w, + height=full_h, + bleed_pt=bleed_pt, + trim_box=trim_box, + 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, + lot_zone=lot_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..4dc89ba --- /dev/null +++ b/src/alt_label/panels/compliance_panel.py @@ -0,0 +1,273 @@ +"""Information panel — compliance only, no decorative elements.""" + +import io + +import qrcode +from reportlab.lib.utils import ImageReader +from reportlab.pdfgen.canvas import Canvas + +from ..colors import MATTE_BLACK, WARM_OFF_WHITE +from ..layout import 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) + _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) + + if compliance and compliance.get("verified"): + render_nutrition_facts(c, layout.nutrition_zone, compliance["nutrition_facts"], typo) + _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) + + +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_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) + 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, +) -> None: + zone = layout.barcode_zone + 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) + + +def _draw_upc_bars(c: Canvas, zone, upc: str) -> None: + 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: + 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(WARM_OFF_WHITE) + c.setFont("Helvetica", typo["compliance_body"]) + 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, + 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) + + 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( + c: Canvas, + layout: LabelLayout, + brand: dict, + sku: dict, + 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, ai["label"]) + y -= typo["compliance_body"] * 1.2 + c.setFont("Helvetica", typo["compliance_body"]) + 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( + 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 = [ + 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"] * 0.95 + + +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", typo["compliance_body"] - 0.5) + for w in warnings[:3]: + for line in _wrap_text(w, 48): + c.drawString(layout.info_panel.x, y, line) + y -= typo["compliance_body"] + + +def _render_lot_areas( + c: Canvas, + layout: LabelLayout, + compliance: dict | None, + typo: dict, +) -> None: + 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(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]: + 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..1e2a0ce --- /dev/null +++ b/src/alt_label/panels/front_panel.py @@ -0,0 +1,103 @@ +"""Front hero panel — Retail Master Lock v2.0 hierarchy.""" + +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, + 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 * line_height + + +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) + + 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(6) + + # 1. Tagline + y = _draw_centered_text( + c, brand["brand"]["tagline"], cx, y, + "Helvetica", typo["tagline"], WARM_OFF_WHITE, + ) + y -= typo["tagline"] * 0.4 + + # 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 = 20 * 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. Positioning — secondary, off-white + y = _draw_centered_text( + c, brand["brand"]["positioning"], cx, y, + "Helvetica", typo["positioning"], WARM_OFF_WHITE, + ) + y -= typo["positioning"] * 0.25 + + # 5. SKU — secondary, off-white + y = _draw_centered_text( + c, sku["name"], cx, y, + "Helvetica-Bold", typo["sku"], WARM_OFF_WHITE, + ) + + # 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, thc_line, cx, y, + "Helvetica-Bold", typo["thc_content"], accent, + line_height=1.4, + ) + y -= typo["thc_content"] * 0.2 + + # 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, + ) + + # 8. Net contents + net = brand.get("net_contents", "12 FL OZ (355 mL)") + _draw_centered_text( + c, net, cx, panel.y + mm_to_pt(8), + "Helvetica", typo["net_contents"], WARM_OFF_WHITE, + ) + + +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 new file mode 100644 index 0000000..3d1a214 --- /dev/null +++ b/src/alt_label/panels/nutrition_facts.py @@ -0,0 +1,68 @@ +"""Manufacturer-provided Nutrition Facts — no estimated values.""" + +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 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"] + + 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-Bold", 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']}") + + 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/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/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 new file mode 100644 index 0000000..45fad67 --- /dev/null +++ b/src/alt_label/renderer.py @@ -0,0 +1,69 @@ +"""Main label PDF renderer — Retail Master Lock v2.0.""" + +from pathlib import Path + +from reportlab.pdfgen import canvas + +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 +from .panels.front_panel import render_front_panel + + +def render_label( + output_path: Path, + sku_id: str, + flavor_id: str, + mode: str = "production", +) -> Path: + 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": + 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 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) + + render_front_panel(c, layout, brand, sku, flavor, typo) + render_compliance_panel(c, layout, brand, sku, compliance, typo) + + c.save() + return output_path + + +def render_all(output_dir: Path, mode: str = "production") -> list[Path]: + 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