{
  "schemaVersion": "1.0",
  "item": {
    "slug": "menuvision",
    "name": "MenuVision",
    "source": "tencent",
    "type": "skill",
    "category": "AI 智能",
    "sourceUrl": "https://clawhub.ai/ademczuk/menuvision",
    "canonicalUrl": "https://clawhub.ai/ademczuk/menuvision",
    "targetPlatform": "OpenClaw"
  },
  "install": {
    "downloadMode": "redirect",
    "downloadUrl": "/downloads/menuvision",
    "sourceDownloadUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=menuvision",
    "sourcePlatform": "tencent",
    "targetPlatform": "OpenClaw",
    "installMethod": "Manual import",
    "extraction": "Extract archive",
    "prerequisites": [
      "OpenClaw"
    ],
    "packageFormat": "ZIP package",
    "includedAssets": [
      "SKILL.md"
    ],
    "primaryDoc": "SKILL.md",
    "quickSetup": [
      "Download the package from Yavira.",
      "Extract the archive and review SKILL.md first.",
      "Import or place the package into your OpenClaw setup."
    ],
    "agentAssist": {
      "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
      "steps": [
        "Download the package from Yavira.",
        "Extract it into a folder your agent can access.",
        "Paste one of the prompts below and point your agent at the extracted folder."
      ],
      "prompts": [
        {
          "label": "New install",
          "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
        },
        {
          "label": "Upgrade existing",
          "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
        }
      ]
    },
    "sourceHealth": {
      "source": "tencent",
      "status": "healthy",
      "reason": "direct_download_ok",
      "recommendedAction": "download",
      "checkedAt": "2026-04-30T16:55:25.780Z",
      "expiresAt": "2026-05-07T16:55:25.780Z",
      "httpStatus": 200,
      "finalUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=network",
      "contentType": "application/zip",
      "probeMethod": "head",
      "details": {
        "probeUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=network",
        "contentDisposition": "attachment; filename=\"network-1.0.0.zip\"",
        "redirectLocation": null,
        "bodySnippet": null
      },
      "scope": "source",
      "summary": "Source download looks usable.",
      "detail": "Yavira can redirect you to the upstream package for this source.",
      "primaryActionLabel": "Download for OpenClaw",
      "primaryActionHref": "/downloads/menuvision"
    },
    "validation": {
      "installChecklist": [
        "Use the Yavira download entry.",
        "Review SKILL.md after the package is downloaded.",
        "Confirm the extracted package contains the expected setup assets."
      ],
      "postInstallChecks": [
        "Confirm the extracted package includes the expected docs or setup files.",
        "Validate the skill or prompts are available in your target agent workspace.",
        "Capture any manual follow-up steps the agent could not complete."
      ]
    },
    "downloadPageUrl": "https://openagent3.xyz/downloads/menuvision",
    "agentPageUrl": "https://openagent3.xyz/skills/menuvision/agent",
    "manifestUrl": "https://openagent3.xyz/skills/menuvision/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/menuvision/agent.md"
  },
  "agentAssist": {
    "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
    "steps": [
      "Download the package from Yavira.",
      "Extract it into a folder your agent can access.",
      "Paste one of the prompts below and point your agent at the extracted folder."
    ],
    "prompts": [
      {
        "label": "New install",
        "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Tell me what you changed and call out any manual steps you could not complete."
      },
      {
        "label": "Upgrade existing",
        "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Summarize what changed and any follow-up checks I should run."
      }
    ]
  },
  "documentation": {
    "source": "clawhub",
    "primaryDoc": "SKILL.md",
    "sections": [
      {
        "title": "MenuVision - Restaurant Menu Builder",
        "body": "Build a beautiful HTML photo menu for any restaurant from URLs, PDFs, or photos."
      },
      {
        "title": "When to Use",
        "body": "When the user wants to create a digital menu for a restaurant. Triggers: \"build a menu\", \"create restaurant menu\", \"menu from PDF\", \"menu from photos\", \"digital menu\", \"menuvision\"."
      },
      {
        "title": "Quick Start",
        "body": "1. Extract:  URL/PDF/photo  →  menu_data.json     (Gemini Vision)\n2. Generate: menu_data.json →  images/*.jpg        (Gemini Image)\n3. Build:    menu_data.json + images → Menu.html   (CSS/JS inline, images relative)"
      },
      {
        "title": "Example usage (ask the AI):",
        "body": "\"Build a menu for https://www.shoyu.at/menus\"\n\"Create a photo menu from this PDF\" (attach file)\n\"Make a digital menu from these photos of a restaurant menu\""
      },
      {
        "title": "Pipeline Components",
        "body": "The AI agent creates these scripts:\n\nScriptPurposeextract_menu.pyExtract menu data from URL/PDF/photo → structured JSONgenerate_images.pyGenerate food photos via Gemini Imagebuild_menu.pyBuild HTML menu from JSON + images (CSS/JS inline, images as relative paths)publish_menu.py(Optional) Publish HTML to GitHub Pages"
      },
      {
        "title": "DATA CONTRACT (Critical)",
        "body": "All three pipeline stages share this exact JSON schema. The AI agent MUST use these field names — any deviation breaks the pipeline."
      },
      {
        "title": "menu_data.json Schema",
        "body": "{\n  \"restaurant\": {\n    \"name\": \"Restaurant Name (if visible)\",\n    \"cuisine\": \"cuisine type (Chinese, Indian, Austrian, Japanese, etc.)\",\n    \"tagline\": \"any subtitle or tagline\"\n  },\n  \"sections\": [\n    {\n      \"title\": \"Section Name (in primary language)\",\n      \"title_secondary\": \"Section name in secondary language (if present, else empty string)\",\n      \"category\": \"food or drink\",\n      \"note\": \"Any section note (e.g. 'served with rice', 'Mon-Fri 11-15h')\",\n      \"items\": [\n        {\n          \"code\": \"M1\",\n          \"name\": \"Dish Name (primary language)\",\n          \"name_secondary\": \"Name in secondary language (if present)\",\n          \"description\": \"Brief description (primary language)\",\n          \"description_secondary\": \"Description in secondary language (if present)\",\n          \"price\": \"12,90\",\n          \"price_prefix\": \"\",\n          \"allergens\": \"A C F\",\n          \"dietary\": [\"vegan\", \"spicy\"],\n          \"variants\": []\n        }\n      ]\n    }\n  ],\n  \"allergen_legend\": {\n    \"A\": \"Gluten\",\n    \"B\": \"Crustaceans\"\n  },\n  \"metadata\": {\n    \"languages\": [\"German\", \"English\"],\n    \"currency\": \"EUR\"\n  }\n}"
      },
      {
        "title": "Field Reference",
        "body": "FieldTypeRequiredNotesrestaurant.namestringYesDisplay name in HTML headerrestaurant.cuisinestringYesPassed to build_food_prompt() as cuisine contextrestaurant.taglinestringNoSubtitle line in HTML headersections[].titlestringYesSection heading in primary languagesections[].title_secondarystringNoSection heading in secondary languagesections[].category\"food\" or \"drink\"YesDrives food grid vs drink list layout. Only \"food\" items get generated images.sections[].notestringNoSection-level note (e.g. \"served with rice\", \"Mon-Fri 11-15h\")items[].codestringYesUnique per item. Links to image filename. Use existing codes (M1, K2) or generate (A1, A2)items[].namestringYesPrimary language. For CJK menus, this is the CJK nameitems[].name_secondarystringNoSecondary language. For CJK menus, this is the English/Latin nameitems[].descriptionstringNoBrief description. Fed to build_food_prompt() for image generationitems[].description_secondarystringNoDescription in secondary languageitems[].pricestringYesPreserve original format (\"12,90\" not \"12.90\")items[].price_prefixstringNoe.g. \"ab\" (starting from), \"ca.\"items[].variantsarrayNo[{\"label\": \"6 Stk\", \"price\": \"8,90\"}, ...] — set main price to smallest variantitems[].allergensstringNoSpace-separated codes exactly as printed: \"A C F\"items[].dietaryarrayNo[\"vegan\", \"vegetarian\", \"spicy\", \"gluten-free\", \"halal\", \"kosher\"]allergen_legendobjectNoMap of allergen codes to display names: {\"A\": \"Gluten\", ...}metadata.currencystringYesISO code: \"EUR\", \"USD\", \"JPY\", \"CNY\", \"THB\", etc.metadata.languagesarrayNoLanguages detected in the menu: [\"German\", \"English\"]"
      },
      {
        "title": "EXTRACTION PROMPT",
        "body": "Send this exact prompt to Gemini. It defines the schema AND the extraction rules. Do not paraphrase it.\n\nYou are a restaurant menu data extractor. Analyze this menu content and extract ALL items into structured JSON.\n\nReturn this exact JSON structure:\n{\n  \"restaurant\": {\n    \"name\": \"Restaurant Name (if visible)\",\n    \"cuisine\": \"cuisine type (Chinese, Indian, Austrian, Japanese, etc.)\",\n    \"tagline\": \"any subtitle or tagline\"\n  },\n  \"sections\": [\n    {\n      \"title\": \"Section Name (in primary language)\",\n      \"title_secondary\": \"Section name in secondary language (if present, else empty string)\",\n      \"category\": \"food or drink\",\n      \"note\": \"Any section note (e.g. 'served with rice', 'Mon-Fri 11-15h')\",\n      \"items\": [\n        {\n          \"code\": \"M1\",\n          \"name\": \"Dish Name (primary language)\",\n          \"name_secondary\": \"Name in secondary language (if present)\",\n          \"description\": \"Brief description (primary language)\",\n          \"description_secondary\": \"Description in secondary language (if present)\",\n          \"price\": \"12,90\",\n          \"price_prefix\": \"\",\n          \"allergens\": \"A C F\",\n          \"dietary\": [\"vegan\", \"spicy\"],\n          \"variants\": []\n        }\n      ]\n    }\n  ],\n  \"allergen_legend\": {\n    \"A\": \"Gluten\",\n    \"B\": \"Crustaceans\"\n  },\n  \"metadata\": {\n    \"languages\": [\"German\", \"English\"],\n    \"currency\": \"EUR\"\n  }\n}\n\nCRITICAL RULES:\n1. Extract EVERY item. Do not skip ANY dish, drink, or menu entry.\n2. Preserve original item codes/numbers if present (M1, K2, S3, etc.). If none exist, generate sequential codes per section (e.g. A1, A2 for appetizers, M1, M2 for mains).\n3. Extract prices EXACTLY as written (preserve comma/period format).\n4. If an item has a price prefix like \"ab\" (starting from), capture it in \"price_prefix\".\n5. If an item has multiple size/quantity variants (e.g. 6 Stk / 12 Stk / 18 Stk at different prices), use the \"variants\" array:\n   [{\"label\": \"6 Stk\", \"price\": \"8,90\"}, {\"label\": \"12 Stk\", \"price\": \"15,90\"}]\n   In this case, set the main \"price\" to the smallest variant's price.\n6. Capture allergen codes exactly as shown (letters, numbers, or symbols).\n7. If an allergen legend is visible anywhere, include it in \"allergen_legend\".\n8. Identify dietary flags from descriptions/icons: vegan, vegetarian, spicy, gluten-free, halal, kosher.\n9. If the menu is bilingual, capture BOTH languages. Put the primary/dominant language in name/description and the secondary in name_secondary/description_secondary.\n10. For set menus or lunch specials with a fixed price covering multiple choices, create a section with note explaining the format, and list each choice as an item.\n11. Classify each section as \"food\" or \"drink\".\n12. For drinks, still extract name, price, and any size variants.\n\nReturn ONLY valid JSON. No markdown fences, no explanatory text."
      },
      {
        "title": "Vision Prompt Variant",
        "body": "For image-based inputs (screenshots, PDF pages, photos), prepend a context line before the base prompt:\n\nEXTRACTION_PROMPT_VISION = (\n    \"You are a restaurant menu data extractor. \"\n    \"This is a photo/scan of a restaurant menu page.\\n\\n\"\n    \"Return this exact JSON structure:\"\n    + EXTRACTION_PROMPT.split(\"Return this exact JSON structure:\")[1]\n)\n\nThen each input type adds its own prefix:\n\nInput TypePrefix prepended to EXTRACTION_PROMPT_VISIONScreenshot\"This is a screenshot of a restaurant menu webpage at {url}. Extract ALL visible menu items.\\n\\n\"PDF page\"This is page {n} of a restaurant menu PDF. Extract ALL menu items from this page.\\n\\n\"Photo\"This is a photograph of a restaurant menu. Extract ALL visible menu items.\\n\\n\"Text (static HTML)Use EXTRACTION_PROMPT directly (no vision variant needed)"
      },
      {
        "title": "GEMINI API CONFIGURATION",
        "body": "import os\nfrom google import genai\n\nclient = genai.Client(api_key=os.environ[\"GOOGLE_API_KEY\"])\n\ndef gemini_config():\n    return genai.types.GenerateContentConfig(\n        max_output_tokens=65536,          # 64K — needed for large menus\n        response_mime_type=\"application/json\",  # JSON mode — critical\n    )\n\n# Model: gemini-2.5-flash (default)\nresponse = client.models.generate_content(\n    model=\"gemini-2.5-flash\",\n    contents=prompt_text,    # or [image, prompt_text] for vision\n    config=gemini_config(),\n)\n\n# ALWAYS check for truncation\nif response.candidates[0].finish_reason.name == \"MAX_TOKENS\":\n    print(\"WARNING: Response truncated. Menu may be incomplete.\")"
      },
      {
        "title": "IMAGE PROMPT TEMPLATE",
        "body": "Use this exact function. It produces the casual phone-photo aesthetic that makes menus look authentic.\n\ndef build_food_prompt(name: str, description: str, cuisine: str = \"\") -> str:\n    cuisine_context = f\" {cuisine}\" if cuisine else \"\"\n    food_desc = f\"{name}\"\n    if description and description != name:\n        food_desc += f\" ({description})\"\n\n    return (\n        f\"A photo of {food_desc} at a{cuisine_context} restaurant. \"\n        f\"Taken casually with a phone from across the table at a 45-degree angle. \"\n        f\"The plate sits on a dark wooden table and takes up only 30% of the frame. \"\n        f\"Lots of visible table surface around the plate. Chopsticks, napkins, \"\n        f\"a glass of water, and small side dishes scattered naturally nearby. \"\n        f\"Blurred restaurant interior in the background — other diners, pendant lights, \"\n        f\"wooden chairs visible but out of focus. Warm ambient lighting. \"\n        f\"NOT a close-up. NOT professional food photography. \"\n        f\"It looks like someone quickly snapped a photo before eating.\"\n    )"
      },
      {
        "title": "Gemini 2.5 Flash Image",
        "body": "import os, io\nfrom PIL import Image\nfrom google import genai\n\nclient = genai.Client(api_key=os.environ[\"GOOGLE_API_KEY\"])\n\ndef generate_gemini(client, name, description, output_path, cuisine=\"\"):\n    prompt = build_food_prompt(name, description, cuisine)\n\n    response = client.models.generate_content(\n        model=\"gemini-2.5-flash-image\",       # NOT gemini-2.5-flash (that's text-only)\n        contents=prompt,\n        config=genai.types.GenerateContentConfig(\n            response_modalities=[\"TEXT\", \"IMAGE\"],  # critical — requests image output\n        ),\n    )\n\n    # Extract generated image from response parts\n    for part in response.candidates[0].content.parts:\n        if part.inline_data is not None:\n            img = Image.open(io.BytesIO(part.inline_data.data)).convert(\"RGB\")\n            # Center-crop to square, resize to 800x800\n            w, h = img.size\n            side = min(w, h)\n            left = (w - side) // 2\n            top = (h - side) // 2\n            img = img.crop((left, top, left + side, top + side))\n            img = img.resize((800, 800), Image.LANCZOS)\n            img.save(str(output_path), \"JPEG\", quality=82)\n            return\n    raise RuntimeError(\"No image in Gemini response\")"
      },
      {
        "title": "Skip drinks",
        "body": "Only generate images for category == \"food\" sections. Drinks get a text-only list in the HTML output."
      },
      {
        "title": "MULTILINGUAL / CJK HANDLING",
        "body": "Menus can be in ANY language. The pipeline handles this through bilingual fields and smart prompt routing."
      },
      {
        "title": "Extraction (all languages)",
        "body": "name / description = primary language (whatever the menu is mostly written in)\nname_secondary / description_secondary = secondary language (if bilingual)\nWorks for: German/English, Chinese/English, Japanese/English, Thai/English, Arabic/English, Korean/English, etc."
      },
      {
        "title": "Image Generation (CJK-safe prompting)",
        "body": "CJK characters produce bad image prompts. Before calling build_food_prompt(), swap to the Latin name:\n\ndef prepare_for_image_gen(name, name_secondary, description):\n    \"\"\"Use Latin-script name for image prompts. CJK → use secondary name.\"\"\"\n    display_name = name\n    if name_secondary:\n        if any(ord(c) > 0x2E80 for c in name):  # CJK/Hangul/Kana detection\n            display_name = name_secondary\n            description = description or name\n        else:\n            description = description or name_secondary\n    return display_name, description\n\nUnicode ranges covered by ord(c) > 0x2E80:\n\nCJK Unified Ideographs (Chinese characters)\nHiragana / Katakana (Japanese)\nHangul (Korean)\nCJK Compatibility, Radicals, Extensions"
      },
      {
        "title": "HTML Output (all scripts)",
        "body": "name renders as the large display text\nname_secondary renders below it in smaller text\nBoth use Google Fonts with CJK fallback (Noto Sans SC, Noto Sans JP, Noto Sans KR)"
      },
      {
        "title": "Auto-derivation",
        "body": "All filenames are derived from the restaurant name or source URL:\n\nstem = \"shoyu\"  # derived from URL domain, PDF filename, or restaurant name\ndata_file = f\"menu_data_{stem}.json\"\nimages_dir = Path(f\"images/{stem}\")\nhtml_file = f\"{restaurant_name}_Menu.html\"  # e.g. \"Shoyu_Menu.html\""
      },
      {
        "title": "Image files",
        "body": "images/{restaurant_stem}/{code}.jpg\n\n# restaurant_stem = data filename minus \"menu_data_\" prefix\n# Example: menu_data_shoyu.json → images/shoyu/M1.jpg"
      },
      {
        "title": "Image path matching (in build step)",
        "body": "Returns POSIX-style string paths with ./ prefix for cross-platform HTML compatibility:\n\ndef find_image(code: str, images_dir: Path):\n    \"\"\"Return relative POSIX path string to image, or None.\"\"\"\n    if not images_dir.is_dir():\n        return None\n    rel = images_dir.as_posix()\n    if not rel.startswith(\"./\"):\n        rel = \"./\" + rel\n    # 1. Exact match\n    for ext in (\"jpg\", \"jpeg\", \"webp\", \"png\"):\n        candidate = images_dir / f\"{code}.{ext}\"\n        if candidate.exists():\n            return f\"{rel}/{code}.{ext}\"\n    # 2. Case-insensitive fallback\n    for f in images_dir.iterdir():\n        if f.stem.lower() == code.lower() and f.suffix.lower() in (\".jpg\", \".jpeg\", \".webp\", \".png\"):\n            return f\"{rel}/{f.name}\"\n    return None"
      },
      {
        "title": "Output HTML",
        "body": "{RestaurantName}_Menu.html    # CSS/JS inline, images as relative file paths"
      },
      {
        "title": "Image rendering (build step)",
        "body": "The build script uses find_image() to resolve each food item's photo, falling back to a gradient SVG placeholder when no image exists:\n\nimport base64\nimport html as html_mod\n\nGRADIENT_COLORS = [\n    (\"#c41e3a\", \"#8b0000\"), (\"#ff6b6b\", \"#ee5a24\"), (\"#fdcb6e\", \"#e17055\"),\n    (\"#00b894\", \"#00cec9\"), (\"#6c5ce7\", \"#a29bfe\"), (\"#e17055\", \"#d63031\"),\n    (\"#00cec9\", \"#0984e3\"), (\"#fab1a0\", \"#e17055\"), (\"#e8a87c\", \"#d4956b\"),\n    (\"#fd79a8\", \"#e84393\"),\n]\n\ndef make_placeholder_svg(code: str, name: str, secondary: str = \"\") -> str:\n    \"\"\"Generate a base64-encoded SVG placeholder when no image exists.\"\"\"\n    idx = hash(code) % len(GRADIENT_COLORS)\n    c1, c2 = GRADIENT_COLORS[idx]\n    display = html_mod.escape(secondary[:12] if secondary else name[:12])\n    svg = f'''<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"220\" height=\"180\" viewBox=\"0 0 220 180\">\n  <defs><linearGradient id=\"g\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n    <stop offset=\"0%\" style=\"stop-color:{c1}\"/>\n    <stop offset=\"100%\" style=\"stop-color:{c2}\"/>\n  </linearGradient></defs>\n  <rect width=\"220\" height=\"180\" fill=\"url(#g)\" rx=\"12\"/>\n  <text x=\"110\" y=\"75\" text-anchor=\"middle\" fill=\"rgba(255,255,255,0.25)\" font-size=\"56\" font-family=\"serif\">{html_mod.escape(code)}</text>\n  <text x=\"110\" y=\"120\" text-anchor=\"middle\" fill=\"white\" font-size=\"26\" font-family=\"serif\" opacity=\"0.9\">{display}</text>\n  <text x=\"110\" y=\"148\" text-anchor=\"middle\" fill=\"rgba(255,255,255,0.6)\" font-size=\"11\" font-family=\"sans-serif\">{html_mod.escape(name[:30])}</text>\n</svg>'''\n    b64 = base64.b64encode(svg.encode(\"utf-8\")).decode(\"ascii\")\n    return f\"data:image/svg+xml;base64,{b64}\"\n\n\ndef image_tag(code: str, name: str, secondary: str, images_dir: Path, portable: bool = False) -> str:\n    \"\"\"Return <img> tag — real image OR gradient SVG placeholder.\n    If portable=True, embed the real image as base64 data URI for single-file output.\"\"\"\n    real = find_image(code, images_dir)\n    if real:\n        if portable:\n            img_path = images_dir.parent / real  # resolve relative path\n            with open(img_path, \"rb\") as f:\n                b64 = base64.b64encode(f.read()).decode(\"ascii\")\n            return f'<img src=\"data:image/jpeg;base64,{b64}\" alt=\"{html_mod.escape(name)}\">'\n        return f'<img src=\"{html_mod.escape(real)}\" alt=\"{html_mod.escape(name)}\" loading=\"lazy\">'\n    else:\n        src = make_placeholder_svg(code, name, secondary)\n        return f'<img src=\"{src}\" alt=\"{html_mod.escape(name)}\">'"
      },
      {
        "title": "Output Modes",
        "body": "The HTML builder supports two output modes controlled by a --portable flag:\n\nModeFlagImagesOutputUse CasePortable (default)--portable or no GITHUB_* env varsBase64 embedded in HTMLSingle self-contained .html fileOpen locally, email, drag-drop to any hostDeployable--no-portable or GITHUB_* env vars setRelative paths (./images/stem/code.jpg)HTML + images/ directoryGitHub Pages, Netlify, any static host\n\nPortable mode embeds all food images as base64 data URIs directly in the HTML. File sizes are larger (~4-6MB for an 80-item menu) but the output is a single file that works everywhere with zero hosting setup. This is the default when no GITHUB_* environment variables are set.\n\nDeployable mode uses relative image paths and requires the HTML file and images/ directory to be hosted together. Use this when publishing to GitHub Pages or any static hosting service."
      },
      {
        "title": "Retry Logic",
        "body": "All Gemini API calls should retry on transient failures:\n\nimport time\n\ndef call_with_retry(fn, *args, max_retries=3, **kwargs):\n    \"\"\"Retry API calls with exponential backoff.\"\"\"\n    for attempt in range(max_retries):\n        try:\n            return fn(*args, **kwargs)\n        except Exception as e:\n            if attempt == max_retries - 1:\n                raise\n            wait = 2 ** attempt\n            print(f\"  Retry {attempt + 1}/{max_retries} in {wait}s: {e}\")\n            time.sleep(wait)"
      },
      {
        "title": "JSON Response Parsing",
        "body": "Gemini sometimes wraps JSON in markdown fences or produces trailing commas. Parse defensively — try raw parse first, apply trailing comma fix only as last resort (unconditional fix can corrupt valid JSON strings containing ,] patterns):\n\nimport re, json\n\ndef parse_gemini_json(raw: str) -> dict:\n    \"\"\"Parse JSON from Gemini, handling markdown fences and quirks.\"\"\"\n    text = raw.strip()\n    # Strip markdown code fences\n    if text.startswith(\"```\"):\n        text = re.sub(r\"^```\\w*\\n?\", \"\", text)\n        text = re.sub(r\"\\n?```$\", \"\", text)\n    text = text.strip()\n    # Try direct parse first\n    try:\n        return json.loads(text)\n    except json.JSONDecodeError:\n        pass\n    # Try extracting JSON object from surrounding text\n    match = re.search(r\"\\{.*\\}\", text, re.DOTALL)\n    if match:\n        candidate = match.group(0)\n        try:\n            return json.loads(candidate)\n        except json.JSONDecodeError:\n            pass\n        # Fix trailing commas and retry\n        candidate = re.sub(r\",\\s*([\\]}])\", r\"\\1\", candidate)\n        try:\n            return json.loads(candidate)\n        except json.JSONDecodeError:\n            pass\n    # Last resort: fix trailing commas on original\n    text = re.sub(r\",\\s*([\\]}])\", r\"\\1\", text)\n    return json.loads(text)"
      },
      {
        "title": "Post-Processing",
        "body": "After extraction, run these cleanups:\n\ndef generate_codes(data: dict) -> dict:\n    \"\"\"Ensure every item has a unique code. Generates sequential codes per section\n    if items have empty/missing codes (e.g. A1, A2 for appetizers, M1, M2 for mains).\"\"\"\n    # ... assign prefix by section title, increment counter per section\n    return data\n\ndef normalize_prices(data: dict) -> dict:\n    \"\"\"Normalize price formats: numeric → string, strip currency symbols,\n    preserve comma/period format as-is.\"\"\"\n    # ... convert float/int to string, strip €/$, etc.\n    return data"
      },
      {
        "title": "CURRENCY_MAP",
        "body": "Maps ISO currency codes to display symbols for the HTML output:\n\nCURRENCY_MAP = {\n    \"EUR\": \"€\", \"USD\": \"$\", \"GBP\": \"£\", \"CHF\": \"CHF \",\n    \"JPY\": \"¥\", \"CNY\": \"¥\", \"INR\": \"₹\", \"AUD\": \"A$\",\n    \"CAD\": \"C$\", \"SEK\": \"kr \", \"NOK\": \"kr \", \"DKK\": \"kr \",\n    \"THB\": \"฿\", \"KRW\": \"₩\", \"HKD\": \"HK$\", \"SGD\": \"S$\",\n    \"CZK\": \"Kč \", \"HUF\": \"Ft \", \"PLN\": \"zł \", \"TRY\": \"₺\",\n}"
      },
      {
        "title": "HTML URLs",
        "body": "Fetch page with requests\nCheck text density to detect static vs JS-rendered:\ndensity = len(soup.get_text(strip=True)) / len(raw_html)\nDensity override: If 5+ price patterns found (r\"[$€£¥₹CHF]\\s*\\d+[.,]\\d{2}|\\d+[.,]\\d{2}\\s*[$€£¥₹]\"), force density to 1.0 (treat as static)\nStatic (density >= 0.02): Clean HTML, send text to Gemini 2.5 Flash (JSON mode)\nJS-rendered (density < 0.02, e.g. Wix, Framer): Screenshot with Playwright, send to Gemini Vision\nScreenshot height cap: If screenshot > 6000px tall, resize proportionally to fit\nLarge menus (>12k chars text): Chunked extraction, merge like PDF multi-page. Deduplicate by tracking seen_codes = set() across chunks — for each item in each chunk's sections, skip if item[\"code\"] already in seen_codes. Only append sections that still have items after dedup."
      },
      {
        "title": "PDF Files",
        "body": "Convert each page to image via PyMuPDF (200 DPI)\nSend each page image to Gemini Vision\nMerge results across pages (deduplicate items by code)"
      },
      {
        "title": "Photos",
        "body": "Load image directly\nResize if >10MB\nSend to Gemini Vision"
      },
      {
        "title": "HTML OUTPUT FEATURES",
        "body": "3-column Instagram-style grid (9:16 portrait tiles)\nGradient text overlay with name + secondary language + price\nTap-to-select with green checkmark\nReceipt/bill on Selection tab with +/- quantity controls\nCategory pill navigation with scroll sync\nDrinks section below grid with currency-prefixed prices\nAllergen legend\nCurrency converter — minimalist button in header (e.g. € pill) that cycles or opens a picker for: EUR, USD, AUD, CAD, GBP. Converts all displayed prices client-side using snapshot exchange rates embedded at build time. Updates grid overlays, receipt totals, drink prices, and variant prices. Source currency comes from metadata.currency.\nFully responsive, dark mode\nAll CSS/JS inline, images via relative file paths (./images/{stem}/{code}.jpg), only Google Fonts external\nGradient SVG placeholders for missing images (inline base64 SVG, not raster)\nCJK font loading via Google Fonts link tag:\nfamily=Noto+Sans+SC:wght@400;700&family=Noto+Sans+JP:wght@400;700&family=Noto+Sans+KR:wght@400;700\nCSS font-family stack: primary font, then 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif"
      },
      {
        "title": "Currency Converter",
        "body": "A minimalist currency toggle built into the HTML output. All client-side, no API calls at runtime.\n\nImplementation:\n\nThe build script embeds a RATES object with snapshot exchange rates (base: USD) at build time\nSource currency is read from metadata.currency in the JSON data\nAll prices are stored in data-price attributes as numeric values (not raw strings like \"12,90\")\nA small pill button in the header shows the current currency symbol (e.g. €)\nTapping opens a mini-picker or cycles through: EUR (€), USD ($), GBP (£), AUD (A$), CAD (C$)\nOn currency change, JavaScript converts all data-price values and updates displayed text\nReceipt totals in the Selection tab also convert via convertPrice() using SOURCE_CURRENCY and currentCurrency\nVariant prices also update\nSelected currency persists in localStorage\n\nPrice parsing helper (build-time — converts string prices to numeric for data-price attributes):\n\nimport re\n\ndef _parse_price_numeric(price: str) -> str:\n    \"\"\"Parse price string to numeric float for data-price attribute.\"\"\"\n    matches = re.findall(r\"(\\d+[.,]\\d+)\", price)\n    if matches:\n        return str(float(matches[-1].replace(\",\", \".\")))\n    return \"0\"\n\n# Usage in HTML template:\n# <div class=\"price\" data-price=\"{_parse_price_numeric(item['price'])}\">€12,90</div>\n\n// Snapshot rates embedded at build time (base: USD)\nconst RATES = { EUR: 0.92, USD: 1.00, GBP: 0.79, AUD: 1.54, CAD: 1.36 };\nconst SYMBOLS = { EUR: \"€\", USD: \"$\", GBP: \"£\", AUD: \"A$\", CAD: \"C$\" };\nconst SOURCE_CURRENCY = \"EUR\";  // from metadata.currency\n\nfunction convertPrice(amount, fromCurrency, toCurrency) {\n    const inUSD = amount / RATES[fromCurrency];\n    return inUSD * RATES[toCurrency];\n}\n\n// Applied to: grid overlay prices, drink list prices, variant prices,\n// AND receipt/selection tab totals (all elements with data-price attribute)\n\nThe build script should fetch current rates at build time (or use reasonable defaults if offline). Prices display with 2 decimal places in the target currency, using the target locale's format."
      },
      {
        "title": "Branding Customization",
        "body": "--name \"Restaurant Name\"     # Header brand text\n--tagline \"Cuisine · City\"   # Subtitle\n--accent \"#ff6b00\"           # Primary color (pills, active tab, drink prices)\n--bg \"#0a0a0a\"               # Background color"
      },
      {
        "title": "COST SUMMARY",
        "body": "ComponentCostExtraction (per page)~$0.001Image generation (per food item)$0.03980 food items~$3.12Time (80 food items)~8 min\n\nDrinks are not image-generated (text-only list), so actual cost depends on food-to-drink ratio."
      },
      {
        "title": "DEPENDENCIES",
        "body": "Requires Python 3.9+.\n\nRequired:\n\ngoogle-genai (extraction + image generation)\nPillow (image processing)\n\nFor HTML URLs:\n\nrequests (HTTP fetching)\nbeautifulsoup4 (HTML parsing)\n\nFor JS-rendered sites:\n\nplaywright (headless browser screenshots)\n\nFor PDF files:\n\nPyMuPDF (PDF to image conversion)\n\npip install google-genai Pillow requests beautifulsoup4 PyMuPDF\npip install playwright && playwright install chromium"
      },
      {
        "title": "ENVIRONMENT VARIABLES",
        "body": "GOOGLE_API_KEY — Required for extraction and image generation\nGITHUB_PAT — Required for GitHub Pages publishing\nGITHUB_OWNER — Your GitHub username (default: reads from git config)\nGITHUB_REPO — Your GitHub Pages repo name (default: menus)"
      },
      {
        "title": "Default: Portable HTML (no setup)",
        "body": "When no GITHUB_* environment variables are set, the pipeline generates a self-contained HTML file with base64-embedded images. Users can:\n\nOpen the file directly in any browser\nEmail it or share via any file-sharing service\nUpload to any static host (Netlify Drop, Vercel, GitHub Pages, S3)\n\nNo hosting setup, no API keys beyond GOOGLE_API_KEY, no git configuration needed."
      },
      {
        "title": "Optional: GitHub Pages (requires setup)",
        "body": "For users who want a persistent gallery with multiple menus:\n\nCreate a GitHub repo for your menus (e.g. your-username/menus)\nEnable GitHub Pages on the main branch\nSet environment variables (must be accessible to the Python process):\n\nexport GITHUB_PAT=\"your-personal-access-token\"   # Required — used for git push auth\nexport GITHUB_OWNER=\"your-username\"               # Required — YOUR GitHub username\nexport GITHUB_REPO=\"menus\"                        # Optional — defaults to \"menus\"\n\nImportant: publish_menu.py MUST read GITHUB_OWNER and GITHUB_REPO from environment variables. Never hardcode a specific user's repo. The generated code should construct the repo URL dynamically:\n\nowner = os.environ[\"GITHUB_OWNER\"]\nrepo = os.environ.get(\"GITHUB_REPO\", \"menus\")\nGITHUB_REPO = f\"{owner}/{repo}\"\nGITHUB_PAGES_BASE = f\"https://{owner}.github.io/{repo}\""
      },
      {
        "title": "Publish",
        "body": "python publish_menu.py Restaurant_Menu.html --name \"Restaurant\" --tagline \"Cuisine · City\" --cuisine Type\n\nGallery: https://<your-username>.github.io/<repo>/"
      },
      {
        "title": "How publishing works",
        "body": "publish_menu.py clones the menus repo to a temp directory on native filesystem (git clone --depth=1), copies files there, commits, and pushes. This avoids all NTFS bind mount permission issues that occur when operating directly on mounted volumes in Docker containers.\n\nKey implementation details:\n\ngit clone --depth=1 to a tempfile.mkdtemp() directory (native FS, proper POSIX permissions)\nCopies HTML + images using shutil.copy() (not copy2 — avoids os.chmod() EPERM on NTFS)\nfind_image_dirs regex uses [^/\"]+ (not [a-z_]+) to match Unicode chars in image dir names\nWrites .meta_ JSON sidecar for gallery metadata\nRebuilds gallery index.html\nAuthenticates push via GITHUB_PAT env var embedded in the clone URL\nTemp directory is cleaned up after push\nMENUS_REPO_DIR (bind mount path) is only used for --list read-only queries"
      },
      {
        "title": "EXTERNAL ENDPOINTS",
        "body": "EndpointData SentPurposegenerativelanguage.googleapis.comMenu text, page screenshots, PDF page images, food photo promptsGemini API for extraction (JSON mode) and image generationTarget restaurant URLHTTP GET onlyFetching the menu page HTML for extractionapi.github.comGenerated HTML file, image filesPublishing menu to GitHub Pages (optional, requires GITHUB_PAT)fonts.googleapis.comNone (CSS link in HTML output)Google Fonts loaded client-side when menu HTML is opened in browser\n\nNo analytics, telemetry, or tracking. No data is sent to any endpoint beyond those listed above."
      },
      {
        "title": "SECURITY & PRIVACY",
        "body": "API keys: GOOGLE_API_KEY is read from environment variables, never hardcoded or logged\nGitHub PAT: Used only for authenticated pushes to the user's own repo; never transmitted elsewhere\nRestaurant data: Menu content is sent to the Gemini API for processing. No data is stored server-side beyond Google's standard API retention\nGenerated images: Stored locally in images/ directory. When published, uploaded only to the user's own GitHub Pages repo\nNo telemetry: The pipeline collects no analytics, metrics, or usage data\nLocal-first: All processing happens locally except Gemini API calls. The HTML output and images remain on the user's machine unless they explicitly publish"
      },
      {
        "title": "KNOWN LIMITATIONS",
        "body": "Tabbed Wix menus: Only first visible tab extracted\nGoogle Maps photo URLs: Not supported (use direct image files)\nVery large menus (300+ items): May need manual chunk review"
      }
    ],
    "body": "MenuVision - Restaurant Menu Builder\n\nBuild a beautiful HTML photo menu for any restaurant from URLs, PDFs, or photos.\n\nWhen to Use\n\nWhen the user wants to create a digital menu for a restaurant. Triggers: \"build a menu\", \"create restaurant menu\", \"menu from PDF\", \"menu from photos\", \"digital menu\", \"menuvision\".\n\nQuick Start\n1. Extract:  URL/PDF/photo  →  menu_data.json     (Gemini Vision)\n2. Generate: menu_data.json →  images/*.jpg        (Gemini Image)\n3. Build:    menu_data.json + images → Menu.html   (CSS/JS inline, images relative)\n\nExample usage (ask the AI):\n\"Build a menu for https://www.shoyu.at/menus\"\n\"Create a photo menu from this PDF\" (attach file)\n\"Make a digital menu from these photos of a restaurant menu\"\nPipeline Components\n\nThe AI agent creates these scripts:\n\nScript\tPurpose\nextract_menu.py\tExtract menu data from URL/PDF/photo → structured JSON\ngenerate_images.py\tGenerate food photos via Gemini Image\nbuild_menu.py\tBuild HTML menu from JSON + images (CSS/JS inline, images as relative paths)\npublish_menu.py\t(Optional) Publish HTML to GitHub Pages\nDATA CONTRACT (Critical)\n\nAll three pipeline stages share this exact JSON schema. The AI agent MUST use these field names — any deviation breaks the pipeline.\n\nmenu_data.json Schema\n{\n  \"restaurant\": {\n    \"name\": \"Restaurant Name (if visible)\",\n    \"cuisine\": \"cuisine type (Chinese, Indian, Austrian, Japanese, etc.)\",\n    \"tagline\": \"any subtitle or tagline\"\n  },\n  \"sections\": [\n    {\n      \"title\": \"Section Name (in primary language)\",\n      \"title_secondary\": \"Section name in secondary language (if present, else empty string)\",\n      \"category\": \"food or drink\",\n      \"note\": \"Any section note (e.g. 'served with rice', 'Mon-Fri 11-15h')\",\n      \"items\": [\n        {\n          \"code\": \"M1\",\n          \"name\": \"Dish Name (primary language)\",\n          \"name_secondary\": \"Name in secondary language (if present)\",\n          \"description\": \"Brief description (primary language)\",\n          \"description_secondary\": \"Description in secondary language (if present)\",\n          \"price\": \"12,90\",\n          \"price_prefix\": \"\",\n          \"allergens\": \"A C F\",\n          \"dietary\": [\"vegan\", \"spicy\"],\n          \"variants\": []\n        }\n      ]\n    }\n  ],\n  \"allergen_legend\": {\n    \"A\": \"Gluten\",\n    \"B\": \"Crustaceans\"\n  },\n  \"metadata\": {\n    \"languages\": [\"German\", \"English\"],\n    \"currency\": \"EUR\"\n  }\n}\n\nField Reference\nField\tType\tRequired\tNotes\nrestaurant.name\tstring\tYes\tDisplay name in HTML header\nrestaurant.cuisine\tstring\tYes\tPassed to build_food_prompt() as cuisine context\nrestaurant.tagline\tstring\tNo\tSubtitle line in HTML header\nsections[].title\tstring\tYes\tSection heading in primary language\nsections[].title_secondary\tstring\tNo\tSection heading in secondary language\nsections[].category\t\"food\" or \"drink\"\tYes\tDrives food grid vs drink list layout. Only \"food\" items get generated images.\nsections[].note\tstring\tNo\tSection-level note (e.g. \"served with rice\", \"Mon-Fri 11-15h\")\nitems[].code\tstring\tYes\tUnique per item. Links to image filename. Use existing codes (M1, K2) or generate (A1, A2)\nitems[].name\tstring\tYes\tPrimary language. For CJK menus, this is the CJK name\nitems[].name_secondary\tstring\tNo\tSecondary language. For CJK menus, this is the English/Latin name\nitems[].description\tstring\tNo\tBrief description. Fed to build_food_prompt() for image generation\nitems[].description_secondary\tstring\tNo\tDescription in secondary language\nitems[].price\tstring\tYes\tPreserve original format (\"12,90\" not \"12.90\")\nitems[].price_prefix\tstring\tNo\te.g. \"ab\" (starting from), \"ca.\"\nitems[].variants\tarray\tNo\t[{\"label\": \"6 Stk\", \"price\": \"8,90\"}, ...] — set main price to smallest variant\nitems[].allergens\tstring\tNo\tSpace-separated codes exactly as printed: \"A C F\"\nitems[].dietary\tarray\tNo\t[\"vegan\", \"vegetarian\", \"spicy\", \"gluten-free\", \"halal\", \"kosher\"]\nallergen_legend\tobject\tNo\tMap of allergen codes to display names: {\"A\": \"Gluten\", ...}\nmetadata.currency\tstring\tYes\tISO code: \"EUR\", \"USD\", \"JPY\", \"CNY\", \"THB\", etc.\nmetadata.languages\tarray\tNo\tLanguages detected in the menu: [\"German\", \"English\"]\nEXTRACTION PROMPT\n\nSend this exact prompt to Gemini. It defines the schema AND the extraction rules. Do not paraphrase it.\n\nYou are a restaurant menu data extractor. Analyze this menu content and extract ALL items into structured JSON.\n\nReturn this exact JSON structure:\n{\n  \"restaurant\": {\n    \"name\": \"Restaurant Name (if visible)\",\n    \"cuisine\": \"cuisine type (Chinese, Indian, Austrian, Japanese, etc.)\",\n    \"tagline\": \"any subtitle or tagline\"\n  },\n  \"sections\": [\n    {\n      \"title\": \"Section Name (in primary language)\",\n      \"title_secondary\": \"Section name in secondary language (if present, else empty string)\",\n      \"category\": \"food or drink\",\n      \"note\": \"Any section note (e.g. 'served with rice', 'Mon-Fri 11-15h')\",\n      \"items\": [\n        {\n          \"code\": \"M1\",\n          \"name\": \"Dish Name (primary language)\",\n          \"name_secondary\": \"Name in secondary language (if present)\",\n          \"description\": \"Brief description (primary language)\",\n          \"description_secondary\": \"Description in secondary language (if present)\",\n          \"price\": \"12,90\",\n          \"price_prefix\": \"\",\n          \"allergens\": \"A C F\",\n          \"dietary\": [\"vegan\", \"spicy\"],\n          \"variants\": []\n        }\n      ]\n    }\n  ],\n  \"allergen_legend\": {\n    \"A\": \"Gluten\",\n    \"B\": \"Crustaceans\"\n  },\n  \"metadata\": {\n    \"languages\": [\"German\", \"English\"],\n    \"currency\": \"EUR\"\n  }\n}\n\nCRITICAL RULES:\n1. Extract EVERY item. Do not skip ANY dish, drink, or menu entry.\n2. Preserve original item codes/numbers if present (M1, K2, S3, etc.). If none exist, generate sequential codes per section (e.g. A1, A2 for appetizers, M1, M2 for mains).\n3. Extract prices EXACTLY as written (preserve comma/period format).\n4. If an item has a price prefix like \"ab\" (starting from), capture it in \"price_prefix\".\n5. If an item has multiple size/quantity variants (e.g. 6 Stk / 12 Stk / 18 Stk at different prices), use the \"variants\" array:\n   [{\"label\": \"6 Stk\", \"price\": \"8,90\"}, {\"label\": \"12 Stk\", \"price\": \"15,90\"}]\n   In this case, set the main \"price\" to the smallest variant's price.\n6. Capture allergen codes exactly as shown (letters, numbers, or symbols).\n7. If an allergen legend is visible anywhere, include it in \"allergen_legend\".\n8. Identify dietary flags from descriptions/icons: vegan, vegetarian, spicy, gluten-free, halal, kosher.\n9. If the menu is bilingual, capture BOTH languages. Put the primary/dominant language in name/description and the secondary in name_secondary/description_secondary.\n10. For set menus or lunch specials with a fixed price covering multiple choices, create a section with note explaining the format, and list each choice as an item.\n11. Classify each section as \"food\" or \"drink\".\n12. For drinks, still extract name, price, and any size variants.\n\nReturn ONLY valid JSON. No markdown fences, no explanatory text.\n\nVision Prompt Variant\n\nFor image-based inputs (screenshots, PDF pages, photos), prepend a context line before the base prompt:\n\nEXTRACTION_PROMPT_VISION = (\n    \"You are a restaurant menu data extractor. \"\n    \"This is a photo/scan of a restaurant menu page.\\n\\n\"\n    \"Return this exact JSON structure:\"\n    + EXTRACTION_PROMPT.split(\"Return this exact JSON structure:\")[1]\n)\n\n\nThen each input type adds its own prefix:\n\nInput Type\tPrefix prepended to EXTRACTION_PROMPT_VISION\nScreenshot\t\"This is a screenshot of a restaurant menu webpage at {url}. Extract ALL visible menu items.\\n\\n\"\nPDF page\t\"This is page {n} of a restaurant menu PDF. Extract ALL menu items from this page.\\n\\n\"\nPhoto\t\"This is a photograph of a restaurant menu. Extract ALL visible menu items.\\n\\n\"\nText (static HTML)\tUse EXTRACTION_PROMPT directly (no vision variant needed)\nGEMINI API CONFIGURATION\nimport os\nfrom google import genai\n\nclient = genai.Client(api_key=os.environ[\"GOOGLE_API_KEY\"])\n\ndef gemini_config():\n    return genai.types.GenerateContentConfig(\n        max_output_tokens=65536,          # 64K — needed for large menus\n        response_mime_type=\"application/json\",  # JSON mode — critical\n    )\n\n# Model: gemini-2.5-flash (default)\nresponse = client.models.generate_content(\n    model=\"gemini-2.5-flash\",\n    contents=prompt_text,    # or [image, prompt_text] for vision\n    config=gemini_config(),\n)\n\n# ALWAYS check for truncation\nif response.candidates[0].finish_reason.name == \"MAX_TOKENS\":\n    print(\"WARNING: Response truncated. Menu may be incomplete.\")\n\nIMAGE PROMPT TEMPLATE\n\nUse this exact function. It produces the casual phone-photo aesthetic that makes menus look authentic.\n\ndef build_food_prompt(name: str, description: str, cuisine: str = \"\") -> str:\n    cuisine_context = f\" {cuisine}\" if cuisine else \"\"\n    food_desc = f\"{name}\"\n    if description and description != name:\n        food_desc += f\" ({description})\"\n\n    return (\n        f\"A photo of {food_desc} at a{cuisine_context} restaurant. \"\n        f\"Taken casually with a phone from across the table at a 45-degree angle. \"\n        f\"The plate sits on a dark wooden table and takes up only 30% of the frame. \"\n        f\"Lots of visible table surface around the plate. Chopsticks, napkins, \"\n        f\"a glass of water, and small side dishes scattered naturally nearby. \"\n        f\"Blurred restaurant interior in the background — other diners, pendant lights, \"\n        f\"wooden chairs visible but out of focus. Warm ambient lighting. \"\n        f\"NOT a close-up. NOT professional food photography. \"\n        f\"It looks like someone quickly snapped a photo before eating.\"\n    )\n\nIMAGE GENERATION API CALLS\nGemini 2.5 Flash Image\nimport os, io\nfrom PIL import Image\nfrom google import genai\n\nclient = genai.Client(api_key=os.environ[\"GOOGLE_API_KEY\"])\n\ndef generate_gemini(client, name, description, output_path, cuisine=\"\"):\n    prompt = build_food_prompt(name, description, cuisine)\n\n    response = client.models.generate_content(\n        model=\"gemini-2.5-flash-image\",       # NOT gemini-2.5-flash (that's text-only)\n        contents=prompt,\n        config=genai.types.GenerateContentConfig(\n            response_modalities=[\"TEXT\", \"IMAGE\"],  # critical — requests image output\n        ),\n    )\n\n    # Extract generated image from response parts\n    for part in response.candidates[0].content.parts:\n        if part.inline_data is not None:\n            img = Image.open(io.BytesIO(part.inline_data.data)).convert(\"RGB\")\n            # Center-crop to square, resize to 800x800\n            w, h = img.size\n            side = min(w, h)\n            left = (w - side) // 2\n            top = (h - side) // 2\n            img = img.crop((left, top, left + side, top + side))\n            img = img.resize((800, 800), Image.LANCZOS)\n            img.save(str(output_path), \"JPEG\", quality=82)\n            return\n    raise RuntimeError(\"No image in Gemini response\")\n\nSkip drinks\n\nOnly generate images for category == \"food\" sections. Drinks get a text-only list in the HTML output.\n\nMULTILINGUAL / CJK HANDLING\n\nMenus can be in ANY language. The pipeline handles this through bilingual fields and smart prompt routing.\n\nExtraction (all languages)\nname / description = primary language (whatever the menu is mostly written in)\nname_secondary / description_secondary = secondary language (if bilingual)\nWorks for: German/English, Chinese/English, Japanese/English, Thai/English, Arabic/English, Korean/English, etc.\nImage Generation (CJK-safe prompting)\n\nCJK characters produce bad image prompts. Before calling build_food_prompt(), swap to the Latin name:\n\ndef prepare_for_image_gen(name, name_secondary, description):\n    \"\"\"Use Latin-script name for image prompts. CJK → use secondary name.\"\"\"\n    display_name = name\n    if name_secondary:\n        if any(ord(c) > 0x2E80 for c in name):  # CJK/Hangul/Kana detection\n            display_name = name_secondary\n            description = description or name\n        else:\n            description = description or name_secondary\n    return display_name, description\n\n\nUnicode ranges covered by ord(c) > 0x2E80:\n\nCJK Unified Ideographs (Chinese characters)\nHiragana / Katakana (Japanese)\nHangul (Korean)\nCJK Compatibility, Radicals, Extensions\nHTML Output (all scripts)\nname renders as the large display text\nname_secondary renders below it in smaller text\nBoth use Google Fonts with CJK fallback (Noto Sans SC, Noto Sans JP, Noto Sans KR)\nFILE NAMING CONVENTIONS\nAuto-derivation\n\nAll filenames are derived from the restaurant name or source URL:\n\nstem = \"shoyu\"  # derived from URL domain, PDF filename, or restaurant name\ndata_file = f\"menu_data_{stem}.json\"\nimages_dir = Path(f\"images/{stem}\")\nhtml_file = f\"{restaurant_name}_Menu.html\"  # e.g. \"Shoyu_Menu.html\"\n\nImage files\nimages/{restaurant_stem}/{code}.jpg\n\n# restaurant_stem = data filename minus \"menu_data_\" prefix\n# Example: menu_data_shoyu.json → images/shoyu/M1.jpg\n\nImage path matching (in build step)\n\nReturns POSIX-style string paths with ./ prefix for cross-platform HTML compatibility:\n\ndef find_image(code: str, images_dir: Path):\n    \"\"\"Return relative POSIX path string to image, or None.\"\"\"\n    if not images_dir.is_dir():\n        return None\n    rel = images_dir.as_posix()\n    if not rel.startswith(\"./\"):\n        rel = \"./\" + rel\n    # 1. Exact match\n    for ext in (\"jpg\", \"jpeg\", \"webp\", \"png\"):\n        candidate = images_dir / f\"{code}.{ext}\"\n        if candidate.exists():\n            return f\"{rel}/{code}.{ext}\"\n    # 2. Case-insensitive fallback\n    for f in images_dir.iterdir():\n        if f.stem.lower() == code.lower() and f.suffix.lower() in (\".jpg\", \".jpeg\", \".webp\", \".png\"):\n            return f\"{rel}/{f.name}\"\n    return None\n\nOutput HTML\n{RestaurantName}_Menu.html    # CSS/JS inline, images as relative file paths\n\nImage rendering (build step)\n\nThe build script uses find_image() to resolve each food item's photo, falling back to a gradient SVG placeholder when no image exists:\n\nimport base64\nimport html as html_mod\n\nGRADIENT_COLORS = [\n    (\"#c41e3a\", \"#8b0000\"), (\"#ff6b6b\", \"#ee5a24\"), (\"#fdcb6e\", \"#e17055\"),\n    (\"#00b894\", \"#00cec9\"), (\"#6c5ce7\", \"#a29bfe\"), (\"#e17055\", \"#d63031\"),\n    (\"#00cec9\", \"#0984e3\"), (\"#fab1a0\", \"#e17055\"), (\"#e8a87c\", \"#d4956b\"),\n    (\"#fd79a8\", \"#e84393\"),\n]\n\ndef make_placeholder_svg(code: str, name: str, secondary: str = \"\") -> str:\n    \"\"\"Generate a base64-encoded SVG placeholder when no image exists.\"\"\"\n    idx = hash(code) % len(GRADIENT_COLORS)\n    c1, c2 = GRADIENT_COLORS[idx]\n    display = html_mod.escape(secondary[:12] if secondary else name[:12])\n    svg = f'''<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"220\" height=\"180\" viewBox=\"0 0 220 180\">\n  <defs><linearGradient id=\"g\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"100%\">\n    <stop offset=\"0%\" style=\"stop-color:{c1}\"/>\n    <stop offset=\"100%\" style=\"stop-color:{c2}\"/>\n  </linearGradient></defs>\n  <rect width=\"220\" height=\"180\" fill=\"url(#g)\" rx=\"12\"/>\n  <text x=\"110\" y=\"75\" text-anchor=\"middle\" fill=\"rgba(255,255,255,0.25)\" font-size=\"56\" font-family=\"serif\">{html_mod.escape(code)}</text>\n  <text x=\"110\" y=\"120\" text-anchor=\"middle\" fill=\"white\" font-size=\"26\" font-family=\"serif\" opacity=\"0.9\">{display}</text>\n  <text x=\"110\" y=\"148\" text-anchor=\"middle\" fill=\"rgba(255,255,255,0.6)\" font-size=\"11\" font-family=\"sans-serif\">{html_mod.escape(name[:30])}</text>\n</svg>'''\n    b64 = base64.b64encode(svg.encode(\"utf-8\")).decode(\"ascii\")\n    return f\"data:image/svg+xml;base64,{b64}\"\n\n\ndef image_tag(code: str, name: str, secondary: str, images_dir: Path, portable: bool = False) -> str:\n    \"\"\"Return <img> tag — real image OR gradient SVG placeholder.\n    If portable=True, embed the real image as base64 data URI for single-file output.\"\"\"\n    real = find_image(code, images_dir)\n    if real:\n        if portable:\n            img_path = images_dir.parent / real  # resolve relative path\n            with open(img_path, \"rb\") as f:\n                b64 = base64.b64encode(f.read()).decode(\"ascii\")\n            return f'<img src=\"data:image/jpeg;base64,{b64}\" alt=\"{html_mod.escape(name)}\">'\n        return f'<img src=\"{html_mod.escape(real)}\" alt=\"{html_mod.escape(name)}\" loading=\"lazy\">'\n    else:\n        src = make_placeholder_svg(code, name, secondary)\n        return f'<img src=\"{src}\" alt=\"{html_mod.escape(name)}\">'\n\nOutput Modes\n\nThe HTML builder supports two output modes controlled by a --portable flag:\n\nMode\tFlag\tImages\tOutput\tUse Case\nPortable (default)\t--portable or no GITHUB_* env vars\tBase64 embedded in HTML\tSingle self-contained .html file\tOpen locally, email, drag-drop to any host\nDeployable\t--no-portable or GITHUB_* env vars set\tRelative paths (./images/stem/code.jpg)\tHTML + images/ directory\tGitHub Pages, Netlify, any static host\n\nPortable mode embeds all food images as base64 data URIs directly in the HTML. File sizes are larger (~4-6MB for an 80-item menu) but the output is a single file that works everywhere with zero hosting setup. This is the default when no GITHUB_* environment variables are set.\n\nDeployable mode uses relative image paths and requires the HTML file and images/ directory to be hosted together. Use this when publishing to GitHub Pages or any static hosting service.\n\nROBUSTNESS PATTERNS\nRetry Logic\n\nAll Gemini API calls should retry on transient failures:\n\nimport time\n\ndef call_with_retry(fn, *args, max_retries=3, **kwargs):\n    \"\"\"Retry API calls with exponential backoff.\"\"\"\n    for attempt in range(max_retries):\n        try:\n            return fn(*args, **kwargs)\n        except Exception as e:\n            if attempt == max_retries - 1:\n                raise\n            wait = 2 ** attempt\n            print(f\"  Retry {attempt + 1}/{max_retries} in {wait}s: {e}\")\n            time.sleep(wait)\n\nJSON Response Parsing\n\nGemini sometimes wraps JSON in markdown fences or produces trailing commas. Parse defensively — try raw parse first, apply trailing comma fix only as last resort (unconditional fix can corrupt valid JSON strings containing ,] patterns):\n\nimport re, json\n\ndef parse_gemini_json(raw: str) -> dict:\n    \"\"\"Parse JSON from Gemini, handling markdown fences and quirks.\"\"\"\n    text = raw.strip()\n    # Strip markdown code fences\n    if text.startswith(\"```\"):\n        text = re.sub(r\"^```\\w*\\n?\", \"\", text)\n        text = re.sub(r\"\\n?```$\", \"\", text)\n    text = text.strip()\n    # Try direct parse first\n    try:\n        return json.loads(text)\n    except json.JSONDecodeError:\n        pass\n    # Try extracting JSON object from surrounding text\n    match = re.search(r\"\\{.*\\}\", text, re.DOTALL)\n    if match:\n        candidate = match.group(0)\n        try:\n            return json.loads(candidate)\n        except json.JSONDecodeError:\n            pass\n        # Fix trailing commas and retry\n        candidate = re.sub(r\",\\s*([\\]}])\", r\"\\1\", candidate)\n        try:\n            return json.loads(candidate)\n        except json.JSONDecodeError:\n            pass\n    # Last resort: fix trailing commas on original\n    text = re.sub(r\",\\s*([\\]}])\", r\"\\1\", text)\n    return json.loads(text)\n\nPost-Processing\n\nAfter extraction, run these cleanups:\n\ndef generate_codes(data: dict) -> dict:\n    \"\"\"Ensure every item has a unique code. Generates sequential codes per section\n    if items have empty/missing codes (e.g. A1, A2 for appetizers, M1, M2 for mains).\"\"\"\n    # ... assign prefix by section title, increment counter per section\n    return data\n\ndef normalize_prices(data: dict) -> dict:\n    \"\"\"Normalize price formats: numeric → string, strip currency symbols,\n    preserve comma/period format as-is.\"\"\"\n    # ... convert float/int to string, strip €/$, etc.\n    return data\n\nCURRENCY_MAP\n\nMaps ISO currency codes to display symbols for the HTML output:\n\nCURRENCY_MAP = {\n    \"EUR\": \"€\", \"USD\": \"$\", \"GBP\": \"£\", \"CHF\": \"CHF \",\n    \"JPY\": \"¥\", \"CNY\": \"¥\", \"INR\": \"₹\", \"AUD\": \"A$\",\n    \"CAD\": \"C$\", \"SEK\": \"kr \", \"NOK\": \"kr \", \"DKK\": \"kr \",\n    \"THB\": \"฿\", \"KRW\": \"₩\", \"HKD\": \"HK$\", \"SGD\": \"S$\",\n    \"CZK\": \"Kč \", \"HUF\": \"Ft \", \"PLN\": \"zł \", \"TRY\": \"₺\",\n}\n\nEXTRACTION DETAILS\nHTML URLs\nFetch page with requests\nCheck text density to detect static vs JS-rendered: density = len(soup.get_text(strip=True)) / len(raw_html)\nDensity override: If 5+ price patterns found (r\"[$€£¥₹CHF]\\s*\\d+[.,]\\d{2}|\\d+[.,]\\d{2}\\s*[$€£¥₹]\"), force density to 1.0 (treat as static)\nStatic (density >= 0.02): Clean HTML, send text to Gemini 2.5 Flash (JSON mode)\nJS-rendered (density < 0.02, e.g. Wix, Framer): Screenshot with Playwright, send to Gemini Vision\nScreenshot height cap: If screenshot > 6000px tall, resize proportionally to fit\nLarge menus (>12k chars text): Chunked extraction, merge like PDF multi-page. Deduplicate by tracking seen_codes = set() across chunks — for each item in each chunk's sections, skip if item[\"code\"] already in seen_codes. Only append sections that still have items after dedup.\nPDF Files\nConvert each page to image via PyMuPDF (200 DPI)\nSend each page image to Gemini Vision\nMerge results across pages (deduplicate items by code)\nPhotos\nLoad image directly\nResize if >10MB\nSend to Gemini Vision\nHTML OUTPUT FEATURES\n3-column Instagram-style grid (9:16 portrait tiles)\nGradient text overlay with name + secondary language + price\nTap-to-select with green checkmark\nReceipt/bill on Selection tab with +/- quantity controls\nCategory pill navigation with scroll sync\nDrinks section below grid with currency-prefixed prices\nAllergen legend\nCurrency converter — minimalist button in header (e.g. € pill) that cycles or opens a picker for: EUR, USD, AUD, CAD, GBP. Converts all displayed prices client-side using snapshot exchange rates embedded at build time. Updates grid overlays, receipt totals, drink prices, and variant prices. Source currency comes from metadata.currency.\nFully responsive, dark mode\nAll CSS/JS inline, images via relative file paths (./images/{stem}/{code}.jpg), only Google Fonts external\nGradient SVG placeholders for missing images (inline base64 SVG, not raster)\nCJK font loading via Google Fonts link tag: family=Noto+Sans+SC:wght@400;700&family=Noto+Sans+JP:wght@400;700&family=Noto+Sans+KR:wght@400;700\nCSS font-family stack: primary font, then 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', sans-serif\nCurrency Converter\n\nA minimalist currency toggle built into the HTML output. All client-side, no API calls at runtime.\n\nImplementation:\n\nThe build script embeds a RATES object with snapshot exchange rates (base: USD) at build time\nSource currency is read from metadata.currency in the JSON data\nAll prices are stored in data-price attributes as numeric values (not raw strings like \"12,90\")\nA small pill button in the header shows the current currency symbol (e.g. €)\nTapping opens a mini-picker or cycles through: EUR (€), USD ($), GBP (£), AUD (A$), CAD (C$)\nOn currency change, JavaScript converts all data-price values and updates displayed text\nReceipt totals in the Selection tab also convert via convertPrice() using SOURCE_CURRENCY and currentCurrency\nVariant prices also update\nSelected currency persists in localStorage\n\nPrice parsing helper (build-time — converts string prices to numeric for data-price attributes):\n\nimport re\n\ndef _parse_price_numeric(price: str) -> str:\n    \"\"\"Parse price string to numeric float for data-price attribute.\"\"\"\n    matches = re.findall(r\"(\\d+[.,]\\d+)\", price)\n    if matches:\n        return str(float(matches[-1].replace(\",\", \".\")))\n    return \"0\"\n\n# Usage in HTML template:\n# <div class=\"price\" data-price=\"{_parse_price_numeric(item['price'])}\">€12,90</div>\n\n// Snapshot rates embedded at build time (base: USD)\nconst RATES = { EUR: 0.92, USD: 1.00, GBP: 0.79, AUD: 1.54, CAD: 1.36 };\nconst SYMBOLS = { EUR: \"€\", USD: \"$\", GBP: \"£\", AUD: \"A$\", CAD: \"C$\" };\nconst SOURCE_CURRENCY = \"EUR\";  // from metadata.currency\n\nfunction convertPrice(amount, fromCurrency, toCurrency) {\n    const inUSD = amount / RATES[fromCurrency];\n    return inUSD * RATES[toCurrency];\n}\n\n// Applied to: grid overlay prices, drink list prices, variant prices,\n// AND receipt/selection tab totals (all elements with data-price attribute)\n\n\nThe build script should fetch current rates at build time (or use reasonable defaults if offline). Prices display with 2 decimal places in the target currency, using the target locale's format.\n\nBranding Customization\n--name \"Restaurant Name\"     # Header brand text\n--tagline \"Cuisine · City\"   # Subtitle\n--accent \"#ff6b00\"           # Primary color (pills, active tab, drink prices)\n--bg \"#0a0a0a\"               # Background color\n\nCOST SUMMARY\nComponent\tCost\nExtraction (per page)\t~$0.001\nImage generation (per food item)\t$0.039\n80 food items\t~$3.12\nTime (80 food items)\t~8 min\n\nDrinks are not image-generated (text-only list), so actual cost depends on food-to-drink ratio.\n\nDEPENDENCIES\n\nRequires Python 3.9+.\n\nRequired:\n\ngoogle-genai (extraction + image generation)\nPillow (image processing)\n\nFor HTML URLs:\n\nrequests (HTTP fetching)\nbeautifulsoup4 (HTML parsing)\n\nFor JS-rendered sites:\n\nplaywright (headless browser screenshots)\n\nFor PDF files:\n\nPyMuPDF (PDF to image conversion)\npip install google-genai Pillow requests beautifulsoup4 PyMuPDF\npip install playwright && playwright install chromium\n\nENVIRONMENT VARIABLES\nGOOGLE_API_KEY — Required for extraction and image generation\nGITHUB_PAT — Required for GitHub Pages publishing\nGITHUB_OWNER — Your GitHub username (default: reads from git config)\nGITHUB_REPO — Your GitHub Pages repo name (default: menus)\nPUBLISHING\nDefault: Portable HTML (no setup)\n\nWhen no GITHUB_* environment variables are set, the pipeline generates a self-contained HTML file with base64-embedded images. Users can:\n\nOpen the file directly in any browser\nEmail it or share via any file-sharing service\nUpload to any static host (Netlify Drop, Vercel, GitHub Pages, S3)\n\nNo hosting setup, no API keys beyond GOOGLE_API_KEY, no git configuration needed.\n\nOptional: GitHub Pages (requires setup)\n\nFor users who want a persistent gallery with multiple menus:\n\nCreate a GitHub repo for your menus (e.g. your-username/menus)\nEnable GitHub Pages on the main branch\nSet environment variables (must be accessible to the Python process):\nexport GITHUB_PAT=\"your-personal-access-token\"   # Required — used for git push auth\nexport GITHUB_OWNER=\"your-username\"               # Required — YOUR GitHub username\nexport GITHUB_REPO=\"menus\"                        # Optional — defaults to \"menus\"\n\n\nImportant: publish_menu.py MUST read GITHUB_OWNER and GITHUB_REPO from environment variables. Never hardcode a specific user's repo. The generated code should construct the repo URL dynamically:\n\nowner = os.environ[\"GITHUB_OWNER\"]\nrepo = os.environ.get(\"GITHUB_REPO\", \"menus\")\nGITHUB_REPO = f\"{owner}/{repo}\"\nGITHUB_PAGES_BASE = f\"https://{owner}.github.io/{repo}\"\n\nPublish\npython publish_menu.py Restaurant_Menu.html --name \"Restaurant\" --tagline \"Cuisine · City\" --cuisine Type\n\n\nGallery: https://<your-username>.github.io/<repo>/\n\nHow publishing works\n\npublish_menu.py clones the menus repo to a temp directory on native filesystem (git clone --depth=1), copies files there, commits, and pushes. This avoids all NTFS bind mount permission issues that occur when operating directly on mounted volumes in Docker containers.\n\nKey implementation details:\n\ngit clone --depth=1 to a tempfile.mkdtemp() directory (native FS, proper POSIX permissions)\nCopies HTML + images using shutil.copy() (not copy2 — avoids os.chmod() EPERM on NTFS)\nfind_image_dirs regex uses [^/\"]+ (not [a-z_]+) to match Unicode chars in image dir names\nWrites .meta_ JSON sidecar for gallery metadata\nRebuilds gallery index.html\nAuthenticates push via GITHUB_PAT env var embedded in the clone URL\nTemp directory is cleaned up after push\nMENUS_REPO_DIR (bind mount path) is only used for --list read-only queries\nEXTERNAL ENDPOINTS\nEndpoint\tData Sent\tPurpose\ngenerativelanguage.googleapis.com\tMenu text, page screenshots, PDF page images, food photo prompts\tGemini API for extraction (JSON mode) and image generation\nTarget restaurant URL\tHTTP GET only\tFetching the menu page HTML for extraction\napi.github.com\tGenerated HTML file, image files\tPublishing menu to GitHub Pages (optional, requires GITHUB_PAT)\nfonts.googleapis.com\tNone (CSS link in HTML output)\tGoogle Fonts loaded client-side when menu HTML is opened in browser\n\nNo analytics, telemetry, or tracking. No data is sent to any endpoint beyond those listed above.\n\nSECURITY & PRIVACY\nAPI keys: GOOGLE_API_KEY is read from environment variables, never hardcoded or logged\nGitHub PAT: Used only for authenticated pushes to the user's own repo; never transmitted elsewhere\nRestaurant data: Menu content is sent to the Gemini API for processing. No data is stored server-side beyond Google's standard API retention\nGenerated images: Stored locally in images/ directory. When published, uploaded only to the user's own GitHub Pages repo\nNo telemetry: The pipeline collects no analytics, metrics, or usage data\nLocal-first: All processing happens locally except Gemini API calls. The HTML output and images remain on the user's machine unless they explicitly publish\nKNOWN LIMITATIONS\nTabbed Wix menus: Only first visible tab extracted\nGoogle Maps photo URLs: Not supported (use direct image files)\nVery large menus (300+ items): May need manual chunk review"
  },
  "trust": {
    "sourceLabel": "tencent",
    "provenanceUrl": "https://clawhub.ai/ademczuk/menuvision",
    "publisherUrl": "https://clawhub.ai/ademczuk/menuvision",
    "owner": "ademczuk",
    "version": "1.0.1",
    "license": null,
    "verificationStatus": "Indexed source record"
  },
  "links": {
    "detailUrl": "https://openagent3.xyz/skills/menuvision",
    "downloadUrl": "https://openagent3.xyz/downloads/menuvision",
    "agentUrl": "https://openagent3.xyz/skills/menuvision/agent",
    "manifestUrl": "https://openagent3.xyz/skills/menuvision/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/menuvision/agent.md"
  }
}