Files
convertisseur-images/docs/superpowers/plans/2026-05-27-convertisseur-images-shopify.md
T

40 KiB
Raw Blame History

Convertisseur Images Shopify — Plan d'implémentation

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Construire une app web client-side qui convertit des photos HEIC de produits en JPG avec suppression automatique du fond blanc et pipette manuelle pour les cas complexes, puis exporte un ZIP en conservant les noms de fichiers d'origine.

Architecture: Page HTML unique avec Alpine.js pour la gestion d'état. Quatre modules ES (heicConverter, bgRemover, imageProcessor, zipExporter) chargés via <script type="module">. Aucun serveur, aucun build step. Un fichier test.html exécute les tests unitaires directement dans le navigateur.

Tech Stack: HTML5 · Alpine.js v3 (CDN) · Tailwind CSS v3 (CDN) · heic2any (CDN) · JSZip (CDN) · Canvas API · EyeDropper API


Carte des fichiers

Fichier Responsabilité
index.html Shell de l'app, état Alpine.js, layout UI
css/app.css Styles custom (drop zone, cartes, transitions)
js/heicConverter.js HEIC → JPEG Blob via heic2any
js/bgRemover.js Flood-fill, distance Chebyshev, anti-aliasing, pipette
js/imageProcessor.js Orchestration pipeline, 3 images en parallèle
js/zipExporter.js Packaging JSZip et déclenchement du téléchargement
test.html Tests unitaires navigateur pour fonctions pures

Tâche 1 : Scaffold du projet

Fichiers :

  • Créer : index.html

  • Créer : css/app.css

  • Créer : test.html

  • Créer : js/heicConverter.js (stub)

  • Créer : js/bgRemover.js (stub)

  • Créer : js/imageProcessor.js (stub)

  • Créer : js/zipExporter.js (stub)

  • Étape 1 : Créer l'arborescence

mkdir -p js css
  • Étape 2 : Créer index.html avec les dépendances CDN et le squelette Alpine.js
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Convertisseur Images — Matériaux Destock</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
  <link rel="stylesheet" href="css/app.css" />
</head>
<body class="bg-gray-50 min-h-screen font-sans">
  <div x-data="imageApp()" class="max-w-6xl mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold text-gray-800 mb-6">
      Convertisseur Images Shopify
    </h1>

    <!-- Zone 1: Upload -->
    <div id="upload-zone" class="mb-8">
      <p class="text-gray-500 text-center">Zone d'upload (à construire)</p>
    </div>

    <!-- Zone 2: Grille -->
    <div id="grid-zone" class="mb-8">
      <p class="text-gray-500 text-center">Grille d'images (à construire)</p>
    </div>

    <!-- Zone 3: Export -->
    <div id="export-zone">
      <p class="text-gray-500 text-center">Zone d'export (à construire)</p>
    </div>
  </div>

  <script type="module">
    import { convertHeicToJpeg } from './js/heicConverter.js';
    import { removeBackground, pickColorFromCanvas } from './js/bgRemover.js';
    import { processBatch } from './js/imageProcessor.js';
    import { downloadAllAsZip } from './js/zipExporter.js';

    // Helper: load a Blob as Image onto a Canvas, apply removeBackground, return JPEG Blob
    async function processImage(blob, seedColor, tolerance) {
      return new Promise((resolve, reject) => {
        const img = new Image();
        img.onload = () => {
          const canvas = document.createElement('canvas');
          canvas.width = img.width;
          canvas.height = img.height;
          canvas.getContext('2d').drawImage(img, 0, 0);
          try {
            removeBackground(canvas, seedColor ?? null, tolerance);
            canvas.toBlob(b => {
              URL.revokeObjectURL(img.src);
              b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'));
            }, 'image/jpeg', 0.92);
          } catch (e) {
            reject(e);
          }
        };
        img.onerror = () => reject(new Error('Impossible de charger l\'image'));
        img.src = URL.createObjectURL(blob);
      });
    }

    window.imageApp = function () {
      return {
        files: [],
        globalTolerance: 30,

        get readyCount() {
          return this.files.filter(f => f.status === 'done').length;
        },

        async handleFiles(fileList) {
          // TODO: Tâche 7
        },

        async reprocess(file) {
          // TODO: Tâche 8
        },

        async startColorPick(file) {
          // TODO: Tâche 8
        },

        async downloadAll() {
          // TODO: Tâche 9
        },
      };
    };
  </script>
</body>
</html>
  • Étape 3 : Créer les stubs des modules

js/heicConverter.js :

export async function convertHeicToJpeg(file) {
  throw new Error('Not implemented');
}

js/bgRemover.js :

export function chebyshevDistance(c1, c2) {
  throw new Error('Not implemented');
}
export function createFloodFillMask(imageData, seedColor, tolerance) {
  throw new Error('Not implemented');
}
export function applyMaskToImageData(imageData, mask, replacementColor) {
  throw new Error('Not implemented');
}
export function softEdges(imageData, mask) {
  throw new Error('Not implemented');
}
export function removeBackground(canvas, seedColor, tolerance = 30) {
  throw new Error('Not implemented');
}
export async function pickColorFromCanvas(canvas) {
  throw new Error('Not implemented');
}

