{"id":117,"date":"2026-08-25T06:29:37","date_gmt":"2026-08-25T06:29:37","guid":{"rendered":"https:\/\/zemirahzheathomas.com\/?page_id=117"},"modified":"2026-09-01T11:46:54","modified_gmt":"2026-09-01T11:46:54","slug":"home","status":"publish","type":"page","link":"https:\/\/zemirahzheathomas.com\/","title":{"rendered":"Home"},"content":{"rendered":"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>ZHEA Creations \u2014 Magnet Photo Prep Tool<\/title>\n<style>\n  :root {\n    --navy: #0d1e42;\n    --navy-dark: #081530;\n    --red: #99040d;\n    --cream: #f7f0e6;\n    --cream-dark: #efe4d3;\n    --border: #ddd0b8;\n    --text: #2c2a24;\n    --muted: #7a7466;\n    --white: #ffffff;\n  }\n  * { box-sizing: border-box; }\n  body {\n    margin: 0;\n    font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n    background: var(--cream);\n    color: var(--text);\n    padding: 24px 16px 64px;\n  }\n  .wrap { max-width: 880px; margin: 0 auto; }\n  header {\n    text-align: center;\n    margin-bottom: 28px;\n  }\n  header h1 {\n    font-size: 22px;\n    font-weight: 700;\n    color: var(--navy);\n    margin: 0 0 4px;\n    letter-spacing: 0.3px;\n  }\n  header p {\n    font-size: 14px;\n    color: var(--muted);\n    margin: 0;\n  }\n  .card {\n    background: var(--white);\n    border: 1px solid var(--border);\n    border-radius: 14px;\n    padding: 20px;\n    margin-bottom: 20px;\n  }\n  .mode-toggle {\n    display: flex;\n    gap: 8px;\n    margin-bottom: 20px;\n  }\n  .mode-btn {\n    flex: 1;\n    padding: 12px;\n    border-radius: 10px;\n    border: 1.5px solid var(--border);\n    background: var(--white);\n    color: var(--navy);\n    font-size: 14px;\n    font-weight: 600;\n    cursor: pointer;\n    transition: all 0.15s ease;\n  }\n  .mode-btn.active {\n    background: var(--navy);\n    color: var(--white);\n    border-color: var(--navy);\n  }\n  .mode-btn:hover:not(.active) {\n    border-color: var(--navy);\n  }\n  .drop-zone {\n    border: 2px dashed var(--border);\n    border-radius: 12px;\n    padding: 36px 16px;\n    text-align: center;\n    cursor: pointer;\n    transition: border-color 0.15s ease, background 0.15s ease;\n    margin-bottom: 18px;\n  }\n  .drop-zone:hover, .drop-zone.dragover {\n    border-color: var(--red);\n    background: #fdf8f0;\n  }\n  .drop-zone .icon {\n    font-size: 28px;\n    color: var(--muted);\n    margin-bottom: 6px;\n  }\n  .drop-zone p {\n    font-size: 14px;\n    color: var(--muted);\n    margin: 0;\n  }\n  .drop-zone .sub {\n    font-size: 12px;\n    color: var(--muted);\n    margin-top: 4px;\n  }\n  input[type=\"file\"] { display: none; }\n  .controls-row {\n    display: flex;\n    gap: 16px;\n    flex-wrap: wrap;\n    margin-bottom: 18px;\n  }\n  .control-group { flex: 1; min-width: 180px; }\n  .control-group label {\n    display: block;\n    font-size: 12.5px;\n    font-weight: 600;\n    color: var(--navy);\n    margin-bottom: 6px;\n  }\n  select {\n    width: 100%;\n    padding: 10px 12px;\n    border-radius: 8px;\n    border: 1.5px solid var(--border);\n    background: var(--white);\n    font-size: 14px;\n    color: var(--text);\n  }\n  .btn {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    gap: 6px;\n    padding: 12px 18px;\n    border-radius: 10px;\n    border: none;\n    font-size: 14px;\n    font-weight: 700;\n    cursor: pointer;\n    transition: opacity 0.15s ease;\n  }\n  .btn:hover { opacity: 0.9; }\n  .btn-primary { background: var(--red); color: var(--white); }\n  .btn-secondary { background: var(--navy); color: var(--white); }\n  .btn-block { width: 100%; }\n  .btn-sm { padding: 8px 12px; font-size: 12.5px; }\n  .empty-hint {\n    text-align: center;\n    font-size: 13.5px;\n    color: var(--muted);\n    padding: 8px 0 2px;\n  }\n  .photo-grid {\n    display: grid;\n    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n    gap: 14px;\n    margin-bottom: 16px;\n  }\n  .photo-tile {\n    background: var(--cream-dark);\n    border-radius: 10px;\n    padding: 10px;\n    text-align: center;\n  }\n  .photo-tile canvas {\n    width: 100%;\n    height: auto;\n    border-radius: 6px;\n    display: block;\n    margin-bottom: 8px;\n    background: var(--white);\n  }\n  .photo-tile .label {\n    font-size: 11.5px;\n    color: var(--muted);\n    margin-bottom: 6px;\n  }\n  .puzzle-grid-preview {\n    display: grid;\n    gap: 3px;\n    background: var(--cream-dark);\n    border-radius: 12px;\n    padding: 14px;\n    margin-bottom: 16px;\n  }\n  .puzzle-grid-preview canvas {\n    width: 100%;\n    height: auto;\n    border-radius: 3px;\n    display: block;\n    background: var(--white);\n  }\n  .footer-note {\n    text-align: center;\n    font-size: 12px;\n    color: var(--muted);\n    margin-top: 8px;\n  }\n  .section-title {\n    font-size: 13px;\n    font-weight: 700;\n    color: var(--navy);\n    margin: 0 0 12px;\n  }\n<\/style>\n<\/head>\n<body>\n<div class=\"wrap\">\n\n  <header>\n    <h1>ZHEA Creations \u2014 Magnet Photo Prep Tool<\/h1>\n    <p>Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser.<\/p>\n  <\/header>\n\n  <div class=\"card\">\n    <div class=\"mode-toggle\">\n      <button class=\"mode-btn active\" id=\"mode-single\">Regular magnets (1\u201315 pcs)<\/button>\n      <button class=\"mode-btn\" id=\"mode-puzzle\">Puzzle magnet set<\/button>\n    <\/div>\n\n    <div id=\"single-section\">\n      <div class=\"drop-zone\" id=\"drop-zone-single\">\n        <div class=\"icon\">&#8593;<\/div>\n        <p>Click or drop photos here \u2014 select as many as the order needs<\/p>\n        <p class=\"sub\">1 piece = 1 photo \u00b7 6 piece = 6 photos \u00b7 etc.<\/p>\n        <input type=\"file\" id=\"file-input-single\" accept=\"image\/*\" multiple>\n      <\/div>\n      <button class=\"btn btn-secondary\" id=\"clear-single\" style=\"margin-bottom:18px;\">Clear all \u2014 start new order<\/button>\n      <div class=\"controls-row\">\n        <div class=\"control-group\">\n          <label>Output size per magnet<\/label>\n          <select id=\"single-size\">\n            <option value=\"720\" selected>720 x 720 px \u2014 61mm die cutter size (50mm finished magnet)<\/option>\n            <option value=\"600\">600 x 600 px \u2014 50mm exact (no bleed, only if cutting by hand)<\/option>\n          <\/select>\n        <\/div>\n      <\/div>\n      <p class=\"empty-hint\" id=\"single-empty-hint\">No photos uploaded yet \u2014 this order&#8217;s photos will appear here.<\/p>\n      <div class=\"photo-grid\" id=\"single-grid\"><\/div>\n      <button class=\"btn btn-primary btn-block\" id=\"download-single-all\" style=\"display:none;\">Download all magnet-ready photos (separate files)<\/button>\n      <button class=\"btn btn-secondary btn-block\" id=\"download-single-a4\" style=\"display:none; margin-top:10px;\">Download as one A4 print sheet (saves paper)<\/button>\n    <\/div>\n\n    <div id=\"puzzle-section\" style=\"display:none;\">\n      <div class=\"drop-zone\" id=\"drop-zone-puzzle\">\n        <div class=\"icon\">&#8593;<\/div>\n        <p>Click or drop the single photo for this puzzle set<\/p>\n        <p class=\"sub\">One photo gets split into the grid below<\/p>\n        <input type=\"file\" id=\"file-input-puzzle\" accept=\"image\/*\">\n      <\/div>\n      <button class=\"btn btn-secondary\" id=\"clear-puzzle\" style=\"margin-bottom:18px;\">Clear \u2014 start new order<\/button>\n      <div class=\"controls-row\">\n        <div class=\"control-group\">\n          <label>Piece count (match the order)<\/label>\n          <select id=\"puzzle-count\">\n            <option value=\"6\">6 piece<\/option>\n            <option value=\"9\" selected>9 piece<\/option>\n            <option value=\"12\">12 piece<\/option>\n            <option value=\"15\">15 piece<\/option>\n          <\/select>\n        <\/div>\n        <div class=\"control-group\">\n          <label>Per-tile output size<\/label>\n          <select id=\"puzzle-tile-size\">\n            <option value=\"720\" selected>720 x 720 px \u2014 61mm die cutter size (50mm finished magnet)<\/option>\n            <option value=\"600\">600 x 600 px \u2014 50mm exact (no bleed, only if cutting by hand)<\/option>\n          <\/select>\n        <\/div>\n      <\/div>\n      <div class=\"control-group\" style=\"margin-bottom:14px;\">\n        <label>Add wording to this puzzle?<\/label>\n        <div class=\"mode-toggle\" style=\"margin-bottom:0;\">\n          <button class=\"mode-btn active\" id=\"wording-no\" type=\"button\">No<\/button>\n          <button class=\"mode-btn\" id=\"wording-yes\" type=\"button\">Yes<\/button>\n        <\/div>\n      <\/div>\n      <div id=\"wording-fields\" style=\"display:none; margin-bottom:14px;\">\n        <div class=\"control-group\" style=\"margin-bottom:12px;\">\n          <label>Top wording<\/label>\n          <input type=\"text\" id=\"wording-top\" placeholder=\"e.g. Happy Birthday\" style=\"width:100%; padding:10px 12px; border-radius:8px; border:1.5px solid var(--border); font-size:14px; font-family:inherit;\">\n        <\/div>\n        <div class=\"control-group\">\n          <label>Bottom wording (optional)<\/label>\n          <input type=\"text\" id=\"wording-bottom\" placeholder=\"e.g. With love, The Tans\" style=\"width:100%; padding:10px 12px; border-radius:8px; border:1.5px solid var(--border); font-size:14px; font-family:inherit;\">\n        <\/div>\n      <\/div>\n      <button class=\"btn btn-secondary btn-block\" id=\"adjust-puzzle-crop\" style=\"display:none; margin-bottom:14px;\">Adjust crop, position &amp; clarity<\/button>\n      <p class=\"empty-hint\" id=\"puzzle-empty-hint\">Upload a photo to preview the puzzle grid split.<\/p>\n      <div class=\"puzzle-grid-preview\" id=\"puzzle-grid-preview\"><\/div>\n      <button class=\"btn btn-primary btn-block\" id=\"download-puzzle-all\" style=\"display:none;\">Download all puzzle tiles (separate files)<\/button>\n      <button class=\"btn btn-secondary btn-block\" id=\"download-puzzle-a4\" style=\"display:none; margin-top:10px;\">Download as one A4 print sheet (saves paper)<\/button>\n    <\/div>\n  <\/div>\n\n  <p class=\"footer-note\">Runs entirely on your device \u2014 no photos are uploaded anywhere. Keep this file saved and open it anytime, even without internet.<\/p>\n<\/div>\n\n<div id=\"crop-modal\" style=\"display:none; position:fixed; inset:0; background:rgba(20,15,10,0.75); z-index:9999; align-items:center; justify-content:center; overflow-y:auto;\">\n  <div style=\"background:#fff; border-radius:16px; padding:24px; max-width:440px; width:92%; text-align:center; margin:24px 0;\">\n    <h3 style=\"margin:0 0 4px; color:#0d1e42; font-size:17px;\">Adjust Photo<\/h3>\n    <p style=\"margin:0 0 16px; color:#7a7466; font-size:13px;\">Drag to reposition \u00b7 use the sliders to zoom &amp; adjust clarity<\/p>\n    <canvas id=\"crop-canvas\" width=\"360\" height=\"360\" style=\"width:100%; max-width:360px; border-radius:10px; background:#efe4d3; cursor:grab; touch-action:none;\"><\/canvas>\n    <div style=\"margin:16px 0; display:flex; align-items:center; gap:10px;\">\n      <span style=\"font-size:12px; color:#7a7466; width:70px; text-align:left;\">Zoom<\/span>\n      <input type=\"range\" id=\"crop-zoom\" min=\"1\" max=\"4\" step=\"0.01\" value=\"1\" style=\"flex:1;\">\n    <\/div>\n\n    <div style=\"border-top:1px solid #eee2d2; padding-top:14px; margin-bottom:4px;\">\n      <div style=\"display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;\">\n        <span style=\"font-size:13px; font-weight:600; color:#0d1e42;\">Clarity &amp; color<\/span>\n        <button id=\"adjust-reset\" style=\"background:none; border:none; color:#99040d; font-size:12px; cursor:pointer; text-decoration:underline;\">Reset<\/button>\n      <\/div>\n      <div style=\"margin-bottom:10px; display:flex; align-items:center; gap:10px;\">\n        <span style=\"font-size:12px; color:#7a7466; width:70px; text-align:left;\">Sharpness<\/span>\n        <input type=\"range\" id=\"adjust-sharpness\" min=\"0\" max=\"100\" step=\"1\" value=\"0\" style=\"flex:1;\">\n      <\/div>\n      <div style=\"margin-bottom:10px; display:flex; align-items:center; gap:10px;\">\n        <span style=\"font-size:12px; color:#7a7466; width:70px; text-align:left;\">Brightness<\/span>\n        <input type=\"range\" id=\"adjust-brightness\" min=\"50\" max=\"150\" step=\"1\" value=\"100\" style=\"flex:1;\">\n      <\/div>\n      <div style=\"margin-bottom:10px; display:flex; align-items:center; gap:10px;\">\n        <span style=\"font-size:12px; color:#7a7466; width:70px; text-align:left;\">Contrast<\/span>\n        <input type=\"range\" id=\"adjust-contrast\" min=\"50\" max=\"150\" step=\"1\" value=\"100\" style=\"flex:1;\">\n      <\/div>\n      <div style=\"margin-bottom:16px; display:flex; align-items:center; gap:10px;\">\n        <span style=\"font-size:12px; color:#7a7466; width:70px; text-align:left;\">Saturation<\/span>\n        <input type=\"range\" id=\"adjust-saturation\" min=\"0\" max=\"200\" step=\"1\" value=\"100\" style=\"flex:1;\">\n      <\/div>\n    <\/div>\n\n    <div style=\"display:flex; gap:10px;\">\n      <button class=\"btn btn-secondary\" id=\"crop-cancel\" style=\"flex:1;\">Cancel<\/button>\n      <button class=\"btn btn-primary\" id=\"crop-confirm\" style=\"flex:1;\">Confirm<\/button>\n    <\/div>\n  <\/div>\n<\/div>\n\n<script>\nlet mode = 'single';\nlet singlePhotos = []; \/\/ {img, name}\nlet puzzleImg = null;\nlet puzzleTiles = [];\n\n\/* Neutral clarity\/color values \u2014 no visual change until the customer or you move a slider *\/\nconst DEFAULT_ADJUST = { brightness: 100, contrast: 100, saturation: 100, sharpness: 0 };\n\n\/* Draws a cropped region of img onto destCtx at (dx,dy,dw,dh), applying brightness\/contrast\/\n   saturation via the canvas filter API, then applying a manual unsharp-mask sharpen afterward\n   (sharpening isn't part of the canvas filter spec, so it needs its own pixel pass). *\/\nfunction drawWithAdjustments(destCtx, img, sx, sy, sw, sh, dx, dy, dw, dh, adjust) {\n  const a = adjust || DEFAULT_ADJUST;\n  destCtx.save();\n  destCtx.filter = `brightness(${a.brightness}%) contrast(${a.contrast}%) saturate(${a.saturation}%)`;\n  destCtx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);\n  destCtx.restore();\n  if (a.sharpness > 0) {\n    applyUnsharpMask(destCtx, dx, dy, dw, dh, a.sharpness);\n  }\n}\n\n\/* Classic unsharp-mask sharpen: blur a copy, then push each pixel further away from its\n   blurred value. amountPercent is the 0-100 slider value. *\/\nfunction applyUnsharpMask(ctx, x, y, w, h, amountPercent) {\n  if (!amountPercent) return;\n  const amount = (amountPercent \/ 100) * 1.2; \/\/ cap strength so it stays natural-looking\n\n  const srcCanvas = document.createElement('canvas');\n  srcCanvas.width = w; srcCanvas.height = h;\n  const srcCtx = srcCanvas.getContext('2d');\n  srcCtx.putImageData(ctx.getImageData(x, y, w, h), 0, 0);\n\n  const blurCanvas = document.createElement('canvas');\n  blurCanvas.width = w; blurCanvas.height = h;\n  const blurCtx = blurCanvas.getContext('2d');\n  blurCtx.filter = 'blur(1.4px)';\n  blurCtx.drawImage(srcCanvas, 0, 0);\n\n  const sharp = srcCtx.getImageData(0, 0, w, h);\n  const blur = blurCtx.getImageData(0, 0, w, h);\n  const out = ctx.createImageData(w, h);\n\n  for (let i = 0; i < sharp.data.length; i += 4) {\n    for (let c = 0; c < 3; c++) { \/\/ R, G, B \u2014 leave alpha untouched\n      const s = sharp.data[i + c];\n      const b = blur.data[i + c];\n      out.data[i + c] = Math.max(0, Math.min(255, s + amount * (s - b)));\n    }\n    out.data[i + 3] = sharp.data[i + 3];\n  }\n  ctx.putImageData(out, x, y);\n}\n\nconst modeSingleBtn = document.getElementById('mode-single');\nconst modePuzzleBtn = document.getElementById('mode-puzzle');\nconst singleSection = document.getElementById('single-section');\nconst puzzleSection = document.getElementById('puzzle-section');\n\nfunction setMode(m) {\n  mode = m;\n  modeSingleBtn.classList.toggle('active', m === 'single');\n  modePuzzleBtn.classList.toggle('active', m === 'puzzle');\n  singleSection.style.display = m === 'single' ? 'block' : 'none';\n  puzzleSection.style.display = m === 'puzzle' ? 'block' : 'none';\n}\nmodeSingleBtn.addEventListener('click', () => setMode('single'));\nmodePuzzleBtn.addEventListener('click', () => setMode('puzzle'));\n\nfunction centerSquareCrop(image) {\n  const side = Math.min(image.width, image.height);\n  return { sx: (image.width - side) \/ 2, sy: (image.height - side) \/ 2, side };\n}\n\n\n\/* Returns the crop rectangle for an image at a given aspect ratio (aspectW:aspectH), using a\n   saved custom crop (cx, cy, zoom) if present, otherwise a centered crop at that aspect ratio.\n   cx\/cy are 0-1 fractions of image size. Used by both single (1:1) and puzzle (cols:rows) modes. *\/\nfunction getCropRect(img, aspectW, aspectH, crop) {\n  const zoom = crop ? crop.zoom : 1;\n  const maxW = Math.min(img.width, img.height * (aspectW \/ aspectH));\n  const maxH = maxW * (aspectH \/ aspectW);\n  const w = maxW \/ zoom;\n  const h = maxH \/ zoom;\n  let cx = crop ? crop.cx : 0.5;\n  let cy = crop ? crop.cy : 0.5;\n  let centerX = cx * img.width;\n  let centerY = cy * img.height;\n  centerX = Math.max(w \/ 2, Math.min(img.width - w \/ 2, centerX));\n  centerY = Math.max(h \/ 2, Math.min(img.height - h \/ 2, centerY));\n  return { sx: centerX - w \/ 2, sy: centerY - h \/ 2, w, h };\n}\n\n\/* Regular magnets are always square (1:1) \u2014 thin wrapper kept for readability at call sites. *\/\nfunction getPhotoCrop(p) {\n  const r = getCropRect(p.img, 1, 1, p.crop);\n  return { sx: r.sx, sy: r.sy, side: r.w };\n}\n\n\/* ---------- Interactive crop editor (drag to reposition, slider to zoom, clarity\/color) ---------- *\/\nconst cropModal = document.getElementById('crop-modal');\nconst cropCanvas = document.getElementById('crop-canvas');\nconst cropZoomSlider = document.getElementById('crop-zoom');\nconst adjustSharpnessSlider = document.getElementById('adjust-sharpness');\nconst adjustBrightnessSlider = document.getElementById('adjust-brightness');\nconst adjustContrastSlider = document.getElementById('adjust-contrast');\nconst adjustSaturationSlider = document.getElementById('adjust-saturation');\nconst adjustResetBtn = document.getElementById('adjust-reset');\nlet cropState = null; \/\/ { photo, cx, cy, zoom, adjust, onDone }\nlet cropDragging = false;\nlet cropDragStart = null;\n\nfunction syncAdjustSliders(adjust) {\n  adjustSharpnessSlider.value = adjust.sharpness;\n  adjustBrightnessSlider.value = adjust.brightness;\n  adjustContrastSlider.value = adjust.contrast;\n  adjustSaturationSlider.value = adjust.saturation;\n}\n\nfunction openCropEditor(photo, onDone, opts = {}) {\n  const aspectW = opts.aspectW || 1;\n  const aspectH = opts.aspectH || 1;\n  const grid = opts.grid || null; \/\/ { cols, rows } \u2014 draws live tile-split lines while adjusting\n  const existing = photo.crop || { cx: 0.5, cy: 0.5, zoom: 1 };\n  const existingAdjust = photo.adjust || { ...DEFAULT_ADJUST };\n  cropState = {\n    photo, cx: existing.cx, cy: existing.cy, zoom: existing.zoom,\n    adjust: { ...existingAdjust }, onDone, aspectW, aspectH, grid\n  };\n  cropZoomSlider.value = existing.zoom;\n  syncAdjustSliders(cropState.adjust);\n\n  \/* Size the modal canvas to match this crop's aspect ratio, capped to a comfortable max dimension *\/\n  const maxDim = 360;\n  let canvasW, canvasH;\n  if (aspectW >= aspectH) { canvasW = maxDim; canvasH = maxDim * (aspectH \/ aspectW); }\n  else { canvasH = maxDim; canvasW = maxDim * (aspectW \/ aspectH); }\n  cropCanvas.width = canvasW;\n  cropCanvas.height = canvasH;\n  cropCanvas.style.width = canvasW + 'px';\n  cropCanvas.style.maxWidth = '100%';\n\n  cropModal.style.display = 'flex';\n  drawCropCanvas();\n}\n\nfunction closeCropEditor() {\n  cropModal.style.display = 'none';\n  cropState = null;\n}\n\nfunction drawCropCanvas() {\n  if (!cropState) return;\n  const { photo, aspectW, aspectH, grid, adjust } = cropState;\n  const r = getCropRect(photo.img, aspectW, aspectH, cropState);\n  cropState.cx = (r.sx + r.w \/ 2) \/ photo.img.width;\n  cropState.cy = (r.sy + r.h \/ 2) \/ photo.img.height;\n\n  const ctx = cropCanvas.getContext('2d');\n  ctx.clearRect(0, 0, cropCanvas.width, cropCanvas.height);\n  drawWithAdjustments(ctx, photo.img, r.sx, r.sy, r.w, r.h, 0, 0, cropCanvas.width, cropCanvas.height, adjust);\n\n  if (grid) {\n    \/* Live preview of exactly where the puzzle will be sliced, so faces\/details can be\n       positioned away from seams before confirming *\/\n    ctx.save();\n    for (let pass = 0; pass < 2; pass++) {\n      ctx.strokeStyle = pass === 0 ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.9)';\n      ctx.lineWidth = pass === 0 ? 2.5 : 1;\n      for (let c = 1; c < grid.cols; c++) {\n        const x = cropCanvas.width * (c \/ grid.cols);\n        ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cropCanvas.height); ctx.stroke();\n      }\n      for (let row = 1; row < grid.rows; row++) {\n        const y = cropCanvas.height * (row \/ grid.rows);\n        ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cropCanvas.width, y); ctx.stroke();\n      }\n    }\n    ctx.restore();\n  }\n}\n\ncropCanvas.addEventListener('pointerdown', (e) => {\n  cropDragging = true;\n  cropCanvas.style.cursor = 'grabbing';\n  cropDragStart = { x: e.clientX, y: e.clientY, cx: cropState.cx, cy: cropState.cy };\n});\nwindow.addEventListener('pointermove', (e) => {\n  if (!cropDragging || !cropState) return;\n  const { photo, aspectW, aspectH } = cropState;\n  const r = getCropRect(photo.img, aspectW, aspectH, cropState);\n  const scaleX = cropCanvas.width \/ r.w;\n  const scaleY = cropCanvas.height \/ r.h;\n  const dx = (e.clientX - cropDragStart.x) \/ scaleX;\n  const dy = (e.clientY - cropDragStart.y) \/ scaleY;\n  const startCenterX = cropDragStart.cx * photo.img.width;\n  const startCenterY = cropDragStart.cy * photo.img.height;\n  cropState.cx = (startCenterX - dx) \/ photo.img.width;\n  cropState.cy = (startCenterY - dy) \/ photo.img.height;\n  drawCropCanvas();\n});\nwindow.addEventListener('pointerup', () => {\n  cropDragging = false;\n  cropCanvas.style.cursor = 'grab';\n});\n\ncropZoomSlider.addEventListener('input', () => {\n  if (!cropState) return;\n  cropState.zoom = parseFloat(cropZoomSlider.value);\n  drawCropCanvas();\n});\n\nadjustSharpnessSlider.addEventListener('input', () => {\n  if (!cropState) return;\n  cropState.adjust.sharpness = parseFloat(adjustSharpnessSlider.value);\n  drawCropCanvas();\n});\nadjustBrightnessSlider.addEventListener('input', () => {\n  if (!cropState) return;\n  cropState.adjust.brightness = parseFloat(adjustBrightnessSlider.value);\n  drawCropCanvas();\n});\nadjustContrastSlider.addEventListener('input', () => {\n  if (!cropState) return;\n  cropState.adjust.contrast = parseFloat(adjustContrastSlider.value);\n  drawCropCanvas();\n});\nadjustSaturationSlider.addEventListener('input', () => {\n  if (!cropState) return;\n  cropState.adjust.saturation = parseFloat(adjustSaturationSlider.value);\n  drawCropCanvas();\n});\nadjustResetBtn.addEventListener('click', () => {\n  if (!cropState) return;\n  cropState.adjust = { ...DEFAULT_ADJUST };\n  syncAdjustSliders(cropState.adjust);\n  drawCropCanvas();\n});\n\ndocument.getElementById('crop-cancel').addEventListener('click', closeCropEditor);\ndocument.getElementById('crop-confirm').addEventListener('click', () => {\n  if (!cropState) return;\n  const { photo, cx, cy, zoom, adjust, onDone } = cropState;\n  photo.crop = { cx, cy, zoom };\n  photo.adjust = { ...adjust };\n  closeCropEditor();\n  if (onDone) onDone();\n});\n\n\/* ---------- Single magnet mode (batch) ---------- *\/\nconst dropZoneSingle = document.getElementById('drop-zone-single');\nconst fileInputSingle = document.getElementById('file-input-single');\nconst singleGrid = document.getElementById('single-grid');\nconst singleEmptyHint = document.getElementById('single-empty-hint');\nconst downloadSingleAllBtn = document.getElementById('download-single-all');\n\ndropZoneSingle.addEventListener('click', () => fileInputSingle.click());\ndropZoneSingle.addEventListener('dragover', (e) => { e.preventDefault(); dropZoneSingle.classList.add('dragover'); });\ndropZoneSingle.addEventListener('dragleave', () => dropZoneSingle.classList.remove('dragover'));\ndropZoneSingle.addEventListener('drop', (e) => {\n  e.preventDefault();\n  dropZoneSingle.classList.remove('dragover');\n  loadSingleFiles(e.dataTransfer.files);\n});\nfileInputSingle.addEventListener('change', (e) => loadSingleFiles(e.target.files));\n\nfunction loadSingleFiles(fileList) {\n  Array.from(fileList).forEach((file) => {\n    const reader = new FileReader();\n    reader.onload = (e) => {\n      const image = new Image();\n      image.onload = () => {\n        singlePhotos.push({ img: image, name: file.name });\n        renderSingleGrid();\n      };\n      image.src = e.target.result;\n    };\n    reader.readAsDataURL(file);\n  });\n}\n\nfunction renderSingleGrid() {\n  const size = parseInt(document.getElementById('single-size').value, 10);\n  const showSafeZone = size === 720;\n  singleGrid.innerHTML = '';\n  singleEmptyHint.style.display = singlePhotos.length ? 'none' : 'block';\n  downloadSingleAllBtn.style.display = singlePhotos.length ? 'block' : 'none';\n  document.getElementById('download-single-a4').style.display = singlePhotos.length ? 'block' : 'none';\n\n  singlePhotos.forEach((p, idx) => {\n    const tile = document.createElement('div');\n    tile.className = 'photo-tile';\n\n    const canvas = document.createElement('canvas');\n    canvas.width = size;\n    canvas.height = size;\n    const { sx, sy, side } = getPhotoCrop(p);\n    const ctx2d = canvas.getContext('2d');\n\n    \/* Photo always fills the full bleed edge-to-edge \u2014 including the outer ~5.5mm ring that\n       folds under during assembly. That ring must stay continuous photo content, not blank\n       background, or a slightly imprecise fold shows a white sliver instead of blending in. *\/\n    drawWithAdjustments(ctx2d, p.img, sx, sy, side, side, 0, 0, size, size, p.adjust);\n\n    const previewCanvas = drawSafeZonePreview(canvas, showSafeZone);\n\n    const label = document.createElement('div');\n    label.className = 'label';\n    label.textContent = 'Photo ' + (idx + 1) + (showSafeZone ? ' \u2014 red=cut edge, green=safety area' : '');\n\n    const adjustBtn = document.createElement('button');\n    adjustBtn.className = 'btn btn-secondary btn-sm';\n    adjustBtn.style.width = '100%';\n    adjustBtn.style.marginBottom = '6px';\n    adjustBtn.textContent = 'Adjust crop & clarity';\n    adjustBtn.addEventListener('click', () => openCropEditor(p, renderSingleGrid));\n\n    const dlBtn = document.createElement('button');\n    dlBtn.className = 'btn btn-secondary btn-sm';\n    dlBtn.style.width = '100%';\n    dlBtn.textContent = 'Download';\n    dlBtn.addEventListener('click', () => downloadCanvas(canvas, `magnet_${idx + 1}.jpg`));\n\n    tile.appendChild(previewCanvas);\n    tile.appendChild(label);\n    tile.appendChild(adjustBtn);\n    tile.appendChild(dlBtn);\n    singleGrid.appendChild(tile);\n    p.canvas = canvas;\n  });\n}\n\n\/* Creates a preview-only copy with a dashed safe-zone guide overlay (not baked into the real download)\n   marginMM: how much of the edge folds under \/ is lost. Defaults to the regular-magnet die-cut\n   measurement (5.5mm). Puzzle mode passes a smaller value since its tiles butt up against each\n   other and a smaller fold margin keeps the assembled image looking continuous. *\/\nfunction drawSafeZonePreview(sourceCanvas, showSafeZone, marginMM = 5.5) {\n  const preview = document.createElement('canvas');\n  preview.width = sourceCanvas.width;\n  preview.height = sourceCanvas.height;\n  const ctx = preview.getContext('2d');\n  ctx.drawImage(sourceCanvas, 0, 0);\n  if (showSafeZone) {\n    \/* Measurements from OneBadge's official 50mm square badge template:\n       61mm full bleed, 50mm actual product size, 45mm safety area (for regular mode, 5.5mm inset).\n       Puzzle mode uses a reduced inset (see call site) to minimize image loss at internal seams. *\/\n    const redInset = sourceCanvas.width * (marginMM \/ 61);\n    const greenInset = sourceCanvas.width * ((marginMM + 2.5) \/ 61); \/\/ keep a 2.5mm safety buffer past the cut line\n\n    ctx.strokeStyle = '#99040d';\n    ctx.setLineDash([8, 6]);\n    ctx.lineWidth = 3;\n    ctx.strokeRect(redInset, redInset, sourceCanvas.width - redInset * 2, sourceCanvas.height - redInset * 2);\n\n    ctx.strokeStyle = '#1a9e5c';\n    ctx.setLineDash([5, 5]);\n    ctx.lineWidth = 2.5;\n    ctx.strokeRect(greenInset, greenInset, sourceCanvas.width - greenInset * 2, sourceCanvas.height - greenInset * 2);\n  }\n  return preview;\n}\n\ndocument.getElementById('single-size').addEventListener('change', renderSingleGrid);\n\ndocument.getElementById('clear-single').addEventListener('click', () => {\n  singlePhotos = [];\n  fileInputSingle.value = '';\n  renderSingleGrid();\n});\n\ndownloadSingleAllBtn.addEventListener('click', () => {\n  singlePhotos.forEach((p, i) => {\n    downloadCanvas(p.canvas, `magnet_${i + 1}.jpg`);\n  });\n});\n\n\/* ---------- Puzzle mode ---------- *\/\n\/* TEST CONFIG: using the 45mm SAFETY AREA (not the 50mm actual-product line) as the fold-margin\n   reference, per request \u2014 a more conservative margin (8mm\/side instead of 5.5mm\/side) that\n   leaves extra blank buffer around the visible photo, to test whether this eliminates any\n   remaining waste\/cut-off issues seen in physical prints. Adjust this single constant to\n   fine-tune further after testing. *\/\nconst PUZZLE_MARGIN_MM = 8; \/\/ (61 - 45) \/ 2\nconst dropZonePuzzle = document.getElementById('drop-zone-puzzle');\nconst fileInputPuzzle = document.getElementById('file-input-puzzle');\nconst puzzleGridPreview = document.getElementById('puzzle-grid-preview');\nconst puzzleEmptyHint = document.getElementById('puzzle-empty-hint');\nconst downloadPuzzleAllBtn = document.getElementById('download-puzzle-all');\nconst adjustPuzzleCropBtn = document.getElementById('adjust-puzzle-crop');\nconst wordingNoBtn = document.getElementById('wording-no');\nconst wordingYesBtn = document.getElementById('wording-yes');\nconst wordingFields = document.getElementById('wording-fields');\nconst wordingTopInput = document.getElementById('wording-top');\nconst wordingBottomInput = document.getElementById('wording-bottom');\n\n\/* puzzlePhoto wraps the single uploaded image with an (optional) saved crop, mirroring the\n   {img, crop} shape used by regular-mode photos so both modes can share getCropRect\/openCropEditor. *\/\nlet puzzlePhoto = null;\n\n\/* Optional top\/bottom wording overlaid across the top-row and bottom-row tiles of the\n   assembled puzzle \u2014 off by default, since not every order wants it. *\/\nlet wordingEnabled = false;\n\nfunction setWordingEnabled(enabled) {\n  wordingEnabled = enabled;\n  wordingNoBtn.classList.toggle('active', !enabled);\n  wordingYesBtn.classList.toggle('active', enabled);\n  wordingFields.style.display = enabled ? 'block' : 'none';\n  renderPuzzleGrid();\n}\nwordingNoBtn.addEventListener('click', () => setWordingEnabled(false));\nwordingYesBtn.addEventListener('click', () => setWordingEnabled(true));\nwordingTopInput.addEventListener('input', renderPuzzleGrid);\nwordingBottomInput.addEventListener('input', renderPuzzleGrid);\n\nfunction getPuzzleLayout(count, isPortrait) {\n  const layouts = {\n    6: isPortrait ? [2, 3] : [3, 2],\n    9: [3, 3],\n    12: isPortrait ? [3, 4] : [4, 3],\n    15: isPortrait ? [3, 5] : [5, 3]\n  };\n  return layouts[count]; \/\/ [cols, rows]\n}\n\ndropZonePuzzle.addEventListener('click', () => fileInputPuzzle.click());\ndropZonePuzzle.addEventListener('dragover', (e) => { e.preventDefault(); dropZonePuzzle.classList.add('dragover'); });\ndropZonePuzzle.addEventListener('dragleave', () => dropZonePuzzle.classList.remove('dragover'));\ndropZonePuzzle.addEventListener('drop', (e) => {\n  e.preventDefault();\n  dropZonePuzzle.classList.remove('dragover');\n  if (e.dataTransfer.files[0]) loadPuzzleFile(e.dataTransfer.files[0]);\n});\nfileInputPuzzle.addEventListener('change', (e) => { if (e.target.files[0]) loadPuzzleFile(e.target.files[0]); });\n\nfunction loadPuzzleFile(file) {\n  const reader = new FileReader();\n  reader.onload = (e) => {\n    const image = new Image();\n    image.onload = () => {\n      puzzleImg = image;\n      puzzlePhoto = { img: image, crop: null }; \/\/ fresh photo = fresh centered crop\n      renderPuzzleGrid();\n    };\n    image.src = e.target.result;\n  };\n  reader.readAsDataURL(file);\n}\n\nadjustPuzzleCropBtn.addEventListener('click', () => {\n  if (!puzzlePhoto) return;\n  const count = parseInt(document.getElementById('puzzle-count').value, 10);\n  const isPortrait = puzzleImg.height >= puzzleImg.width;\n  const [cols, rows] = getPuzzleLayout(count, isPortrait);\n  openCropEditor(puzzlePhoto, renderPuzzleGrid, { aspectW: cols, aspectH: rows, grid: { cols, rows } });\n});\n\n\/* Builds one canvas spanning the FULL assembled visible puzzle (cols x rows of visibleSize),\n   with an optional banner drawn across the top row's height and\/or the bottom row's height.\n   Transparent everywhere else, so it composites cleanly on top of each tile's photo. *\/\nfunction buildWordingOverlay(cols, rows, visibleSize) {\n  const canvas = document.createElement('canvas');\n  canvas.width = cols * visibleSize;\n  canvas.height = rows * visibleSize;\n  const ctx = canvas.getContext('2d');\n\n  const topText = wordingEnabled ? wordingTopInput.value.trim() : '';\n  const bottomText = wordingEnabled ? wordingBottomInput.value.trim() : '';\n  if (!topText && !bottomText) return canvas; \/\/ nothing to draw \u2014 stays fully transparent\n\n  const bandHeight = visibleSize * 0.32; \/\/ confined within the top\/bottom ROW of tiles only\n\n  function drawBanner(text, bandTop) {\n    if (!text) return;\n    ctx.fillStyle = '#0d1e42'; \/\/ fully opaque \u2014 must reliably hide whatever's behind it (photo detail, or even text baked into the source photo itself)\n    ctx.fillRect(0, bandTop, canvas.width, bandHeight);\n\n    ctx.fillStyle = '#ffffff';\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'middle';\n    let fontSize = Math.floor(bandHeight * 0.5);\n    ctx.font = `bold ${fontSize}px sans-serif`;\n    while (ctx.measureText(text).width > canvas.width * 0.9 && fontSize > 10) {\n      fontSize -= 2;\n      ctx.font = `bold ${fontSize}px sans-serif`;\n    }\n    ctx.fillText(text, canvas.width \/ 2, bandTop + bandHeight \/ 2);\n  }\n\n  drawBanner(topText, 0);\n  drawBanner(bottomText, canvas.height - bandHeight);\n\n  return canvas;\n}\n\nfunction renderPuzzleGrid() {\n  if (!puzzleImg) return;\n  puzzleEmptyHint.style.display = 'none';\n  downloadPuzzleAllBtn.style.display = 'block';\n  document.getElementById('download-puzzle-a4').style.display = 'block';\n  adjustPuzzleCropBtn.style.display = 'block';\n\n  const count = parseInt(document.getElementById('puzzle-count').value, 10);\n  const tileSize = parseInt(document.getElementById('puzzle-tile-size').value, 10);\n  const isPortrait = puzzleImg.height >= puzzleImg.width;\n\n  const [cols, rows] = getPuzzleLayout(count, isPortrait);\n\n  const cropRect = getCropRect(puzzleImg, cols, rows, puzzlePhoto ? puzzlePhoto.crop : null);\n  const sx = cropRect.sx, sy = cropRect.sy;\n  const tileW = cropRect.w \/ cols;\n  const tileH = cropRect.h \/ rows;\n\n  puzzleGridPreview.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;\n  puzzleGridPreview.innerHTML = '';\n  puzzleTiles = [];\n  const showSafeZone = tileSize === 720;\n  const puzzleAdjust = puzzlePhoto ? puzzlePhoto.adjust : null;\n\n  \/* Photo content stops exactly at the 50mm ACTUAL PRODUCT line on every side of every tile \u2014\n     nothing is drawn into the 61mm bleed\/fold ring. TEST CONFIG: using 45mm (the SAFETY AREA)\n     as the visible-content target instead of 50mm (the actual-product line) \u2014 a more\n     conservative margin (8mm\/side) to test whether it further reduces any risk of visible\n     issues at the fold, at the cost of a slightly smaller visible photo per tile. *\/\n  const BLEED_MM = 61, PRODUCT_MM = 45;\n  const productPx = tileSize * (PRODUCT_MM \/ BLEED_MM); \/\/ ~531px visible-after-fold zone\n  const productInset = (tileSize - productPx) \/ 2; \/\/ ~94px fold ring on each side, left blank\n\n  \/* Wording overlay: built once across the FULL assembled visible area (cols x rows of\n     visibleSize), so top\/bottom banners read as one continuous line across however many\n     tiles sit in that row \u2014 not as separate, misaligned fragments per tile. visibleSize is\n     the part of each tile that's actually visible after folding (or the whole tile in 600px\n     exact mode, where there's no fold ring to account for). *\/\n  const visibleSize = tileSize === 720 ? productPx : tileSize;\n  const visibleInset = tileSize === 720 ? productInset : 0;\n  const wordingOverlay = buildWordingOverlay(cols, rows, visibleSize);\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const canvas = document.createElement('canvas');\n      canvas.width = tileSize;\n      canvas.height = tileSize;\n\n      const ctx2d = canvas.getContext('2d');\n      ctx2d.fillStyle = '#ffffff'; \/\/ blank fold ring on every side \u2014 nothing to duplicate or lose\n      ctx2d.fillRect(0, 0, tileSize, tileSize);\n      drawWithAdjustments(\n        ctx2d, puzzleImg,\n        sx + c * tileW, sy + r * tileH, tileW, tileH,\n        visibleInset, visibleInset, visibleSize, visibleSize,\n        puzzleAdjust\n      );\n\n      \/* Composite this tile's slice of the wording overlay on top of the photo. The overlay\n         is transparent everywhere except the top\/bottom banner bands, so tiles outside those\n         rows are untouched. Uses the exact same destination rect as the photo's visible area\n         so text and photo line up pixel-for-pixel across tile boundaries. *\/\n      ctx2d.drawImage(\n        wordingOverlay,\n        c * visibleSize, r * visibleSize, visibleSize, visibleSize,\n        visibleInset, visibleInset, visibleSize, visibleSize\n      );\n\n      const previewCanvas = drawSafeZonePreview(canvas, showSafeZone, PUZZLE_MARGIN_MM);\n      puzzleGridPreview.appendChild(previewCanvas);\n      puzzleTiles.push({ canvas, row: r, col: c });\n    }\n  }\n  if (showSafeZone) {\n    puzzleEmptyHint.style.display = 'block';\n    puzzleEmptyHint.textContent = `Dashed line = visible after die-cut fold. Note: tile edges that touch neighboring tiles will lose ~${PUZZLE_MARGIN_MM}mm to the fold, so keep faces\/important details away from seams.`;\n    puzzleEmptyHint.style.color = '#99040d';\n  }\n}\n\ndocument.getElementById('puzzle-count').addEventListener('change', renderPuzzleGrid);\ndocument.getElementById('puzzle-tile-size').addEventListener('change', renderPuzzleGrid);\n\ndocument.getElementById('clear-puzzle').addEventListener('click', () => {\n  puzzleImg = null;\n  puzzlePhoto = null;\n  puzzleTiles = [];\n  fileInputPuzzle.value = '';\n  puzzleGridPreview.innerHTML = '';\n  puzzleEmptyHint.style.display = 'block';\n  puzzleEmptyHint.textContent = 'Upload a photo to preview the puzzle grid split.';\n  puzzleEmptyHint.style.color = '';\n  downloadPuzzleAllBtn.style.display = 'none';\n  document.getElementById('download-puzzle-a4').style.display = 'none';\n  adjustPuzzleCropBtn.style.display = 'none';\n  wordingEnabled = false;\n  wordingNoBtn.classList.add('active');\n  wordingYesBtn.classList.remove('active');\n  wordingFields.style.display = 'none';\n  wordingTopInput.value = '';\n  wordingBottomInput.value = '';\n});\n\ndownloadPuzzleAllBtn.addEventListener('click', () => {\n  puzzleTiles.forEach((t, i) => {\n    downloadCanvas(t.canvas, `puzzle_tile_r${t.row + 1}_c${t.col + 1}.jpg`);\n  });\n});\n\n\/* ---------- shared ---------- *\/\nfunction downloadCanvas(canvas, filename) {\n  const link = document.createElement('a');\n  link.download = filename;\n  link.href = canvas.toDataURL('image\/jpeg', 0.95);\n  link.click();\n}\n\n\/* A4 at 300 DPI = 2480 x 3508 px (portrait). Returns an ARRAY of page canvases. Splits the tile\n   grid across as many pages as needed in BOTH directions \u2014 a grid can be too wide (many columns)\n   as well as too tall (many rows), so pagination has to handle both, not just rows. Every tile\n   carries its own R#C# label, so pieces can be assembled correctly regardless of which physical\n   page they were printed on. *\/\nfunction buildA4Sheet(canvasList, opts) {\n  opts = opts || {};\n  const PORTRAIT_W = 2480, PORTRAIT_H = 3508;\n  const LANDSCAPE_W = 3508, LANDSCAPE_H = 2480;\n  const MARGIN = 150, GAP = 90, MIN_SIDE_MARGIN = 40;\n\n  if (canvasList.length === 0) {\n    const empty = document.createElement('canvas');\n    empty.width = PORTRAIT_W; empty.height = PORTRAIT_H;\n    return [empty];\n  }\n\n  const tileSize = canvasList[0].canvas ? canvasList[0].canvas.width : canvasList[0].width;\n\n  let cols, totalRows;\n  if (opts.gridCols && opts.gridRows) {\n    cols = opts.gridCols;\n    totalRows = opts.gridRows;\n  } else {\n    cols = Math.max(1, Math.floor((PORTRAIT_W - 2 * MARGIN + GAP) \/ (tileSize + GAP)));\n    totalRows = Math.ceil(canvasList.length \/ cols);\n  }\n\n  \/* For a given page size, how many columns\/rows fit per page, and how many pages does that need? *\/\n  function planFor(pageW, pageH) {\n    const colsPerPage = Math.max(1, Math.floor((pageW - 2 * MIN_SIDE_MARGIN + GAP) \/ (tileSize + GAP)));\n    const rowsPerPage = Math.max(1, Math.floor((pageH - 2 * MARGIN + GAP) \/ (tileSize + GAP)));\n    const pageCols = Math.ceil(cols \/ colsPerPage);\n    const pageRows = Math.ceil(totalRows \/ rowsPerPage);\n    return { colsPerPage, rowsPerPage, pageCols, pageRows, total: pageCols * pageRows };\n  }\n\n  \/* Pick whichever orientation needs fewer total pages (ties go to portrait) *\/\n  const portraitPlan = planFor(PORTRAIT_W, PORTRAIT_H);\n  const landscapePlan = planFor(LANDSCAPE_W, LANDSCAPE_H);\n  let PAGE_W, PAGE_H, plan;\n  if (landscapePlan.total < portraitPlan.total) {\n    PAGE_W = LANDSCAPE_W; PAGE_H = LANDSCAPE_H; plan = landscapePlan;\n  } else {\n    PAGE_W = PORTRAIT_W; PAGE_H = PORTRAIT_H; plan = portraitPlan;\n  }\n\n  const { colsPerPage, rowsPerPage, pageCols, pageRows } = plan;\n  const pageCount = pageCols * pageRows;\n  const startY = MARGIN;\n\n  const pages = [];\n  let pageIdx = 0;\n  for (let pr = 0; pr < pageRows; pr++) {\n    const rowStart = pr * rowsPerPage;\n    const rowEnd = Math.min(totalRows, rowStart + rowsPerPage);\n    const rowsThisPage = rowEnd - rowStart;\n\n    for (let pc = 0; pc < pageCols; pc++) {\n      const colStart = pc * colsPerPage;\n      const colEnd = Math.min(cols, colStart + colsPerPage);\n      const colsThisPage = colEnd - colStart;\n\n      const gridWThisPage = colsThisPage * tileSize + (colsThisPage - 1) * GAP;\n      const startX = Math.max(MIN_SIDE_MARGIN, (PAGE_W - gridWThisPage) \/ 2);\n\n      const sheet = document.createElement('canvas');\n      sheet.width = PAGE_W;\n      sheet.height = PAGE_H;\n      const ctx = sheet.getContext('2d');\n      ctx.fillStyle = '#ffffff';\n      ctx.fillRect(0, 0, PAGE_W, PAGE_H);\n\n      \/* Full straight grid lines through the middle of each gap \u2014 for rough pre-cutting with a ruler\/blade *\/\n      ctx.strokeStyle = '#333333';\n      ctx.lineWidth = 2;\n      ctx.setLineDash([]);\n      for (let c = 1; c < colsThisPage; c++) {\n        const lineX = startX + c * tileSize + (c - 0.5) * GAP;\n        ctx.beginPath(); ctx.moveTo(lineX, 0); ctx.lineTo(lineX, PAGE_H); ctx.stroke();\n      }\n      for (let r = 1; r < rowsThisPage; r++) {\n        const lineY = startY + r * tileSize + (r - 0.5) * GAP;\n        ctx.beginPath(); ctx.moveTo(0, lineY); ctx.lineTo(PAGE_W, lineY); ctx.stroke();\n      }\n\n      canvasList.forEach((item, i) => {\n        const c = item.canvas || item;\n        let col, row;\n        if (opts.gridCols) {\n          row = item.row !== undefined ? item.row : Math.floor(i \/ cols);\n          col = item.col !== undefined ? item.col : i % cols;\n        } else {\n          col = i % cols;\n          row = Math.floor(i \/ cols);\n        }\n        if (row < rowStart || row >= rowEnd || col < colStart || col >= colEnd) return; \/\/ different page\n\n        const localRow = row - rowStart;\n        const localCol = col - colStart;\n        const x = startX + localCol * (tileSize + GAP);\n        const y = startY + localRow * (tileSize + GAP);\n        ctx.drawImage(c, x, y, tileSize, tileSize);\n\n        if (opts.outlineStyle === 'full') {\n          \/* Continuous outline around the true tile edge \u2014 easier to align a FIXED-SIZE die-cutter\n             against than 4 scattered corner marks, since it's one uninterrupted line to center\n             the tool on rather than eyeballing between disconnected brackets. Sits just outside\n             the tile (never touching the photo), floating in the gap like the corner marks did. *\/\n          const outlineOffset = 8;\n          ctx.strokeStyle = '#99040d';\n          ctx.lineWidth = 2.5;\n          ctx.setLineDash([]);\n          ctx.strokeRect(\n            x - outlineOffset, y - outlineOffset,\n            tileSize + outlineOffset * 2, tileSize + outlineOffset * 2\n          );\n        } else {\n          \/* Corner alignment marks \u2014 float clearly OUTSIDE each tile corner with a small gap,\n             so nothing (not even a single pixel) touches the photo itself *\/\n          ctx.strokeStyle = '#99040d';\n          ctx.lineWidth = 2.5;\n          ctx.setLineDash([]);\n          const ck = 20, markOffset = 8;\n          [[x, y, -1, -1], [x + tileSize, y, 1, -1], [x, y + tileSize, -1, 1], [x + tileSize, y + tileSize, 1, 1]].forEach(([px, py, dx, dy]) => {\n            const ox = px + dx * markOffset;\n            const oy = py + dy * markOffset;\n            ctx.beginPath();\n            ctx.moveTo(ox, oy + dy * ck); ctx.lineTo(ox, oy); ctx.lineTo(ox + dx * ck, oy);\n            ctx.stroke();\n          });\n        }\n      });\n\n      if (pageCount > 1) {\n        ctx.setLineDash([]);\n        ctx.fillStyle = '#0d1e42';\n        ctx.font = 'bold 22px sans-serif';\n        ctx.fillText(`Page ${pageIdx + 1} of ${pageCount}`, MARGIN, PAGE_H - 40);\n      }\n\n      pages.push(sheet);\n      pageIdx++;\n    }\n  }\n\n  return pages;\n}\n\ndocument.getElementById('download-single-a4').addEventListener('click', () => {\n  const canvases = singlePhotos.map(p => p.canvas);\n  const pages = buildA4Sheet(canvases);\n  pages.forEach((sheet, i) => {\n    const name = pages.length > 1 ? `A4_print_sheet_magnets_page${i + 1}.jpg` : 'A4_print_sheet_magnets.jpg';\n    setTimeout(() => downloadCanvas(sheet, name), i * 400); \/\/ stagger so browsers don't drop later downloads\n  });\n});\n\ndocument.getElementById('download-puzzle-a4').addEventListener('click', () => {\n  const count = parseInt(document.getElementById('puzzle-count').value, 10);\n  const isPortrait = puzzleImg ? puzzleImg.height >= puzzleImg.width : true;\n  const [gridCols, gridRows] = getPuzzleLayout(count, isPortrait);\n  const pages = buildA4Sheet(puzzleTiles, { gridCols, gridRows });\n  pages.forEach((sheet, i) => {\n    const name = pages.length > 1 ? `A4_print_sheet_puzzle_tiles_page${i + 1}.jpg` : 'A4_print_sheet_puzzle_tiles.jpg';\n    setTimeout(() => downloadCanvas(sheet, name), i * 400); \/\/ stagger so browsers don't drop later downloads\n  });\n});\n\n<\/script>\n\n<\/html>\n","protected":false},"excerpt":{"rendered":"<p>ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set &#8593; Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7&#8230;<\/p>\n","protected":false},"author":3,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-117","page","type-page","status-publish","hentry"],"aioseo_notices":[],"aioseo_head":"\n\t\t<!-- All in One SEO 5.0.1.1 - aioseo.com -->\n\t<meta name=\"description\" content=\"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7\" \/>\n\t<meta name=\"robots\" content=\"max-image-preview:large\" \/>\n\t<link rel=\"canonical\" href=\"https:\/\/zemirahzheathomas.com\/\" \/>\n\t<meta name=\"generator\" content=\"All in One SEO (AIOSEO) 5.0.1.1\" \/>\n\t\t<meta property=\"og:locale\" content=\"en_US\" \/>\n\t\t<meta property=\"og:site_name\" content=\"My Blog - My WordPress Blog\" \/>\n\t\t<meta property=\"og:type\" content=\"article\" \/>\n\t\t<meta property=\"og:title\" content=\"Home - My Blog\" \/>\n\t\t<meta property=\"og:description\" content=\"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7\" \/>\n\t\t<meta property=\"og:url\" content=\"https:\/\/zemirahzheathomas.com\/\" \/>\n\t\t<meta property=\"article:published_time\" content=\"2026-08-25T06:29:37+00:00\" \/>\n\t\t<meta property=\"article:modified_time\" content=\"2026-09-01T11:46:54+00:00\" \/>\n\t\t<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n\t\t<meta name=\"twitter:title\" content=\"Home - My Blog\" \/>\n\t\t<meta name=\"twitter:description\" content=\"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7\" \/>\n\t\t<script type=\"application\/ld+json\" class=\"aioseo-schema\">\n\t\t\t{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#breadcrumblist\",\"itemListElement\":[{\"@type\":\"ListItem\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com#listItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/zemirahzheathomas.com\",\"nextItem\":{\"@type\":\"ListItem\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#listItem\",\"name\":\"Home\"}},{\"@type\":\"ListItem\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#listItem\",\"position\":2,\"name\":\"Home\",\"previousItem\":{\"@type\":\"ListItem\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com#listItem\",\"name\":\"Home\"}}]},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#organization\",\"name\":\"My Blog\",\"description\":\"My WordPress Blog\",\"url\":\"https:\\\/\\\/zemirahzheathomas.com\\\/\"},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#webpage\",\"url\":\"https:\\\/\\\/zemirahzheathomas.com\\\/\",\"name\":\"Home - My Blog\",\"description\":\"ZHEA Creations \\u2014 Magnet Photo Prep Tool ZHEA Creations \\u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\\u201315 pcs) Puzzle magnet set \\u2191 Click or drop photos here \\u2014 select as many as the order needs 1 piece = 1 photo \\u00b7\",\"inLanguage\":\"en-US\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#website\"},\"breadcrumb\":{\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#breadcrumblist\"},\"datePublished\":\"2026-08-25T06:29:37+00:00\",\"dateModified\":\"2026-09-01T11:46:54+00:00\"},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#website\",\"url\":\"https:\\\/\\\/zemirahzheathomas.com\\\/\",\"name\":\"My Blog\",\"description\":\"My WordPress Blog\",\"inLanguage\":\"en-US\",\"publisher\":{\"@id\":\"https:\\\/\\\/zemirahzheathomas.com\\\/#organization\"}}]}\n\t\t<\/script>\n\t\t<!-- All in One SEO -->\n\n","aioseo_head_json":{"title":"Home - My Blog","description":"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7","canonical_url":"https:\/\/zemirahzheathomas.com\/","robots":"max-image-preview:large","keywords":"","webmasterTools":{"miscellaneous":""},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"BreadcrumbList","@id":"https:\/\/zemirahzheathomas.com\/#breadcrumblist","itemListElement":[{"@type":"ListItem","@id":"https:\/\/zemirahzheathomas.com#listItem","position":1,"name":"Home","item":"https:\/\/zemirahzheathomas.com","nextItem":{"@type":"ListItem","@id":"https:\/\/zemirahzheathomas.com\/#listItem","name":"Home"}},{"@type":"ListItem","@id":"https:\/\/zemirahzheathomas.com\/#listItem","position":2,"name":"Home","previousItem":{"@type":"ListItem","@id":"https:\/\/zemirahzheathomas.com#listItem","name":"Home"}}]},{"@type":"Organization","@id":"https:\/\/zemirahzheathomas.com\/#organization","name":"My Blog","description":"My WordPress Blog","url":"https:\/\/zemirahzheathomas.com\/"},{"@type":"WebPage","@id":"https:\/\/zemirahzheathomas.com\/#webpage","url":"https:\/\/zemirahzheathomas.com\/","name":"Home - My Blog","description":"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7","inLanguage":"en-US","isPartOf":{"@id":"https:\/\/zemirahzheathomas.com\/#website"},"breadcrumb":{"@id":"https:\/\/zemirahzheathomas.com\/#breadcrumblist"},"datePublished":"2026-08-25T06:29:37+00:00","dateModified":"2026-09-01T11:46:54+00:00"},{"@type":"WebSite","@id":"https:\/\/zemirahzheathomas.com\/#website","url":"https:\/\/zemirahzheathomas.com\/","name":"My Blog","description":"My WordPress Blog","inLanguage":"en-US","publisher":{"@id":"https:\/\/zemirahzheathomas.com\/#organization"}}]},"og:locale":"en_US","og:site_name":"My Blog - My WordPress Blog","og:type":"article","og:title":"Home - My Blog","og:description":"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7","og:url":"https:\/\/zemirahzheathomas.com\/","article:published_time":"2026-08-25T06:29:37+00:00","article:modified_time":"2026-09-01T11:46:54+00:00","twitter:card":"summary_large_image","twitter:title":"Home - My Blog","twitter:description":"ZHEA Creations \u2014 Magnet Photo Prep Tool ZHEA Creations \u2014 Magnet Photo Prep Tool Turn customer-uploaded photos into print-ready magnet files. Works fully offline, right in your browser. Regular magnets (1\u201315 pcs) Puzzle magnet set \u2191 Click or drop photos here \u2014 select as many as the order needs 1 piece = 1 photo \u00b7"},"aioseo_meta_data":{"post_id":"117","title":null,"description":null,"keywords":null,"keyphrases":{"focus":{"keyphrase":"","score":0,"analysis":{"keyphraseInTitle":{"score":0,"maxScore":9,"error":1}}},"additional":[]},"primary_term":null,"canonical_url":null,"og_title":null,"og_description":null,"og_object_type":"default","og_image_type":"default","og_image_url":null,"og_image_width":null,"og_image_height":null,"og_image_custom_url":null,"og_image_custom_fields":null,"og_video":"","og_custom_url":null,"og_article_section":null,"og_article_tags":null,"twitter_use_og":false,"twitter_card":"default","twitter_image_type":"default","twitter_image_url":null,"twitter_image_custom_url":null,"twitter_image_custom_fields":null,"twitter_title":null,"twitter_description":null,"schema":{"blockGraphs":[],"customGraphs":[],"default":{"data":{"Article":[],"Course":[],"Dataset":[],"FAQPage":[],"Movie":[],"Person":[],"Product":[],"ProductReview":[],"Car":[],"Recipe":[],"Service":[],"SoftwareApplication":[],"WebPage":[]},"graphName":"WebPage","isEnabled":true},"graphs":[]},"schema_type":"default","schema_type_options":null,"pillar_content":false,"robots_default":true,"robots_noindex":false,"robots_noarchive":false,"robots_nosnippet":false,"robots_nofollow":false,"robots_noimageindex":false,"robots_noodp":false,"robots_notranslate":false,"robots_max_snippet":"-1","robots_max_videopreview":"-1","robots_max_imagepreview":"large","priority":null,"frequency":"default","local_seo":null,"breadcrumb_settings":null,"limit_modified_date":false,"ai":{"faqs":[],"keyPoints":[],"schemas":[],"titles":[],"descriptions":[],"socialPosts":{"email":{"subject":"","preview":"","content":""},"linkedin":[],"twitter":[],"facebook":[],"instagram":[]}},"created":"2026-08-25 06:29:38","updated":"2026-09-02 10:01:26","seo_analyzer_scan_date":null,"focus_keyword":null,"additional_keywords":null,"truseo_locale":null},"aioseo_breadcrumb":"<div class=\"aioseo-breadcrumbs\"><span class=\"aioseo-breadcrumb\">\n\t\t\t<a href=\"https:\/\/zemirahzheathomas.com\" title=\"Home\">Home<\/a>\n\t\t<\/span><span class=\"aioseo-breadcrumb-separator\">&raquo;<\/span><span class=\"aioseo-breadcrumb\">\n\t\t\tHome\n\t\t<\/span><\/div>","aioseo_breadcrumb_json":[{"label":"Home","link":"https:\/\/zemirahzheathomas.com"},{"label":"Home","link":"https:\/\/zemirahzheathomas.com\/"}],"_links":{"self":[{"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/pages\/117","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/users\/3"}],"replies":[{"embeddable":true,"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=117"}],"version-history":[{"count":9,"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/pages\/117\/revisions"}],"predecessor-version":[{"id":135,"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=\/wp\/v2\/pages\/117\/revisions\/135"}],"wp:attachment":[{"href":"https:\/\/zemirahzheathomas.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=117"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}