<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Utility on Gejun&#39;s Blog</title>
    <link>https://gejun.name/tags/utility/</link>
    <description>Recent content in Utility on Gejun&#39;s Blog</description>
    <image>
      <title>Gejun&#39;s Blog</title>
      <url>https://gejun.name/</url>
      <link>https://gejun.name/</link>
    </image>
    <generator>Hugo -- 0.162.1</generator>
    <language>en-us</language>
    <lastBuildDate>Tue, 24 Feb 2026 00:00:00 +0000</lastBuildDate>
    <atom:link href="https://gejun.name/tags/utility/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>China Passport Photo — 4×6 Print Sheet</title>
      <link>https://gejun.name/tools/passport-photo/</link>
      <pubDate>Tue, 24 Feb 2026 00:00:00 +0000</pubDate>
      <guid>https://gejun.name/tools/passport-photo/</guid>
      <description>&lt;!doctype html&gt;
&lt;html lang=&#34;en&#34;&gt;
  &lt;head&gt;
    &lt;meta charset=&#34;UTF-8&#34; /&gt;
    &lt;title&gt;China Passport Photo — 4×6 Print Sheet&lt;/title&gt;
    &lt;style&gt;
      :root {
        --bg: #f4f1ea;
        --paper: #ffffff;
        --ink: #1a1a1a;
        --line: #1a1a1a;
        --muted: #6b6b6b;
        --accent: #c2410c;
        --accent-soft: #fef3e8;
        --rule: #d4ccbe;
      }
      * {
        box-sizing: border-box;
      }
      html,
      body {
        margin: 0;
        background: var(--bg);
        color: var(--ink);
        font-family: &#39;Iowan Old Style&#39;, &#39;Palatino Linotype&#39;, Georgia, serif;
        font-size: 15px;
        line-height: 1.5;
      }
      .mono {
        font-family: &#39;JetBrains Mono&#39;, &#39;SF Mono&#39;, Menlo, Consolas, monospace;
      }
      .wrap {
        max-width: 1180px;
        margin: 0 auto;
        padding: 32px 24px 64px;
      }
      header {
        display: flex;
        align-items: flex-end;
        justify-content: space-between;
        border-bottom: 2px solid var(--line);
        padding-bottom: 18px;
        margin-bottom: 28px;
        gap: 24px;
        flex-wrap: wrap;
      }
      h1 {
        font-size: 32px;
        margin: 0 0 4px;
        letter-spacing: -0.01em;
        font-weight: 600;
      }
      .subtitle {
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 11px;
        text-transform: uppercase;
        letter-spacing: 0.18em;
        color: var(--muted);
      }
      .spec {
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 11px;
        text-align: right;
        color: var(--muted);
        line-height: 1.7;
      }
      .spec strong {
        color: var(--ink);
        font-weight: 600;
      }

      .layout {
        display: grid;
        grid-template-columns: 1fr 320px;
        gap: 28px;
      }
      @media (max-width: 880px) {
        .layout {
          grid-template-columns: 1fr;
        }
      }

      .canvas-area {
        background: var(--paper);
        border: 1px solid var(--rule);
        padding: 24px;
        position: relative;
      }
      .canvas-label {
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 10px;
        text-transform: uppercase;
        letter-spacing: 0.16em;
        color: var(--muted);
        margin-bottom: 12px;
        display: flex;
        justify-content: space-between;
        align-items: center;
      }
      .canvas-label .live-dim {
        color: var(--ink);
      }
      #previewCanvas {
        width: 100%;
        height: auto;
        display: block;
        background: #fafafa;
        border: 1px solid var(--rule);
        cursor: grab;
        touch-action: none;
      }
      #previewCanvas.dragging {
        cursor: grabbing;
      }
      #previewCanvas.empty {
        cursor: default;
      }

      .controls {
        background: var(--paper);
        border: 1px solid var(--rule);
        padding: 20px;
        display: flex;
        flex-direction: column;
        gap: 18px;
        align-self: start;
      }
      .ctrl-title {
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 10px;
        text-transform: uppercase;
        letter-spacing: 0.18em;
        color: var(--muted);
        margin-bottom: 6px;
      }
      .upload {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 8px;
        border: 1.5px dashed var(--line);
        padding: 24px 14px;
        text-align: center;
        cursor: pointer;
        transition:
          background 0.15s,
          border-color 0.15s;
        background: var(--accent-soft);
        min-height: 130px;
      }
      .upload:hover {
        background: #fde9d8;
        border-color: var(--accent);
      }
      .upload.has-file {
        background: #ecf5ec;
        border-style: solid;
        border-color: #2d6a2d;
      }
      .upload input {
        display: none;
      }
      .upload-icon {
        width: 28px;
        height: 28px;
        color: var(--accent);
        flex-shrink: 0;
      }
      .upload.has-file .upload-icon {
        color: #2d6a2d;
      }
      .upload-text {
        font-size: 14px;
        line-height: 1.35;
        word-break: break-word;
        max-width: 100%;
      }
      .upload.has-file .upload-text {
        font-weight: 600;
      }
      .upload-hint {
        font-size: 11px;
        color: var(--muted);
        font-family: &#39;JetBrains Mono&#39;, monospace;
        letter-spacing: 0.04em;
      }
      .upload.has-file .upload-hint {
        color: #2d6a2d;
      }

      /* Format picker */
      .format-picker {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
      }
      .format-btn {
        display: flex;
        flex-direction: column;
        align-items: flex-start;
        gap: 2px;
        padding: 10px 12px;
        background: var(--paper);
        color: var(--ink);
        border: 1.5px solid var(--rule);
        cursor: pointer;
        text-align: left;
        transition: all 0.15s;
        font-family: inherit;
      }
      .format-btn:hover:not(.active) {
        border-color: var(--ink);
        background: var(--paper);
      }
      .format-btn.active {
        border-color: var(--accent);
        background: var(--accent-soft);
        color: var(--ink);
      }
      .format-btn .format-name {
        font-size: 14px;
        font-weight: 600;
        letter-spacing: -0.005em;
      }
      .format-btn .format-dim {
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 10px;
        color: var(--muted);
        letter-spacing: 0.04em;
      }
      .format-btn.active .format-dim {
        color: var(--accent);
      }

      .slider-group {
        display: flex;
        flex-direction: column;
        gap: 6px;
      }
      .slider-row {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 11px;
      }
      .slider-row span:last-child {
        color: var(--muted);
      }
      input[type=&#39;range&#39;] {
        -webkit-appearance: none;
        appearance: none;
        width: 100%;
        height: 2px;
        background: var(--line);
        outline: none;
      }
      input[type=&#39;range&#39;]:disabled {
        opacity: 0.3;
        cursor: not-allowed;
      }
      .slider-group.disabled {
        opacity: 0.45;
      }
      input[type=&#39;range&#39;]::-webkit-slider-thumb {
        -webkit-appearance: none;
        appearance: none;
        width: 14px;
        height: 14px;
        background: var(--accent);
        border-radius: 50%;
        cursor: pointer;
      }
      input[type=&#39;range&#39;]::-moz-range-thumb {
        width: 14px;
        height: 14px;
        background: var(--accent);
        border: none;
        border-radius: 50%;
        cursor: pointer;
      }

      .check-row {
        display: flex;
        align-items: center;
        gap: 8px;
        font-size: 13px;
      }
      .check-row input {
        accent-color: var(--accent);
      }

      button {
        font-family: inherit;
        font-size: 14px;
        padding: 12px 16px;
        border: 1.5px solid var(--line);
        background: var(--ink);
        color: var(--paper);
        cursor: pointer;
        transition: all 0.15s;
        letter-spacing: 0.02em;
      }
      button:hover:not(:disabled) {
        background: var(--accent);
        border-color: var(--accent);
      }
      button:disabled {
        opacity: 0.35;
        cursor: not-allowed;
      }
      button.secondary {
        background: transparent;
        color: var(--ink);
      }
      button.secondary:hover:not(:disabled) {
        background: var(--ink);
        color: var(--paper);
        border-color: var(--ink);
      }

      .divider {
        border: none;
        border-top: 1px solid var(--rule);
        margin: 4px 0;
      }

      .info {
        background: var(--accent-soft);
        border-left: 3px solid var(--accent);
        padding: 12px 14px;
        font-size: 12px;
        line-height: 1.55;
      }
      .info strong {
        display: block;
        margin-bottom: 4px;
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 10px;
        letter-spacing: 0.12em;
        text-transform: uppercase;
      }

      details {
        border-top: 1px solid var(--rule);
        padding-top: 16px;
        margin-top: 8px;
      }
      details summary {
        cursor: pointer;
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 11px;
        letter-spacing: 0.14em;
        text-transform: uppercase;
        color: var(--muted);
        list-style: none;
      }
      details summary::after {
        content: &#39;  +&#39;;
      }
      details[open] summary::after {
        content: &#39;  −&#39;;
      }
      details ol {
        font-size: 13px;
        padding-left: 18px;
        margin: 10px 0 0;
      }
      details li {
        margin-bottom: 6px;
      }

      footer {
        margin-top: 32px;
        padding-top: 16px;
        border-top: 1px solid var(--rule);
        font-family: &#39;JetBrains Mono&#39;, monospace;
        font-size: 10px;
        color: var(--muted);
        text-transform: uppercase;
        letter-spacing: 0.14em;
        display: flex;
        justify-content: space-between;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div class=&#34;wrap&#34;&gt;
      &lt;header&gt;
        &lt;div&gt;
          &lt;div class=&#34;subtitle&#34;&gt;Passport Photo Print Sheet&lt;/div&gt;
          &lt;h1 id=&#34;titleText&#34;&gt;China · 33 × 48 mm × 4&lt;/h1&gt;
        &lt;/div&gt;
        &lt;div class=&#34;spec&#34;&gt;
          &lt;strong&gt;OUTPUT&lt;/strong&gt; 1800 × 1200 px @ 300 DPI&lt;br /&gt;
          &lt;strong&gt;PAPER&lt;/strong&gt; 4 × 6 in · 152.4 × 101.6 mm&lt;br /&gt;
          &lt;strong&gt;PHOTOS&lt;/strong&gt; &lt;span id=&#34;specPhotos&#34;&gt;4 (2 × 2 grid)&lt;/span&gt;
        &lt;/div&gt;
      &lt;/header&gt;

      &lt;div class=&#34;layout&#34;&gt;
        &lt;div class=&#34;canvas-area&#34;&gt;
          &lt;div class=&#34;canvas-label&#34;&gt;
            &lt;span id=&#34;previewLabel&#34;
              &gt;PREVIEW · 6 × 4 in landscape · 2 × 2 grid&lt;/span
            &gt;
            &lt;span class=&#34;live-dim mono&#34; id=&#34;liveDim&#34;&gt;— no image —&lt;/span&gt;
          &lt;/div&gt;
          &lt;canvas id=&#34;previewCanvas&#34; class=&#34;empty&#34;&gt;&lt;/canvas&gt;
        &lt;/div&gt;

        &lt;aside class=&#34;controls&#34;&gt;
          &lt;div&gt;
            &lt;div class=&#34;ctrl-title&#34;&gt;1 · Passport Format&lt;/div&gt;
            &lt;div
              class=&#34;format-picker&#34;
              role=&#34;radiogroup&#34;
              aria-label=&#34;Passport format&#34;
            &gt;
              &lt;button
                class=&#34;format-btn active&#34;
                data-format=&#34;china&#34;
                type=&#34;button&#34;
              &gt;
                &lt;span class=&#34;format-name&#34;&gt;China&lt;/span&gt;
                &lt;span class=&#34;format-dim&#34;&gt;33 × 48 mm&lt;/span&gt;
              &lt;/button&gt;
              &lt;button class=&#34;format-btn&#34; data-format=&#34;us&#34; type=&#34;button&#34;&gt;
                &lt;span class=&#34;format-name&#34;&gt;United States&lt;/span&gt;
                &lt;span class=&#34;format-dim&#34;&gt;2 × 2 in&lt;/span&gt;
              &lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;div class=&#34;ctrl-title&#34;&gt;2 · Source Photo&lt;/div&gt;
            &lt;label class=&#34;upload&#34; id=&#34;uploadLabel&#34;&gt;
              &lt;input type=&#34;file&#34; id=&#34;fileInput&#34; accept=&#34;image/*&#34; /&gt;
              &lt;svg
                class=&#34;upload-icon&#34;
                id=&#34;uploadIcon&#34;
                viewBox=&#34;0 0 24 24&#34;
                fill=&#34;none&#34;
                stroke=&#34;currentColor&#34;
                stroke-width=&#34;1.8&#34;
                stroke-linecap=&#34;round&#34;
                stroke-linejoin=&#34;round&#34;
                aria-hidden=&#34;true&#34;
              &gt;
                &lt;path d=&#34;M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4&#34;&gt;&lt;/path&gt;
                &lt;polyline points=&#34;17 8 12 3 7 8&#34;&gt;&lt;/polyline&gt;
                &lt;line x1=&#34;12&#34; y1=&#34;3&#34; x2=&#34;12&#34; y2=&#34;15&#34;&gt;&lt;/line&gt;
              &lt;/svg&gt;
              &lt;div class=&#34;upload-text&#34; id=&#34;uploadText&#34;&gt;
                Click to upload your photo
              &lt;/div&gt;
              &lt;div class=&#34;upload-hint&#34; id=&#34;uploadHint&#34;&gt;
                JPG / PNG · portrait recommended
              &lt;/div&gt;
            &lt;/label&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;div class=&#34;ctrl-title&#34;&gt;3 · Adjust Crop&lt;/div&gt;
            &lt;div class=&#34;slider-group&#34;&gt;
              &lt;div class=&#34;slider-row&#34;&gt;
                &lt;span&gt;Zoom&lt;/span&gt;&lt;span id=&#34;zoomVal&#34;&gt;100%&lt;/span&gt;
              &lt;/div&gt;
              &lt;input
                type=&#34;range&#34;
                id=&#34;zoom&#34;
                min=&#34;50&#34;
                max=&#34;300&#34;
                value=&#34;100&#34;
                step=&#34;1&#34;
              /&gt;
            &lt;/div&gt;
            &lt;div
              style=&#34;
                font-size: 11px;
                color: var(--muted);
                margin-top: 6px;
                font-family: &#39;JetBrains Mono&#39;, monospace;
              &#34;
            &gt;
              Drag photo on canvas to reposition
            &lt;/div&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;div class=&#34;ctrl-title&#34;&gt;4 · Options&lt;/div&gt;
            &lt;div class=&#34;slider-group&#34; style=&#34;margin-bottom: 10px&#34;&gt;
              &lt;div class=&#34;slider-row&#34;&gt;
                &lt;span&gt;Gap between photos&lt;/span&gt;&lt;span id=&#34;gapVal&#34;&gt;2.0 mm&lt;/span&gt;
              &lt;/div&gt;
              &lt;input
                type=&#34;range&#34;
                id=&#34;gap&#34;
                min=&#34;0&#34;
                max=&#34;5&#34;
                value=&#34;2&#34;
                step=&#34;0.5&#34;
              /&gt;
            &lt;/div&gt;
            &lt;label class=&#34;check-row&#34;&gt;
              &lt;input type=&#34;checkbox&#34; id=&#34;cutMarks&#34; checked /&gt;
              &lt;span&gt;Show cut lines&lt;/span&gt;
            &lt;/label&gt;
            &lt;label class=&#34;check-row&#34;&gt;
              &lt;input type=&#34;checkbox&#34; id=&#34;border&#34; checked /&gt;
              &lt;span&gt;Cut-guide border on each photo&lt;/span&gt;
            &lt;/label&gt;
            &lt;label class=&#34;check-row&#34;&gt;
              &lt;input type=&#34;checkbox&#34; id=&#34;faceGuide&#34; checked /&gt;
              &lt;span&gt;Face position guide (preview only)&lt;/span&gt;
            &lt;/label&gt;
            &lt;label class=&#34;check-row&#34;&gt;
              &lt;input type=&#34;checkbox&#34; id=&#34;ruler&#34; checked /&gt;
              &lt;span&gt;Include 33 × 48 mm size-check frame&lt;/span&gt;
            &lt;/label&gt;
          &lt;/div&gt;

          &lt;hr class=&#34;divider&#34; /&gt;

          &lt;button id=&#34;downloadBtn&#34; disabled&gt;↓ Download 4×6 print file&lt;/button&gt;
          &lt;button class=&#34;secondary&#34; id=&#34;resetBtn&#34; disabled&gt;
            Reset position
          &lt;/button&gt;

          &lt;div class=&#34;info&#34;&gt;
            &lt;strong&gt;⚠ On screen, the ruler will not measure 33 mm&lt;/strong&gt;
            The PNG is sized for 300 DPI printing. Your screen displays the
            pixels at its own density, so the SIZE CHECK frame appears larger
            than 33 × 48 mm in any image viewer. It will measure correctly only
            on the printed paper. To verify the file itself: check that it&#39;s
            1800 × 1200 px at 300 DPI in its properties.
          &lt;/div&gt;

          &lt;div class=&#34;info&#34;&gt;
            &lt;strong&gt;How to print at Walmart&lt;/strong&gt;
            Upload the downloaded PNG to Walmart Photo as a
            &lt;em&gt;4×6 standard print&lt;/em&gt;. Choose
            &lt;strong&gt;&#34;Full photo (no crop)&#34;&lt;/strong&gt; or &#34;fit&#34; — never auto-crop.
            After printing, lay a metric ruler over the
            &lt;span id=&#34;sizeCheckRef&#34;&gt;dashed SIZE CHECK frame&lt;/span&gt;: it must read
            &lt;span id=&#34;sizeCheckDims&#34;&gt;33 × 48 mm&lt;/span&gt; exactly.
          &lt;/div&gt;

          &lt;details&gt;
            &lt;summary&gt;Specs &amp; cutting&lt;/summary&gt;
            &lt;ol&gt;
              &lt;li&gt;Each photo is exactly 33 × 48 mm.&lt;/li&gt;
              &lt;li&gt;
                Photos sit in a 2 × 2 grid, touching, centered on the sheet.
              &lt;/li&gt;
              &lt;li&gt;
                Cut along the gray borders with a paper trimmer or sharp
                scissors.
              &lt;/li&gt;
              &lt;li&gt;
                The dashed 33 × 48 mm SIZE CHECK frame matches one photo exactly
                — measure it (or any photo) with a metric ruler to verify
                Walmart printed at correct scale.
              &lt;/li&gt;
            &lt;/ol&gt;
          &lt;/details&gt;
        &lt;/aside&gt;
      &lt;/div&gt;

      &lt;footer&gt;
        &lt;span&gt;4 × 6 PRINT SHEET&lt;/span&gt;
        &lt;span&gt;33 × 48 MM · CHINA STANDARD&lt;/span&gt;
      &lt;/footer&gt;
    &lt;/div&gt;

    &lt;script&gt;
      // ============== CONSTANTS ==============
      const DPI = 300;
      const MM_PER_INCH = 25.4;
      const PX_PER_MM = DPI / MM_PER_INCH; // 11.811

      // Canvas: 6 x 4 inch landscape
      const CANVAS_W_MM = 152.4;
      const CANVAS_H_MM = 101.6;
      const CANVAS_W_PX = 1800;
      const CANVAS_H_PX = 1200;

      // ============== FORMATS ==============
      const FORMATS = {
        china: {
          label: &#39;China&#39;,
          photoWmm: 33,
          photoHmm: 48,
          cols: 2,
          rows: 2,
          defaultGapMm: 2,
          maxGapMm: 5,
          notes: &#39;Photos sit centered with comfortable margins all around.&#39;,
        },
        us: {
          label: &#39;United States&#39;,
          photoWmm: 50.8, // exactly 2 inches
          photoHmm: 50.8,
          cols: 2,
          rows: 2,
          defaultGapMm: 0,
          maxGapMm: 0, // 2× 50.8mm = 101.6mm = full canvas height; no room for gap
          notes:
            &#39;Photos extend the full 4-inch height. Side margins 25.4 mm. Use &#34;no crop&#34; at Walmart so the top and bottom of each photo are preserved.&#39;,
        },
      };

      let currentFormatKey = &#39;china&#39;;

      // Face-position overlay per format.
      // Drawn ONLY on the preview (not in the downloaded PNG).
      // All values in mm, measured from the top-left of one photo.
      const FACE_GUIDES = {
        china: {
          // Head: 15–22 mm wide × 28–33 mm tall. Top of head 3–5 mm from top edge.
          cxMm: 16.5,
          cyMm: 19,
          outerWmm: 22,
          outerHmm: 33,
          innerWmm: 15,
          innerHmm: 28,
          eyeYmm: null,
        },
        us: {
          // Head: ~17–24 mm wide × 25–35 mm tall. Eye line 28–35 mm from BOTTOM
          // (i.e. 15.8–22.8 mm from top of a 50.8 mm photo). Midpoint ~19.3 mm.
          cxMm: 25.4,
          cyMm: 20,
          outerWmm: 24,
          outerHmm: 35,
          innerWmm: 17,
          innerHmm: 25,
          eyeYmm: 19.3,
        },
      };

      // Mutable layout state — populated by applyFormat()
      let PHOTO_W_MM, PHOTO_H_MM, PHOTO_W_PX, PHOTO_H_PX, COLS, ROWS;
      let gapMm;

      function applyFormat(key) {
        currentFormatKey = key;
        const f = FORMATS[key];
        PHOTO_W_MM = f.photoWmm;
        PHOTO_H_MM = f.photoHmm;
        PHOTO_W_PX = Math.round(PHOTO_W_MM * PX_PER_MM);
        PHOTO_H_PX = Math.round(PHOTO_H_MM * PX_PER_MM);
        COLS = f.cols;
        ROWS = f.rows;
        gapMm = f.defaultGapMm;
      }
      applyFormat(currentFormatKey);

      // Gap (mutable — controlled by slider)
      let GAP_PX = Math.round(gapMm * PX_PER_MM);

      // Margins recomputed when gap changes
      let GRID_W_PX, GRID_H_PX, MARGIN_X_PX, MARGIN_Y_PX;
      function recomputeLayout() {
        GAP_PX = Math.round(gapMm * PX_PER_MM);
        GRID_W_PX = COLS * PHOTO_W_PX + (COLS - 1) * GAP_PX;
        GRID_H_PX = ROWS * PHOTO_H_PX + (ROWS - 1) * GAP_PX;
        MARGIN_X_PX = Math.round((CANVAS_W_PX - GRID_W_PX) / 2);
        MARGIN_Y_PX = Math.round((CANVAS_H_PX - GRID_H_PX) / 2);
      }
      recomputeLayout();

      // ============== STATE ==============
      let sourceImage = null;
      let imgOffsetX = 0; // in photo-px, offset of image center from photo center
      let imgOffsetY = 0;
      let imgZoom = 1.0; // multiplier on &#34;cover&#34; base scale
      let dragging = false;
      let dragStart = null;

      // ============== DOM ==============
      const previewCanvas = document.getElementById(&#39;previewCanvas&#39;);
      const ctx = previewCanvas.getContext(&#39;2d&#39;);
      const fileInput = document.getElementById(&#39;fileInput&#39;);
      const uploadLabel = document.getElementById(&#39;uploadLabel&#39;);
      const uploadText = document.getElementById(&#39;uploadText&#39;);
      const zoomSlider = document.getElementById(&#39;zoom&#39;);
      const zoomVal = document.getElementById(&#39;zoomVal&#39;);
      const gapSlider = document.getElementById(&#39;gap&#39;);
      const gapVal = document.getElementById(&#39;gapVal&#39;);
      const cutMarksCb = document.getElementById(&#39;cutMarks&#39;);
      const borderCb = document.getElementById(&#39;border&#39;);
      const rulerCb = document.getElementById(&#39;ruler&#39;);
      const faceGuideCb = document.getElementById(&#39;faceGuide&#39;);
      const downloadBtn = document.getElementById(&#39;downloadBtn&#39;);
      const resetBtn = document.getElementById(&#39;resetBtn&#39;);
      const liveDim = document.getElementById(&#39;liveDim&#39;);
      const formatBtns = document.querySelectorAll(&#39;.format-btn&#39;);
      const titleText = document.getElementById(&#39;titleText&#39;);
      const specPhotos = document.getElementById(&#39;specPhotos&#39;);
      const previewLabel = document.getElementById(&#39;previewLabel&#39;);
      const gapSliderGroup = gapSlider.closest(&#39;.slider-group&#39;);

      // ============== PREVIEW SIZING ==============
      function sizePreview() {
        const parent = previewCanvas.parentElement;
        const availW = parent.clientWidth - 48; // padding
        const scale = availW / CANVAS_W_PX;
        previewCanvas.style.width = CANVAS_W_PX * scale + &#39;px&#39;;
        previewCanvas.style.height = CANVAS_H_PX * scale + &#39;px&#39;;
        previewCanvas.width = CANVAS_W_PX;
        previewCanvas.height = CANVAS_H_PX;
        render();
      }
      window.addEventListener(&#39;resize&#39;, sizePreview);

      // ============== RENDER ==============
      function render(targetCtx = ctx, targetCanvas = previewCanvas) {
        targetCtx.fillStyle = &#39;#ffffff&#39;;
        targetCtx.fillRect(0, 0, CANVAS_W_PX, CANVAS_H_PX);

        if (!sourceImage) {
          // Empty state — show placeholder grid
          targetCtx.strokeStyle = &#39;#d4ccbe&#39;;
          targetCtx.lineWidth = 1;
          targetCtx.setLineDash([6, 6]);
          for (let r = 0; r &lt; ROWS; r++) {
            for (let c = 0; c &lt; COLS; c++) {
              const x = MARGIN_X_PX + c * (PHOTO_W_PX + GAP_PX);
              const y = MARGIN_Y_PX + r * (PHOTO_H_PX + GAP_PX);
              targetCtx.strokeRect(x, y, PHOTO_W_PX, PHOTO_H_PX);
            }
          }
          targetCtx.setLineDash([]);
          targetCtx.fillStyle = &#39;#999&#39;;
          targetCtx.font = &#39;28px Georgia, serif&#39;;
          targetCtx.textAlign = &#39;center&#39;;
          targetCtx.fillText(
            &#39;upload a photo →&#39;,
            CANVAS_W_PX / 2,
            CANVAS_H_PX / 2,
          );
          return;
        }

        // Draw each photo
        for (let r = 0; r &lt; ROWS; r++) {
          for (let c = 0; c &lt; COLS; c++) {
            const x = MARGIN_X_PX + c * (PHOTO_W_PX + GAP_PX);
            const y = MARGIN_Y_PX + r * (PHOTO_H_PX + GAP_PX);
            drawPhoto(targetCtx, x, y);
          }
        }

        // Cut marks
        if (cutMarksCb.checked) drawCutMarks(targetCtx);

        // Per-photo border
        if (borderCb.checked) {
          targetCtx.strokeStyle = &#39;#999&#39;;
          targetCtx.lineWidth = 1;
          for (let r = 0; r &lt; ROWS; r++) {
            for (let c = 0; c &lt; COLS; c++) {
              const x = MARGIN_X_PX + c * (PHOTO_W_PX + GAP_PX);
              const y = MARGIN_Y_PX + r * (PHOTO_H_PX + GAP_PX);
              targetCtx.strokeRect(
                x + 0.5,
                y + 0.5,
                PHOTO_W_PX - 1,
                PHOTO_H_PX - 1,
              );
            }
          }
        }

        // Ruler
        if (rulerCb.checked) drawRuler(targetCtx);

        // Face position guide — preview only, never in the downloaded print
        const isPreview = targetCanvas === previewCanvas;
        if (isPreview &amp;&amp; faceGuideCb.checked) drawFaceGuides(targetCtx);
      }

      function drawFaceGuides(targetCtx) {
        const guide = FACE_GUIDES[currentFormatKey];
        if (!guide) return;

        const cxOff = guide.cxMm * PX_PER_MM;
        const cyOff = guide.cyMm * PX_PER_MM;
        const outerRx = (guide.outerWmm * PX_PER_MM) / 2;
        const outerRy = (guide.outerHmm * PX_PER_MM) / 2;
        const innerRx = (guide.innerWmm * PX_PER_MM) / 2;
        const innerRy = (guide.innerHmm * PX_PER_MM) / 2;
        const eyeOff = guide.eyeYmm != null ? guide.eyeYmm * PX_PER_MM : null;

        for (let r = 0; r &lt; ROWS; r++) {
          for (let c = 0; c &lt; COLS; c++) {
            const px = MARGIN_X_PX + c * (PHOTO_W_PX + GAP_PX);
            const py = MARGIN_Y_PX + r * (PHOTO_H_PX + GAP_PX);
            const cx = px + cxOff;
            const cy = py + cyOff;

            targetCtx.save();

            // Outer oval — max head envelope (solid)
            targetCtx.strokeStyle = &#39;rgba(220, 38, 38, 0.85)&#39;;
            targetCtx.lineWidth = 4;
            targetCtx.setLineDash([]);
            targetCtx.beginPath();
            targetCtx.ellipse(cx, cy, outerRx, outerRy, 0, 0, Math.PI * 2);
            targetCtx.stroke();

            // Inner oval — min head envelope (dashed)
            targetCtx.strokeStyle = &#39;rgba(220, 38, 38, 0.65)&#39;;
            targetCtx.lineWidth = 3;
            targetCtx.setLineDash([14, 8]);
            targetCtx.beginPath();
            targetCtx.ellipse(cx, cy, innerRx, innerRy, 0, 0, Math.PI * 2);
            targetCtx.stroke();
            targetCtx.setLineDash([]);

            // Eye line — for formats that specify eye position (US)
            if (eyeOff != null) {
              targetCtx.strokeStyle = &#39;rgba(220, 38, 38, 0.45)&#39;;
              targetCtx.lineWidth = 2;
              const eyeY = py + eyeOff;
              targetCtx.beginPath();
              targetCtx.moveTo(px + 8, eyeY);
              targetCtx.lineTo(px + PHOTO_W_PX - 8, eyeY);
              targetCtx.stroke();
            }

            targetCtx.restore();
          }
        }
      }

      function drawPhoto(targetCtx, x, y) {
        targetCtx.save();
        targetCtx.beginPath();
        targetCtx.rect(x, y, PHOTO_W_PX, PHOTO_H_PX);
        targetCtx.clip();

        // Cover-fit base scale
        const baseScale = Math.max(
          PHOTO_W_PX / sourceImage.width,
          PHOTO_H_PX / sourceImage.height,
        );
        const scale = baseScale * imgZoom;
        const drawW = sourceImage.width * scale;
        const drawH = sourceImage.height * scale;

        const cx = x + PHOTO_W_PX / 2 + imgOffsetX;
        const cy = y + PHOTO_H_PX / 2 + imgOffsetY;
        targetCtx.drawImage(
          sourceImage,
          cx - drawW / 2,
          cy - drawH / 2,
          drawW,
          drawH,
        );

        targetCtx.restore();
      }

      function drawCutMarks(targetCtx) {
        targetCtx.strokeStyle = &#39;#000&#39;;
        targetCtx.lineWidth = 1;
        targetCtx.setLineDash([]);

        const markLen = Math.round(2.5 * PX_PER_MM); // 2.5mm tick marks
        const gridBottom = MARGIN_Y_PX + GRID_H_PX;
        const gridRight = MARGIN_X_PX + GRID_W_PX;

        // Vertical cut lines: at outer grid edges + center of each gap
        for (let c = 0; c &lt;= COLS; c++) {
          let x;
          if (c === 0) x = MARGIN_X_PX;
          else if (c === COLS) x = gridRight;
          else x = MARGIN_X_PX + c * PHOTO_W_PX + (c - 1) * GAP_PX + GAP_PX / 2;
          x = Math.round(x) + 0.5;

          targetCtx.beginPath();
          targetCtx.moveTo(x, MARGIN_Y_PX - markLen);
          targetCtx.lineTo(x, MARGIN_Y_PX);
          targetCtx.stroke();
          targetCtx.beginPath();
          targetCtx.moveTo(x, gridBottom);
          targetCtx.lineTo(x, gridBottom + markLen);
          targetCtx.stroke();
        }

        // Horizontal cut lines: at outer grid edges + center of each gap
        for (let r = 0; r &lt;= ROWS; r++) {
          let y;
          if (r === 0) y = MARGIN_Y_PX;
          else if (r === ROWS) y = gridBottom;
          else y = MARGIN_Y_PX + r * PHOTO_H_PX + (r - 1) * GAP_PX + GAP_PX / 2;
          y = Math.round(y) + 0.5;

          targetCtx.beginPath();
          targetCtx.moveTo(MARGIN_X_PX - markLen, y);
          targetCtx.lineTo(MARGIN_X_PX, y);
          targetCtx.stroke();
          targetCtx.beginPath();
          targetCtx.moveTo(gridRight, y);
          targetCtx.lineTo(gridRight + markLen, y);
          targetCtx.stroke();
        }
      }

      function drawRuler(targetCtx) {
        // Decide which size-check element to draw based on available right-margin width.
        // If a full PHOTO_W × PHOTO_H reference frame fits, use it (China).
        // Otherwise fall back to a vertical scale bar matching photo height (US).
        const rightMarginX = MARGIN_X_PX + GRID_W_PX;
        const rightMarginW = CANVAS_W_PX - rightMarginX;
        const needed = PHOTO_W_PX + Math.round(7 * PX_PER_MM);
        if (rightMarginW &gt;= needed) {
          drawReferenceFrame(targetCtx);
        } else {
          drawScaleBar(targetCtx);
        }
      }

      function drawReferenceFrame(targetCtx) {
        // Dashed rectangle exactly matching a single photo&#39;s dimensions, placed in
        // the right margin. Hold a metric ruler over it after printing to verify.
        const refW = PHOTO_W_PX;
        const refH = PHOTO_H_PX;
        const rightMarginX = MARGIN_X_PX + GRID_W_PX;
        const rightMarginW = CANVAS_W_PX - rightMarginX;
        const refX = Math.round(rightMarginX + (rightMarginW - refW) / 2);
        const refY = Math.round((CANVAS_H_PX - refH) / 2);

        targetCtx.strokeStyle = &#39;#000&#39;;
        targetCtx.fillStyle = &#39;#000&#39;;
        targetCtx.lineWidth = 1;

        targetCtx.setLineDash([
          Math.round(1.5 * PX_PER_MM),
          Math.round(1 * PX_PER_MM),
        ]);
        targetCtx.strokeRect(refX + 0.5, refY + 0.5, refW, refH);
        targetCtx.setLineDash([]);

        // 10 mm tick marks on left and bottom edges
        const tickLen = Math.round(1.8 * PX_PER_MM);
        const maxH = Math.floor(PHOTO_H_MM / 10) * 10;
        for (let mm = 0; mm &lt;= maxH; mm += 10) {
          const y = refY + mm * PX_PER_MM;
          targetCtx.beginPath();
          targetCtx.moveTo(refX - tickLen, y + 0.5);
          targetCtx.lineTo(refX, y + 0.5);
          targetCtx.stroke();
        }
        const maxW = Math.floor(PHOTO_W_MM / 10) * 10;
        for (let mm = 0; mm &lt;= maxW; mm += 10) {
          const x = refX + mm * PX_PER_MM;
          targetCtx.beginPath();
          targetCtx.moveTo(x + 0.5, refY + refH);
          targetCtx.lineTo(x + 0.5, refY + refH + tickLen);
          targetCtx.stroke();
        }

        const labelPx = Math.round(2.4 * PX_PER_MM);
        const smallPx = Math.round(1.7 * PX_PER_MM);

        targetCtx.font = `${labelPx}px monospace`;
        targetCtx.textAlign = &#39;center&#39;;
        targetCtx.textBaseline = &#39;top&#39;;
        targetCtx.fillText(
          `${PHOTO_W_MM} mm`,
          refX + refW / 2,
          refY + refH + tickLen + Math.round(1.5 * PX_PER_MM),
        );

        targetCtx.save();
        targetCtx.translate(
          refX - tickLen - Math.round(1.5 * PX_PER_MM),
          refY + refH / 2,
        );
        targetCtx.rotate(-Math.PI / 2);
        targetCtx.textBaseline = &#39;bottom&#39;;
        targetCtx.fillText(`${PHOTO_H_MM} mm`, 0, 0);
        targetCtx.restore();

        targetCtx.font = `${smallPx}px monospace`;
        targetCtx.textAlign = &#39;center&#39;;
        targetCtx.textBaseline = &#39;bottom&#39;;
        targetCtx.fillText(
          &#39;SIZE CHECK&#39;,
          refX + refW / 2,
          refY - Math.round(1.5 * PX_PER_MM),
        );

        targetCtx.textBaseline = &#39;alphabetic&#39;;
      }

      function drawScaleBar(targetCtx) {
        // Vertical I-bar in the right margin, exactly one photo tall.
        // Lay a metric ruler against it after printing to verify scale.
        const barLen = PHOTO_H_PX;
        const rightMarginX = MARGIN_X_PX + GRID_W_PX;
        const rightMarginW = CANVAS_W_PX - rightMarginX;
        const barX = Math.round(rightMarginX + rightMarginW / 2);
        const barY = Math.round((CANVAS_H_PX - barLen) / 2);

        targetCtx.strokeStyle = &#39;#000&#39;;
        targetCtx.fillStyle = &#39;#000&#39;;
        targetCtx.lineWidth = 1;

        // main shaft
        targetCtx.beginPath();
        targetCtx.moveTo(barX + 0.5, barY);
        targetCtx.lineTo(barX + 0.5, barY + barLen);
        targetCtx.stroke();

        // top and bottom serifs
        const capHalf = Math.round(2 * PX_PER_MM);
        targetCtx.beginPath();
        targetCtx.moveTo(barX - capHalf + 0.5, barY + 0.5);
        targetCtx.lineTo(barX + capHalf + 0.5, barY + 0.5);
        targetCtx.moveTo(barX - capHalf + 0.5, barY + barLen + 0.5);
        targetCtx.lineTo(barX + capHalf + 0.5, barY + barLen + 0.5);
        targetCtx.stroke();

        // 10 mm tick marks
        const tickLen = Math.round(1.3 * PX_PER_MM);
        const maxTick = Math.floor(PHOTO_H_MM / 10) * 10;
        for (let mm = 10; mm &lt;= maxTick; mm += 10) {
          const y = barY + mm * PX_PER_MM;
          targetCtx.beginPath();
          targetCtx.moveTo(barX - tickLen + 0.5, y + 0.5);
          targetCtx.lineTo(barX + 0.5, y + 0.5);
          targetCtx.stroke();
        }

        const labelPx = Math.round(2.3 * PX_PER_MM);
        const smallPx = Math.round(1.6 * PX_PER_MM);

        // Vertical dimension label
        targetCtx.font = `${labelPx}px monospace`;
        targetCtx.save();
        targetCtx.translate(
          barX + capHalf + Math.round(2 * PX_PER_MM),
          barY + barLen / 2,
        );
        targetCtx.rotate(-Math.PI / 2);
        targetCtx.textAlign = &#39;center&#39;;
        targetCtx.textBaseline = &#39;bottom&#39;;
        const isUS = currentFormatKey === &#39;us&#39;;
        targetCtx.fillText(isUS ? &#39;2 in · 50.8 mm&#39; : `${PHOTO_H_MM} mm`, 0, 0);
        targetCtx.restore();

        // Header
        targetCtx.font = `${smallPx}px monospace`;
        targetCtx.textAlign = &#39;center&#39;;
        targetCtx.textBaseline = &#39;bottom&#39;;
        targetCtx.fillText(
          &#39;SIZE CHECK&#39;,
          barX,
          barY - Math.round(2 * PX_PER_MM),
        );

        targetCtx.textBaseline = &#39;alphabetic&#39;;
      }

      // ============== FILE LOAD ==============
      fileInput.addEventListener(&#39;change&#39;, (e) =&gt; {
        const file = e.target.files[0];
        if (!file) return;
        const reader = new FileReader();
        reader.onload = (ev) =&gt; {
          const img = new Image();
          img.onload = () =&gt; {
            sourceImage = img;
            imgOffsetX = 0;
            imgOffsetY = 0;
            imgZoom = 1.0;
            zoomSlider.value = 100;
            zoomVal.textContent = &#39;100%&#39;;
            uploadText.textContent = file.name;
            document.getElementById(&#39;uploadHint&#39;).textContent =
              `${img.width} × ${img.height} px loaded`;
            // swap upload arrow for checkmark
            document.getElementById(&#39;uploadIcon&#39;).innerHTML =
              &#39;&lt;polyline points=&#34;20 6 9 17 4 12&#34;&gt;&lt;/polyline&gt;&#39;;
            uploadLabel.classList.add(&#39;has-file&#39;);
            previewCanvas.classList.remove(&#39;empty&#39;);
            downloadBtn.disabled = false;
            resetBtn.disabled = false;
            liveDim.textContent = `${img.width} × ${img.height} px source`;
            render();
          };
          img.src = ev.target.result;
        };
        reader.readAsDataURL(file);
      });

      // ============== INTERACTIONS ==============

      // Format switching — update layout, UI text, gap slider, then re-render
      function switchFormat(key) {
        if (!FORMATS[key]) return;
        applyFormat(key);
        const f = FORMATS[key];

        // Sync format-button active state
        formatBtns.forEach((b) =&gt;
          b.classList.toggle(&#39;active&#39;, b.dataset.format === key),
        );

        // Sync header text
        const isUS = key === &#39;us&#39;;
        const dimText = isUS ? &#39;2 × 2 in&#39; : `${f.photoWmm} × ${f.photoHmm} mm`;
        const photoCount = f.cols * f.rows;
        titleText.textContent = `${f.label} · ${dimText} × ${photoCount}`;
        specPhotos.textContent = `${photoCount} (${f.cols} × ${f.rows} grid)`;
        previewLabel.textContent = `PREVIEW · 6 × 4 in landscape · ${f.cols} × ${f.rows} grid`;

        // Sync info text
        const sizeCheckRef = document.getElementById(&#39;sizeCheckRef&#39;);
        const sizeCheckDims = document.getElementById(&#39;sizeCheckDims&#39;);
        if (sizeCheckRef &amp;&amp; sizeCheckDims) {
          if (isUS) {
            sizeCheckRef.textContent = &#39;SIZE CHECK scale bar&#39;;
            sizeCheckDims.textContent = &#39;50.8 mm (2 in)&#39;;
          } else {
            sizeCheckRef.textContent = &#39;dashed SIZE CHECK frame&#39;;
            sizeCheckDims.textContent = `${f.photoWmm} × ${f.photoHmm} mm`;
          }
        }

        // Update gap slider: range, value, disabled state
        gapSlider.max = f.maxGapMm;
        gapSlider.value = f.defaultGapMm;
        gapVal.textContent = `${f.defaultGapMm.toFixed(1)} mm`;
        if (f.maxGapMm === 0) {
          gapSlider.disabled = true;
          gapSliderGroup.classList.add(&#39;disabled&#39;);
        } else {
          gapSlider.disabled = false;
          gapSliderGroup.classList.remove(&#39;disabled&#39;);
        }

        recomputeLayout();
        render();
      }

      formatBtns.forEach((btn) =&gt; {
        btn.addEventListener(&#39;click&#39;, () =&gt; switchFormat(btn.dataset.format));
      });

      zoomSlider.addEventListener(&#39;input&#39;, () =&gt; {
        imgZoom = parseInt(zoomSlider.value) / 100;
        zoomVal.textContent = `${zoomSlider.value}%`;
        render();
      });

      gapSlider.addEventListener(&#39;input&#39;, () =&gt; {
        gapMm = parseFloat(gapSlider.value);
        gapVal.textContent = `${gapMm.toFixed(1)} mm`;
        recomputeLayout();
        render();
      });

      cutMarksCb.addEventListener(&#39;change&#39;, () =&gt; render());
      borderCb.addEventListener(&#39;change&#39;, () =&gt; render());
      rulerCb.addEventListener(&#39;change&#39;, () =&gt; render());
      faceGuideCb.addEventListener(&#39;change&#39;, () =&gt; render());

      resetBtn.addEventListener(&#39;click&#39;, () =&gt; {
        imgOffsetX = 0;
        imgOffsetY = 0;
        imgZoom = 1.0;
        zoomSlider.value = 100;
        zoomVal.textContent = &#39;100%&#39;;
        render();
      });

      // Drag to reposition
      function getCanvasPos(e) {
        const rect = previewCanvas.getBoundingClientRect();
        const scale = CANVAS_W_PX / rect.width;
        const clientX = e.touches ? e.touches[0].clientX : e.clientX;
        const clientY = e.touches ? e.touches[0].clientY : e.clientY;
        return {
          x: (clientX - rect.left) * scale,
          y: (clientY - rect.top) * scale,
        };
      }

      function startDrag(e) {
        if (!sourceImage) return;
        dragging = true;
        previewCanvas.classList.add(&#39;dragging&#39;);
        dragStart = getCanvasPos(e);
        dragStart.offsetX = imgOffsetX;
        dragStart.offsetY = imgOffsetY;
        e.preventDefault();
      }
      function doDrag(e) {
        if (!dragging) return;
        const pos = getCanvasPos(e);
        imgOffsetX = dragStart.offsetX + (pos.x - dragStart.x);
        imgOffsetY = dragStart.offsetY + (pos.y - dragStart.y);
        render();
        e.preventDefault();
      }
      function endDrag() {
        dragging = false;
        previewCanvas.classList.remove(&#39;dragging&#39;);
      }

      previewCanvas.addEventListener(&#39;mousedown&#39;, startDrag);
      window.addEventListener(&#39;mousemove&#39;, doDrag);
      window.addEventListener(&#39;mouseup&#39;, endDrag);
      previewCanvas.addEventListener(&#39;touchstart&#39;, startDrag, {
        passive: false,
      });
      window.addEventListener(&#39;touchmove&#39;, doDrag, { passive: false });
      window.addEventListener(&#39;touchend&#39;, endDrag);

      // ============== DOWNLOAD ==============
      downloadBtn.addEventListener(&#39;click&#39;, () =&gt; {
        // Render to an off-screen canvas at exact 1800x1200
        const out = document.createElement(&#39;canvas&#39;);
        out.width = CANVAS_W_PX;
        out.height = CANVAS_H_PX;
        const outCtx = out.getContext(&#39;2d&#39;);
        outCtx.imageSmoothingEnabled = true;
        outCtx.imageSmoothingQuality = &#39;high&#39;;
        render(outCtx, out);

        // Convert to blob and trigger download
        out.toBlob((blob) =&gt; {
          // Inject 300 DPI pHYs chunk so print apps read it as 4×6
          injectDpiAndDownload(
            blob,
            300,
            `${currentFormatKey}_passport_4x6_300dpi.png`,
          );
        }, &#39;image/png&#39;);
      });

      // Insert pHYs chunk into PNG so it reports 300 DPI metadata
      function injectDpiAndDownload(blob, dpi, filename) {
        const reader = new FileReader();
        reader.onload = function () {
          const buf = new Uint8Array(reader.result);
          // PPM = dots per meter
          const ppm = Math.round(dpi * 39.3701);
          // pHYs chunk: 4 byte length, &#39;pHYs&#39;, 9 bytes data, 4 byte CRC
          const data = new Uint8Array(9);
          const dv = new DataView(data.buffer);
          dv.setUint32(0, ppm); // x ppm
          dv.setUint32(4, ppm); // y ppm
          data[8] = 1; // unit: meter

          const type = new Uint8Array([0x70, 0x48, 0x59, 0x73]); // &#39;pHYs&#39;
          const crc = crc32(concatUint8(type, data));

          const chunk = new Uint8Array(4 + 4 + 9 + 4);
          const cv = new DataView(chunk.buffer);
          cv.setUint32(0, 9); // length
          chunk.set(type, 4); // &#39;pHYs&#39;
          chunk.set(data, 8); // data
          cv.setUint32(17, crc); // CRC

          // PNG: 8 byte signature, then IHDR is first. Insert pHYs after IHDR.
          // IHDR: 4 length + 4 type + 13 data + 4 crc = 25 bytes. So after byte 8+25=33.
          const insertAt = 33;
          const result = new Uint8Array(buf.length + chunk.length);
          result.set(buf.subarray(0, insertAt), 0);
          result.set(chunk, insertAt);
          result.set(buf.subarray(insertAt), insertAt + chunk.length);

          const newBlob = new Blob([result], { type: &#39;image/png&#39; });
          const url = URL.createObjectURL(newBlob);
          const a = document.createElement(&#39;a&#39;);
          a.href = url;
          a.download = filename;
          document.body.appendChild(a);
          a.click();
          document.body.removeChild(a);
          URL.revokeObjectURL(url);
        };
        reader.readAsArrayBuffer(blob);
      }

      function concatUint8(a, b) {
        const c = new Uint8Array(a.length + b.length);
        c.set(a, 0);
        c.set(b, a.length);
        return c;
      }

      // CRC32 for PNG
      const CRC_TABLE = (() =&gt; {
        const table = new Uint32Array(256);
        for (let n = 0; n &lt; 256; n++) {
          let c = n;
          for (let k = 0; k &lt; 8; k++) {
            c = c &amp; 1 ? 0xedb88320 ^ (c &gt;&gt;&gt; 1) : c &gt;&gt;&gt; 1;
          }
          table[n] = c &gt;&gt;&gt; 0;
        }
        return table;
      })();
      function crc32(bytes) {
        let c = 0xffffffff;
        for (let i = 0; i &lt; bytes.length; i++) {
          c = CRC_TABLE[(c ^ bytes[i]) &amp; 0xff] ^ (c &gt;&gt;&gt; 8);
        }
        return (c ^ 0xffffffff) &gt;&gt;&gt; 0;
      }

      // init
      sizePreview();
    &lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;</description>
    </item>
  </channel>
</rss>
