using * object-fit: cover. Falls back to 50/50/1 (dead center, no zoom — today's * default behavior) when the row predates this feature or the values are * otherwise missing, so old photos render exactly as they always have. */ function photo_crop_style(array $row): string { $x = isset($row['photo_focal_x']) && $row['photo_focal_x'] !== null ? (float)$row['photo_focal_x'] : 50.0; $y = isset($row['photo_focal_y']) && $row['photo_focal_y'] !== null ? (float)$row['photo_focal_y'] : 50.0; $zoom = isset($row['photo_zoom']) && $row['photo_zoom'] !== null ? (float)$row['photo_zoom'] : 1.0; $x = max(0, min(100, $x)); $y = max(0, min(100, $y)); $zoom = max(1, min(3, $zoom)); // transform-origin matches object-position — see photo-crop.js's file // header for why this (rather than a fixed 50% 50%) is required: a // fixed center has zero pan room on whichever axis has no overflow at // zoom=1 (common for a portrait photo in a landscape box), so panning // that axis goes dead the moment you zoom in. Matching the origin to // (X%, Y%) pins that natural-image point to the same box position at // any zoom, with no such zero-slack case in either axis. return sprintf( 'object-position:%.2f%% %.2f%%;transform:scale(%.3f);transform-origin:%.2f%% %.2f%%;', $x, $y, $zoom, $x, $y ); }