js/imageProcessor.js :

export async function processBatch(items, processFn, concurrency = 3) {
  throw new Error('Not implemented');
}

js/zipExporter.js :

export async function downloadAllAsZip(processedFiles) {
  throw new Error('Not implemented');
}
  • Étape 4 : Créer css/app.css
.drop-active {
  border-color: #3b82f6 !important;
  background-color: #eff6ff !important;
}
.image-card {
  transition: box-shadow 0.2s;
}
.image-card:hover {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.picking-color {
  cursor: crosshair;
  outline: 2px solid #7c3aed;
}
  • Étape 5 : Créer test.html avec l'assistant d'assertions
<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8" />
  <title>Tests — Convertisseur Images</title>
  <style>
    body { font-family: monospace; padding: 20px; background: #f9f9f9; }
    .pass { color: green; margin: 2px 0; }
    .fail { color: red; font-weight: bold; margin: 2px 0; }
    h2 { margin-top: 20px; border-top: 1px solid #ddd; padding-top: 10px; }
    #summary { font-size: 1.2em; font-weight: bold; margin-top: 24px; padding: 12px;
               border-radius: 8px; display: inline-block; }
  </style>
</head>
<body>
  <h1>Tests unitaires</h1>
  <div id="results"></div>
  <div id="summary"></div>

  <script type="module">
    let passed = 0, failed = 0;

    function assert(description, condition) {
      const ok = Boolean(condition);
      ok ? passed++ : failed++;
      const el = document.createElement('div');
      el.className = ok ? 'pass' : 'fail';
      el.textContent = `${ok ? '✅' : '❌'} ${description}`;
      document.getElementById('results').appendChild(el);
    }

    function section(title) {
      const el = document.createElement('h2');
      el.textContent = title;
      document.getElementById('results').appendChild(el);
    }

    // Helper: construit un ImageData-like depuis un tableau 2D de [R,G,B]
    function makeImageData(grid) {
      const h = grid.length, w = grid[0].length;
      const data = new Uint8ClampedArray(w * h * 4);
      for (let y = 0; y < h; y++) {
        for (let x = 0; x < w; x++) {
          const i = (y * w + x) * 4;
          const [r, g, b] = grid[y][x];
          data[i] = r; data[i+1] = g; data[i+2] = b; data[i+3] = 255;
        }
      }
      return { data, width: w, height: h };
    }

    // ── Les tests seront ajoutés dans les tâches suivantes ──

    // Résumé
    const summary = document.getElementById('summary');
    summary.textContent = `${passed} passé(s), ${failed} échoué(s)`;
    summary.style.background = failed === 0 ? '#dcfce7' : '#fee2e2';
    summary.style.color = failed === 0 ? '#166534' : '#991b1b';
  </script>
</body>
</html>
  • Étape 6 : Ouvrir index.html dans Chrome et vérifier le chargement

Attendu : titre "Convertisseur Images Shopify" visible, aucune erreur console.

  • Étape 7 : Ouvrir test.html dans Chrome et vérifier "0 passé(s), 0 échoué(s)"

  • Étape 8 : Commit initial

git init
git add .
git commit -m "feat: scaffold projet avec stubs et test runner navigateur"

Tâche 2 : Module HEIC Converter

Fichiers :

  • Modifier : js/heicConverter.js

  • Modifier : test.html

  • Étape 1 : Ajouter les tests dans test.html

Dans le <script type="module"> de test.html, avant le bloc "Résumé" :

import { convertHeicToJpeg } from './js/heicConverter.js';

section('heicConverter');

// Test : null en entrée → TypeError
try {
  await convertHeicToJpeg(null);
  assert('convertHeicToJpeg(null) lance TypeError', false);
} catch (e) {
  assert('convertHeicToJpeg(null) lance TypeError', e instanceof TypeError);
}

// Test : fichier PNG → TypeError
try {
  const pngFile = new File([new Uint8Array([137,80,78,71])], 'test.png', { type: 'image/png' });
  await convertHeicToJpeg(pngFile);
  assert('convertHeicToJpeg avec PNG lance TypeError', false);
} catch (e) {
  assert('convertHeicToJpeg avec PNG lance TypeError', e instanceof TypeError);
}
  • Étape 2 : Ouvrir test.html, vérifier que les 2 tests ÉCHOUENT ()

Attendu : erreur "Not implemented" capturée, mais e instanceof TypeError est false → 2 .

  • Étape 3 : Implémenter heicConverter.js
/**
 * Convertit un fichier HEIC/HEIF en Blob JPEG.
 * @param {File} file
 * @returns {Promise<Blob>}
 * @throws {TypeError} si l'entrée n'est pas un fichier HEIC/HEIF
 * @throws {Error} si la conversion échoue
 */
export async function convertHeicToJpeg(file) {
  if (!(file instanceof File)) {
    throw new TypeError('L\'entrée doit être un objet File');
  }
  // Certains iPhone envoient un type MIME vide pour les HEIC — on l'autorise
  const type = file.type.toLowerCase();
  if (type !== '' && !['image/heic', 'image/heif'].includes(type)) {
    throw new TypeError(`Format attendu : HEIC/HEIF. Reçu : ${file.type}`);
  }

  const result = await heic2any({
    blob: file,
    toType: 'image/jpeg',
    quality: 0.92,
  });

  const blob = Array.isArray(result) ? result[0] : result;
  if (!(blob instanceof Blob)) {
    throw new Error('heic2any n\'a pas retourné un Blob');
  }
  return blob;
}
  • Étape 4 : Ouvrir test.html, vérifier que les 2 tests PASSENT ()

  • Étape 5 : Commit

git add js/heicConverter.js test.html
git commit -m "feat: implémenter le convertisseur HEIC → JPEG"

Tâche 3 : bgRemover — algorithme de base

Fichiers :

  • Modifier : js/bgRemover.js

  • Modifier : test.html

  • Étape 1 : Ajouter les tests chebyshevDistance dans test.html

import { chebyshevDistance, createFloodFillMask, applyMaskToImageData } from './js/bgRemover.js';

section('bgRemover — chebyshevDistance');

assert('couleurs identiques → distance 0',
  chebyshevDistance([255,255,255], [255,255,255]) === 0);

assert('blanc vs quasi-blanc (diff max=10) → distance 10',
  chebyshevDistance([255,255,255], [245,250,252]) === 10);

assert('blanc vs gris 128 → distance 127',
  chebyshevDistance([255,255,255], [128,128,128]) === 127);

assert('distance = max des écarts par canal',
  chebyshevDistance([200,100,50], [180,90,20]) === 30);
  • Étape 2 : Ouvrir test.html, vérifier 4 tests ÉCHOUENT

  • Étape 3 : Implémenter chebyshevDistance dans bgRemover.js

Remplacer le stub chebyshevDistance par :

/**
 * Distance de Chebyshev entre deux couleurs RGB.
 * max(|R1-R2|, |G1-G2|, |B1-B2|)
 * @param {number[]} c1 — [R, G, B]
 * @param {number[]} c2 — [R, G, B]
 * @returns {number}
 */
export function chebyshevDistance(c1, c2) {
  return Math.max(
    Math.abs(c1[0] - c2[0]),
    Math.abs(c1[1] - c2[1]),
    Math.abs(c1[2] - c2[2])
  );
}
  • Étape 4 : Ouvrir test.html, vérifier 4 tests chebyshevDistance PASSENT

  • Étape 5 : Ajouter les tests createFloodFillMask dans test.html

section('bgRemover — createFloodFillMask');

const W = [255,255,255], R = [200,50,50], B = [50,50,200];

// Image 3×3 : bordure blanche, centre rouge
const img3x3 = makeImageData([
  [W, W, W],
  [W, R, W],
  [W, W, W],
]);
const mask3x3 = createFloodFillMask(img3x3, [255,255,255], 30);
assert('3×3 : 8 pixels blancs de bordure masqués (somme=8)',
  Array.from(mask3x3).reduce((a,b) => a+b, 0) === 8);
assert('3×3 : pixel rouge central NON masqué',
  mask3x3[1 * 3 + 1] === 0);

// Image 4×4 : cadre blanc autour de 2×2 bleu
const img4x4 = makeImageData([
  [W, W, W, W],
  [W, B, B, W],
  [W, B, B, W],
  [W, W, W, W],
]);
const mask4x4 = createFloodFillMask(img4x4, [255,255,255], 30);
assert('4×4 : 12 pixels blancs masqués',
  Array.from(mask4x4).reduce((a,b) => a+b, 0) === 12);
assert('4×4 : 4 pixels bleus intérieurs non masqués',
  mask4x4[1*4+1] === 0 && mask4x4[1*4+2] === 0 &&
  mask4x4[2*4+1] === 0 && mask4x4[2*4+2] === 0);
  • Étape 6 : Ouvrir test.html, vérifier 4 tests createFloodFillMask ÉCHOUENT

  • Étape 7 : Implémenter createFloodFillMask dans bgRemover.js

Remplacer le stub createFloodFillMask par :

/**
 * Flood-fill BFS depuis les 4 coins. Retourne un masque Uint8Array
 * où 1 = fond, 0 = premier plan.
 * @param {{ data: Uint8ClampedArray, width: number, height: number }} imageData
 * @param {number[]} seedColor — [R, G, B]
 * @param {number} tolerance — tolérance Chebyshev
 * @returns {Uint8Array}
 */
export function createFloodFillMask(imageData, seedColor, tolerance) {
  const { data, width, height } = imageData;
  const mask    = new Uint8Array(width * height);
  const visited = new Uint8Array(width * height);
  const queue   = [];

  const corners = [0, width - 1, (height - 1) * width, height * width - 1];
  for (const idx of corners) {
    if (!visited[idx]) { visited[idx] = 1; queue.push(idx); }
  }

  while (queue.length > 0) {
    const idx = queue.pop();
    const di = idx * 4;
    const px = [data[di], data[di+1], data[di+2]];
    if (chebyshevDistance(px, seedColor) > tolerance) continue;

    mask[idx] = 1;
    const x = idx % width, y = Math.floor(idx / width);

    const neighbors = [
      x > 0        ? idx - 1      : -1,
      x < width-1  ? idx + 1      : -1,
      y > 0        ? idx - width  : -1,
      y < height-1 ? idx + width  : -1,
    ];
    for (const n of neighbors) {
      if (n >= 0 && !visited[n]) { visited[n] = 1; queue.push(n); }
    }
  }
  return mask;
}
  • Étape 8 : Ouvrir test.html, vérifier les 8 tests (4+4) PASSENT

  • Étape 9 : Commit

git add js/bgRemover.js test.html
git commit -m "feat: implémenter chebyshevDistance et createFloodFillMask"

Tâche 4 : bgRemover — application du masque + anti-aliasing + pipeline

Fichiers :

  • Modifier : js/bgRemover.js

  • Modifier : test.html

  • Étape 1 : Ajouter tests applyMaskToImageData dans test.html

section('bgRemover — applyMaskToImageData');

const imgApply = makeImageData([
  [[200,50,50], [50,50,200]],
  [[50,50,200], [50,50,200]],
]);
const maskApply = new Uint8Array([1, 0, 0, 0]);
applyMaskToImageData(imgApply, maskApply, [255,255,255]);

const d = imgApply.data;
assert('pixel masqué → blanc pur',
  d[0] === 255 && d[1] === 255 && d[2] === 255);
assert('pixel non masqué → inchangé (bleu)',
  d[4] === 50 && d[5] === 50 && d[6] === 200);
  • Étape 2 : Ouvrir test.html, vérifier 2 tests ÉCHOUENT

  • Étape 3 : Implémenter applyMaskToImageData dans bgRemover.js

/**
 * Remplace les pixels de fond (mask=1) par replacementColor, en place.
 * @param {{ data: Uint8ClampedArray }} imageData
 * @param {Uint8Array} mask
 * @param {number[]} replacementColor — [R, G, B]
 */
export function applyMaskToImageData(imageData, mask, replacementColor) {
  const { data } = imageData;
  const [r, g, b] = replacementColor;
  for (let i = 0; i < mask.length; i++) {
    if (mask[i] === 1) {
      const di = i * 4;
      data[di] = r; data[di+1] = g; data[di+2] = b; data[di+3] = 255;
    }
  }
}
  • Étape 4 : Ouvrir test.html, vérifier les 2 tests applyMask PASSENT

  • Étape 5 : Implémenter softEdges dans bgRemover.js

/**
 * Adoucit la bordure masque/premier-plan en mélangeant sur 1px.
 * @param {{ data: Uint8ClampedArray, width: number, height: number }} imageData
 * @param {Uint8Array} mask
 */
export function softEdges(imageData, mask) {
  const { data, width, height } = imageData;
  const total = width * height;
  const edgePixels = [];

  for (let i = 0; i < total; i++) {
    if (mask[i] !== 1) continue;
    const x = i % width, y = Math.floor(i / width);
    const neighbors = [
      x > 0        ? i - 1      : -1,
      x < width-1  ? i + 1      : -1,
      y > 0        ? i - width  : -1,
      y < height-1 ? i + width  : -1,
    ];
    if (neighbors.some(n => n >= 0 && mask[n] === 0)) edgePixels.push(i);
  }

  for (const i of edgePixels) {
    const x = i % width, y = Math.floor(i / width);
    const fgNeighbors = [
      x > 0        ? i - 1      : -1,
      x < width-1  ? i + 1      : -1,
      y > 0        ? i - width  : -1,
      y < height-1 ? i + width  : -1,
    ].filter(n => n >= 0 && mask[n] === 0);

    if (fgNeighbors.length === 0) continue;
    let sR = 0, sG = 0, sB = 0;
    for (const n of fgNeighbors) {
      sR += data[n*4]; sG += data[n*4+1]; sB += data[n*4+2];
    }
    const di = i * 4, c = fgNeighbors.length;
    data[di]   = Math.round((data[di]   + sR / c) / 2);
    data[di+1] = Math.round((data[di+1] + sG / c) / 2);
    data[di+2] = Math.round((data[di+2] + sB / c) / 2);
  }
}
  • Étape 6 : Implémenter removeBackground dans bgRemover.js
/**
 * Supprime le fond d'un canvas en place.
 * @param {HTMLCanvasElement} canvas
 * @param {number[]|null} seedColor — [R,G,B]; si null, utilise le coin haut-gauche
 * @param {number} tolerance
 */
export function removeBackground(canvas, seedColor = null, tolerance = 30) {
  const ctx = canvas.getContext('2d');
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const seed = seedColor ?? [imageData.data[0], imageData.data[1], imageData.data[2]];

  const mask = createFloodFillMask(imageData, seed, tolerance);

  const maskedCount = mask.reduce((a, b) => a + b, 0);
  if (maskedCount === 0) {
    throw new Error(
      'Aucun fond détecté. Augmentez la tolérance ou utilisez la pipette.'
    );
  }

  applyMaskToImageData(imageData, mask, [255, 255, 255]);
  softEdges(imageData, mask);
  ctx.putImageData(imageData, 0, 0);
}
  • Étape 7 : Implémenter pickColorFromCanvas dans bgRemover.js
/**
 * Laisse l'utilisateur choisir une couleur.
 * Chrome/Edge : EyeDropper API (plein écran).
 * Safari/Firefox : clic sur le canvas fourni.
 * @param {HTMLCanvasElement|null} canvas — requis uniquement en fallback Safari
 * @returns {Promise<number[]>} [R, G, B]
 */
export async function pickColorFromCanvas(canvas) {
  if (window.EyeDropper) {
    const dropper = new window.EyeDropper();
    const result = await dropper.open(); // résultat: { sRGBHex: "#rrggbb" }
    const hex = result.sRGBHex.replace('#', '');
    return [
      parseInt(hex.slice(0, 2), 16),
      parseInt(hex.slice(2, 4), 16),
      parseInt(hex.slice(4, 6), 16),
    ];
  }

  if (!canvas) throw new Error('Canvas requis pour la pipette sur ce navigateur');

  return new Promise((resolve) => {
    canvas.classList.add('picking-color');
    function onClick(e) {
      canvas.removeEventListener('click', onClick);
      canvas.classList.remove('picking-color');
      const rect = canvas.getBoundingClientRect();
      const x = Math.floor((e.clientX - rect.left) * (canvas.width  / rect.width));
      const y = Math.floor((e.clientY - rect.top)  * (canvas.height / rect.height));
      const px = canvas.getContext('2d').getImageData(x, y, 1, 1).data;
      resolve([px[0], px[1], px[2]]);
    }
    canvas.addEventListener('click', onClick);
  });
}
  • Étape 8 : Ouvrir test.html, vérifier tous les tests précédents PASSENT encore (pas de régression)

Attendu : 10 tests .

  • Étape 9 : Commit
git add js/bgRemover.js test.html
git commit -m "feat: implémenter applyMask, softEdges, removeBackground, pickColorFromCanvas"

Tâche 5 : imageProcessor — orchestration du pipeline

Fichiers :

  • Modifier : js/imageProcessor.js

  • Modifier : test.html

  • Étape 1 : Ajouter les tests processBatch dans test.html

import { processBatch } from './js/imageProcessor.js';

section('imageProcessor — processBatch');

// Test : traite tous les éléments
const resultsA = [];
await processBatch([1,2,3,4,5], async (item) => { resultsA.push(item * 2); }, 3);
assert('processBatch traite les 5 éléments',
  resultsA.length === 5);
assert('processBatch retourne les bons résultats',
  [2,4,6,8,10].every(v => resultsA.includes(v)));

// Test : respecte la concurrence max
let concurrent = 0, maxConcurrent = 0;
await processBatch([1,2,3,4,5,6], async () => {
  concurrent++;
  maxConcurrent = Math.max(maxConcurrent, concurrent);
  await new Promise(r => setTimeout(r, 10));
  concurrent--;
}, 3);
assert('processBatch ne dépasse jamais 3 en simultané',
  maxConcurrent <= 3);
  • Étape 2 : Ouvrir test.html, vérifier 3 tests processBatch ÉCHOUENT

  • Étape 3 : Implémenter processBatch dans imageProcessor.js

/**
 * Traite un tableau d'items avec au plus `concurrency` appels simultanés.
 * @param {any[]} items
 * @param {(item: any) => Promise<void>} processFn
 * @param {number} concurrency
 * @returns {Promise<void>}
 */
export async function processBatch(items, processFn, concurrency = 3) {
  const queue = [...items];
  const workers = Array.from({ length: concurrency }, async () => {
    while (queue.length > 0) {
      const item = queue.shift();
      if (item !== undefined) await processFn(item);
    }
  });
  await Promise.all(workers);
}
  • Étape 4 : Ouvrir test.html, vérifier les 3 tests PASSENT

  • Étape 5 : Commit

git add js/imageProcessor.js test.html
git commit -m "feat: implémenter processBatch avec concurrence configurable"

Tâche 6 : ZIP exporter

Fichiers :

  • Modifier : js/zipExporter.js

  • Modifier : test.html

  • Étape 1 : Ajouter le test downloadAllAsZip dans test.html

import { downloadAllAsZip } from './js/zipExporter.js';

section('zipExporter — downloadAllAsZip');

// Neutraliser le téléchargement réel pendant le test
let capturedHref = null;
const origAppend = document.body.appendChild.bind(document.body);
const origRemove = document.body.removeChild.bind(document.body);
document.body.appendChild = (el) => {
  if (el.tagName === 'A') { capturedHref = el.href; return el; }
  return origAppend(el);
};
document.body.removeChild = (el) => { try { origRemove(el); } catch {} };

const fakeFiles = [
  { name: '12345.jpg', blob: new Blob(['data1'], { type: 'image/jpeg' }) },
  { name: '67890.jpg', blob: new Blob(['data2'], { type: 'image/jpeg' }) },
];
await downloadAllAsZip(fakeFiles);

document.body.appendChild = origAppend;
document.body.removeChild = origRemove;

assert('downloadAllAsZip génère une URL blob:',
  capturedHref !== null && capturedHref.startsWith('blob:'));
  • Étape 2 : Ouvrir test.html, vérifier le test ÉCHOUE

  • Étape 3 : Implémenter downloadAllAsZip dans zipExporter.js

/**
 * Compresse les fichiers en ZIP et déclenche le téléchargement.
 * @param {{ name: string, blob: Blob }[]} processedFiles
 * @returns {Promise<void>}
 */
export async function downloadAllAsZip(processedFiles) {
  const zip = new JSZip();
  for (const { name, blob } of processedFiles) {
    zip.file(name, blob);
  }
  const zipBlob = await zip.generateAsync({ type: 'blob' });
  const url = URL.createObjectURL(zipBlob);

  const a = document.createElement('a');
  a.href = url;
  a.download = `photos-produits-${new Date().toISOString().slice(0, 10)}.zip`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);

  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
  • Étape 4 : Ouvrir test.html, vérifier le test PASSE

Attendu : 14 tests au total.

  • Étape 5 : Commit
git add js/zipExporter.js test.html
git commit -m "feat: implémenter l'exporteur ZIP"

Tâche 7 : UI — Zone d'upload

Fichiers :

  • Modifier : index.html

  • Étape 1 : Remplacer le placeholder de la zone d'upload dans index.html

Remplacer :

<!-- Zone 1: Upload -->
<div id="upload-zone" class="mb-8">
  <p class="text-gray-500 text-center">Zone d'upload (à construire)</p>
</div>

Par :

<!-- Zone 1: Upload -->
<div class="mb-8">
  <div class="flex items-center gap-4 mb-4">
    <label class="text-sm font-medium text-gray-700 whitespace-nowrap">
      Tolérance globale :
    </label>
    <input type="range" min="10" max="80"
      x-model.number="globalTolerance"
      class="w-48" />
    <span class="text-sm text-gray-600 w-8" x-text="globalTolerance"></span>
    <span class="text-xs text-gray-400">(10 = strict · 80 = permissif)</span>
  </div>

  <div
    class="border-2 border-dashed border-gray-300 rounded-xl p-12 text-center cursor-pointer hover:border-blue-400 transition-colors"
    @dragover.prevent="$el.classList.add('drop-active')"
    @dragleave="$el.classList.remove('drop-active')"
    @drop.prevent="$el.classList.remove('drop-active'); handleFiles($event.dataTransfer.files)"
    @click="$refs.fileInput.click()"
  >
    <svg class="mx-auto w-12 h-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
        d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
    </svg>
    <p class="text-gray-600 font-medium">Glissez vos fichiers HEIC ici</p>
    <p class="text-gray-400 text-sm mt-1">ou cliquez pour parcourir</p>
    <input type="file" x-ref="fileInput"
      accept=".heic,.heif,image/heic,image/heif"
      multiple class="hidden"
      @change="handleFiles($event.target.files)" />
  </div>
</div>
  • Étape 2 : Implémenter handleFiles dans le script Alpine.js de index.html

Remplacer le stub handleFiles par :

async handleFiles(fileList) {
  const validExts = ['.heic', '.heif'];
  const allFiles  = Array.from(fileList);
  const skipped   = allFiles.filter(f =>
    !validExts.some(ext => f.name.toLowerCase().endsWith(ext))
  );

  if (skipped.length > 0) {
    alert(
      `${skipped.length} fichier(s) ignoré(s) — seuls .heic et .heif sont acceptés.\n` +
      `Ignorés : ${skipped.map(f => f.name).join(', ')}`
    );
  }

  const heicFiles = allFiles.filter(f =>
    validExts.some(ext => f.name.toLowerCase().endsWith(ext))
  );
  if (heicFiles.length === 0) return;

  const newFiles = heicFiles.map(file => ({
    id:            crypto.randomUUID(),
    name:          file.name.replace(/\.[^.]+$/, ''), // supprime l'extension
    status:        'pending',
    originalFile:  file,
    originalBlob:  null,
    processedBlob: null,
    originalUrl:   null,
    processedUrl:  null,
    tolerance:     this.globalTolerance,
    _seedColor:    null, // défini par la pipette
    error:         null,
    pickingColor:  false,
  }));

  this.files.push(...newFiles);

  await processBatch(newFiles, async (fileObj) => {
    // Étape 1 : HEIC → JPEG
    fileObj.status = 'converting';
    try {
      fileObj.originalBlob = await convertHeicToJpeg(fileObj.originalFile);
      fileObj.originalUrl  = URL.createObjectURL(fileObj.originalBlob);
    } catch (e) {
      fileObj.status = 'error';
      fileObj.error  = `Conversion échouée : ${e.message}`;
      return;
    }

    // Étape 2 : suppression du fond
    fileObj.status = 'processing';
    try {
      fileObj.processedBlob = await processImage(
        fileObj.originalBlob, fileObj._seedColor, fileObj.tolerance
      );
      fileObj.processedUrl = URL.createObjectURL(fileObj.processedBlob);
      fileObj.status = 'done';
    } catch (e) {
      fileObj.status = 'error';
      fileObj.error  = `Détourage échoué : ${e.message}`;
    }
  }, 3);
},
  • Étape 3 : Ouvrir index.html dans Chrome, vérifier que la zone d'upload s'affiche correctement

Attendu : zone de dépôt visible avec icône et texte, curseur de tolérance fonctionnel, aucune erreur console.

  • Étape 4 : Commit
git add index.html
git commit -m "feat: zone d'upload avec drag & drop et tolérance globale"

Tâche 8 : UI — Grille d'images

Fichiers :

  • Modifier : index.html

  • Étape 1 : Remplacer le placeholder de la grille dans index.html

Remplacer :

<!-- Zone 2: Grille -->
<div id="grid-zone" class="mb-8">
  <p class="text-gray-500 text-center">Grille d'images (à construire)</p>
</div>

Par :

<!-- Zone 2: Grille -->
<div class="mb-8">
  <template x-if="files.length > 0">
    <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
      <template x-for="file in files" :key="file.id">
        <div class="image-card bg-white rounded-xl border border-gray-200 overflow-hidden">

          <!-- En-tête de carte -->
          <div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
            <span class="text-sm font-medium text-gray-700 truncate" x-text="file.name"></span>
            <span class="text-xs px-2 py-1 rounded-full ml-2 shrink-0"
              :class="{
                'bg-yellow-100 text-yellow-700': ['pending','converting','processing'].includes(file.status),
                'bg-green-100  text-green-700':  file.status === 'done',
                'bg-red-100    text-red-700':    file.status === 'error',
              }"
              x-text="{
                pending:    '⏳ En attente',
                converting: '⏳ Conversion…',
                processing: '⏳ Détourage…',
                done:       '✅ OK',
                error:      '❌ Erreur',
              }[file.status]">
            </span>
          </div>

          <!-- Message d'erreur -->
          <template x-if="file.status === 'error'">
            <div class="p-4 text-sm text-red-600" x-text="file.error"></div>
          </template>

          <!-- Avant / Après -->
          <template x-if="file.status !== 'error' || file.originalUrl">
            <div class="p-3 grid grid-cols-2 gap-2">
              <div>
                <p class="text-xs text-gray-400 text-center mb-1">Avant</p>
                <div class="bg-gray-100 rounded overflow-hidden aspect-square flex items-center justify-center">
                  <template x-if="file.originalUrl">
                    <img :src="file.originalUrl" class="object-contain w-full h-full" />
                  </template>
                  <template x-if="!file.originalUrl">
                    <span class="text-gray-300 text-xs"></span>
                  </template>
                </div>
              </div>
              <div>
                <p class="text-xs text-gray-400 text-center mb-1">Après</p>
                <div class="bg-gray-100 rounded overflow-hidden aspect-square flex items-center justify-center">
                  <template x-if="file.processedUrl">
                    <img :src="file.processedUrl" class="object-contain w-full h-full" />
                  </template>
                  <template x-if="!file.processedUrl">
                    <span class="text-gray-300 text-xs"></span>
                  </template>
                </div>
              </div>
            </div>
          </template>

          <!-- Contrôles (disponibles une fois le traitement terminé ou en erreur) -->
          <template x-if="file.status === 'done' || file.status === 'error'">
            <div class="px-3 pb-3 space-y-2">
              <div class="flex items-center gap-2 text-xs text-gray-600">
                <span class="whitespace-nowrap">Tolérance :</span>
                <input type="range" min="10" max="80"
                  x-model.number="file.tolerance" class="flex-1" />
                <span class="w-6 text-right" x-text="file.tolerance"></span>
              </div>
              <div class="flex gap-2">
                <button @click="startColorPick(file)"
                  class="flex-1 text-xs bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-lg py-1.5 px-2 transition-colors"
                  :class="{ 'ring-2 ring-purple-400': file.pickingColor }">
                  🎨 Pipette
                </button>
                <button @click="reprocess(file)"
                  class="flex-1 text-xs bg-blue-50 hover:bg-blue-100 text-blue-700 border border-blue-200 rounded-lg py-1.5 px-2 transition-colors">
                  🔄 Retraiter
                </button>
              </div>
            </div>
          </template>

        </div>
      </template>
    </div>
  </template>
