"""Composites the manage-button overlay -- "scan to manage" QR, battery, location/date-taken, share-QR, named face labels -- server-side, onto an already-composed image (any mode: a photo, or a calendar view), before quantization. Replaces what used to be firmware/main/manage_qr_overlay.c generating and positioning all of this on-device. Corner/spacing constants below are plain Python now, not a protocol contract with firmware -- adjustable here without touching anything else. Uses the same toolkit image_pipeline.render_placeholder already does (PIL ImageDraw/ImageFont, the qrcode library), just doing more with it. """ from __future__ import annotations from PIL import Image, ImageDraw, ImageFont from . import panel_style from .image_pipeline import draw_text PADDING = 16 QR_TEXT_GAP = 8 LINE_GAP = 4 PANEL_MARGIN = 20 QR_TARGET_PX = 180 TITLE_FONT_SIZE = 22 BODY_FONT_SIZE = 20 BATTERY_ICON_W = 40 BATTERY_ICON_H = 22 # Stroke/nub width/height are no longer fixed constants here -- panel_ # style.draw_battery_icon derives them from icon_w/icon_h itself (same # formula widgets/battery.py's own icon already used). BATTERY_NUB_W # below is kept only as this box's own outer-width estimate, not fed # into the icon drawing itself. BATTERY_NUB_W = 5 BATTERY_ICON_TEXT_GAP = 8 BATTERY_REGION_GAP = 8 # vertical gap below the manage QR box FACE_LABEL_PADDING = 8 FACE_LABEL_GAP = 4 # distance from the face's anchor point to the label box def _qr_image(url: str, target_px: int = QR_TARGET_PX) -> Image.Image: import qrcode qr = qrcode.QRCode(border=1, box_size=1) qr.add_data(url) qr.make(fit=True) raw = qr.make_image().get_image().convert("RGB") scale = max(1, target_px // raw.width) return raw.resize((raw.width * scale, raw.height * scale), Image.NEAREST) def _text_box(draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont) -> tuple[int, int]: """(width, height) of `lines` stacked with LINE_GAP between them, at `font` -- the box _draw_text_box below will need.""" w = 0 h = 0 for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) w = max(w, bbox[2] - bbox[0]) h += (bbox[3] - bbox[1]) + (LINE_GAP if i else 0) return w, h def _draw_centered_lines(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], font: ImageFont.FreeTypeFont, center_x: int, top: int) -> None: y = top for line in lines: bbox = draw.textbbox((0, 0), line, font=font) w = bbox[2] - bbox[0] draw_text(img, (center_x - w // 2, y), line, font) y += (bbox[3] - bbox[1]) + LINE_GAP def _draw_qr_box(img: Image.Image, draw: ImageDraw.ImageDraw, url: str, caption: list[str], corner: str) -> tuple[int, int, int, int]: """White-padded box with a QR code and centered caption lines below it, placed in one of the panel's four corners. Returns (x0, y0, w, h) -- callers that need to anchor something else relative to this box (the battery, below the manage QR) use it instead of recomputing the same geometry a second time.""" qr_img = _qr_image(url) caption_font = panel_style.font_bold(TITLE_FONT_SIZE) text_w, text_h = _text_box(draw, caption, caption_font) if caption else (0, 0) content_w = max(qr_img.width, text_w) content_h = qr_img.height + (QR_TEXT_GAP + text_h if caption else 0) w = content_w + PADDING * 2 h = content_h + PADDING * 2 x0, y0 = _corner_origin(img.size, (w, h), corner) draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, fill=(255, 255, 255), outline=(0, 0, 0)) center_x = x0 + w // 2 img.paste(qr_img, (center_x - qr_img.width // 2, y0 + PADDING)) if caption: _draw_centered_lines(img, draw, caption, caption_font, center_x, y0 + PADDING + qr_img.height + QR_TEXT_GAP) return x0, y0, w, h def _draw_text_box(img: Image.Image, draw: ImageDraw.ImageDraw, lines: list[str], corner: str) -> None: """White-padded box with centered text lines, placed in one of the panel's four corners.""" font = panel_style.font_regular(BODY_FONT_SIZE) text_w, text_h = _text_box(draw, lines, font) w = text_w + PADDING * 2 h = text_h + PADDING * 2 x0, y0 = _corner_origin(img.size, (w, h), corner) draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, fill=(255, 255, 255), outline=(0, 0, 0)) _draw_centered_lines(img, draw, lines, font, x0 + w // 2, y0 + PADDING) def _corner_origin(img_size: tuple[int, int], box_size: tuple[int, int], corner: str) -> tuple[int, int]: img_w, img_h = img_size box_w, box_h = box_size if corner == "top-left": return PANEL_MARGIN, PANEL_MARGIN if corner == "top-right": return img_w - PANEL_MARGIN - box_w, PANEL_MARGIN if corner == "bottom-left": return PANEL_MARGIN, img_h - PANEL_MARGIN - box_h return img_w - PANEL_MARGIN - box_w, img_h - PANEL_MARGIN - box_h # bottom-right def _draw_battery(img: Image.Image, draw: ImageDraw.ImageDraw, percent: int, anchor_x0: int, anchor_y0: int, anchor_w: int, anchor_h: int) -> None: """Battery glyph + "NN%" text, right-aligned under the given anchor box (the manage QR box) -- a sensible default position, not a constraint anything else has to route around; move this call site's arguments to place it anywhere else instead. The glyph itself is panel_style.draw_battery_icon -- the one shared implementation replacing what used to be a second, independent copy of widgets/ battery.py's own icon-drawing code (same shape, same red/yellow/ green thresholds, previously kept in sync by convention only).""" font = panel_style.font_regular(BODY_FONT_SIZE) text = f"{percent}%" icon_total_w = BATTERY_ICON_W + BATTERY_NUB_W text_w = draw.textlength(text, font=font) content_w = icon_total_w + BATTERY_ICON_TEXT_GAP + text_w content_h = max(font.size, BATTERY_ICON_H) w = int(content_w + PADDING * 2) h = int(content_h + PADDING * 2) x0 = anchor_x0 + anchor_w - w y0 = anchor_y0 + anchor_h + BATTERY_REGION_GAP draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, fill=(255, 255, 255), outline=(0, 0, 0)) icon_x = x0 + PADDING icon_y = y0 + PADDING + (content_h - BATTERY_ICON_H) // 2 panel_style.draw_battery_icon(draw, icon_x, icon_y, BATTERY_ICON_W, BATTERY_ICON_H, percent) draw_text(img, (icon_x + icon_total_w + BATTERY_ICON_TEXT_GAP, y0 + PADDING + (content_h - font.size) // 2), text, font, panel_style.battery_fill_color(percent)) def _draw_face_label(img: Image.Image, draw: ImageDraw.ImageDraw, name: str, anchor_x: int, anchor_y: int) -> None: """White-padded name label centered under an arbitrary (anchor_x, anchor_y) point, flipped above if there's no room below, clamped to stay fully on-panel -- unlike the four corner boxes (always in-bounds by construction), a face can be anywhere, including near an edge.""" font = panel_style.font_regular(BODY_FONT_SIZE) text_w = draw.textlength(name, font=font) bbox = draw.textbbox((0, 0), name, font=font) text_h = bbox[3] - bbox[1] w = int(text_w + FACE_LABEL_PADDING * 2) h = int(text_h + FACE_LABEL_PADDING * 2) img_w, img_h = img.size x0 = anchor_x - w // 2 y0 = anchor_y + FACE_LABEL_GAP if y0 + h > img_h: y0 = anchor_y - FACE_LABEL_GAP - h # no room below -- place above instead x0 = max(0, min(x0, img_w - w)) y0 = max(0, min(y0, img_h - h)) draw.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=panel_style.CHIP_RADIUS, fill=(255, 255, 255), outline=(0, 0, 0)) draw_text(img, (x0 + FACE_LABEL_PADDING, y0 + FACE_LABEL_PADDING - bbox[1]), name, font) def compose(image: Image.Image, management_url: str, battery_percent: int | None = None, location_lines: tuple[str, str] | None = None, taken_at: str | None = None, share_url: str | None = None, face_labels: list[dict] | None = None) -> Image.Image: """Draws the manage overlay onto a copy of `image` (RGB, any mode's already-composed/enhanced logical-space canvas) and returns it. management_url's "scan to manage" box always shows; everything else is optional and simply omitted when not given -- battery_percent None or out of 0-100 skips the battery box, location_lines/taken_at/ share_url empty/None skip their own box, face_labels empty skips those.""" img = image.copy() draw = ImageDraw.Draw(img) qr_x0, qr_y0, qr_w, qr_h = _draw_qr_box(img, draw, management_url, ["SCAN TO", "MANAGE"], "top-right") if battery_percent is not None and 0 <= battery_percent <= 100: _draw_battery(img, draw, battery_percent, qr_x0, qr_y0, qr_w, qr_h) if location_lines and location_lines[0]: lines = [line for line in location_lines if line] _draw_text_box(img, draw, lines, "top-left") if taken_at: _draw_text_box(img, draw, [taken_at], "bottom-right") if share_url: _draw_qr_box(img, draw, share_url, ["SCAN TO", "DOWNLOAD"], "bottom-left") for label in face_labels or []: if label.get("name"): _draw_face_label(img, draw, label["name"], label["x"], label["y"]) return img