Six-skill presentation system: ingest talks into a rhetoric vault, run interactive clarification, generate a speaker profile, create presentations that match your documented patterns, produce the deck illustrations + thumbnail visual layer, and publish talk pages to a Jekyll shownotes site. Includes a 102-entry Presentation Patterns taxonomy (91 observable, 11 unobservable go-live items) for scoring, brainstorming, and go-live preparation.
86
92%
Does it follow best practices?
Impact
86%
1.24xAverage score across 26 eval scenarios
Advisory
Suggest reviewing before use
#!/usr/bin/env python3
"""Generate a QR code and optionally insert it into a PowerPoint deck.
Generates an unbranded QR code encoding a shownotes URL (optionally shortened
via bit.ly or rebrand.ly), matches the QR background color to the target slide,
auto-selects foreground color for contrast, and inserts the QR image into the
deck at the configured slide position.
Two URL-resolution modes:
--shownotes-url URL Script resolves shortening itself via {vault}/secrets.json
--short-url URL Agent pre-resolved the short URL (via MCP); script just
generates the QR and inserts it
PNG-only mode (no deck required):
--png-only Generate the QR PNG without opening or modifying a deck.
Use --bg-color R,G,B and --output PATH to control colors
and output location. URL shortening and tracking DB
updates still run normally.
Usage:
python3 generate-qr.py <deck.pptx> --talk-slug SLUG --shownotes-url URL
python3 generate-qr.py <deck.pptx> --talk-slug SLUG --short-url URL
python3 generate-qr.py <deck.pptx> --talk-slug SLUG --shownotes-url URL --dry-run
python3 generate-qr.py <deck.pptx> --talk-slug SLUG --shownotes-url URL --profile PATH --vault PATH
python3 generate-qr.py --png-only --talk-slug SLUG --shownotes-url URL --output qr.png --bg-color 128,0,128
Requires:
- python-pptx (pip install python-pptx)
- qrcode (pip install qrcode)
- Pillow (pip install Pillow — transitive dep of python-pptx)
"""
import argparse
import datetime
import json
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_M
except ImportError:
print("ERROR: 'qrcode' package not installed. Run: pip install qrcode")
sys.exit(1)
try:
from PIL import Image, ImageChops, ImageStat
except ImportError:
print("ERROR: 'Pillow' package not installed. Run: pip install Pillow")
sys.exit(1)
import io
from pptx import Presentation # read-only: background-color match + slide-finding
from pptx.enum.shapes import MSO_SHAPE_TYPE
from pptx.util import Inches
# --- Constants ---
QR_BOX_SIZE = 10 # pixels per QR module
QR_BORDER = 4 # quiet-zone modules
QR_ERROR_CORRECTION = ERROR_CORRECT_M
# QR placement: bottom-right, 2 inches wide, 0.3 inch margin from edges
QR_WIDTH_INCHES = 2.0
QR_MARGIN_INCHES = 0.3
# Existing-QR detection (content-based, size-independent). A QR is a SQUARE
# picture that is BOTH essentially two colors AND roughly balanced between them:
# - it reconstructs near-perfectly when quantized to 2 colors (reconErr ≈ 0),
# unlike photos/diagrams (many colors → high error); and
# - its minority color covers a large fraction (~⅓+), unlike a text screenshot
# that is mostly one background color with sparse text.
# Both axes are needed: a mostly-white doc screenshot is ~2-color but unbalanced.
# Size is NOT a signal — a deck adapted from another talk carries inherited QRs at
# arbitrary sizes (the same QR appeared at 1.8" and 2.8"); only a floor guards
# against tiny 2-color icons. Validated on a real inherited deck: QRs scored
# reconErr 0.0 / minority 0.34; a colored Venn 68 / 0.50; a martinfowler.com
# screenshot 6.4 / 0.18. The VBA writer can't run PIL, so detection lives here and
# the resulting geometry is handed to InsertQR.
QR_DETECT_MIN_INCHES = 1.5 # side floor — excludes small 2-color icons
QR_SQUARE_TOL_INCHES = 0.1 # |width − height| tolerance
QR_RECON_ERR_MAX = 5.0 # max mean 2-color reconstruction error
QR_MIN_MINORITY = 0.25 # min minority-color fraction (QRs are balanced)
# --- Vault / Config Loading ---
def load_vault_config(vault_path, profile_path=None):
"""Load speaker profile, secrets, and tracking database from the vault.
Returns:
tuple (speaker_profile, secrets, tracking_db)
Any of these may be empty dicts if the file doesn't exist.
"""
speaker_profile = {}
secrets = {}
tracking_db = {}
# Speaker profile
sp_path = profile_path or os.path.join(vault_path, "speaker-profile.json")
if os.path.isfile(sp_path):
with open(sp_path, "r", encoding="utf-8") as f:
speaker_profile = json.load(f)
# Secrets
secrets_path = os.path.join(vault_path, "secrets.json")
if os.path.isfile(secrets_path):
with open(secrets_path, "r", encoding="utf-8") as f:
secrets = json.load(f)
# Tracking database
tdb_path = os.path.join(vault_path, "tracking-database.json")
if os.path.isfile(tdb_path):
with open(tdb_path, "r", encoding="utf-8") as f:
tracking_db = json.load(f)
return speaker_profile, secrets, tracking_db
# --- URL Shortening ---
def _http_request(url, data=None, headers=None, method="GET"):
"""Make an HTTP request using stdlib urllib. Returns parsed JSON or raises."""
if headers is None:
headers = {}
if data is not None:
data = json.dumps(data).encode("utf-8")
if "Content-Type" not in headers:
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def create_bitly_link(long_url, api_token, custom_back_half=None, domain=None):
"""Create a new bit.ly short link.
Args:
long_url: The URL to shorten
api_token: Bitly API token
custom_back_half: Custom back-half for the short URL (e.g., talk slug).
If provided, creates {domain}/{custom_back_half} instead of a random hash.
domain: Custom Bitly domain (e.g., "jbaru.ch"). Defaults to "bit.ly".
Returns:
dict with keys: short_url, link_id, short_path
"""
bitly_domain = domain or "bit.ly"
payload = {"long_url": long_url, "domain": bitly_domain}
if custom_back_half:
payload["title"] = custom_back_half # for tracking
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
# Create the link first
result = _http_request(
"https://api-ssl.bitly.com/v4/bitlinks",
data=payload,
headers=headers,
method="POST",
)
link_id = result["id"]
short_url = result["link"]
short_path = link_id.split("/", 1)[-1] if "/" in link_id else link_id
# Set custom back-half if requested
if custom_back_half:
try:
_http_request(
f"https://api-ssl.bitly.com/v4/custom_bitlinks",
data={
"bitlink_id": link_id,
"custom_bitlink": f"{bitly_domain}/{custom_back_half}",
},
headers=headers,
method="POST",
)
short_url = f"https://{bitly_domain}/{custom_back_half}"
short_path = custom_back_half
print(f" Custom back-half set: {bitly_domain}/{custom_back_half}")
except Exception as e:
# A random hash would silently break the slug=back-half contract
# (rules/qr-generation-rules.md §2), so surface the failure instead.
raise RuntimeError(
f"could not set custom back-half '{custom_back_half}' on {bitly_domain}: {e}"
) from e
return {
"short_url": short_url,
"link_id": link_id,
"short_path": short_path,
}
def update_bitly_link(link_id, new_long_url, api_token):
"""Update an existing bit.ly link's target URL via PATCH."""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json",
}
_http_request(
f"https://api-ssl.bitly.com/v4/bitlinks/{link_id}",
data={"long_url": new_long_url},
headers=headers,
method="PATCH",
)
def create_rebrandly_link(long_url, api_key, domain=None, slashtag=None):
"""Create a new rebrand.ly short link.
Returns:
dict with keys: short_url, link_id, short_path
"""
payload = {"destination": long_url}
if domain:
payload["domain"] = {"fullName": domain}
if slashtag:
payload["slashtag"] = slashtag
headers = {
"apikey": api_key,
"Content-Type": "application/json",
}
result = _http_request(
"https://api.rebrandly.com/v1/links",
data=payload,
headers=headers,
method="POST",
)
return {
"short_url": f"https://{result['shortUrl']}",
"link_id": result["id"],
"short_path": result["slashtag"],
}
def update_rebrandly_link(link_id, new_long_url, api_key):
"""Update an existing rebrand.ly link's target URL."""
headers = {
"apikey": api_key,
"Content-Type": "application/json",
}
_http_request(
f"https://api.rebrandly.com/v1/links/{link_id}",
data={"destination": new_long_url},
headers=headers,
method="POST",
)
def _print_missing_key_help(service, key_name, vault_path):
"""Print actionable help when an API key is missing from secrets.json."""
secrets_path = os.path.join(vault_path, "secrets.json") if vault_path else "secrets.json"
if vault_path and not os.path.isfile(secrets_path):
print(f" WARNING: No {service}.{key_name} found — secrets.json does not exist. Falling back to raw URL.")
print(f" Create it:")
print(f' echo \'{{\"{service}\": {{\"{key_name}\": \"YOUR_KEY\"}}}}\' > {secrets_path}')
print(f" chmod 600 {secrets_path}")
else:
print(f" WARNING: No {service}.{key_name} in secrets.json, falling back to raw URL.")
print(f" Add to {secrets_path}:")
print(f' \"{service}\": {{\"{key_name}\": \"YOUR_KEY\"}}')
def _require_domain_decision(config, shortener, vault_path):
"""A custom-domain decision must be recorded before the FIRST short link is
created. An ABSENT `{shortener}_domain` key means the user was never asked —
STOP so the agent asks and saves the answer. A present value (a domain string,
or null for "no custom domain") is a recorded decision and proceeds.
"""
key = f"{shortener}_domain"
if key in config:
return
default_domain = "bit.ly" if shortener == "bitly" else "the shortener default"
profile = os.path.join(vault_path, "speaker-profile.json") if vault_path else "the speaker profile"
print(f"ERROR: No custom-domain decision recorded for shortener '{shortener}'.")
print(" Before creating the first short link, ask the user whether they have a")
print(f" custom domain (e.g. jbaru.ch), then save the answer under")
print(f" publishing_process.qr_code.{key} in {profile}:")
print(f' a domain string (e.g. "jbaru.ch"), or null for no custom domain ({default_domain}).')
sys.exit(1)
def resolve_short_url(shownotes_url, talk_slug, config, secrets, tracking_db, dry_run=False, vault_path=None):
"""Resolve the short URL for a talk, using cache or API as needed.
Args:
shownotes_url: The full shownotes URL to shorten
talk_slug: Unique talk identifier
config: QR config from speaker profile (publishing_process.qr_code)
secrets: Parsed secrets.json
tracking_db: Parsed tracking-database.json
dry_run: If True, skip API calls
vault_path: Path to vault (for actionable error messages)
Returns:
tuple (short_url, metadata_dict)
metadata_dict has keys: shortener, short_path, link_id, short_url, target_url
"""
shortener = config.get("shortener")
qr_codes = tracking_db.get("qr_codes", [])
# Look up existing entry
existing = None
for entry in qr_codes:
if entry.get("talk_slug") == talk_slug:
existing = entry
break
# Enforce slug-only back-half on EXISTING links too: a tracked shortened link
# whose back-half isn't the slug is legacy — don't reuse it from cache or
# retarget it in place; drop it so a slug-based link is recreated below.
if existing and existing.get("shortener_link_id") and existing.get("short_path") != talk_slug:
print(f" Legacy non-slug back-half '{existing.get('short_path')}' for '{talk_slug}' — recreating with the slug")
existing = None
# If cached and target matches, reuse
if existing and existing.get("target_url") == shownotes_url:
print(f" Reusing cached short URL for '{talk_slug}': {existing['short_url']}")
return existing["short_url"], existing
# Distinguish "not configured" from "explicitly disabled"
if shortener is None:
print(" WARNING: No URL shortener configured in speaker profile (publishing_process.qr_code.shortener).")
print(" Add 'shortener: bitly' or 'shortener: rebrandly' to your profile,")
print(" or set 'shortener: none' to explicitly use raw URLs.")
print(" Proceeding with raw URL.")
if shortener in (None, "none"):
meta = {
"talk_slug": talk_slug,
"target_url": shownotes_url,
"shortener": "none",
"short_path": None,
"short_url": shownotes_url,
"shortener_link_id": None,
}
return shownotes_url, meta
if dry_run:
print(f" DRY RUN: would call {shortener} API for {shownotes_url}")
return shownotes_url, {
"talk_slug": talk_slug,
"target_url": shownotes_url,
"shortener": shortener,
"short_path": None,
"short_url": shownotes_url,
"shortener_link_id": None,
}
# The custom back-half (bit.ly) / slashtag (rebrand.ly) is ALWAYS the talk
# slug — no override (see rules/qr-generation-rules.md). It is the decoupling
# layer and the human-readable, traceable short path.
custom_back_half = talk_slug
try:
if shortener == "bitly":
api_token = secrets.get("bitly", {}).get("api_token")
if not api_token:
_print_missing_key_help("bitly", "api_token", vault_path)
return shownotes_url, _none_meta(talk_slug, shownotes_url)
bitly_domain = config.get("bitly_domain") # e.g., "jbaru.ch"
if existing and existing.get("shortener_link_id"):
# Update existing link target
print(f" Updating bit.ly link {existing['shortener_link_id']} → {shownotes_url}")
update_bitly_link(existing["shortener_link_id"], shownotes_url, api_token)
meta = dict(existing)
meta["target_url"] = shownotes_url
meta["updated_at"] = datetime.date.today().isoformat()
return existing["short_url"], meta
else:
_require_domain_decision(config, "bitly", vault_path)
# Create new link with talk slug as custom back-half
domain_label = bitly_domain or "bit.ly"
print(f" Creating {domain_label} link for {shownotes_url} (back-half: {custom_back_half})")
result = create_bitly_link(shownotes_url, api_token, custom_back_half, domain=bitly_domain)
meta = {
"talk_slug": talk_slug,
"target_url": shownotes_url,
"shortener": "bitly",
"short_path": result["short_path"],
"short_url": result["short_url"],
"shortener_link_id": result["link_id"],
}
return result["short_url"], meta
elif shortener == "rebrandly":
api_key = secrets.get("rebrandly", {}).get("api_key")
if not api_key:
_print_missing_key_help("rebrandly", "api_key", vault_path)
return shownotes_url, _none_meta(talk_slug, shownotes_url)
domain = config.get("rebrandly_domain")
if existing and existing.get("shortener_link_id"):
print(f" Updating rebrand.ly link {existing['shortener_link_id']} → {shownotes_url}")
update_rebrandly_link(existing["shortener_link_id"], shownotes_url, api_key)
meta = dict(existing)
meta["target_url"] = shownotes_url
meta["updated_at"] = datetime.date.today().isoformat()
return existing["short_url"], meta
else:
_require_domain_decision(config, "rebrandly", vault_path)
print(f" Creating rebrand.ly link for {shownotes_url} (slashtag: {custom_back_half})")
result = create_rebrandly_link(shownotes_url, api_key, domain, custom_back_half)
meta = {
"talk_slug": talk_slug,
"target_url": shownotes_url,
"shortener": "rebrandly",
"short_path": result["short_path"],
"short_url": result["short_url"],
"shortener_link_id": result["link_id"],
}
return result["short_url"], meta
else:
print(f" WARNING: Unknown shortener '{shortener}', using raw URL")
return shownotes_url, _none_meta(talk_slug, shownotes_url)
except (urllib.error.HTTPError, urllib.error.URLError, Exception) as e:
print(f" WARNING: {shortener} API failed ({e}), falling back to raw URL")
return shownotes_url, _none_meta(talk_slug, shownotes_url)
def _none_meta(talk_slug, shownotes_url):
"""Build a metadata dict for the shortener=none fallback."""
return {
"talk_slug": talk_slug,
"target_url": shownotes_url,
"shortener": "none",
"short_path": None,
"short_url": shownotes_url,
"shortener_link_id": None,
}
# --- Slide Background Color Resolution ---
def resolve_slide_bg_rgb(slide):
"""Walk slide → layout → master to find an explicit solid fill background.
Returns:
tuple (R, G, B) as ints 0-255, or None if no solid fill found.
"""
for obj in [slide, slide.slide_layout, slide.slide_layout.slide_master]:
bg = obj.background
fill = bg.fill
if fill.type is not None:
# Check for solid fill
try:
color = fill.fore_color
if color.type is not None:
rgb = color.rgb
return (rgb[0], rgb[1], rgb[2])
except (AttributeError, TypeError):
continue
return None
def choose_fg_color(bg_rgb):
"""Auto-contrast: pick black or white foreground based on background luminance.
Uses WCAG relative luminance formula:
L = 0.2126*R/255 + 0.7152*G/255 + 0.0722*B/255
Returns:
tuple (R, G, B) — white (255,255,255) for dark backgrounds,
black (0,0,0) for light backgrounds.
"""
if bg_rgb is None:
return (0, 0, 0) # default to black on unknown background
r, g, b = bg_rgb
luminance = 0.2126 * r / 255 + 0.7152 * g / 255 + 0.0722 * b / 255
if luminance < 0.5:
return (255, 255, 255) # white foreground on dark background
else:
return (0, 0, 0) # black foreground on light background
# --- QR Code Generation ---
def generate_qr_png(url, fg_rgb, bg_rgb, out_path):
"""Generate a QR code PNG file.
Args:
url: The URL to encode
fg_rgb: Foreground color as (R, G, B) tuple
bg_rgb: Background color as (R, G, B) tuple, or None for white
out_path: Path to save the PNG file
"""
if bg_rgb is None:
bg_rgb = (255, 255, 255)
qr = qrcode.QRCode(
error_correction=QR_ERROR_CORRECTION,
box_size=QR_BOX_SIZE,
border=QR_BORDER,
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(fill_color=fg_rgb, back_color=bg_rgb)
img.save(out_path)
return out_path
# --- Slide Detection ---
def find_shownotes_slide(prs, shownotes_url):
"""Find the slide index containing the shownotes URL text.
Returns:
int (0-based slide index) or None if not found.
"""
for idx, slide in enumerate(prs.slides):
for shape in slide.shapes:
if shape.has_text_frame:
for paragraph in shape.text_frame.paragraphs:
full_text = "".join(run.text for run in paragraph.runs)
if shownotes_url in full_text:
return idx
return None
def _two_color_metrics(blob):
"""(reconstruction error, minority-color fraction) for the image quantized to
two colors.
A QR is BOTH ~2-color (low reconstruction error) AND ~balanced (large minority
fraction). Photos/diagrams have many colors → high error; text screenshots are
~2-color but mostly one background → tiny minority fraction. Color-agnostic, so
black-on-white and white-on-purple QRs both qualify. Returns (255, 0) if the
image can't be read.
"""
try:
im = Image.open(io.BytesIO(blob)).convert("RGB")
except Exception:
return 255.0, 0.0
q = im.quantize(colors=2)
means = ImageStat.Stat(ImageChops.difference(im, q.convert("RGB"))).mean
err = sum(means) / len(means) if means else 255.0
counts = sorted((c for c in q.histogram() if c > 0), reverse=True)
total = sum(counts)
minority = counts[1] / total if len(counts) > 1 and total else 0.0
return err, minority
def _picture_is_qr(shape):
"""A picture is an (inherited) QR when it is square, at least
QR_DETECT_MIN_INCHES on a side, ~2-color, and roughly balanced between those
colors.
Size-independent by design: a deck adapted from another talk carries that
talk's QR at whatever size it used (the same QR can appear at 1.8" and 2.8"),
so a size band can't identify it. The two-color + balance test does, while the
floor rejects tiny icons and the square test rejects banners/photos.
"""
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
return False
w, h = shape.width, shape.height
if w is None or h is None:
return False
if abs(w - h) >= Inches(QR_SQUARE_TOL_INCHES) or min(w, h) < Inches(QR_DETECT_MIN_INCHES):
return False
try:
blob = shape.image.blob
except Exception:
return False
err, minority = _two_color_metrics(blob)
return err < QR_RECON_ERR_MAX and minority >= QR_MIN_MINORITY
def find_qr_rects(slide):
"""QR pictures on the slide as (left, top, width, height) tuples in POINTS,
in slide z-order (first = lowest-index shape).
The VBA writer can't run PIL, so QR identification happens here and the
geometry is handed to InsertQR for exact in-place replacement.
"""
rects = []
for shape in slide.shapes:
if _picture_is_qr(shape) and shape.left is not None and shape.top is not None:
rects.append((shape.left.pt, shape.top.pt, shape.width.pt, shape.height.pt))
return rects
def slide_has_existing_qr(slide):
"""True if the slide carries an (inherited) QR picture (see find_qr_rects)."""
return bool(find_qr_rects(slide))
def resolve_target_slide_indices(prs, config, shownotes_url):
"""Determine which slides should receive the QR code.
Args:
prs: Presentation object
config: QR config from speaker profile
shownotes_url: The shownotes URL (for content detection)
Returns:
list of 0-based slide indices
"""
position = config.get("slide_position", "closing")
slide_count = len(prs.slides)
indices = []
if position in ("shownotes_slide", "both"):
sn_idx = find_shownotes_slide(prs, shownotes_url)
if sn_idx is not None:
indices.append(sn_idx)
else:
# Fallback: slide index 3 (common shownotes position)
fallback = min(3, slide_count - 1)
print(f" WARNING: Shownotes URL not found in slide text, falling back to slide {fallback + 1}")
indices.append(fallback)
if position in ("closing", "both"):
closing_idx = slide_count - 1
if closing_idx not in indices:
indices.append(closing_idx)
# Always target every slide that already carries a QR — a deck adapted from
# another talk inherits stale QRs (e.g. an early shownotes slide + closing)
# that must be replaced in place, not left beside a freshly-added one.
for idx, slide in enumerate(prs.slides):
if idx not in indices and slide_has_existing_qr(slide):
indices.append(idx)
if not indices:
# Default: last slide
indices.append(slide_count - 1)
return sorted(indices)
# --- QR Insertion ---
def _format_qr_spec(slide_specs):
"""Encode per-slide insertion targets for the InsertQR macro.
Each entry is "<1-based num>", optionally followed by
":<rL,rT,rW,rH>[,<rL,rT,rW,rH>...]" giving the rects (POINTS) of existing QR
pictures to remove on that slide. The macro deletes those shapes and places
the new QR at the FIRST rect (exact in-place replacement, preserving the
inherited size); with no rects it places a new QR bottom-right. Entries are
joined by ";". Detection is content-based (see find_qr_rects) because the VBA
writer can't run PIL — so the chosen geometry travels in this spec.
"""
parts = []
for num, rects in slide_specs:
if rects:
flat = ",".join(f"{v:.2f}" for rect in rects for v in rect)
parts.append(f"{num}:{flat}")
else:
parts.append(str(num))
return ";".join(parts)
def insert_qr_via_powerpoint(deck_path, jobs, scripts_dir):
"""Insert the QR PNG(s) into the deck via the real PowerPoint app.
Replaces the old python-pptx write (`insert-qr.sh` → InsertQR VBA macro), so
PowerPoint serializes a valid .pptx (see rules/deck-editing-rules.md). Python
has already identified each slide's existing QR(s) by content; the macro just
removes those exact shapes and places the new QR there (or bottom-right when a
slide has none). macOS + Microsoft PowerPoint only.
jobs: list of (png_path, slide_specs) — one per QR color variant, where
slide_specs is a list of (1-based slide number, [removal rects in points]).
Each variant is a separate InsertQR pass; the deck is threaded through
uniquely-named intermediates (PowerPoint keys open decks by filename) and the
final result is moved back to deck_path.
"""
wrapper = os.path.join(scripts_dir, "insert-qr.sh")
if not os.path.isfile(wrapper):
raise SystemExit(f"insert-qr.sh not found at {wrapper} — reinstall the tile")
current = deck_path
for n, (png_path, slide_specs) in enumerate(jobs):
out = f"{deck_path}.qrtmp{n}.pptx" # distinct basename — no open-deck collision
spec = _format_qr_spec(slide_specs)
try:
subprocess.run([wrapper, current, out, png_path, spec], check=True)
except subprocess.CalledProcessError:
raise SystemExit(
f"ERROR: QR insertion failed in PowerPoint (insert-qr.sh, spec '{spec}'). "
"Confirm DeckOps.pptm is open with macros enabled and Automation consent "
"granted — see skills/presentation-creator/references/deck-editing-setup.md"
)
if current != deck_path:
os.remove(current) # drop the prior intermediate
current = out
shutil.move(current, deck_path)
# --- Tracking Database ---
def update_tracking_db(tracking_db, entry, qr_png_rel_path):
"""Append or replace a qr_codes entry in the tracking database.
Args:
tracking_db: The full tracking database dict (mutated in place)
entry: Metadata dict from resolve_short_url
qr_png_rel_path: Relative path to the QR PNG file
"""
if "qr_codes" not in tracking_db:
tracking_db["qr_codes"] = []
today = datetime.date.today().isoformat()
talk_slug = entry["talk_slug"]
new_entry = {
"talk_slug": talk_slug,
"target_url": entry["target_url"],
"shortener": entry["shortener"],
"short_path": entry.get("short_path"),
"short_url": entry["short_url"],
"shortener_link_id": entry.get("shortener_link_id"),
"qr_png_rel_path": qr_png_rel_path,
"created_at": today,
"updated_at": today,
}
# Replace existing entry for same talk_slug, or append
replaced = False
for i, existing in enumerate(tracking_db["qr_codes"]):
if existing.get("talk_slug") == talk_slug:
new_entry["created_at"] = existing.get("created_at", today)
tracking_db["qr_codes"][i] = new_entry
replaced = True
break
if not replaced:
tracking_db["qr_codes"].append(new_entry)
# --- Main ---
def main():
parser = argparse.ArgumentParser(
description="Generate a QR code and insert it into a PowerPoint deck.",
epilog="Examples:\n"
" %(prog)s deck.pptx --talk-slug arc-of-ai --shownotes-url https://jbaru.ch/arc-of-ai\n"
" %(prog)s deck.pptx --talk-slug arc-of-ai --short-url https://bit.ly/arcofai\n"
" %(prog)s deck.pptx --talk-slug arc-of-ai --shownotes-url URL --dry-run\n"
" %(prog)s --png-only --talk-slug SLUG --shownotes-url URL --output qr.png\n"
" %(prog)s --png-only --talk-slug SLUG --short-url URL --bg-color 128,0,128\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("deck", nargs="?", default=None, help="Path to the .pptx deck file (not required with --png-only)")
parser.add_argument("--talk-slug", required=True, help="Unique talk identifier (e.g., arc-of-ai)")
url_group = parser.add_mutually_exclusive_group(required=True)
url_group.add_argument("--shownotes-url", help="Full shownotes URL (script resolves shortening)")
url_group.add_argument("--short-url", help="Pre-resolved short URL (skip shortening)")
parser.add_argument("--png-only", action="store_true",
help="Generate QR PNG only, without opening or modifying a deck")
parser.add_argument("--output", metavar="PATH",
help="Output path for QR PNG (default: {deck_dir}/{talk-slug}-qr.png, or ./{talk-slug}-qr.png with --png-only)")
parser.add_argument("--bg-color", metavar="R,G,B",
help="QR background color as R,G,B (e.g., 128,0,128). Default: detected from deck, or white with --png-only")
parser.add_argument("--profile", help="Path to speaker-profile.json (default: {vault}/speaker-profile.json)")
parser.add_argument("--vault", help="Path to vault root directory")
parser.add_argument("--dry-run", action="store_true", help="Skip API calls and deck modification")
args = parser.parse_args()
if not args.png_only and not args.deck:
parser.error("deck is required unless --png-only is specified")
if args.deck and not os.path.isfile(args.deck):
print(f"ERROR: Deck file not found: {args.deck}")
sys.exit(1)
# Determine vault path
vault_path = args.vault
if not vault_path:
vault_path = os.path.expanduser("~/.claude/rhetoric-knowledge-vault")
# Load config
speaker_profile, secrets, tracking_db = load_vault_config(vault_path, args.profile)
qr_config = speaker_profile.get("publishing_process", {}).get("qr_code", {})
# Determine the URL to encode in the QR
if args.short_url:
# MCP-preresolved mode
qr_url = args.short_url
shownotes_url = args.shownotes_url or qr_url # for tracking
meta = {
"talk_slug": args.talk_slug,
"target_url": qr_url,
"shortener": "mcp_preresolved",
"short_path": None,
"short_url": qr_url,
"shortener_link_id": None,
}
print(f"Using pre-resolved short URL: {qr_url}")
else:
# Direct resolution mode
shownotes_url = args.shownotes_url
print(f"Resolving short URL for: {shownotes_url}")
qr_url, meta = resolve_short_url(
shownotes_url, args.talk_slug, qr_config, secrets, tracking_db, args.dry_run,
vault_path=vault_path,
)
print(f"QR will encode: {qr_url}")
# Parse --bg-color if provided
explicit_bg = None
if args.bg_color:
try:
parts = [int(x.strip()) for x in args.bg_color.split(",")]
if len(parts) != 3 or not all(0 <= x <= 255 for x in parts):
raise ValueError
explicit_bg = tuple(parts)
except ValueError:
print(f"ERROR: --bg-color must be R,G,B with values 0-255 (got: {args.bg_color})")
sys.exit(1)
# --- PNG-only mode: no deck needed ---
if args.png_only:
qr_bg = explicit_bg or (255, 255, 255)
qr_fg = choose_fg_color(qr_bg)
print(f"QR colors: fg=RGB{qr_fg}, bg=RGB{qr_bg}")
qr_filename = f"{args.talk_slug}-qr.png"
qr_path = args.output or os.path.join(".", qr_filename)
if not args.dry_run:
generate_qr_png(qr_url, qr_fg, qr_bg, qr_path)
size_kb = os.path.getsize(qr_path) / 1024
print(f"QR PNG saved: {qr_path} ({size_kb:.1f} KB)")
else:
print(f"DRY RUN: would save QR to {qr_path}")
# --- Deck mode: open deck, detect colors, insert ---
else:
prs = Presentation(args.deck)
# Determine target slides
target_url_for_detection = shownotes_url if args.shownotes_url else qr_url
slide_indices = resolve_target_slide_indices(prs, qr_config, target_url_for_detection)
print(f"Target slides: {[i + 1 for i in slide_indices]}")
bg_match = qr_config.get("bg_color_match", True)
deck_dir = os.path.dirname(os.path.abspath(args.deck))
# Resolve background color per slide — different slides may have
# different backgrounds (e.g., shownotes vs closing/thank-you).
# Group slides by their QR color scheme to avoid redundant PNGs.
slide_colors = {} # idx -> (qr_bg, qr_fg)
# Existing-QR geometry per target slide (points), captured while the deck
# is open — the PowerPoint writer can't run PIL, so it gets these rects.
qr_rects_by_idx = {} # idx -> [(L, T, W, H), ...]
for idx in slide_indices:
if explicit_bg:
bg_rgb = explicit_bg
else:
slide_bg = resolve_slide_bg_rgb(prs.slides[idx])
if slide_bg:
bg_rgb = slide_bg
else:
print(f" WARNING: Could not detect background for slide {idx + 1}, defaulting to white")
bg_rgb = (255, 255, 255)
qr_bg = bg_rgb if bg_match else (255, 255, 255)
qr_fg = choose_fg_color(qr_bg)
slide_colors[idx] = (qr_bg, qr_fg)
qr_rects_by_idx[idx] = find_qr_rects(prs.slides[idx])
placement = "replace in place" if qr_rects_by_idx[idx] else "new (bottom-right)"
print(f" Slide {idx + 1}: bg=RGB{qr_bg}, fg=RGB{qr_fg} — {placement}")
# Group slides by color scheme to generate minimal QR PNGs
color_groups = {} # (qr_bg, qr_fg) -> [slide_indices]
for idx, colors in slide_colors.items():
color_groups.setdefault(colors, []).append(idx)
if not args.dry_run:
qr_paths_generated = []
insert_jobs = [] # (png_path, [(1-based num, [removal rects])]) per color variant
for (qr_bg, qr_fg), indices in color_groups.items():
if len(color_groups) == 1:
qr_filename = f"{args.talk_slug}-qr.png"
else:
# Multiple color variants — suffix with bg hex
bg_hex = "{:02x}{:02x}{:02x}".format(*qr_bg)
qr_filename = f"{args.talk_slug}-qr-{bg_hex}.png"
qr_path = args.output if (args.output and len(color_groups) == 1) else os.path.join(deck_dir, qr_filename)
generate_qr_png(qr_url, qr_fg, qr_bg, qr_path)
size_kb = os.path.getsize(qr_path) / 1024
print(f" QR PNG saved: {qr_filename} ({size_kb:.1f} KB) — for slide(s) {[i + 1 for i in indices]}")
qr_paths_generated.append(qr_path)
# VBA is 1-based; pair each slide with its existing-QR rects
insert_jobs.append((qr_path, [(i + 1, qr_rects_by_idx[i]) for i in indices]))
# Release the read-only deck handle, then write via the real
# PowerPoint app (valid OOXML; see rules/deck-editing-rules.md).
prs = None
here = os.path.dirname(os.path.abspath(__file__))
insert_qr_via_powerpoint(args.deck, insert_jobs, here)
print(f"Deck updated via PowerPoint: {args.deck}")
# Use first generated path for tracking DB
qr_filename = os.path.basename(qr_paths_generated[0])
else:
# Dry run — just report what would happen
for (qr_bg, qr_fg), indices in color_groups.items():
print(f" DRY RUN: would generate QR bg=RGB{qr_bg} fg=RGB{qr_fg} for slides {[i + 1 for i in indices]}")
qr_filename = f"{args.talk_slug}-qr.png"
# Update tracking database
meta["target_url"] = shownotes_url if args.shownotes_url else qr_url
update_tracking_db(tracking_db, meta, qr_filename)
if not args.dry_run:
tdb_path = os.path.join(vault_path, "tracking-database.json")
if os.path.isdir(vault_path):
with open(tdb_path, "w", encoding="utf-8") as f:
json.dump(tracking_db, f, indent=2, ensure_ascii=False)
print(f"Tracking DB updated: {tdb_path}")
else:
print(f" NOTE: Vault path {vault_path} not found, tracking DB not persisted")
print("\nDone.")
if __name__ == "__main__":
main().github
eval-resources
humor-postmortem-blind-spots
qr-bitly-slug-from-outline
qr-missing-shortener-detection
shownotes-publisher-omit-placeholder
shownotes-publisher-publish-no-date
shownotes-publisher-publish-with-date
shownotes-publisher-update-add-video
video-extraction-diagnostics
evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
scenario-6
scenario-7
scenario-8
scenario-9
scenario-10
scenario-11
scenario-12
scenario-13
scenario-14
scenario-15
scenario-16
scenario-17
scenario-18
scenario-19
scenario-20
scenario-21
scenario-22
scenario-23
scenario-24
scenario-25
scenario-26
rules
scripts
skills
illustrations
presentation-creator
references
patterns
build
deliver
prepare
scripts
shownotes-publisher
vault-clarification
vault-ingress
vault-profile
tests