</div>
  • Étape 2 : Implémenter reprocess dans le script Alpine.js de index.html

Remplacer le stub reprocess par :

async reprocess(file) {
  if (!file.originalBlob) return;
  file.status = 'processing';
  file.error  = null;
  if (file.processedUrl) { URL.revokeObjectURL(file.processedUrl); file.processedUrl = null; }

  try {
    file.processedBlob = await processImage(file.originalBlob, file._seedColor, file.tolerance);
    file.processedUrl  = URL.createObjectURL(file.processedBlob);
    file.status = 'done';
  } catch (e) {
    file.status = 'error';
    file.error  = `Détourage échoué : ${e.message}`;
  }
},
  • Étape 3 : Implémenter startColorPick dans le script Alpine.js de index.html

Remplacer le stub startColorPick par :

async startColorPick(file) {
  if (!file.originalUrl) return;
  file.pickingColor = true;

  try {
    let seedColor;
    if (window.EyeDropper) {
      // Chrome/Edge : EyeDropper plein écran (pas besoin de canvas)
      seedColor = await pickColorFromCanvas(null);
    } else {
      // Safari/Firefox : clic sur un canvas temporaire de l'image originale
      const img = new Image();
      await new Promise((res, rej) => { img.onload = res; img.onerror = rej; img.src = file.originalUrl; });
      const tempCanvas = document.createElement('canvas');
      tempCanvas.width  = img.width;
      tempCanvas.height = img.height;
      tempCanvas.getContext('2d').drawImage(img, 0, 0);
      tempCanvas.style.cssText =
        'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);' +
        'max-width:80vw;max-height:80vh;z-index:9999;border:3px solid #7c3aed;';
      const overlay = document.createElement('div');
      overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:9998;';
      const hint = document.createElement('p');
      hint.style.cssText =
        'position:fixed;bottom:10%;left:50%;transform:translateX(-50%);' +
        'color:white;background:#7c3aed;padding:8px 16px;border-radius:8px;z-index:10000;';
      hint.textContent = 'Cliquez sur la couleur de fond à supprimer';
      document.body.append(overlay, tempCanvas, hint);

      seedColor = await pickColorFromCanvas(tempCanvas);
      overlay.remove(); tempCanvas.remove(); hint.remove();
    }

    file._seedColor = seedColor;
    await this.reprocess(file);
  } catch {
    // L'utilisateur a annulé la pipette — pas d'action
  } finally {
    file.pickingColor = false;
  }
},
  • Étape 4 : Tester dans Chrome avec 2-3 fichiers HEIC

Vérifier : cartes apparaissent, miniatures avant/après s'affichent, statuts se mettent à jour.

  • Étape 5 : Commit
git add index.html
git commit -m "feat: grille d'images avec avant/après, pipette et retraitement"

Tâche 9 : UI — Zone d'export

Fichiers :

  • Modifier : index.html

  • Étape 1 : Remplacer le placeholder de la zone d'export dans index.html

Remplacer :

<!-- Zone 3: Export -->
<div id="export-zone">
  <p class="text-gray-500 text-center">Zone d'export (à construire)</p>
</div>

Par :

<!-- Zone 3: Export -->
<template x-if="files.length > 0">
  <div class="sticky bottom-4 flex justify-center mt-4">
    <div class="bg-white border border-gray-200 rounded-2xl shadow-lg px-6 py-4 flex items-center gap-6">
      <span class="text-sm text-gray-600">
        <span class="font-semibold text-gray-800" x-text="readyCount"></span>
        /
        <span x-text="files.length"></span>
        images prêtes
      </span>
      <button @click="downloadAll()"
        :disabled="readyCount === 0"
        class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-medium px-5 py-2 rounded-xl transition-colors text-sm">
        ⬇️ Télécharger tout en ZIP
      </button>
    </div>
  </div>
</template>
  • Étape 2 : Implémenter downloadAll dans le script Alpine.js de index.html

Remplacer le stub downloadAll par :

async downloadAll() {
  const readyFiles = this.files
    .filter(f => f.status === 'done' && f.processedBlob)
    .map(f => ({ name: `${f.name}.jpg`, blob: f.processedBlob }));
  if (readyFiles.length === 0) return;
  await downloadAllAsZip(readyFiles);
},
  • Étape 3 : Test bout-en-bout dans Chrome
  1. Déposer 3 fichiers HEIC
  2. Attendre la fin du traitement
  3. Vérifier les miniatures avant/après
  4. Cliquer "Télécharger tout en ZIP"
  5. Ouvrir le ZIP → vérifier que les fichiers s'appellent [numéro-produit].jpg
  • Étape 4 : Commit
git add index.html
git commit -m "feat: zone d'export avec compteur et téléchargement ZIP"

Tâche 10 : Checklist de tests navigateur final

Fichiers : Aucune modification de code — validation uniquement.

  • Test 1 : Fichiers HEIC réels depuis un iPhone

Charger 3 photos produits HEIC. Attendu : conversion réussie, miniatures visibles, avant/après affichés.

  • Test 2 : Lot de 20+ fichiers

Attendu : traitement 3 par 3 (observer les statuts ""), pas de gel du navigateur.

  • Test 3 : Pipette sur un pot de peinture (Chrome)
  1. Cliquer 🎨 sur une image où le détourage auto est insuffisant
  2. Utiliser l'EyeDropper sur la zone de fond dans la page
  3. Attendu : retraitement avec la nouvelle couleur, résultat amélioré
  • Test 4 : Curseur de tolérance individuel

Régler tolérance de 10 à 60, cliquer 🔄 Retraiter. Attendu : le résultat change visiblement.

  • Test 5 : Téléchargement ZIP

Cliquer "Télécharger tout en ZIP". Attendu : ZIP nommé photos-produits-AAAA-MM-JJ.zip, contenu = [numéro-produit].jpg.

  • Test 6 : Fichier non-HEIC rejeté

Glisser un fichier .jpg ou .png. Attendu : alerte listant le(s) fichier(s) ignoré(s), les HEIC présents sont quand même traités.

  • Test 7 : Fallback Safari

Ouvrir index.html dans Safari. Attendu : app fonctionne, pipette = clic sur canvas (overlay violet visible), résultat correct.

  • Test 8 : Vérification du nommage

Source : 12345.heic → ZIP contient : 12345.jpg

  • Commit final
git add .
git commit -m "chore: tous les tests navigateur passent — app prête"