(function (root, factory) {
  const api = factory();
  if (typeof module === "object" && module.exports) module.exports = api;
  root.JianglaiVectorEngine = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
  "use strict";

  const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value)));
  const round = value => Math.round(Number(value) * 100) / 100;
  const pointKey = (x, y) => `${x},${y}`;
  const colorHex = color => `#${color.map(value => Math.round(clamp(value, 0, 255)).toString(16).padStart(2, "0")).join("")}`;
  const colorDistance2 = (a, b) => {
    const dr = a[0] - b[0];
    const dg = a[1] - b[1];
    const db = a[2] - b[2];
    return dr * dr + dg * dg + db * db;
  };
  const escapeXml = value => String(value || "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
  const MAX_EDIT_COORDINATE = 10000000;

  function assertFiniteCoordinate(value, label = "坐标") {
    if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > MAX_EDIT_COORDINATE) {
      throw new TypeError(`${label}必须是有限数字`);
    }
    return round(value);
  }

  function contourBounds(points) {
    if (!points.length) return { x: 0, y: 0, width: 0, height: 0 };
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;
    for (const point of points) {
      const x = assertFiniteCoordinate(point.x, "节点 x 坐标");
      const y = assertFiniteCoordinate(point.y, "节点 y 坐标");
      minX = Math.min(minX, x);
      minY = Math.min(minY, y);
      maxX = Math.max(maxX, x);
      maxY = Math.max(maxY, y);
    }
    return { x: round(minX), y: round(minY), width: round(maxX - minX), height: round(maxY - minY) };
  }

  function combineBounds(bounds) {
    if (!bounds.length) return { x: 0, y: 0, width: 0, height: 0 };
    const minX = Math.min(...bounds.map(box => box.x));
    const minY = Math.min(...bounds.map(box => box.y));
    const maxX = Math.max(...bounds.map(box => box.x + box.width));
    const maxY = Math.max(...bounds.map(box => box.y + box.height));
    return { x: round(minX), y: round(minY), width: round(maxX - minX), height: round(maxY - minY) };
  }

  function normalizeOptions(options = {}) {
    const mode = ["logo", "line", "packaging", "illustration", "photo"].includes(options.mode) ? options.mode : "logo";
    const presets = {
      logo: { colors: 6, simplify: 1.05, smoothing: 0.58, speckle: 4 },
      line: { colors: 2, simplify: 0.9, smoothing: 0.48, speckle: 2 },
      // 包装稿既可能是黑卡白字，也可能包含完整插画。默认保留足够的印刷
      // 色阶；真正的黑白稿会由语义检测自动收敛为黑、白两版。
      packaging: { colors: 12, simplify: 0.8, smoothing: 0.7, speckle: 2 },
      illustration: { colors: 10, simplify: 0.8, smoothing: 0.78, speckle: 3 },
      photo: { colors: 12, simplify: 0.55, smoothing: 0.82, speckle: 2 }
    };
    const preset = presets[mode];
    return {
      mode,
      // Complex packaging sheets often mix spot-color line work, ordinary
      // copy, illustrations and photographic panels.  Twenty colors is enough
      // for a flat logo but forces rich artwork into visible bands.  Keep the
      // default compact, while allowing the professional profile to retain up
      // to 32 production color groups.
      colors: Math.round(clamp(options.colors ?? preset.colors, 2, 32)),
      simplify: clamp(options.simplify ?? preset.simplify, 0, 4),
      smoothing: clamp(options.smoothing ?? preset.smoothing, 0, 1),
      speckle: Math.round(clamp(options.speckle ?? preset.speckle, 0, 1000)),
      removeBackground: options.removeBackground !== false,
      backgroundTolerance: clamp(options.backgroundTolerance ?? 30, 0, 120),
      // 包装黑底上的白字来自抗锯齿位图。阈值过低会把灰色边缘全部并入
      // 白版，字重会明显变粗；保留为独立参数，便于用客户原稿标定。
      packagingLightThreshold: Math.round(clamp(options.packagingLightThreshold ?? 128, 32, 248)),
      alphaThreshold: Math.round(clamp(options.alphaThreshold ?? 10, 0, 254)),
      maxEdges: Math.round(clamp(options.maxEdges ?? 100000, 1000, 1500000)),
      maxPaths: Math.round(clamp(options.maxPaths ?? 12000, 100, 80000)),
      maxNodes: Math.round(clamp(options.maxNodes ?? 160000, 1000, 900000)),
      title: String(options.title || "将莱矢量作品").slice(0, 160)
    };
  }

  function complexityError(detail) {
    const error = new Error(detail || "图片细节过密，已停止生成以保护软件稳定性");
    error.code = "VECTOR_COMPLEXITY_LIMIT";
    return error;
  }

  function estimateBackground(data, width, height, alphaThreshold) {
    const samples = [];
    const push = (x, y) => {
      const index = (y * width + x) * 4;
      if (data[index + 3] > alphaThreshold) samples.push([data[index], data[index + 1], data[index + 2]]);
    };
    const edgeStep = Math.max(1, Math.floor(Math.min(width, height) / 24));
    for (let x = 0; x < width; x += edgeStep) { push(x, 0); push(x, height - 1); }
    for (let y = edgeStep; y < height - 1; y += edgeStep) { push(0, y); push(width - 1, y); }
    if (!samples.length) return [255, 255, 255];
    const buckets = new Map();
    for (const color of samples) {
      const key = color.map(value => Math.round(value / 16)).join(",");
      const current = buckets.get(key) || { count: 0, sum: [0, 0, 0] };
      current.count += 1;
      current.sum[0] += color[0]; current.sum[1] += color[1]; current.sum[2] += color[2];
      buckets.set(key, current);
    }
    const best = [...buckets.values()].sort((a, b) => b.count - a.count)[0];
    return best.sum.map(value => value / best.count);
  }

  function connectedBackgroundMask(data, width, height, options, background) {
    const total = width * height;
    const mask = new Uint8Array(total);
    if (!options.removeBackground) return mask;
    const queued = new Uint8Array(total);
    const queue = new Int32Array(total);
    const tolerance2 = options.backgroundTolerance * options.backgroundTolerance * 3;
    let head = 0;
    let tail = 0;
    const matchesBackground = pixel => {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold) return true;
      return colorDistance2([data[index], data[index + 1], data[index + 2]], background) <= tolerance2;
    };
    const enqueue = pixel => {
      if (pixel < 0 || pixel >= total || queued[pixel] || !matchesBackground(pixel)) return;
      queued[pixel] = 1;
      queue[tail++] = pixel;
    };
    for (let x = 0; x < width; x += 1) {
      enqueue(x);
      enqueue((height - 1) * width + x);
    }
    for (let y = 1; y < height - 1; y += 1) {
      enqueue(y * width);
      enqueue(y * width + width - 1);
    }
    while (head < tail) {
      const pixel = queue[head++];
      mask[pixel] = 1;
      const x = pixel % width;
      if (x > 0) enqueue(pixel - 1);
      if (x + 1 < width) enqueue(pixel + 1);
      if (pixel >= width) enqueue(pixel - width);
      if (pixel + width < total) enqueue(pixel + width);
    }
    return mask;
  }

  function collectSamples(data, width, height, options, backgroundMask, excludedMask = null) {
    const total = width * height;
    const stride = Math.max(1, Math.floor(total / 18000));
    const samples = [];
    for (let pixel = 0; pixel < total; pixel += stride) {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold) continue;
      if (options.removeBackground && backgroundMask[pixel]) continue;
      if (excludedMask?.[pixel]) continue;
      samples.push([data[index], data[index + 1], data[index + 2]]);
    }
    return samples;
  }

  function packagingSemanticPalette(samples) {
    if (!Array.isArray(samples) || samples.length < 20) return null;
    let neutral = 0;
    let dark = 0;
    let light = 0;
    for (const color of samples) {
      const maximum = Math.max(...color);
      const minimum = Math.min(...color);
      if (maximum - minimum <= 34) neutral += 1;
      const luminance = color[0] * .299 + color[1] * .587 + color[2] * .114;
      if (luminance < 92) dark += 1;
      if (luminance > 170) light += 1;
    }
    // 黑卡、白字和黄色刀模是包装行业最常见的二值结构。抗锯齿灰边不应
    // 被量化成第三个印刷色，否则会在制版文件里产生不存在的灰版。
    if (neutral / samples.length >= .9 && dark >= samples.length * .08 && light >= samples.length * .002) {
      return [[0, 0, 0], [255, 255, 255]];
    }
    return null;
  }

  function colorHue(color) {
    const r = color[0] / 255;
    const g = color[1] / 255;
    const b = color[2] / 255;
    const maximum = Math.max(r, g, b);
    const minimum = Math.min(r, g, b);
    const delta = maximum - minimum;
    if (delta <= .0001) return 0;
    let hue;
    if (maximum === r) hue = ((g - b) / delta) % 6;
    else if (maximum === g) hue = (b - r) / delta + 2;
    else hue = (r - g) / delta + 4;
    return ((hue * 60) + 360) % 360;
  }

  function analyzeArtworkPalette(samples) {
    const hueCounts = new Uint32Array(12);
    const coarse = new Map();
    let chromatic = 0;
    for (const color of samples || []) {
      const maximum = Math.max(...color);
      const minimum = Math.min(...color);
      const delta = maximum - minimum;
      const saturation = maximum ? delta / maximum : 0;
      if (delta >= 34 && saturation >= .18) {
        chromatic += 1;
        hueCounts[Math.min(11, Math.floor(colorHue(color) / 30))] += 1;
      }
      const key = color.map(value => Math.round(value / 32)).join(",");
      coarse.set(key, (coarse.get(key) || 0) + 1);
    }
    const total = Math.max(1, samples?.length || 0);
    const hueFloor = Math.max(2, Math.floor(total * .002));
    const bucketFloor = Math.max(2, Math.floor(total * .0015));
    const activeHues = [...hueCounts].filter(count => count >= hueFloor).length;
    const activeBuckets = [...coarse.values()].filter(count => count >= bucketFloor).length;
    const chromaticRatio = chromatic / total;
    const multicolor = chromaticRatio >= .08 && (activeHues >= 3 || activeBuckets >= 6);
    const suggestedColors = multicolor
      ? Math.round(clamp(Math.max(8, activeHues + 4, Math.sqrt(activeBuckets) * 2.8), 8, 24))
      : 2;
    return { multicolor, chromaticRatio: round(chromaticRatio), activeHues, activeBuckets, suggestedColors };
  }

  function detectDarkPackagingPanel(data, width, height, backgroundMask, excludedMask, alphaThreshold) {
    const rowCounts = new Uint32Array(height);
    const isDark = (x, y) => {
      const pixel = y * width + x;
      if (backgroundMask[pixel] || excludedMask?.[pixel]) return false;
      const index = pixel * 4;
      if (data[index + 3] <= alphaThreshold) return false;
      return data[index] * .299 + data[index + 1] * .587 + data[index + 2] * .114 < 72;
    };
    for (let y = 0; y < height; y += 1) {
      let count = 0;
      for (let x = 0; x < width; x += 1) if (isDark(x, y)) count += 1;
      rowCounts[y] = count;
    }
    const rows = [];
    for (let y = 0; y < height; y += 1) if (rowCounts[y] >= width * .3) rows.push(y);
    if (rows.length < height * .12) return null;
    const top = rows[0];
    const bottom = rows[rows.length - 1] + 1;
    const requiredColumnHits = Math.max(2, (bottom - top) * .42);
    const columns = [];
    for (let x = 0; x < width; x += 1) {
      let hits = 0;
      for (let y = top; y < bottom; y += 1) if (isDark(x, y)) hits += 1;
      if (hits >= requiredColumnHits) columns.push(x);
    }
    if (columns.length < width * .12) return null;
    return { x: columns[0], y: top, width: columns[columns.length - 1] - columns[0] + 1, height: bottom - top };
  }

  function assignNeutralPackagingPixels(data, width, height, options, backgroundMask, excludedMask) {
    const assignments = new Int16Array(width * height);
    assignments.fill(-1);
    const counts = new Uint32Array(2);
    const panel = detectDarkPackagingPanel(data, width, height, backgroundMask, excludedMask, options.alphaThreshold);
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold || backgroundMask[pixel] || excludedMask?.[pixel]) continue;
      const x = pixel % width;
      const y = Math.floor(pixel / width);
      const luminance = data[index] * .299 + data[index + 1] * .587 + data[index + 2] * .114;
      const insidePanel = panel && x >= panel.x && x < panel.x + panel.width && y >= panel.y && y < panel.y + panel.height;
      // 深色版面中的浅色小字要把抗锯齿边一并归入白版，否则十几个像素高的
      // 字会断笔；白色页面上的黑字则采用相反阈值。两者都只生成黑白两版。
      const colorIndex = insidePanel ? (luminance >= options.packagingLightThreshold ? 1 : 0) : (luminance < 218 ? 0 : 1);
      assignments[pixel] = colorIndex;
      counts[colorIndex] += 1;
    }
    return { assignments, counts, panel };
  }

  function dilateBinaryMask(mask, width, height, radius = 2) {
    if (!mask || radius <= 0) return mask;
    const expanded = new Uint8Array(mask.length);
    for (let pixel = 0; pixel < mask.length; pixel += 1) {
      if (!mask[pixel]) continue;
      const x = pixel % width;
      const y = Math.floor(pixel / width);
      for (let dy = -radius; dy <= radius; dy += 1) {
        const yy = y + dy;
        if (yy < 0 || yy >= height) continue;
        for (let dx = -radius; dx <= radius; dx += 1) {
          const xx = x + dx;
          if (xx >= 0 && xx < width) expanded[yy * width + xx] = 1;
        }
      }
    }
    return expanded;
  }

  function seedCentroids(samples, count) {
    if (!samples.length) return [[0, 0, 0]];
    const histogram = new Map();
    for (const color of samples) {
      const key = color.map(value => Math.floor(value / 24)).join(",");
      const bucket = histogram.get(key) || { count: 0, sum: [0, 0, 0] };
      bucket.count += 1;
      bucket.sum[0] += color[0]; bucket.sum[1] += color[1]; bucket.sum[2] += color[2];
      histogram.set(key, bucket);
    }
    const buckets = [...histogram.values()].sort((a, b) => b.count - a.count);
    const first = buckets[0];
    const centroids = [first.sum.map(value => value / first.count)];
    while (centroids.length < count && centroids.length < buckets.length) {
      let best = null;
      let bestScore = -1;
      for (const bucket of buckets) {
        const color = bucket.sum.map(value => value / bucket.count);
        const distance = Math.min(...centroids.map(centroid => colorDistance2(color, centroid)));
        const score = distance * Math.sqrt(bucket.count);
        if (score > bestScore) { best = color; bestScore = score; }
      }
      if (!best || bestScore < 4) break;
      centroids.push(best);
    }
    return centroids;
  }

  function quantize(samples, requestedCount) {
    let centroids = seedCentroids(samples, requestedCount);
    for (let iteration = 0; iteration < 7; iteration += 1) {
      const sums = centroids.map(() => [0, 0, 0, 0]);
      for (const color of samples) {
        let bestIndex = 0;
        let bestDistance = Infinity;
        for (let index = 0; index < centroids.length; index += 1) {
          const distance = colorDistance2(color, centroids[index]);
          if (distance < bestDistance) { bestDistance = distance; bestIndex = index; }
        }
        const sum = sums[bestIndex];
        sum[0] += color[0]; sum[1] += color[1]; sum[2] += color[2]; sum[3] += 1;
      }
      let movement = 0;
      centroids = centroids.map((centroid, index) => {
        const sum = sums[index];
        if (!sum[3]) return centroid;
        const next = [sum[0] / sum[3], sum[1] / sum[3], sum[2] / sum[3]];
        movement += colorDistance2(centroid, next);
        return next;
      });
      if (movement < 1) break;
    }
    return centroids;
  }

  function assignPixels(data, width, height, centroids, options, backgroundMask) {
    const total = width * height;
    const assignments = new Int16Array(total);
    assignments.fill(-1);
    const counts = new Uint32Array(centroids.length);
    for (let pixel = 0; pixel < total; pixel += 1) {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold) continue;
      if (options.removeBackground && backgroundMask[pixel]) continue;
      const color = [data[index], data[index + 1], data[index + 2]];
      let bestIndex = 0;
      let bestDistance = Infinity;
      for (let colorIndex = 0; colorIndex < centroids.length; colorIndex += 1) {
        const distance = colorDistance2(color, centroids[colorIndex]);
        if (distance < bestDistance) { bestDistance = distance; bestIndex = colorIndex; }
      }
      assignments[pixel] = bestIndex;
      counts[bestIndex] += 1;
    }
    return { assignments, counts };
  }

  function cleanupIsolatedAssignments(assigned, width, height) {
    if (!assigned?.assignments?.length || assigned.counts.length < 2) return assigned;
    const source = assigned.assignments;
    const cleaned = new Int16Array(source);
    let changed = 0;
    for (let y = 1; y < height - 1; y += 1) {
      for (let x = 1; x < width - 1; x += 1) {
        const pixel = y * width + x;
        const current = source[pixel];
        if (current < 0) continue;
        const votes = new Uint8Array(assigned.counts.length);
        let same = 0;
        for (let dy = -1; dy <= 1; dy += 1) {
          for (let dx = -1; dx <= 1; dx += 1) {
            if (!dx && !dy) continue;
            const neighbor = source[(y + dy) * width + x + dx];
            if (neighbor < 0) continue;
            votes[neighbor] += 1;
            if (neighbor === current) same += 1;
          }
        }
        if (same > 1) continue;
        let replacement = current;
        let bestVotes = 0;
        for (let index = 0; index < votes.length; index += 1) {
          if (votes[index] > bestVotes) { replacement = index; bestVotes = votes[index]; }
        }
        // 只清理被同一邻色严密包围的孤立像素，不吞掉细线、衬线或小文字。
        if (replacement !== current && bestVotes >= 6) {
          cleaned[pixel] = replacement;
          changed += 1;
        }
      }
    }
    if (!changed) return assigned;
    const counts = new Uint32Array(assigned.counts.length);
    for (const colorIndex of cleaned) if (colorIndex >= 0) counts[colorIndex] += 1;
    return { ...assigned, assignments: cleaned, counts };
  }

  function filterSmallAssignmentComponents(assigned, width, height, minimumArea) {
    const threshold = Math.max(1, Math.round(Number(minimumArea) || 1));
    if (threshold <= 1 || !assigned?.assignments?.length) return assigned;
    const assignments = new Int16Array(assigned.assignments);
    const visited = new Uint8Array(assignments.length);
    const queue = new Int32Array(assignments.length);
    const counts = new Uint32Array(assigned.counts);
    const neighborVotes = new Uint32Array(Math.max(1, counts.length));
    for (let seed = 0; seed < assignments.length; seed += 1) {
      const colorIndex = assignments[seed];
      if (colorIndex < 0 || visited[seed]) continue;
      let head = 0;
      let tail = 0;
      queue[tail++] = seed;
      visited[seed] = 1;
      neighborVotes.fill(0);
      while (head < tail) {
        const pixel = queue[head++];
        const x = pixel % width;
        const visit = neighbor => {
          if (neighbor < 0 || neighbor >= assignments.length) return;
          const neighborColor = assignments[neighbor];
          if (neighborColor === colorIndex) {
            if (!visited[neighbor]) {
              visited[neighbor] = 1;
              queue[tail++] = neighbor;
            }
          } else if (neighborColor >= 0) {
            neighborVotes[neighborColor] += 1;
          }
        };
        if (x > 0) visit(pixel - 1);
        if (x + 1 < width) visit(pixel + 1);
        if (pixel >= width) visit(pixel - width);
        if (pixel + width < assignments.length) visit(pixel + width);
      }
      if (tail >= threshold) continue;
      let replacement = -1;
      let bestVotes = 0;
      for (let index = 0; index < neighborVotes.length; index += 1) {
        if (index !== colorIndex && neighborVotes[index] > bestVotes) {
          replacement = index;
          bestVotes = neighborVotes[index];
        }
      }
      for (let index = 0; index < tail; index += 1) assignments[queue[index]] = replacement;
      counts[colorIndex] = Math.max(0, counts[colorIndex] - tail);
      if (replacement >= 0) counts[replacement] += tail;
    }
    return { ...assigned, assignments, counts };
  }

  function inpaintMaskedAssignments(assigned, mask, width, height, centroids) {
    if (!mask || !centroids.length) return assigned;
    const nextCounts = new Uint32Array(centroids.length);
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      if (!mask[pixel]) continue;
      const x = pixel % width;
      const y = Math.floor(pixel / width);
      const votes = new Uint16Array(centroids.length);
      for (let dy = -3; dy <= 3; dy += 1) {
        const yy = y + dy;
        if (yy < 0 || yy >= height) continue;
        for (let dx = -3; dx <= 3; dx += 1) {
          const xx = x + dx;
          if (xx < 0 || xx >= width || (!dx && !dy)) continue;
          const neighbor = yy * width + xx;
          const colorIndex = assigned.assignments[neighbor];
          if (!mask[neighbor] && colorIndex >= 0) votes[colorIndex] += 1;
        }
      }
      let best = -1;
      let bestVotes = 0;
      for (let colorIndex = 0; colorIndex < votes.length; colorIndex += 1) {
        if (votes[colorIndex] > bestVotes) { best = colorIndex; bestVotes = votes[colorIndex]; }
      }
      if (best < 0) best = centroids.reduce((darkest, color, index) => (
        color[0] + color[1] + color[2] < centroids[darkest][0] + centroids[darkest][1] + centroids[darkest][2] ? index : darkest
      ), 0);
      assigned.assignments[pixel] = best;
    }
    for (let pixel = 0; pixel < assigned.assignments.length; pixel += 1) {
      const colorIndex = assigned.assignments[pixel];
      if (colorIndex >= 0) nextCounts[colorIndex] += 1;
    }
    assigned.counts = nextCounts;
    return assigned;
  }

  function otsuThreshold(histogram, total) {
    if (!total) return 0;
    let weightedTotal = 0;
    for (let value = 0; value < histogram.length; value += 1) weightedTotal += value * histogram[value];
    let backgroundWeight = 0;
    let backgroundSum = 0;
    let bestVariance = -1;
    let bestThreshold = 0;
    for (let value = 0; value < histogram.length; value += 1) {
      backgroundWeight += histogram[value];
      if (!backgroundWeight) continue;
      const foregroundWeight = total - backgroundWeight;
      if (!foregroundWeight) break;
      backgroundSum += value * histogram[value];
      const backgroundMean = backgroundSum / backgroundWeight;
      const foregroundMean = (weightedTotal - backgroundSum) / foregroundWeight;
      const variance = backgroundWeight * foregroundWeight * (backgroundMean - foregroundMean) ** 2;
      if (variance > bestVariance) {
        bestVariance = variance;
        bestThreshold = value;
      }
    }
    return bestThreshold;
  }

  // 文字和线稿不能像照片一样把抗锯齿灰阶拆成多个色层。这里先按与边缘背景
  // 的色差做一次自适应二值化，再只描一层轮廓，避免细字出现重影和毛边。
  function assignMonochromePixels(data, width, height, options, background) {
    const total = width * height;
    const distances = new Uint8Array(total);
    const histogram = new Uint32Array(256);
    let opaque = 0;
    for (let pixel = 0; pixel < total; pixel += 1) {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold) continue;
      const distance = Math.min(255, Math.round(Math.sqrt(colorDistance2(
        [data[index], data[index + 1], data[index + 2]],
        background
      ) / 3)));
      distances[pixel] = distance;
      histogram[distance] += 1;
      opaque += 1;
    }
    const automatic = otsuThreshold(histogram, opaque);
    const threshold = Math.max(4, Math.round(options.backgroundTolerance * 0.55), automatic);
    const assignments = new Int16Array(total);
    assignments.fill(-1);
    const counts = new Uint32Array(1);
    const sum = [0, 0, 0];
    for (let pixel = 0; pixel < total; pixel += 1) {
      const index = pixel * 4;
      if (data[index + 3] <= options.alphaThreshold || distances[pixel] <= threshold) continue;
      assignments[pixel] = 0;
      counts[0] += 1;
      sum[0] += data[index];
      sum[1] += data[index + 1];
      sum[2] += data[index + 2];
    }
    if (!counts[0]) return { assignments, counts, centroids: [] };
    return {
      assignments,
      counts,
      centroids: [[sum[0] / counts[0], sum[1] / counts[0], sum[2] / counts[0]]]
    };
  }

  function edgeDirection(edge) {
    if (edge.ex > edge.sx) return 0;
    if (edge.ey > edge.sy) return 1;
    if (edge.ex < edge.sx) return 2;
    return 3;
  }

  function polygonArea(points) {
    let area = 0;
    for (let index = 0; index < points.length; index += 1) {
      const current = points[index];
      const next = points[(index + 1) % points.length];
      area += current.x * next.y - next.x * current.y;
    }
    return area / 2;
  }

  function perpendicularDistance(point, start, end) {
    const dx = end.x - start.x;
    const dy = end.y - start.y;
    if (!dx && !dy) return Math.hypot(point.x - start.x, point.y - start.y);
    return Math.abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / Math.hypot(dx, dy);
  }

  function rdp(points, epsilon) {
    if (points.length <= 2 || epsilon <= 0) return points.slice();
    let maxDistance = 0;
    let splitIndex = 0;
    for (let index = 1; index < points.length - 1; index += 1) {
      const distance = perpendicularDistance(points[index], points[0], points[points.length - 1]);
      if (distance > maxDistance) { maxDistance = distance; splitIndex = index; }
    }
    if (maxDistance <= epsilon) return [points[0], points[points.length - 1]];
    const left = rdp(points.slice(0, splitIndex + 1), epsilon);
    const right = rdp(points.slice(splitIndex), epsilon);
    return left.slice(0, -1).concat(right);
  }

  function simplifyRing(points, epsilon) {
    if (points.length < 6 || epsilon <= 0) return points.slice();
    let anchor = 0;
    for (let index = 1; index < points.length; index += 1) {
      if (points[index].x < points[anchor].x || (points[index].x === points[anchor].x && points[index].y < points[anchor].y)) anchor = index;
    }
    const ordered = points.slice(anchor).concat(points.slice(0, anchor));
    let farthest = 1;
    let farthestDistance = -1;
    for (let index = 1; index < ordered.length; index += 1) {
      const distance = (ordered[index].x - ordered[0].x) ** 2 + (ordered[index].y - ordered[0].y) ** 2;
      if (distance > farthestDistance) { farthestDistance = distance; farthest = index; }
    }
    const firstArc = rdp(ordered.slice(0, farthest + 1), epsilon);
    const secondArc = rdp(ordered.slice(farthest).concat([ordered[0]]), epsilon);
    const combined = firstArc.slice(0, -1).concat(secondArc.slice(0, -1));
    if (combined.length >= 3) return combined;
    // 很小的文字孔洞在较高简化值下可能只剩两点。旧逻辑会突然退回全部
    // 像素节点，造成节点数反向暴涨；逐级降低阈值可保住轮廓且保持平滑。
    return epsilon > .08 ? simplifyRing(points, epsilon * .62) : points.slice();
  }

  function traceLayer(assignments, width, height, colorIndex, options, budgets) {
    const edges = [];
    const starts = new Map();
    const isColor = (x, y) => x >= 0 && x < width && y >= 0 && y < height && assignments[y * width + x] === colorIndex;
    let requiredEdges = 0;
    for (let y = 0; y < height; y += 1) {
      for (let x = 0; x < width; x += 1) {
        if (!isColor(x, y)) continue;
        if (!isColor(x, y - 1)) requiredEdges += 1;
        if (!isColor(x + 1, y)) requiredEdges += 1;
        if (!isColor(x, y + 1)) requiredEdges += 1;
        if (!isColor(x - 1, y)) requiredEdges += 1;
        if (requiredEdges > budgets.edges) {
          throw complexityError("图片轮廓过于密集，正在降低解析度后重试");
        }
      }
    }
    const addEdge = (sx, sy, ex, ey) => {
      const index = edges.length;
      edges.push({ sx, sy, ex, ey, used: false });
      const key = pointKey(sx, sy);
      if (!starts.has(key)) starts.set(key, []);
      starts.get(key).push(index);
    };
    for (let y = 0; y < height; y += 1) {
      for (let x = 0; x < width; x += 1) {
        if (!isColor(x, y)) continue;
        if (!isColor(x, y - 1)) addEdge(x, y, x + 1, y);
        if (!isColor(x + 1, y)) addEdge(x + 1, y, x + 1, y + 1);
        if (!isColor(x, y + 1)) addEdge(x + 1, y + 1, x, y + 1);
        if (!isColor(x - 1, y)) addEdge(x, y + 1, x, y);
      }
    }
    const paths = [];
    let nodeCount = 0;
    const turnPriority = [1, 0, 3, 2];
    for (let edgeIndex = 0; edgeIndex < edges.length; edgeIndex += 1) {
      if (edges[edgeIndex].used) continue;
      const first = edges[edgeIndex];
      const ring = [{ x: first.sx, y: first.sy }];
      let currentIndex = edgeIndex;
      let guard = 0;
      while (guard < edges.length + 8) {
        guard += 1;
        const current = edges[currentIndex];
        if (current.used) break;
        current.used = true;
        ring.push({ x: current.ex, y: current.ey });
        if (current.ex === first.sx && current.ey === first.sy) break;
        const candidates = (starts.get(pointKey(current.ex, current.ey)) || []).filter(index => !edges[index].used);
        if (!candidates.length) break;
        const direction = edgeDirection(current);
        candidates.sort((a, b) => {
          const turnA = (edgeDirection(edges[a]) - direction + 4) % 4;
          const turnB = (edgeDirection(edges[b]) - direction + 4) % 4;
          return turnPriority.indexOf(turnA) - turnPriority.indexOf(turnB);
        });
        currentIndex = candidates[0];
      }
      if (ring.length > 3 && ring[0].x === ring[ring.length - 1].x && ring[0].y === ring[ring.length - 1].y) ring.pop();
      if (ring.length < 3 || Math.abs(polygonArea(ring)) < Math.max(1, options.speckle)) continue;
      const simplified = simplifyRing(ring, options.simplify);
      if (simplified.length >= 3) {
        paths.push(simplified);
        nodeCount += simplified.length;
        if (paths.length > budgets.paths || nodeCount > budgets.nodes) {
          throw complexityError("图片碎片或节点过多，正在降低解析度后重试");
        }
      }
    }
    return { paths, edges: requiredEdges, nodes: nodeCount };
  }

  function pathData(points, smoothing) {
    if (!points.length) return "";
    const format = point => `${round(point.x)} ${round(point.y)}`;
    if (smoothing < 0.06 || points.length < 4) {
      return `M ${format(points[0])} ${points.slice(1).map(point => `L ${format(point)}`).join(" ")} Z`;
    }
    const factor = smoothing * .3;
    const continuity = (previous, current, next) => {
      const inX = current.x - previous.x;
      const inY = current.y - previous.y;
      const outX = next.x - current.x;
      const outY = next.y - current.y;
      const inLength = Math.max(.001, Math.hypot(inX, inY));
      const outLength = Math.max(.001, Math.hypot(outX, outY));
      const direction = (inX * outX + inY * outY) / (inLength * outLength);
      // 直线和自然弧线继续平滑；90°及更尖的包装折角保持为硬角，避免
      // 面板、文字和刀版结构被统一抹圆。
      return clamp((direction - .2) / .75, 0, 1);
    };
    let output = `M ${format(points[0])}`;
    for (let index = 0; index < points.length; index += 1) {
      const p0 = points[(index - 1 + points.length) % points.length];
      const p1 = points[index];
      const p2 = points[(index + 1) % points.length];
      const p3 = points[(index + 2) % points.length];
      const segmentLength = Math.max(.001, Math.hypot(p2.x - p1.x, p2.y - p1.y));
      const previousLength = Math.max(.001, Math.hypot(p1.x - p0.x, p1.y - p0.y));
      const nextLength = Math.max(.001, Math.hypot(p3.x - p2.x, p3.y - p2.y));
      const tangent1Length = Math.max(.001, Math.hypot(p2.x - p0.x, p2.y - p0.y));
      const tangent2Length = Math.max(.001, Math.hypot(p3.x - p1.x, p3.y - p1.y));
      const handle1 = Math.min(segmentLength * .32, Math.min(segmentLength, previousLength) * factor) * continuity(p0, p1, p2);
      const handle2 = Math.min(segmentLength * .32, Math.min(segmentLength, nextLength) * factor) * continuity(p1, p2, p3);
      const c1 = { x: p1.x + (p2.x - p0.x) / tangent1Length * handle1, y: p1.y + (p2.y - p0.y) / tangent1Length * handle1 };
      const c2 = { x: p2.x - (p3.x - p1.x) / tangent2Length * handle2, y: p2.y - (p3.y - p1.y) / tangent2Length * handle2 };
      output += ` C ${format(c1)} ${format(c2)} ${format(p2)}`;
    }
    return `${output} Z`;
  }

  function contourPathData(contourOrPoints, smoothing = 0) {
    const points = Array.isArray(contourOrPoints) ? contourOrPoints : contourOrPoints?.points;
    if (!Array.isArray(points)) throw new TypeError("需要有效的轮廓节点");
    return pathData(points, clamp(smoothing, 0, 1));
  }

  function openPathData(points) {
    if (!Array.isArray(points) || points.length < 2) return "";
    return `M ${points.map(point => `${assertFiniteCoordinate(point.x)} ${assertFiniteCoordinate(point.y)}`).join(" L ")}`;
  }

  function editableContour(points, contourId, source = "trace") {
    if (!Array.isArray(points) || points.length < 3) throw new RangeError("闭合轮廓至少需要 3 个节点");
    const id = String(contourId || "contour-1");
    const editablePoints = points.map((point, index) => ({
      id: String(point?.id || `${id}-node-${index + 1}`),
      x: assertFiniteCoordinate(point?.x, "节点 x 坐标"),
      y: assertFiniteCoordinate(point?.y, "节点 y 坐标")
    }));
    return {
      id,
      closed: true,
      points: editablePoints,
      bbox: contourBounds(editablePoints),
      area: round(Math.abs(polygonArea(editablePoints))),
      source: String(source || "trace").slice(0, 80)
    };
  }

  function cloneResult(result) {
    if (!result || !Array.isArray(result.layers)) throw new TypeError("需要有效的矢量结果");
    return {
      ...result,
      options: result.options ? { ...result.options } : result.options,
      metrics: result.metrics ? { ...result.metrics } : result.metrics,
      layers: result.layers.map(layer => ({
        ...layer,
        bbox: layer.bbox ? { ...layer.bbox } : layer.bbox,
        contours: Array.isArray(layer.contours) ? layer.contours.map(contour => ({
          ...contour,
          bbox: contour.bbox ? { ...contour.bbox } : contour.bbox,
          points: Array.isArray(contour.points) ? contour.points.map(point => ({ ...point })) : []
        })) : layer.contours,
        strokes: Array.isArray(layer.strokes) ? layer.strokes.map(stroke => ({
          ...stroke,
          bbox: stroke.bbox ? { ...stroke.bbox } : stroke.bbox,
          points: Array.isArray(stroke.points) ? stroke.points.map(point => ({ ...point })) : []
        })) : layer.strokes
      }))
    };
  }

  function editableStroke(points, strokeId, source = "structure") {
    if (!Array.isArray(points) || points.length < 2) throw new RangeError("开放线至少需要 2 个节点");
    const id = String(strokeId || "stroke-1");
    const editablePoints = points.map((point, index) => ({
      id: String(point?.id || `${id}-node-${index + 1}`),
      x: assertFiniteCoordinate(point?.x, "线段 x 坐标"),
      y: assertFiniteCoordinate(point?.y, "线段 y 坐标")
    }));
    return {
      id,
      closed: false,
      points: editablePoints,
      bbox: contourBounds(editablePoints),
      source: String(source || "structure").slice(0, 80)
    };
  }

  function rebuildLayer(layer, smoothing = 0) {
    if (!layer || typeof layer !== "object") throw new TypeError("需要有效的矢量图层");
    const layerId = String(layer.id || "layer-1");
    const contours = (Array.isArray(layer.contours) ? layer.contours : []).map((contour, index) => editableContour(
      contour?.points,
      contour?.id || `${layerId}-contour-${index + 1}`,
      contour?.source || "trace"
    ));
    const strokes = (Array.isArray(layer.strokes) ? layer.strokes : []).map((stroke, index) => editableStroke(
      stroke?.points,
      stroke?.id || `${layerId}-stroke-${index + 1}`,
      stroke?.source || "structure"
    ));
    if (!contours.length && !strokes.length) return { ...layer, bbox: layer.bbox ? { ...layer.bbox } : layer.bbox };
    const geometryBounds = contours.map(contour => contour.bbox).concat(strokes.map(stroke => stroke.bbox));
    const rebuilt = {
      ...layer,
      id: layerId,
      contours,
      bbox: combineBounds(geometryBounds),
      area: round(contours.reduce((sum, contour) => sum + contour.area, 0)),
      paths: contours.length + strokes.length,
      nodes: contours.reduce((sum, contour) => sum + contour.points.length, 0) + strokes.reduce((sum, stroke) => sum + stroke.points.length, 0),
      d: contours.map(contour => pathData(contour.points, smoothing)).join(" ")
    };
    if (Array.isArray(layer.strokes) || strokes.length) rebuilt.strokes = strokes;
    return rebuilt;
  }

  function recalculateMetrics(result) {
    const cloned = cloneResult(result);
    const smoothing = clamp(cloned.options?.smoothing ?? 0, 0, 1);
    cloned.layers = cloned.layers.map(layer => rebuildLayer(layer, smoothing));
    const distinctColors = new Set(
      cloned.layers
        .map(layer => String(layer?.color || "").trim().toLowerCase())
        .filter(Boolean)
    );
    cloned.metrics = {
      ...(cloned.metrics || {}),
      // Text reconstruction intentionally creates one layer per line. A layer
      // is not a colour, so count actual inks instead of reporting a simple
      // black/white/gold package as a 22-colour job.
      colors: distinctColors.size,
      paths: cloned.layers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0),
      nodes: cloned.layers.reduce((sum, layer) => sum + Number(layer.nodes || 0), 0),
      foregroundPixels: cloned.layers.reduce((sum, layer) => sum + Number(layer.pixels || 0), 0)
    };
    return cloned;
  }

  function editableTarget(result, layerId, contourId) {
    const cloned = cloneResult(result);
    const layer = cloned.layers.find(item => item.id === layerId);
    if (!layer) throw new Error("没有找到要编辑的图层");
    if (!Array.isArray(layer.contours)) throw new Error("该图层没有可编辑节点");
    const contour = layer.contours.find(item => item.id === contourId);
    if (!contour) throw new Error("没有找到要编辑的轮廓");
    return { cloned, layer, contour };
  }

  function moveNode(result, layerId, contourId, nodeId, x, y) {
    const nextX = assertFiniteCoordinate(x, "节点 x 坐标");
    const nextY = assertFiniteCoordinate(y, "节点 y 坐标");
    const { cloned, contour } = editableTarget(result, layerId, contourId);
    const point = contour.points.find(item => item.id === nodeId);
    if (!point) throw new Error("没有找到要移动的节点");
    point.x = nextX;
    point.y = nextY;
    return recalculateMetrics(cloned);
  }

  function nextNodeId(contour) {
    const used = new Set(contour.points.map(point => String(point.id || "")));
    let serial = 1;
    while (used.has(`${contour.id}-node-${serial}`)) serial += 1;
    return `${contour.id}-node-${serial}`;
  }

  function insertNodeAtLongestSegment(result, layerId, contourId) {
    const { cloned, contour } = editableTarget(result, layerId, contourId);
    if (contour.points.length < 2) throw new RangeError("轮廓节点不足，无法插入节点");
    let segmentIndex = 0;
    let longest = -1;
    for (let index = 0; index < contour.points.length; index += 1) {
      const current = contour.points[index];
      const next = contour.points[(index + 1) % contour.points.length];
      const length = (next.x - current.x) ** 2 + (next.y - current.y) ** 2;
      if (length > longest) {
        longest = length;
        segmentIndex = index;
      }
    }
    const current = contour.points[segmentIndex];
    const next = contour.points[(segmentIndex + 1) % contour.points.length];
    contour.points.splice(segmentIndex + 1, 0, {
      id: nextNodeId(contour),
      x: round((current.x + next.x) / 2),
      y: round((current.y + next.y) / 2)
    });
    return recalculateMetrics(cloned);
  }

  function removeNode(result, layerId, contourId, nodeId) {
    const { cloned, contour } = editableTarget(result, layerId, contourId);
    const nodeIndex = contour.points.findIndex(item => item.id === nodeId);
    if (nodeIndex < 0) throw new Error("没有找到要删除的节点");
    if (contour.points.length <= 3) throw new RangeError("闭合轮廓至少需要 3 个节点");
    contour.points.splice(nodeIndex, 1);
    return recalculateMetrics(cloned);
  }

  function translateContours(contours, offsetX, offsetY, options = {}) {
    if (!Array.isArray(contours)) throw new TypeError("需要有效的轮廓数组");
    const dx = assertFiniteCoordinate(offsetX, "水平偏移");
    const dy = assertFiniteCoordinate(offsetY, "垂直偏移");
    const normalizedOptions = typeof options === "string" ? { idPrefix: options } : (options || {});
    const prefix = String(normalizedOptions.idPrefix || "").trim().replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80);
    return contours.map((contour, contourIndex) => {
      const originalId = String(contour?.id || `contour-${contourIndex + 1}`);
      const translatedId = prefix ? `${prefix}-${originalId}` : originalId;
      const points = (contour?.points || []).map((point, pointIndex) => ({
        id: prefix ? `${translatedId}-node-${pointIndex + 1}` : String(point?.id || `${translatedId}-node-${pointIndex + 1}`),
        x: assertFiniteCoordinate(Number(point?.x) + dx, "节点 x 坐标"),
        y: assertFiniteCoordinate(Number(point?.y) + dy, "节点 y 坐标")
      }));
      return editableContour(points, translatedId, normalizedOptions.source || contour?.source || "trace");
    });
  }

  function translateResult(result, offsetX, offsetY, options = {}) {
    const cloned = cloneResult(result);
    const normalizedOptions = typeof options === "string" ? { idPrefix: options } : (options || {});
    const prefix = String(normalizedOptions.idPrefix || "").trim().replace(/[^a-z0-9_-]+/gi, "-").slice(0, 80);
    cloned.layers = cloned.layers.map((layer, layerIndex) => {
      const originalLayerId = String(layer.id || `layer-${layerIndex + 1}`);
      const layerId = prefix ? `${prefix}-${originalLayerId}` : originalLayerId;
      return {
        ...layer,
        id: layerId,
        contours: translateContours(layer.contours || [], offsetX, offsetY, {
          idPrefix: prefix,
          source: normalizedOptions.source
        }),
        strokes: (layer.strokes || []).map((stroke, strokeIndex) => editableStroke(
          (stroke.points || []).map(point => ({ x: Number(point.x) + Number(offsetX), y: Number(point.y) + Number(offsetY) })),
          prefix ? `${prefix}-${stroke.id || `stroke-${strokeIndex + 1}`}` : stroke.id,
          normalizedOptions.source || stroke.source || "structure"
        ))
      };
    });
    return recalculateMetrics(cloned);
  }

  function packagingDielinePixel(r, g, b, family = "yellow") {
    if (family === "red") return r >= 128 && r >= g * 1.42 && r >= b * 1.34 && r - Math.max(g, b) >= 42;
    if (family === "green") return g >= 92 && g >= r * 1.2 && g >= b * 1.12 && g - Math.max(r, b) >= 24;
    if (family === "blue") return b >= 105 && b >= r * 1.22 && b >= g * 1.1 && b - Math.max(r, g) >= 26;
    return r >= 72 && g >= 62 && b <= Math.min(r, g) * 0.72 && (r + g - b * 2) >= 92 && Math.abs(r - g) <= 105;
  }

  function mergeAxisRuns(runs, proximity) {
    const groups = [];
    for (const run of runs.sort((a, b) => a.axis - b.axis || a.start - b.start)) {
      let best = null;
      let bestOverlap = -Infinity;
      for (const group of groups) {
        if (run.axis - group.maxAxis > proximity) continue;
        const overlap = Math.min(run.end, group.end) - Math.max(run.start, group.start);
        const shorter = Math.min(run.end - run.start, group.end - group.start);
        if (overlap >= Math.max(2, shorter * 0.35) && overlap > bestOverlap) {
          best = group;
          bestOverlap = overlap;
        }
      }
      if (!best) {
        groups.push({ minAxis: run.axis, maxAxis: run.axis, weightedAxis: run.axis * run.hits, weight: run.hits, start: run.start, end: run.end });
      } else {
        best.maxAxis = Math.max(best.maxAxis, run.axis);
        best.weightedAxis += run.axis * run.hits;
        best.weight += run.hits;
        best.start = Math.min(best.start, run.start);
        best.end = Math.max(best.end, run.end);
      }
    }
    return groups.map(group => ({
      axis: round(group.weightedAxis / Math.max(1, group.weight)),
      start: round(group.start),
      end: round(group.end),
      thickness: round(group.maxAxis - group.minAxis + 1)
    }));
  }

  function axisRuns(mask, width, height, horizontal, minLength) {
    const major = horizontal ? width : height;
    const minor = horizontal ? height : width;
    const runs = [];
    const value = (axis, position) => horizontal ? mask[axis * width + position] : mask[position * width + axis];
    for (let axis = 0; axis < minor; axis += 1) {
      let start = -1;
      let last = -1;
      let hits = 0;
      for (let position = 0; position <= major; position += 1) {
        const active = position < major && value(axis, position);
        if (active) {
          if (start < 0) start = position;
          last = position;
          hits += 1;
        }
        if ((!active && start >= 0 && position - last > 2) || (position === major && start >= 0)) {
          if (last - start + 1 >= minLength && hits >= minLength * 0.72) runs.push({ axis, start, end: last + 1, hits });
          start = -1;
          last = -1;
          hits = 0;
        }
      }
    }
    return runs;
  }

  function snapDielineIntersections(horizontal, vertical, tolerance) {
    for (const h of horizontal) {
      for (const v of vertical) {
        if (v.axis < h.start - tolerance || v.axis > h.end + tolerance || h.axis < v.start - tolerance || h.axis > v.end + tolerance) continue;
        if (Math.abs(h.start - v.axis) <= tolerance) h.start = v.axis;
        if (Math.abs(h.end - v.axis) <= tolerance) h.end = v.axis;
        if (Math.abs(v.start - h.axis) <= tolerance) v.start = h.axis;
        if (Math.abs(v.end - h.axis) <= tolerance) v.end = h.axis;
      }
    }
  }

  function structuralDielineNetwork(horizontal, vertical, width, height, tolerance) {
    const horizontalSeedLength = Math.max(12, width * .14);
    const verticalSeedLength = Math.max(12, height * .14);
    const longHorizontal = horizontal.filter(line => line.end - line.start >= horizontalSeedLength);
    const longVertical = vertical.filter(line => line.end - line.start >= verticalSeedLength);
    const retainedHorizontal = new Set();
    const retainedVertical = new Set();
    const intersects = (h, v) => v.axis >= h.start - tolerance && v.axis <= h.end + tolerance
      && h.axis >= v.start - tolerance && h.axis <= v.end + tolerance;
    // A genuine dieline network has intersecting long orthogonal anchors.
    // Merely having long strokes somewhere else on the page is not enough:
    // warning text can contain long horizontal bars but not a page-scale
    // perpendicular structure at the same location.
    for (const h of longHorizontal) for (const v of longVertical) {
      if (!intersects(h, v)) continue;
      retainedHorizontal.add(h);
      retainedVertical.add(v);
    }
    // Short flap and
    // notch segments may join those anchors, but isolated glyph strokes must
    // never bootstrap their own network.
    if (!retainedHorizontal.size || !retainedVertical.size) return { horizontal: [], vertical: [] };
    let changed = true;
    while (changed) {
      changed = false;
      for (const line of horizontal) {
        if (retainedHorizontal.has(line)) continue;
        if ([...retainedVertical].some(other => intersects(line, other))) {
          retainedHorizontal.add(line); changed = true;
        }
      }
      for (const line of vertical) {
        if (retainedVertical.has(line)) continue;
        if ([...retainedHorizontal].some(other => intersects(other, line))) {
          retainedVertical.add(line); changed = true;
        }
      }
    }

    // Keep complete page-scale orthogonal components only. Large Chinese
    // warning glyphs can themselves contain several intersecting horizontal
    // and vertical strokes, so an intersection alone is not proof of a
    // packaging dieline. A real dieline component must occupy meaningful
    // extents in both page directions; a text glyph component is normally
    // wide *or* tall, but not both at packaging scale.
    const acceptedHorizontal = new Set();
    const acceptedVertical = new Set();
    const visitedHorizontal = new Set();
    const visitedVertical = new Set();
    const minimumComponentWidth = Math.max(14, width * .2);
    const minimumComponentHeight = Math.max(14, height * .2);
    for (const seed of retainedHorizontal) {
      if (visitedHorizontal.has(seed)) continue;
      const componentHorizontal = new Set();
      const componentVertical = new Set();
      const horizontalQueue = [seed];
      const verticalQueue = [];
      while (horizontalQueue.length || verticalQueue.length) {
        while (horizontalQueue.length) {
          const line = horizontalQueue.pop();
          if (visitedHorizontal.has(line)) continue;
          visitedHorizontal.add(line);
          componentHorizontal.add(line);
          for (const other of retainedVertical) {
            if (!visitedVertical.has(other) && intersects(line, other)) verticalQueue.push(other);
          }
        }
        while (verticalQueue.length) {
          const line = verticalQueue.pop();
          if (visitedVertical.has(line)) continue;
          visitedVertical.add(line);
          componentVertical.add(line);
          for (const other of retainedHorizontal) {
            if (!visitedHorizontal.has(other) && intersects(other, line)) horizontalQueue.push(other);
          }
        }
      }
      if (!componentHorizontal.size || !componentVertical.size) continue;
      const minX = Math.min(
        ...[...componentHorizontal].map(line => line.start),
        ...[...componentVertical].map(line => line.axis)
      );
      const maxX = Math.max(
        ...[...componentHorizontal].map(line => line.end),
        ...[...componentVertical].map(line => line.axis)
      );
      const minY = Math.min(
        ...[...componentHorizontal].map(line => line.axis),
        ...[...componentVertical].map(line => line.start)
      );
      const maxY = Math.max(
        ...[...componentHorizontal].map(line => line.axis),
        ...[...componentVertical].map(line => line.end)
      );
      if (maxX - minX < minimumComponentWidth || maxY - minY < minimumComponentHeight) continue;
      componentHorizontal.forEach(line => acceptedHorizontal.add(line));
      componentVertical.forEach(line => acceptedVertical.add(line));
    }
    return {
      horizontal: horizontal.filter(line => acceptedHorizontal.has(line)),
      vertical: vertical.filter(line => acceptedVertical.has(line))
    };
  }

  function detectPackagingDielines(data, width, height, options, backgroundMask) {
    const minHorizontal = Math.max(10, Math.round(width * 0.035));
    const minVertical = Math.max(10, Math.round(height * 0.035));
    const proximity = Math.max(2, Math.round(Math.min(width, height) * 0.004));
    const maxLineThickness = Math.max(4, Math.round(Math.min(width, height) * .006));
    const candidates = ["yellow", "red", "green", "blue"].map(family => {
      const candidateMask = new Uint8Array(width * height);
      let candidatePixels = 0;
      for (let pixel = 0; pixel < width * height; pixel += 1) {
        if (backgroundMask[pixel]) continue;
        const index = pixel * 4;
        const r = data[index], g = data[index + 1], b = data[index + 2];
        if (data[index + 3] <= options.alphaThreshold || !packagingDielinePixel(r, g, b, family)) continue;
        candidateMask[pixel] = 1;
        candidatePixels += 1;
      }
      const horizontalCandidates = mergeAxisRuns(axisRuns(candidateMask, width, height, true, minHorizontal), proximity)
        .filter(line => line.thickness <= maxLineThickness);
      const verticalCandidates = mergeAxisRuns(axisRuns(candidateMask, width, height, false, minVertical), proximity)
        .filter(line => line.thickness <= maxLineThickness);
      const network = structuralDielineNetwork(
        horizontalCandidates,
        verticalCandidates,
        width,
        height,
        Math.max(3, proximity * 2)
      );
      const horizontal = network.horizontal;
      const vertical = network.vertical;
      const totalLength = horizontal.reduce((sum, line) => sum + line.end - line.start, 0)
        + vertical.reduce((sum, line) => sum + line.end - line.start, 0);
      const lineArea = horizontal.concat(vertical).reduce(
        (sum, line) => sum + (line.end - line.start) * Math.max(1, Number(line.thickness || 1)),
        0
      );
      // A production dieline is predominantly thin structural strokes. A
      // printed artwork color can also form a large orthogonal grid, but only
      // a small fraction of all pixels in that color belongs to those lines.
      // Purity prevents filled red artwork from outvoting a genuine blue or
      // green crease/cut network merely because its edges are longer.
      const structuralPurity = Math.min(1, lineArea / Math.max(1, candidatePixels));
      const structureScore = totalLength * structuralPurity;
      const structured = horizontal.length > 0 && vertical.length > 0;
      return { family, candidateMask, candidatePixels, horizontal, vertical, totalLength, lineArea, structuralPurity, structureScore, structured };
    }).filter(candidate => candidate.structured);
    const selected = candidates.sort((left, right) => right.structureScore - left.structureScore || right.totalLength - left.totalLength)[0];
    if (!selected) {
      return { mask: new Uint8Array(width * height), pixels: 0, color: "#fff200", sourceColor: "", sourceFamily: "", strokes: [], averageThickness: 1 };
    }
    const { candidateMask, candidatePixels, horizontal, vertical, family } = selected;
    snapDielineIntersections(horizontal, vertical, Math.max(3, proximity * 2));
    const strokes = [];
    horizontal.forEach((line, index) => strokes.push(editableStroke([
      { x: line.start, y: line.axis }, { x: line.end, y: line.axis }
    ], `dieline-h-${index + 1}`, "packaging-dieline")));
    vertical.forEach((line, index) => strokes.push(editableStroke([
      { x: line.axis, y: line.start }, { x: line.axis, y: line.end }
    ], `dieline-v-${index + 1}`, "packaging-dieline")));
    // 黄色 / 金色也常用于包装图案和烫金文字，不能把所有黄色像素都当成
    // 刀模删除。只有形成足够长的横竖结构线的候选像素才进入刀线遮罩。
    const mask = new Uint8Array(width * height);
    const markLine = (line, horizontalLine) => {
      const radius = Math.max(1, Math.ceil(Number(line.thickness || 1) / 2));
      const start = Math.max(0, Math.floor(line.start));
      const end = Math.min(horizontalLine ? width : height, Math.ceil(line.end));
      for (let axis = Math.floor(line.axis) - radius; axis <= Math.ceil(line.axis) + radius; axis += 1) {
        if (axis < 0 || axis >= (horizontalLine ? height : width)) continue;
        for (let position = start; position < end; position += 1) {
          const pixel = horizontalLine ? axis * width + position : position * width + axis;
          if (candidateMask[pixel]) mask[pixel] = 1;
        }
      }
    };
    horizontal.forEach(line => markLine(line, true));
    vertical.forEach(line => markLine(line, false));
    const colorSum = [0, 0, 0];
    let pixels = 0;
    for (let pixel = 0; pixel < mask.length; pixel += 1) {
      if (!mask[pixel]) continue;
      const index = pixel * 4;
      pixels += 1;
      colorSum[0] += data[index]; colorSum[1] += data[index + 1]; colorSum[2] += data[index + 2];
    }
    const thicknesses = horizontal.concat(vertical).map(line => line.thickness).filter(Number.isFinite);
    const sourceColor = pixels ? colorHex(colorSum.map(value => value / pixels)) : "";
    return {
      mask,
      pixels,
      // 屏幕预览和 SVG 必须忠实保留客户原稿的结构线颜色。生产专色
      // 语义由图层的 spotName / cmyk 单独承载，不能为了区分刀线和折线
      // 把客户原稿强行改成红色、黑色或纯黄。
      color: sourceColor || "#fff200",
      sourceColor,
      sourceFamily: family,
      strokes: strokes.slice(0, 240),
      averageThickness: thicknesses.length ? Math.max(0.6, thicknesses.reduce((sum, value) => sum + value, 0) / thicknesses.length) : 1
    };
  }

  function rebuildPackagingPanelContours(contours) {
    if (!Array.isArray(contours) || !contours.length) return contours;
    const largest = contours.reduce((best, contour) => contour.area > (best?.area || 0) ? contour : best, null);
    if (!largest?.bbox?.width || !largest?.bbox?.height) return contours;
    const rectangularity = largest.area / (largest.bbox.width * largest.bbox.height);
    if (rectangularity < 0.92) return contours;
    const box = largest.bbox;
    const panel = editableContour([
      { x: box.x, y: box.y },
      { x: box.x + box.width, y: box.y },
      { x: box.x + box.width, y: box.y + box.height },
      { x: box.x, y: box.y + box.height }
    ], largest.id, "packaging-panel");
    const outside = contours.filter(contour => {
      if (contour.id === largest.id) return false;
      const centerX = contour.bbox.x + contour.bbox.width / 2;
      const centerY = contour.bbox.y + contour.bbox.height / 2;
      return centerX < box.x || centerX > box.x + box.width || centerY < box.y || centerY > box.y + box.height;
    });
    return [panel].concat(outside);
  }

  function vectorizePixels(imageData, inputOptions = {}) {
    if (!imageData || !Number.isInteger(imageData.width) || !Number.isInteger(imageData.height) || !imageData.data) {
      throw new TypeError("需要有效的 RGBA 图像数据");
    }
    const width = imageData.width;
    const height = imageData.height;
    if (width < 2 || height < 2 || imageData.data.length < width * height * 4) throw new RangeError("图像尺寸或像素数据无效");
    const options = normalizeOptions(inputOptions);
    const background = estimateBackground(imageData.data, width, height, options.alphaThreshold);
    const backgroundMask = connectedBackgroundMask(imageData.data, width, height, options, background);
    const packaging = options.mode === "packaging"
      ? detectPackagingDielines(imageData.data, width, height, options, backgroundMask)
      : null;
    const backgroundLuminance = background[0] * .299 + background[1] * .587 + background[2] * .114;
    // 深色满版包装的四角就是印刷底色，不是应删除的摄影背景。检测到真实
    // 结构网时保留该底色为可编辑印刷色层；刀线仍使用之前的边缘背景掩码
    // 定位，避免把整块黑版只作为预览背景而从导出 SVG 中丢失。
    const preserveFullBleedBackground = Boolean(
      options.mode === "packaging"
      && packaging?.strokes?.length
      && backgroundLuminance < 190
    );
    const artworkBackgroundMask = preserveFullBleedBackground
      ? new Uint8Array(width * height)
      : backgroundMask;
    const packagingArtworkMask = packaging?.pixels ? dilateBinaryMask(packaging.mask, width, height, 2) : packaging?.mask;
    const samples = collectSamples(imageData.data, width, height, options, artworkBackgroundMask, packagingArtworkMask);
    if (!samples.length && !packaging?.strokes?.length) throw new Error("没有检测到可矢量化的前景，请关闭去背景或降低背景容差");
    let centroids;
    let assigned;
    if (options.mode === "line" && options.removeBackground) {
      assigned = assignMonochromePixels(imageData.data, width, height, options, background);
      centroids = assigned.centroids;
      if (!centroids.length) throw new Error("文字与背景对比不足，请降低背景容差或换用更清晰的原图");
    } else {
      const semanticPalette = options.mode === "packaging" ? packagingSemanticPalette(samples) : null;
      const artworkAnalysis = options.mode === "packaging" ? analyzeArtworkPalette(samples) : null;
      const requestedColors = options.mode === "packaging"
        ? Math.max(2, options.colors - 1, artworkAnalysis?.suggestedColors || 2)
        : options.colors;
      centroids = semanticPalette || (samples.length ? quantize(samples, Math.min(requestedColors, samples.length)) : []);
      assigned = semanticPalette
        ? assignNeutralPackagingPixels(imageData.data, width, height, options, artworkBackgroundMask, packagingArtworkMask)
        : centroids.length
          ? assignPixels(imageData.data, width, height, centroids, options, artworkBackgroundMask)
          : { assignments: new Int16Array(width * height).fill(-1), counts: new Uint32Array(0) };
      if (packaging?.pixels) assigned = inpaintMaskedAssignments(assigned, packagingArtworkMask, width, height, centroids);
      if (!semanticPalette && ["packaging", "illustration", "photo"].includes(options.mode)) {
        assigned = cleanupIsolatedAssignments(assigned, width, height);
        // Remove JPEG dust before constructing pixel-boundary edges.
        // Filtering only after tracing still allocates hundreds of thousands
        // of useless edges and forces a large print sheet down to a tiny
        // working resolution.
        assigned = filterSmallAssignmentComponents(assigned, width, height, options.speckle);
      }
      if (options.mode === "packaging") {
        packaging.artworkAnalysis = artworkAnalysis;
        packaging.neutralArtwork = Boolean(semanticPalette);
      }
    }
    const ordered = centroids.map((color, index) => ({ color, originalIndex: index, count: assigned.counts[index] }))
      .filter(item => item.count > 0)
      .sort((a, b) => b.count - a.count);
    const layers = [];
    const budget = { edges: options.maxEdges, paths: options.maxPaths, nodes: options.maxNodes };
    for (const item of ordered) {
      const traced = traceLayer(assigned.assignments, width, height, item.originalIndex, options, budget);
      const rings = traced.paths;
      budget.edges -= traced.edges;
      budget.paths -= rings.length;
      budget.nodes -= traced.nodes;
      if (!rings.length) continue;
      const layerId = `color-${item.originalIndex + 1}`;
      let contours = rings.map((points, index) => editableContour(
        points,
        `${layerId}-contour-${index + 1}`,
        "trace"
      ));
      const averageLightness = item.color.reduce((sum, value) => sum + value, 0) / 3;
      if (options.mode === "packaging" && packaging?.neutralArtwork && averageLightness < 70) contours = rebuildPackagingPanelContours(contours);
      const appendArtworkLayer = (groupContours, suffix, name, role, smoothing, pixels = 0) => {
        if (!groupContours.length) return;
        const exactColor = options.mode === "packaging" && packaging?.neutralArtwork && averageLightness < 70
          ? "#000000"
          : options.mode === "packaging" && packaging?.neutralArtwork && averageLightness > 185
            ? "#ffffff"
            : colorHex(item.color);
        layers.push({
          id: `${layerId}${suffix}`,
          name,
          role,
          color: exactColor,
          cmyk: exactColor === "#000000" ? [0, 0, 0, 1] : exactColor === "#ffffff" ? [0, 0, 0, 0] : null,
          spotName: role === "foil" ? "FOIL_WHITE" : "",
          smoothing,
          pixels: Number(pixels),
          area: round(groupContours.reduce((sum, contour) => sum + contour.area, 0)),
          paths: groupContours.length,
          nodes: groupContours.reduce((sum, contour) => sum + contour.points.length, 0),
          bbox: combineBounds(groupContours.map(contour => contour.bbox)),
          contours: groupContours,
          d: groupContours.map(contour => pathData(contour.points, smoothing)).join(" "),
          colorGroup: exactColor,
          sourceModel: "adaptive-color-cluster",
          visible: true
        });
      };
      if (options.mode === "packaging" && packaging?.neutralArtwork && averageLightness < 70) {
        const panels = contours.filter(contour => contour.source === "packaging-panel");
        const details = contours.filter(contour => contour.source !== "packaging-panel");
        appendArtworkLayer(panels, "-panel", "印刷底色（标准黑）", "print-black", 0, item.count);
        appendArtworkLayer(details, "-details", "黑色说明文字", "annotation", options.smoothing, 0);
      } else if (options.mode === "packaging" && packaging?.neutralArtwork && averageLightness > 185) {
        appendArtworkLayer(contours, "", "白色 / 烫印内容", "foil", options.smoothing, item.count);
      } else {
        appendArtworkLayer(
          contours,
          "",
          options.mode === "packaging" ? "印刷颜色" : `颜色 ${layers.length + 1}`,
          options.mode === "packaging" ? "artwork" : "color",
          options.smoothing,
          item.count
        );
      }
    }
    if (packaging?.strokes?.length) {
      layers.push({
        id: "packaging-dieline",
        name: "刀模线（专色）",
        role: "dieline",
        color: packaging.color,
        sourceColor: packaging.sourceColor || packaging.color,
        cmyk: [0, 0, 1, 0],
        spotName: "DIELINE",
        pixels: packaging.pixels,
        area: 0,
        paths: packaging.strokes.length,
        nodes: packaging.strokes.reduce((sum, stroke) => sum + stroke.points.length, 0),
        bbox: combineBounds(packaging.strokes.map(stroke => stroke.bbox)),
        contours: [],
        strokes: packaging.strokes,
        strokeWidth: round(Math.max(0.75, Math.min(2.5, packaging.averageThickness))),
        strokeWidthMm: 0.25,
        visible: true
      });
    }
    if (!layers.length) throw new Error("没有形成有效路径，请降低杂点过滤或提高细节");
    return {
      width,
      height,
      outputWidth: Math.round(clamp(inputOptions.outputWidth ?? width, 1, 100000)),
      outputHeight: Math.round(clamp(inputOptions.outputHeight ?? height, 1, 100000)),
      background: colorHex(background),
      options,
      layers,
      metrics: {
        colors: layers.length,
        paths: layers.reduce((sum, layer) => sum + layer.paths, 0),
        nodes: layers.reduce((sum, layer) => sum + layer.nodes, 0),
        foregroundPixels: layers.reduce((sum, layer) => sum + layer.pixels, 0),
        dielinePaths: packaging?.strokes?.length || 0
      },
      structure: options.mode === "packaging" ? {
        kind: "packaging",
        artworkKind: packaging?.neutralArtwork ? "neutral" : "multicolor",
        artworkColors: layers.filter(layer => layer.role !== "dieline").length,
        suggestedArtworkColors: packaging?.artworkAnalysis?.suggestedColors || 2,
        dielineDetected: Boolean(packaging?.strokes?.length),
        dielinePaths: packaging?.strokes?.length || 0,
        dielineColor: packaging?.color || "",
        dielineSourceColor: packaging?.sourceColor || "",
        dielineSourceFamily: packaging?.sourceFamily || "",
        fullBleedArtworkPreserved: preserveFullBleedBackground,
        dielineStrokeMm: packaging?.strokes?.length ? 0.25 : 0
      } : null
    };
  }

  function renderSvg(result, overrides = {}) {
    if (!result?.layers?.length) throw new Error("请先生成矢量结果");
    const title = escapeXml(overrides.title || result.options?.title || "将莱矢量作品");
    const layerState = overrides.layers || {};
    const paths = result.layers.map(layer => {
      const state = layerState[layer.id] || {};
      if (state.visible === false || layer.visible === false) return "";
      const colorCandidate = /^#[0-9a-f]{6}$/i.test(state.color || "") ? state.color : layer.color;
      const color = /^#[0-9a-f]{6}$/i.test(colorCandidate || "") ? colorCandidate : "#000000";
      if (Array.isArray(layer.strokes) && layer.strokes.length) {
        const strokePaths = layer.strokes.map(stroke => openPathData(stroke.points)).filter(Boolean).join(" ");
        if (!strokePaths || !/^[0-9eE+.,\-\sML]+$/.test(strokePaths)) return "";
        const physicalWidth = Number(overrides.widthMm || 0);
        const strokeWidth = physicalWidth > 0 && Number(layer.strokeWidthMm) > 0
          ? Number(layer.strokeWidthMm) * Number(result.width) / physicalWidth
          : clamp(layer.strokeWidth ?? 1, 0.1, 1000);
        return `<path data-layer="${escapeXml(layer.id)}" data-role="${escapeXml(layer.role || "stroke")}" data-spot="${escapeXml(layer.spotName || "")}" fill="none" stroke="${color}" stroke-width="${round(strokeWidth)}" stroke-linecap="square" stroke-linejoin="miter" d="${strokePaths}"/>`;
      }
      const nativePath = String(layer.nativePathD || "");
      const nativeTransform = Array.isArray(layer.nativeTransform)
        ? layer.nativeTransform.map(Number)
        : [];
      if (nativePath) {
        if (!/^[0-9eE+.,\-\sMmLlHhVvCcSsQqTtAaZz]+$/.test(nativePath)) return "";
        if (nativeTransform.length !== 6 || !nativeTransform.every(Number.isFinite)) return "";
        const transform = nativeTransform.map(value => round(value)).join(" ");
        const nativeSubpaths = Array.isArray(layer.nativeSubpaths)
          ? layer.nativeSubpaths
            .map(item => String(item?.d || ""))
            .filter(path => path && /^[0-9eE+.,\-\sMmLlHhVvCcSsQqTtAaZz]+$/.test(path))
          : [];
        // VTracer preserves separate source <path> elements for independent
        // letters, counters and small artwork details. Joining every element
        // into one even-odd path makes overlapping letter bounds cancel each
        // other (for example, a white "freddi" logo can lose f/r/i on a black
        // panel). Keep each traced source path as its own compound path so its
        // internal counters still use even-odd fill without affecting sibling
        // glyphs in the same editable colour layer.
        if (nativeSubpaths.length) {
          const role = escapeXml(layer.role || "text");
          const layerId = escapeXml(layer.id);
          const children = nativeSubpaths.map((path, index) => (
            `<path data-layer="${layerId}" data-subpath="${index + 1}" data-role="${role}" fill="${color}" fill-rule="evenodd" d="${path}"/>`
          )).join("");
          return `<g data-layer="${layerId}" data-role="${role}" transform="matrix(${transform})">${children}</g>`;
        }
        return `<path data-layer="${escapeXml(layer.id)}" data-role="${escapeXml(layer.role || "text")}" fill="${color}" fill-rule="evenodd" transform="matrix(${transform})" d="${nativePath}"/>`;
      }
      const d = Array.isArray(layer.contours)
        ? layer.contours.map(contour => pathData(editableContour(contour.points, contour.id, contour.source).points, layer.smoothing ?? result.options?.smoothing ?? 0)).join(" ")
        : String(layer.d || "");
      if (!d || !/^[0-9eE+.,\-\sMLCZ]+$/.test(d)) return "";
      return `<path data-layer="${escapeXml(layer.id)}" fill="${color}" fill-rule="evenodd" d="${d}"/>`;
    }).filter(Boolean).join("\n  ");
    if (!paths) throw new Error("矢量结果没有安全有效的路径");
    const metadata = escapeXml(JSON.stringify({
      generator: "Jianglai Vector Studio",
      colors: Number(result.metrics?.colors || 0),
      paths: Number(result.metrics?.paths || 0),
      nodes: Number(result.metrics?.nodes || 0),
      dielinePaths: Number(result.metrics?.dielinePaths || 0)
    }));
    const width = assertFiniteCoordinate(Number(result.width), "画布宽度");
    const height = assertFiniteCoordinate(Number(result.height), "画布高度");
    const outputWidth = assertFiniteCoordinate(Number(result.outputWidth || width), "输出宽度");
    const outputHeight = assertFiniteCoordinate(Number(result.outputHeight || height), "输出高度");
    if (width <= 0 || height <= 0 || outputWidth <= 0 || outputHeight <= 0) throw new RangeError("画布尺寸必须大于 0");
    const physicalWidth = Number(overrides.widthMm || 0);
    const physicalHeight = physicalWidth > 0 ? physicalWidth * height / width : 0;
    const widthAttribute = physicalWidth > 0 ? `${round(physicalWidth)}mm` : outputWidth;
    const heightAttribute = physicalHeight > 0 ? `${round(physicalHeight)}mm` : outputHeight;
    return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${widthAttribute}" height="${heightAttribute}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${title}">\n  <title>${title}</title>\n  <metadata>${metadata}</metadata>\n  ${paths}\n</svg>`;
  }

  function inspect(result, svg = "") {
    const source = svg || (result ? renderSvg(result) : "");
    return {
      colors: Number(result?.metrics?.colors || 0),
      paths: Number(result?.metrics?.paths || 0),
      nodes: Number(result?.metrics?.nodes || 0),
      bytes: new TextEncoder().encode(source).length,
      hasEmbeddedRaster: /<image\b/i.test(source),
      validSvgRoot: /^<\?xml[\s\S]*<svg\b/i.test(source)
    };
  }

  return {
    normalizeOptions,
    vectorizePixels,
    renderSvg,
    inspect,
    pathData,
    contourPathData,
    openPathData,
    cloneResult,
    rebuildLayer,
    recalculateMetrics,
    moveNode,
    insertNodeAtLongestSegment,
    removeNode,
    translateContours,
    translateResult,
    filterSmallAssignmentComponents
  };
});
