{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Generate Branded AGOL Thumbnails\n", "\n", "Stamp a consistent branded thumbnail (item **type** in the header, item **title** in the footer) onto every ArcGIS Online item you own, from one PNG template. Text **auto-fits** each band by measured pixel width.\n", "\n", "**How to use:** edit the **CONFIG** cell, then *Run All*. Leave `DRY_RUN = True` for the first pass (renders PNGs locally, touches nothing), review the output folder, then set `DRY_RUN = False` to write the thumbnails to your portal.\n", "\n", "**Make it yours:** the geometry constants (`HEADER_BOX`, `FOOTER_BOX`, the font auto-fit ranges) live in the *render core* cell -- edit them to match your own template. Adapted from an All Things Spatial workflow." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 1 - Config" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# ============================================================================\n", "# CONFIG -- edit these, then Run All. Nothing else needs changing for a basic run.\n", "# ============================================================================\n", "TEMPLATE_PATH = \"ats-base-template.png\" # your 600x400 PNG template\n", "OUTPUT_DIR = \"thumbnail_uploads\" # local folder for rendered PNGs\n", "SCOPE = \"entire\" # \"entire\" OR \"selected\"\n", "FOLDERS = [] # used when SCOPE=\"selected\"; \"(root)\" = root\n", "ITEM_TYPE = None # e.g. \"Feature Service\"; None = all types\n", "MAX_TITLE_CHARS = 50 # pre-cap before ellipsis\n", "MAX_TITLE_LINES = 3 # footer title line budget\n", "BRAND_BLUE = \"#1C77FF\" # header (item type) color\n", "MAX_ITEMS = 2000 # safety cap\n", "DRY_RUN = True # True = render locally only; flip to write to AGOL\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 2 - Render core (edit geometry here to fit your template)" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "from pathlib import Path\n", "from PIL import Image, ImageDraw, ImageFont\n", "\n", "# ---- template geometry (px). Edit these to match YOUR template ----------\n", "HEADER_BOX = {\"x\": 95, \"y\": 14, \"w\": 410, \"h\": 110} # item TYPE zone (centered)\n", "FOOTER_BOX = {\"x\": 24, \"y\": 296, \"w\": 338, \"h\": 98} # item TITLE zone (left-aligned)\n", "\n", "# Font auto-fit ranges (px): Hi = biggest tried; Lo = smallest before ellipsis.\n", "TYPE_SIZE_HI, TYPE_SIZE_LO = 54, 30\n", "TITLE_SIZE_HI, TITLE_SIZE_LO = 40, 20\n", "LINE_SPACING = 1.06 # multiplier on the font's line height\n", "DEFAULT_BRAND_BLUE = \"#1C77FF\"\n", "\n", "# First existing bold TrueType wins (Windows / ArcGIS Notebooks / macOS / Linux).\n", "FONT_CANDIDATES = [\n", " \"arialbd.ttf\",\n", " \"C:/Windows/Fonts/arialbd.ttf\",\n", " \"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\",\n", " \"/Library/Fonts/Arial Bold.ttf\",\n", " \"DejaVuSans-Bold.ttf\",\n", "]\n", "\n", "\n", "def resolve_font_path():\n", " \"\"\"Return the first usable bold TrueType font path.\"\"\"\n", " for cand in FONT_CANDIDATES:\n", " try:\n", " ImageFont.truetype(cand, 24)\n", " return cand\n", " except OSError:\n", " continue\n", " raise OSError(\"No bold TrueType font found -- add one to FONT_CANDIDATES.\")\n", "\n", "\n", "def _cap_chars(text, max_chars):\n", " \"\"\"Pre-cap an absurdly long title at a word boundary <= max_chars, + ellipsis.\"\"\"\n", " text = \" \".join(text.split())\n", " if max_chars and len(text) > max_chars:\n", " clipped = text[:max_chars].rsplit(\" \", 1)[0].rstrip()\n", " text = (clipped or text[:max_chars]) + \"\u2026\"\n", " return text\n", "\n", "\n", "def _wrap_to_width(draw, text, font, box_w):\n", " \"\"\"Greedy word-wrap by MEASURED pixel width; hard-splits a word wider than the box.\"\"\"\n", " words = text.split()\n", " lines, cur = [], \"\"\n", " for word in words:\n", " trial = word if not cur else cur + \" \" + word\n", " if draw.textlength(trial, font=font) <= box_w:\n", " cur = trial\n", " continue\n", " if cur:\n", " lines.append(cur); cur = \"\"\n", " if draw.textlength(word, font=font) > box_w:\n", " piece = \"\"\n", " for ch in word:\n", " if draw.textlength(piece + ch, font=font) <= box_w:\n", " piece += ch\n", " else:\n", " if piece:\n", " lines.append(piece)\n", " piece = ch\n", " cur = piece\n", " else:\n", " cur = word\n", " if cur:\n", " lines.append(cur)\n", " return lines\n", "\n", "\n", "def _line_height(font):\n", " asc, desc = font.getmetrics()\n", " return int((asc + desc) * LINE_SPACING)\n", "\n", "\n", "def fit_text_block(draw, text, font_path, box, max_lines, size_hi, size_lo):\n", " \"\"\"Largest font size at which text wraps inside box (width, height, max_lines).\n", " If even size_lo overflows, wrap at size_lo, clip to max_lines, ellipsize last line.\"\"\"\n", " for size in range(size_hi, size_lo - 1, -1):\n", " font = ImageFont.truetype(font_path, size)\n", " lines = _wrap_to_width(draw, text, font, box[\"w\"])\n", " lh = _line_height(font)\n", " if len(lines) <= max_lines and len(lines) * lh <= box[\"h\"]:\n", " return font, lines, lh\n", " font = ImageFont.truetype(font_path, size_lo)\n", " lh = _line_height(font)\n", " max_lines = max(1, min(max_lines, box[\"h\"] // lh))\n", " lines = _wrap_to_width(draw, text, font, box[\"w\"])\n", " if len(lines) > max_lines:\n", " lines = lines[:max_lines]\n", " last = lines[-1]\n", " while last and draw.textlength(last + \"\u2026\", font=font) > box[\"w\"]:\n", " last = last[:-1].rstrip()\n", " lines[-1] = last + \"\u2026\"\n", " return font, lines, lh\n", "\n", "\n", "def render_thumbnail(template_path, out_dir, title, item_type, item_id,\n", " brand_blue=DEFAULT_BRAND_BLUE, max_title_chars=50,\n", " max_title_lines=3, font_path=None):\n", " \"\"\"Stamp item TYPE (header, centered) + TITLE (footer, left) onto the template.\"\"\"\n", " template_path = Path(template_path); out_dir = Path(out_dir)\n", " out_dir.mkdir(parents=True, exist_ok=True)\n", " font_path = font_path or resolve_font_path()\n", " img = Image.open(template_path).convert(\"RGBA\")\n", " draw = ImageDraw.Draw(img)\n", "\n", " type_text = \" \".join((item_type or \"\").upper().split())\n", " if type_text:\n", " tfont, tlines, tlh = fit_text_block(draw, type_text, font_path, HEADER_BOX,\n", " 2, TYPE_SIZE_HI, TYPE_SIZE_LO)\n", " y = HEADER_BOX[\"y\"] + max(0, (HEADER_BOX[\"h\"] - len(tlines) * tlh) // 2)\n", " for line in tlines:\n", " w = draw.textlength(line, font=tfont)\n", " x = HEADER_BOX[\"x\"] + (HEADER_BOX[\"w\"] - w) / 2\n", " draw.text((x, y), line, font=tfont, fill=brand_blue); y += tlh\n", "\n", " title_text = _cap_chars(title or \"\", max_title_chars)\n", " if title_text:\n", " ffont, flines, flh = fit_text_block(draw, title_text, font_path, FOOTER_BOX,\n", " max_title_lines, TITLE_SIZE_HI, TITLE_SIZE_LO)\n", " y = FOOTER_BOX[\"y\"] + max(0, (FOOTER_BOX[\"h\"] - len(flines) * flh) // 2)\n", " for line in flines:\n", " draw.text((FOOTER_BOX[\"x\"], y), line, font=ffont, fill=\"white\"); y += flh\n", "\n", " out_path = out_dir / \"{}_thumbnail.png\".format(item_id)\n", " img.save(out_path)\n", " return out_path\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 3 - AGOL helpers" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# ---------------------------------------------------------------------------\n", "# AGOL helpers -- take an already-connected `gis` (this module never sees a password)\n", "# ---------------------------------------------------------------------------\n", "def _folder_name(f):\n", " if isinstance(f, dict):\n", " return f.get(\"title\") or f.get(\"name\")\n", " return getattr(f, \"name\", None) or getattr(f, \"title\", None)\n", "\n", "\n", "def list_folder_titles(gis):\n", " me = gis.users.me\n", " return [n for n in (_folder_name(f) for f in (me.folders or [])) if n]\n", "\n", "\n", "def iter_items(gis, scope, folders=None, item_type=None, max_items=2000):\n", " \"\"\"Yield owned items. scope 'entire' = root + all folders; 'selected' = `folders`\n", " (use '(root)' for the root folder). item_type filters on exact Item.type.\"\"\"\n", " me = gis.users.me\n", " want_type = (item_type or \"\").strip().lower()\n", " if scope == \"entire\":\n", " targets = [None] + list_folder_titles(gis)\n", " else:\n", " targets = [None if n == \"(root)\" else n for n in (folders or [])]\n", " seen, count = set(), 0\n", " for folder in targets:\n", " try:\n", " items = me.items(folder=folder, max_items=max_items)\n", " except Exception:\n", " items = []\n", " for it in items:\n", " if it.id in seen:\n", " continue\n", " seen.add(it.id)\n", " if want_type and (it.type or \"\").lower() != want_type:\n", " continue\n", " yield it\n", " count += 1\n", " if count >= max_items:\n", " return" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## 4 - Run" ] }, { "cell_type": "code", "metadata": {}, "execution_count": null, "outputs": [], "source": [ "# ---- connect (no credentials in the notebook) ------------------------------\n", "import os, getpass\n", "from arcgis.gis import GIS\n", "try:\n", " gis = GIS(\"home\") # ArcGIS Notebooks / hosted\n", "except Exception:\n", " try:\n", " gis = GIS(\"pro\") # running inside ArcGIS Pro\n", " except Exception:\n", " u = os.environ.get(\"AGOL_USERNAME\") or input(\"ArcGIS Online username: \")\n", " p = os.environ.get(\"AGOL_PASSWORD\") or getpass.getpass(\"Password: \")\n", " gis = GIS(\"https://www.arcgis.com\", u, p)\n", "print(\"Signed in as\", gis.users.me.username)\n", "\n", "# ---- render every matching item (writes to AGOL only when DRY_RUN = False) --\n", "rendered = updated = 0\n", "for it in iter_items(gis, SCOPE, folders=FOLDERS or None,\n", " item_type=ITEM_TYPE, max_items=MAX_ITEMS):\n", " png = render_thumbnail(TEMPLATE_PATH, OUTPUT_DIR, it.title, it.type, it.id,\n", " brand_blue=BRAND_BLUE, max_title_chars=MAX_TITLE_CHARS,\n", " max_title_lines=MAX_TITLE_LINES)\n", " rendered += 1\n", " if DRY_RUN:\n", " print(\"RENDERED:\", it.title, \"->\", png.name)\n", " else:\n", " it.update(thumbnail=str(png)); updated += 1\n", " print(\"UPDATED:\", it.title, \"(\", it.type, \")\")\n", "print(\"Done. Rendered {}{}.\".format(rendered, \"\" if DRY_RUN else \", updated {}\".format(updated)))\n" ] } ], "metadata": { "kernelspec": { "display_name": "ArcGIS Pro (arcgispro-py3)", "language": "python", "name": "arcgispro-py3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }