(function () {
  "use strict";

  const VECTOR_POINTS = 30;
  const MAX_PREVIEW_ZOOM = 24;

  const state = {
    initialized: false,
    file: null,
    sourceKind: "",
    nativeVector: null,
    sourceUrl: "",
    resultUrl: "",
    image: null,
    imageData: null,
    sourceWidth: 0,
    sourceHeight: 0,
    sourceAnalysis: null,
    processingMaxEdge: 0,
    result: null,
    svg: "",
    outputKind: "",
    fidelityDataUrl: "",
    fidelityReason: "",
    chargeRequestId: "",
    chargeSettled: false,
    commercialTextRebuilt: false,
    rebuiltTextRects: [],
    reconstructionRequested: false,
    reconstructionReviewRequired: false,
    layerState: {},
    mode: "logo",
    qualityGoal: "fidelity",
    autoOptimize: true,
    optimization: null,
    previewMode: "original",
    compare: 50,
    zoom: 1,
    panX: 0,
    panY: 0,
    dragStart: null,
    spacePan: false,
    textRebuildApplying: false,
    rerunTimer: null,
    runId: 0,
    editor: {
      tool: "browse",
      lastEditTool: "",
      selectedLayerId: "",
      selectedContourId: "",
      selectedNodeId: "",
      dirty: false,
      undo: [],
      redo: [],
      drag: null,
      retraceStart: null,
      retraceRect: null,
      lastTextRect: null,
      lastTextSpecs: [],
      revision: 0
    }
  };

  const byId = id => document.getElementById(id);
  const all = selector => [...document.querySelectorAll(selector)];
  const notifyUser = message => typeof window.notify === "function" ? window.notify(message) : console.info(message);
  const formatBytes = bytes => {
    const size = Number(bytes || 0);
    if (size < 1024) return `${size} B`;
    if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
    return `${(size / 1024 / 1024).toFixed(1)} MB`;
  };
  const safeFilename = value => String(value || "矢量作品")
    .replace(/\.[^.]+$/, "")
    .replace(/[\\/:*?"<>|\x00-\x1f]+/g, "-")
    .trim()
    .slice(0, 80) || "矢量作品";
  const fileExtension = value => (String(value || "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || "");
  const supportedFileKind = file => {
    const type = String(file?.type || "").toLowerCase();
    const extension = fileExtension(file?.name);
    if (type === "application/pdf" || extension === "pdf") return "pdf";
    if (type === "image/svg+xml" || extension === "svg") return "svg";
    if (/^image\/(png|jpeg|webp|bmp)$/.test(type) || ["png", "jpg", "jpeg", "webp", "bmp"].includes(extension)) return "raster";
    return "";
  };
  const svgElement = name => document.createElementNS("http://www.w3.org/2000/svg", name);
  const cloneJson = value => JSON.parse(JSON.stringify(value));
  const finiteNumber = value => Number.isFinite(Number(value)) ? Number(value) : 0;
  const escapeXml = value => String(value || "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
  const newChargeRequestId = () => {
    const random = globalThis.crypto?.randomUUID?.().replace(/-/g, "")
      || `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
    return `vector_${random}`.slice(0, 96);
  };

  function setStatus(message, tone = "") {
    const status = byId("jlVectorStatus");
    if (!status) return;
    status.textContent = message;
    status.dataset.tone = tone;
  }

  function setProcessing(active, title = "正在生成矢量路径…", detail = "正在分析颜色、轮廓和背景，请稍候。", progress = null) {
    const overlay = byId("jlVectorProcessing");
    if (!overlay) return;
    overlay.classList.toggle("is-visible", Boolean(active));
    overlay.setAttribute("aria-busy", active ? "true" : "false");
    const strong = overlay.querySelector("strong");
    const span = overlay.querySelector("span");
    if (strong) strong.textContent = title;
    if (span) span.textContent = detail;
    let meter = overlay.querySelector(".jl-vector-processing-progress");
    if (active && progress !== null && !meter) {
      meter = document.createElement("div");
      meter.className = "jl-vector-processing-progress";
      meter.innerHTML = '<div><i></i></div><em></em>';
      overlay.firstElementChild?.appendChild(meter);
    }
    if (meter) {
      const numeric = Number(progress);
      const determinate = active && Number.isFinite(numeric);
      meter.hidden = !determinate;
      if (determinate) {
        const percent = Math.max(0, Math.min(100, Math.round(numeric * 100)));
        meter.querySelector("i").style.width = `${percent}%`;
        meter.querySelector("em").textContent = `${percent}%`;
        meter.setAttribute("role", "progressbar");
        meter.setAttribute("aria-valuemin", "0");
        meter.setAttribute("aria-valuemax", "100");
        meter.setAttribute("aria-valuenow", String(percent));
      }
    }
  }

  function yieldToRenderer() {
    return new Promise(resolve => {
      if (typeof globalThis.requestAnimationFrame === "function") {
        globalThis.requestAnimationFrame(() => resolve());
      } else {
        globalThis.setTimeout(resolve, 0);
      }
    });
  }

  function setResultButtons(enabled) {
    all("[data-vector-export]").forEach(button => {
      const fidelityDownload = state.outputKind === "fidelity-preview" && button.id === "jlVectorDownloadSvg";
      button.disabled = !enabled || (state.outputKind === "fidelity-preview" && !fidelityDownload);
    });
  }

  function setPreviewAvailability(enabled) {
    all("[data-vector-view]").forEach(button => {
      if (button.dataset.vectorView !== "original") button.disabled = !enabled;
    });
    if (!enabled && state.previewMode !== "original") setPreviewMode("original");
  }

  function presetValues(mode) {
    return {
      logo: { colors: 6, simplify: 1.05, smoothing: 58, speckle: 4, tolerance: 30, background: true },
      line: { colors: 2, simplify: .9, smoothing: 48, speckle: 2, tolerance: 24, background: true },
      packaging: { colors: 12, simplify: .8, smoothing: 70, speckle: 2, tolerance: 24, background: true },
      illustration: { colors: 10, simplify: .8, smoothing: 78, speckle: 3, tolerance: 24, background: true },
      photo: { colors: 12, simplify: .55, smoothing: 82, speckle: 2, tolerance: 18, background: false }
    }[mode] || { colors: 6, simplify: 1.05, smoothing: 58, speckle: 4, tolerance: 30, background: true };
  }

  function updateRangeOutputs() {
    const pairs = [
      ["jlVectorColors", "jlVectorColorsValue", value => `${value} 色`],
      ["jlVectorSimplify", "jlVectorSimplifyValue", value => Number(value).toFixed(2)],
      ["jlVectorSmoothing", "jlVectorSmoothingValue", value => `${value}%`],
      ["jlVectorSpeckle", "jlVectorSpeckleValue", value => `${value}px²`],
      ["jlVectorTolerance", "jlVectorToleranceValue", value => `${value}`]
    ];
    for (const [inputId, outputId, formatter] of pairs) {
      const input = byId(inputId);
      const output = byId(outputId);
      if (input && output) output.textContent = formatter(input.value);
    }
  }

  function selectPreset(mode, applyValues = true, rerun = true) {
    if (!["logo", "line", "packaging", "illustration", "photo"].includes(mode)) mode = "logo";
    state.mode = mode;
    all("[data-vector-preset]").forEach(button => button.classList.toggle("is-active", button.dataset.vectorPreset === mode));
    const packagingNotice = byId("jlVectorPackagingNotice");
    if (packagingNotice) packagingNotice.hidden = mode !== "packaging" || Boolean(state.sourceAnalysis?.complexArtwork);
    const widthLabel = byId("jlVectorWidthLabel");
    if (widthLabel) widthLabel.textContent = mode === "packaging" ? "当前页面总宽（mm）" : "成品宽度（mm）";
    if (applyValues) {
      const preset = presetValues(mode);
      byId("jlVectorColors").value = preset.colors;
      byId("jlVectorSimplify").value = preset.simplify;
      byId("jlVectorSmoothing").value = preset.smoothing;
      byId("jlVectorSpeckle").value = preset.speckle;
      byId("jlVectorTolerance").value = preset.tolerance;
      byId("jlVectorRemoveBackground").checked = preset.background;
      updateRangeOutputs();
    }
    updateAutoSummary();
    if (state.file && applyValues && rerun) scheduleRerun();
  }

  function currentOptions() {
    return {
      mode: state.mode,
      colors: Number(byId("jlVectorColors")?.value || 6),
      simplify: Number(byId("jlVectorSimplify")?.value || 1),
      smoothing: Number(byId("jlVectorSmoothing")?.value || 70) / 100,
      speckle: Number(byId("jlVectorSpeckle")?.value || 4),
      backgroundTolerance: Number(byId("jlVectorTolerance")?.value || 30),
      removeBackground: byId("jlVectorRemoveBackground")?.checked !== false,
      boundaryMode: byId("jlVectorBoundaryMode")?.value === "stacked" ? "stacked" : "cutout",
      outputWidth: state.image?.naturalWidth || state.image?.width || state.imageData?.width || 1,
      outputHeight: state.image?.naturalHeight || state.image?.height || state.imageData?.height || 1,
      maxEdges: state.sourceAnalysis?.complexArtwork ? 1200000 : 100000,
      maxPaths: state.sourceAnalysis?.complexArtwork ? 80000 : 12000,
      maxNodes: state.sourceAnalysis?.complexArtwork ? 900000 : 160000,
      title: safeFilename(state.file?.name)
    };
  }

  function structuralLinePixel(r, g, b, family) {
    if (family === "green") return g >= 78 && g - Math.min(r, b) >= 18 && g >= r * 1.04 && g >= b * 1.04;
    if (family === "blue") return b >= 88 && b - Math.min(r, g) >= 18 && b >= r * 1.05 && b >= g * 1.03;
    if (family === "red") return r >= 100 && r - Math.max(g, b) >= 45 && g <= r * .72 && b <= r * .72;
    return r >= 72 && g >= 62 && b <= Math.min(r, g) * .72 && r + g - b * 2 >= 92 && Math.abs(r - g) <= 105;
  }

  function countStructuralRuns(mask, width, height, horizontal, minLength) {
    const major = horizontal ? width : height;
    const minor = horizontal ? height : width;
    const valueAt = (axis, position) => horizontal ? mask[axis * width + position] : mask[position * width + axis];
    let count = 0;
    let longest = 0;
    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 && valueAt(axis, position);
        if (active) {
          if (start < 0) start = position;
          last = position;
          hits += 1;
        }
        if (start >= 0 && ((!active && position - last > 2) || position === major)) {
          const length = last - start + 1;
          if (length >= minLength && hits >= length * .68) {
            count += 1;
            longest = Math.max(longest, length);
          }
          start = -1;
          last = -1;
          hits = 0;
        }
      }
    }
    return { count, longest };
  }

  const SEMANTIC_INK_FAMILIES = Object.freeze([
    { id: "neutral", name: "黑色文字 / 条码 / 图形", color: "#000000", spotName: "BLACK_INK" },
    { id: "red", name: "红色刀线 / 批注", color: "#ff3b30", spotName: "RED_INK" },
    { id: "yellow", name: "黄色专色", color: "#9b8f22", spotName: "YELLOW_INK" },
    { id: "blue", name: "蓝色专色", color: "#1769d2", spotName: "BLUE_INK" },
    { id: "green", name: "绿色专色", color: "#268a42", spotName: "GREEN_INK" }
  ]);
  const semanticRuleCache = new WeakMap();

  function semanticInkFamily(r, g, b, alpha = 255) {
    if (alpha < 24) return "";
    const maximum = Math.max(r, g, b);
    const minimum = Math.min(r, g, b);
    const chroma = maximum - minimum;
    const luminance = .299 * r + .587 * g + .114 * b;
    // Detect spot inks before neutral ink. JPEG chroma bleeding around a red
    // rule otherwise becomes alternating red/black fragments after tracing.
    if (r >= 76 && g >= 68 && r - b >= 25 && g - b >= 20) return "yellow";
    if (r >= 92 && r - g >= 24 && r - b >= 24) return "red";
    if (b >= 82 && b - r >= 24 && b - g >= 18) return "blue";
    if (g >= 78 && g - r >= 22 && g - b >= 16) return "green";
    // Preserve antialiased black type until very light edge coverage. A second
    // colour cluster must never be allowed to tint one half of a black glyph.
    if ((chroma <= 52 && luminance < 241) || luminance < 88) return "neutral";
    return "";
  }

  function semanticInkCoreFamily(r, g, b, alpha = 255) {
    if (alpha < 24) return "";
    const maximum = Math.max(r, g, b);
    const minimum = Math.min(r, g, b);
    const chroma = maximum - minimum;
    const luminance = .299 * r + .587 * g + .114 * b;
    // Trace the solid stroke core, not every JPEG/antialias fringe pixel.
    // The old 241-luminance neutral threshold expanded 6px type by 1–2px on
    // each side and closed counters.  A 120-ish midpoint keeps the original
    // glyph weight at the high-resolution production canvas used for dense print copy.
    if (r >= 104 && g >= 94 && r - b >= 34 && g - b >= 28) return "yellow";
    if (r >= 126 && r - g >= 34 && r - b >= 34) return "red";
    if (b >= 112 && b - r >= 32 && b - g >= 24) return "blue";
    if (g >= 104 && g - r >= 30 && g - b >= 22) return "green";
    if ((chroma <= 38 && luminance < 124) || luminance < 58) return "neutral";
    return "";
  }

  function analyzeSemanticInkProfile(imageData) {
    const { data, width, height } = imageData || {};
    const counts = Object.fromEntries(SEMANTIC_INK_FAMILIES.map(family => [family.id, 0]));
    if (!data || !width || !height) return { counts, total: 0, active: [], candidate: false };
    const step = Math.max(1, Math.floor(Math.sqrt(width * height / 180000)));
    let samples = 0;
    for (let y = 0; y < height; y += step) {
      for (let x = 0; x < width; x += step) {
        const index = (y * width + x) * 4;
        const family = semanticInkFamily(data[index], data[index + 1], data[index + 2], data[index + 3]);
        if (family) counts[family] += 1;
        samples += 1;
      }
    }
    const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
    const active = SEMANTIC_INK_FAMILIES
      .filter(family => counts[family.id] >= Math.max(8, samples * .00004))
      .map(family => family.id);
    const inkRatio = total / Math.max(1, samples);
    return {
      counts,
      total,
      active,
      samples,
      inkRatio: roundTo(inkRatio, 4),
      candidate: counts.neutral >= samples * .001
        && active.some(family => family !== "neutral")
        && inkRatio <= .28
    };
  }

  function analyzeFineInk(imageData) {
    const { data, width, height } = imageData || {};
    if (!data || !width || !height) {
      return { denseFineInk: false, fineInkComponents: 0, fineInkPixels: 0, fineInkBands: 0 };
    }
    const pixelCount = width * height;
    const mask = new Uint8Array(pixelCount);
    const cornerIndexes = [
      0,
      Math.max(0, width - 1),
      Math.max(0, (height - 1) * width),
      Math.max(0, width * height - 1)
    ];
    const cornerColor = [0, 0, 0];
    cornerIndexes.forEach(pixel => {
      const index = pixel * 4;
      cornerColor[0] += data[index];
      cornerColor[1] += data[index + 1];
      cornerColor[2] += data[index + 2];
    });
    cornerColor[0] /= cornerIndexes.length;
    cornerColor[1] /= cornerIndexes.length;
    cornerColor[2] /= cornerIndexes.length;
    for (let pixel = 0; pixel < pixelCount; pixel += 1) {
      const index = pixel * 4;
      if (data[index + 3] < 24) continue;
      const r = data[index], g = data[index + 1], b = data[index + 2];
      const luminance = .299 * r + .587 * g + .114 * b;
      const backgroundDistance = Math.sqrt(
        (r - cornerColor[0]) ** 2 + (g - cornerColor[1]) ** 2 + (b - cornerColor[2]) ** 2
      );
      // 普通说明文字既可能是白纸黑字，也可能是黑卡白字。排除与四角底色
      // 接近的像素后同时检查两种极性，避免黑底包装稿因没有“深色小字”
      // 而跳过 OCR 分层。饱和结构线仍不会进入普通文字掩码。
      const neutralInk = Math.max(r, g, b) - Math.min(r, g, b) <= 56;
      if (backgroundDistance >= 28 && neutralInk && (luminance < 132 || luminance > 168)) {
        mask[pixel] = 1;
      }
    }
    const visited = new Uint8Array(pixelCount);
    const smallRows = new Uint8Array(height);
    const maxSmallWidth = Math.max(4, Math.round(width * .035));
    const maxSmallHeight = Math.max(3, Math.round(height * .022));
    let fineInkComponents = 0;
    let fineInkPixels = 0;
    for (let pixel = 0; pixel < pixelCount; pixel += 1) {
      if (!mask[pixel] || visited[pixel]) continue;
      const stack = [pixel];
      visited[pixel] = 1;
      let area = 0;
      let minX = width, maxX = 0, minY = height, maxY = 0;
      while (stack.length) {
        const current = stack.pop();
        const x = current % width;
        const y = Math.floor(current / width);
        area += 1;
        minX = Math.min(minX, x); maxX = Math.max(maxX, x);
        minY = Math.min(minY, y); maxY = Math.max(maxY, y);
        for (let nextY = Math.max(0, y - 1); nextY <= Math.min(height - 1, y + 1); nextY += 1) {
          for (let nextX = Math.max(0, x - 1); nextX <= Math.min(width - 1, x + 1); nextX += 1) {
            const next = nextY * width + nextX;
            if (!mask[next] || visited[next]) continue;
            visited[next] = 1;
            stack.push(next);
          }
        }
      }
      const componentWidth = maxX - minX + 1;
      const componentHeight = maxY - minY + 1;
      const compactEnough = componentWidth <= componentHeight * 8 && componentHeight <= componentWidth * 8;
      if (area < 2 || componentWidth > maxSmallWidth || componentHeight > maxSmallHeight || !compactEnough) continue;
      fineInkComponents += 1;
      fineInkPixels += area;
      for (let y = minY; y <= maxY; y += 1) smallRows[y] = 1;
    }
    let fineInkBands = 0;
    let lastActive = -4;
    for (let y = 0; y < height; y += 1) {
      if (!smallRows[y]) continue;
      if (y - lastActive > 3) fineInkBands += 1;
      lastActive = y;
    }
    const componentFloor = Math.max(24, Math.round(Math.sqrt(pixelCount) * .07));
    const pixelFloor = Math.max(60, Math.round(pixelCount * .0014));
    const denseFineInk = fineInkComponents >= componentFloor && fineInkPixels >= pixelFloor && fineInkBands >= 3;
    return { denseFineInk, fineInkComponents, fineInkPixels, fineInkBands };
  }

  function analyzePrintSheet(imageData) {
    const { data, width, height } = imageData;
    const pixelCount = width * height;
    const families = ["green", "blue", "yellow", "red"].map(name => {
      const mask = new Uint8Array(pixelCount);
      let pixels = 0;
      for (let pixel = 0; pixel < pixelCount; pixel += 1) {
        const index = pixel * 4;
        if (data[index + 3] < 24 || !structuralLinePixel(data[index], data[index + 1], data[index + 2], name)) continue;
        mask[pixel] = 1;
        pixels += 1;
      }
      const horizontal = countStructuralRuns(mask, width, height, true, Math.max(12, Math.round(width * .028)));
      const vertical = countStructuralRuns(mask, width, height, false, Math.max(12, Math.round(height * .028)));
      const density = pixels / Math.max(1, pixelCount);
      // Red is also common in customer proofing notes and headings. Only regard it
      // as a dieline family when both axes contain genuinely long continuous rules.
      const longRedRules = name !== "red"
        || (horizontal.longest >= width * .12 && vertical.longest >= height * .12);
      const structured = density >= .00004 && density <= .055 && horizontal.count >= 2 && vertical.count >= 2 && longRedRules;
      return { name, pixels, density, horizontal, vertical, structured };
    });
    const sampleStep = Math.max(1, Math.floor(pixelCount / 36000));
    const colorBuckets = new Set();
    let light = 0;
    let dark = 0;
    let samples = 0;
    for (let pixel = 0; pixel < pixelCount; pixel += sampleStep) {
      const index = pixel * 4;
      if (data[index + 3] < 24) continue;
      const r = data[index], g = data[index + 1], b = data[index + 2];
      colorBuckets.add(`${r >> 4},${g >> 4},${b >> 4}`);
      const luminance = .299 * r + .587 * g + .114 * b;
      if (luminance >= 226) light += 1;
      if (luminance <= 72) dark += 1;
      samples += 1;
    }
    const structuredFamilies = families.filter(family => family.structured);
    const structuralRuns = structuredFamilies.reduce((sum, family) => sum + family.horizontal.count + family.vertical.count, 0);
    const lightRatio = samples ? light / samples : 0;
    const darkRatio = samples ? dark / samples : 0;
    // 黑卡、深色满版包装稿不具备白色纸张占比，但只要存在跨版面的连续
    // 横竖结构网，仍必须进入包装专业重建，不能降级成普通插画量化。
    const darkProductionSheet = structuredFamilies.length > 0 && structuralRuns >= 6 && darkRatio >= .25;
    // 深色满版包装稿在缩略识别阶段，少量刀线可能被黑色画面吞掉。既然已经
    // 同时满足“连续横竖结构线 + 大面积深色印刷面”，不能再用普通包装稿的
    // 8 段门槛把它降级成整图描摹；真实 freddi 稿只能稳定检测到 7 段。
    const isPrintSheet = structuredFamilies.length > 0
      && (structuralRuns >= 8 || darkProductionSheet)
      && (lightRatio >= .32 || darkProductionSheet);
    const fineInk = analyzeFineInk(imageData);
    const semanticInk = analyzeSemanticInkProfile(imageData);
    const richArtwork = colorBuckets.size >= 150;
    const complexArtwork = isPrintSheet && (richArtwork || fineInk.denseFineInk || darkProductionSheet);
    const semanticSpotCandidate = complexArtwork
      && !darkProductionSheet
      && lightRatio >= .72
      && semanticInk.candidate;
    return {
      isPrintSheet,
      complexArtwork,
      protectionReason: complexArtwork
        ? (darkProductionSheet ? "dark-packaging-sheet" : fineInk.denseFineInk ? "dense-fine-ink" : "rich-artwork")
        : "",
      colorBuckets: colorBuckets.size,
      structuralRuns,
      lightRatio: Math.round(lightRatio * 1000) / 1000,
      darkRatio: Math.round(darkRatio * 1000) / 1000,
      darkProductionSheet,
      semanticSpotCandidate,
      semanticInk,
      ...fineInk,
      families: families.map(({ mask, ...family }) => family)
    };
  }

  function detectMode(imageData) {
    const { data, width, height } = imageData;
    const printSheet = analyzePrintSheet(imageData);
    if (printSheet.isPrintSheet) return "packaging";
    const step = Math.max(1, Math.floor(width * height / 12000));
    const buckets = new Set();
    let grayscale = 0;
    let opaque = 0;
    let strongEdges = 0;
    let edgeChecks = 0;
    let outerLight = 0;
    let outerChecks = 0;
    let innerDark = 0;
    let innerChecks = 0;
    let yellowLine = 0;
    for (let pixel = 0; pixel < width * height; pixel += step) {
      const index = pixel * 4;
      if (data[index + 3] < 16) continue;
      const r = data[index], g = data[index + 1], b = data[index + 2];
      const x = pixel % width;
      const y = Math.floor(pixel / width);
      const lum = .299 * r + .587 * g + .114 * b;
      opaque += 1;
      if (Math.max(r, g, b) - Math.min(r, g, b) < 16) grayscale += 1;
      buckets.add(`${r >> 4},${g >> 4},${b >> 4}`);
      if (r >= 72 && g >= 62 && b <= Math.min(r, g) * .72 && r + g - b * 2 >= 92 && Math.abs(r - g) <= 105) yellowLine += 1;
      if (x < width * .08 || x > width * .92 || y < height * .08 || y > height * .92) {
        outerChecks += 1;
        if (lum > 225) outerLight += 1;
      } else {
        innerChecks += 1;
        if (lum < 48) innerDark += 1;
      }
      if (x + step < width) {
        const next = index + step * 4;
        const nextLum = .299 * data[next] + .587 * data[next + 1] + .114 * data[next + 2];
        if (Math.abs(lum - nextLum) > 58) strongEdges += 1;
        edgeChecks += 1;
      }
    }
    const grayscaleRatio = opaque ? grayscale / opaque : 0;
    const edgeRatio = edgeChecks ? strongEdges / edgeChecks : 0;
    const outerLightRatio = outerChecks ? outerLight / outerChecks : 0;
    const innerDarkRatio = innerChecks ? innerDark / innerChecks : 0;
    const yellowLineRatio = opaque ? yellowLine / opaque : 0;
    // A white page containing a large dark packaging/artwork panel is a mixed layout,
    // not a monochrome glyph sheet. Treating it as line art turns the whole dark panel
    // into one foreground path and leaves type as pixel-shaped holes.
    const mixedDarkLayout = outerLightRatio > .68 && innerDarkRatio > .34;
    if (mixedDarkLayout && (yellowLine >= 4 || yellowLineRatio > .00035)) return "packaging";
    if (mixedDarkLayout) return "illustration";
    if (grayscaleRatio > .84 && (edgeRatio > .035 || buckets.size <= 40)) return "line";
    if (buckets.size <= 26) return "logo";
    if (buckets.size <= 230 || edgeRatio > .17) return "illustration";
    return "photo";
  }

  function analysisLabel(mode) {
    return { logo: "扁平 Logo", line: "黑白线稿", packaging: "包装刀模设计稿", illustration: "彩色插画", photo: "照片（海报化）" }[mode] || "图片";
  }

  function updateAutoSummary(detail = "") {
    const title = byId("jlVectorDetectedMode");
    const summary = byId("jlVectorAutoResult");
    if (!title || !summary) return;
    const goalLabel = { fidelity: "精准还原", balanced: "平衡推荐", compact: "路径精简" }[state.qualityGoal] || "精准还原";
    if (!state.file) {
      title.textContent = "等待图片分析";
      const small = summary.querySelector("small");
      if (small) small.textContent = "放入图片后，系统会自动选择类型并比较多组参数。";
      return;
    }
    title.textContent = state.sourceAnalysis?.complexArtwork
      ? "复杂包装稿 · 专业矢量重建"
      : `${analysisLabel(state.mode)} · ${state.autoOptimize ? goalLabel : "专业参数"}`;
    const small = summary.querySelector("small");
    if (small) small.textContent = detail || (state.autoOptimize
      ? "生成时自动比较多组参数，选择画面与路径综合得分最高的方案。"
      : "当前使用专业设置中的参数，不再自动比较方案。");
  }

  function setQualityGoal(goal, rerun = true) {
    if (!["fidelity", "balanced", "compact"].includes(goal)) goal = "fidelity";
    state.qualityGoal = goal;
    state.autoOptimize = true;
    all("[data-vector-goal]").forEach(button => button.classList.toggle("is-active", button.dataset.vectorGoal === goal));
    selectPreset(state.mode, true, false);
    updateAutoSummary();
    if (state.file && rerun) scheduleRerun();
  }

  function clampNumber(value, minimum, maximum, digits = 2) {
    const rounded = Math.round(Math.max(minimum, Math.min(maximum, Number(value))) * (10 ** digits)) / (10 ** digits);
    return Number.isFinite(rounded) ? rounded : minimum;
  }

  function optimizationCandidates(mode = state.mode, goal = state.qualityGoal) {
    const base = presetValues(mode);
    const fixedColors = mode === "line" ? 2 : null;
    const create = (label, colorsFactor, simplifyFactor, smoothingOffset, speckleFactor, toleranceOffset = 0) => ({
      label,
      options: {
        colors: fixedColors || Math.round(clampNumber(base.colors * colorsFactor, 2, 32, 0)),
        simplify: clampNumber(base.simplify * simplifyFactor, 0, 4),
        smoothing: clampNumber((base.smoothing + smoothingOffset) / 100, 0, 1),
        speckle: Math.round(clampNumber(base.speckle * speckleFactor, 0, 80, 0)),
        backgroundTolerance: Math.round(clampNumber(base.tolerance + toleranceOffset, 0, 120, 0)),
        removeBackground: base.background,
        boundaryMode: byId("jlVectorBoundaryMode")?.value === "stacked" ? "stacked" : "cutout"
      }
    });
    const createProfessional = (label, colors, simplify, smoothing, speckle, tolerance) => ({
      label,
      options: {
        colors: Math.round(clampNumber(colors, 2, 32, 0)),
        simplify: clampNumber(simplify, 0, 4),
        smoothing: clampNumber(smoothing, 0, 1),
        speckle: Math.round(clampNumber(speckle, 0, 80, 0)),
        backgroundTolerance: Math.round(clampNumber(tolerance, 0, 120, 0)),
        removeBackground: true,
        boundaryMode: byId("jlVectorBoundaryMode")?.value === "stacked" ? "stacked" : "cutout"
      }
    });
    if (mode === "packaging" && state.sourceAnalysis?.complexArtwork) {
      const sourceColors = Number(state.sourceAnalysis?.colorBuckets || 0);
      const richArtwork = sourceColors >= 180;
      // Professional tracers do not use the same preset for line art and
      // photographic packaging panels. Compare a clean structural separation,
      // a print-oriented illustration palette and a high-color posterized
      // reconstruction, then keep the best foreground-weighted result.
      return richArtwork ? [
        createProfessional("刀线与图文分层", 12, .9, .68, 36, 30),
        createProfessional("插画分色高保真", 18, .68, .76, 22, 24),
        createProfessional("照片区域高保真", 24, .52, .82, 12, 20)
      ] : [
        createProfessional("刀线与图文分层", 8, .95, .68, 24, 30),
        createProfessional("印刷分色高保真", 12, .72, .75, 14, 25),
        createProfessional("细节曲线重建", 16, .58, .8, 8, 21)
      ];
    }
    if (goal === "compact") return [
      create("精简优先", .75, 1.65, 10, 1.8, 5),
      create("清晰精简", .9, 1.3, 4, 1.35, 2),
      create("平衡基准", 1, 1, 0, 1, 0)
    ];
    if (goal === "balanced") return [
      create("平衡基准", 1, 1, 0, 1, 0),
      create("清晰边缘", 1.15, .76, -9, .65, -2),
      create("平滑轮廓", .9, 1.18, 10, 1.3, 3),
      create("细节保留", 1.25, .62, -15, .45, -4)
    ];
    return [
      create("高保真细节", 1.35, .42, -20, .25, -5),
      create("清晰边缘", 1.15, .65, -10, .5, -2),
      create("曲线保真", 1, .82, 5, .75, 0),
      create("稳健基准", 1, 1, 0, 1, 0)
    ];
  }

  function applyOptimizedOptions(options) {
    if (!options) return;
    if (byId("jlVectorColors")) byId("jlVectorColors").value = options.colors;
    if (byId("jlVectorSimplify")) byId("jlVectorSimplify").value = options.simplify;
    if (byId("jlVectorSmoothing")) byId("jlVectorSmoothing").value = Math.round(Number(options.smoothing) * 100);
    if (byId("jlVectorSpeckle")) byId("jlVectorSpeckle").value = options.speckle;
    if (byId("jlVectorTolerance")) byId("jlVectorTolerance").value = options.backgroundTolerance;
    if (byId("jlVectorRemoveBackground")) byId("jlVectorRemoveBackground").checked = options.removeBackground !== false;
    if (byId("jlVectorBoundaryMode")) byId("jlVectorBoundaryMode").value = options.boundaryMode === "stacked" ? "stacked" : "cutout";
    updateRangeOutputs();
  }

  function svgCandidateImageData(result, sourceImageData) {
    return new Promise((resolve, reject) => {
      let url = "";
      try {
        let svg = window.JianglaiVectorEngine.renderSvg(result, { title: "智能参数比较" });
        const background = /^#[0-9a-f]{6}$/i.test(result.background || "") ? result.background : "#ffffff";
        svg = svg.replace("<metadata>", `<rect width="100%" height="100%" fill="${background}"/><metadata>`);
        const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
        url = URL.createObjectURL(blob);
        const image = new Image();
        image.onload = () => {
          try {
            const canvas = document.createElement("canvas");
            canvas.width = sourceImageData.width;
            canvas.height = sourceImageData.height;
            const context = canvas.getContext("2d", { willReadFrequently: true });
            context.imageSmoothingEnabled = true;
            context.imageSmoothingQuality = "high";
            context.drawImage(image, 0, 0, canvas.width, canvas.height);
            resolve(context.getImageData(0, 0, canvas.width, canvas.height));
          } catch (error) {
            reject(error);
          } finally {
            URL.revokeObjectURL(url);
          }
        };
        image.onerror = () => { URL.revokeObjectURL(url); reject(new Error("候选方案预览失败")); };
        image.src = url;
      } catch (error) {
        if (url) URL.revokeObjectURL(url);
        reject(error);
      }
    });
  }

  function edgeMagnitude(data, index, rightIndex, downIndex) {
    const luminance = offset => .299 * data[offset] + .587 * data[offset + 1] + .114 * data[offset + 2];
    const center = luminance(index);
    return Math.max(Math.abs(center - luminance(rightIndex)), Math.abs(center - luminance(downIndex)));
  }

  function sampledCornerBackground(data, width, height) {
    const points = [
      [0, 0], [Math.max(0, width - 1), 0],
      [0, Math.max(0, height - 1)], [Math.max(0, width - 1), Math.max(0, height - 1)]
    ];
    const color = [0, 0, 0];
    points.forEach(([x, y]) => {
      const index = (y * width + x) * 4;
      color[0] += data[index];
      color[1] += data[index + 1];
      color[2] += data[index + 2];
    });
    return color.map(value => value / points.length);
  }

  function rgbDistanceFrom(data, index, color) {
    return Math.sqrt(
      (data[index] - color[0]) ** 2
      + (data[index + 1] - color[1]) ** 2
      + (data[index + 2] - color[2]) ** 2
    );
  }

  function scoreSemanticInkFidelity(sourceImageData, renderedImageData) {
    const source = sourceImageData?.data;
    const target = renderedImageData?.data;
    const width = Number(sourceImageData?.width || 0);
    const height = Number(sourceImageData?.height || 0);
    if (!source || !target || !width || !height) {
      return { semanticInkRecall: 0, neutralInkRecall: 0, redInkRecall: 0, semanticInkPrecision: 0 };
    }
    const sourceCounts = Object.fromEntries(SEMANTIC_INK_FAMILIES.map(family => [family.id, 0]));
    const matchedCounts = Object.fromEntries(SEMANTIC_INK_FAMILIES.map(family => [family.id, 0]));
    const structureMasks = {
      neutral: semanticRuleRecord(sourceImageData, "neutral").mask,
      red: semanticRuleRecord(sourceImageData, "red").mask
    };
    const step = Math.max(1, Math.floor(Math.sqrt(width * height / 1200000)));
    let targetInk = 0;
    let matchedInk = 0;
    for (let y = 0; y < height; y += step) {
      for (let x = 0; x < width; x += step) {
        const index = (y * width + x) * 4;
        const sourceFamily = semanticInkCoreFamily(source[index], source[index + 1], source[index + 2], source[index + 3]);
        const targetFamily = semanticInkCoreFamily(target[index], target[index + 1], target[index + 2], target[index + 3]);
        // Dielines and creases have their own geometry/spot-colour gate. Their
        // normalized 0.25mm production stroke must not be scored as if it were
        // the original antialiased text width.
        if (sourceFamily && structureMasks[sourceFamily]?.[y * width + x]) continue;
        if (sourceFamily) sourceCounts[sourceFamily] += 1;
        if (targetFamily) targetInk += 1;
        if (sourceFamily && sourceFamily === targetFamily) {
          matchedCounts[sourceFamily] += 1;
          matchedInk += 1;
        }
      }
    }
    const sourceInk = Object.values(sourceCounts).reduce((sum, count) => sum + count, 0);
    const familyRecall = family => sourceCounts[family] ? matchedCounts[family] / sourceCounts[family] : 1;
    return {
      semanticInkRecall: roundTo((sourceInk ? matchedInk / sourceInk : 1) * 100, 1),
      neutralInkRecall: roundTo(familyRecall("neutral") * 100, 1),
      redInkRecall: roundTo(familyRecall("red") * 100, 1),
      semanticInkPrecision: roundTo((targetInk ? matchedInk / targetInk : 1) * 100, 1),
      semanticSourceInkSamples: sourceInk,
      semanticMatchedInkSamples: matchedInk
    };
  }

  async function scoreVectorCandidate(result, sourceImageData, goal = state.qualityGoal, mode = state.mode) {
    let rendered;
    try {
      rendered = await svgCandidateImageData(result, sourceImageData);
    } catch (_) {
      const paths = Number(result.metrics?.paths || 0);
      const nodes = Number(result.metrics?.nodes || 0);
      const fallback = Math.max(0, 70 - Math.log10(Math.max(1, paths + nodes)) * (goal === "compact" ? 8 : 2));
      return {
        score: roundTo(fallback, 1), visual: 0, edge: 0, complexity: roundTo(fallback, 1),
        paletteRetention: 0, fragmentation: 0, resolutionRetention: 0,
        resolutionAdequate: false, structureAdequate: false, productionReady: false
      };
    }
    const width = sourceImageData.width;
    const height = sourceImageData.height;
    const source = sourceImageData.data;
    const target = rendered.data;
    const step = Math.max(1, Math.ceil(Math.sqrt(width * height / 24000)));
    let colorError = 0;
    let samples = 0;
    let sourceEdges = 0;
    let targetEdges = 0;
    let matchingEdges = 0;
    let contentError = 0;
    let contentSamples = 0;
    const sourcePalette = new Set();
    const sourceBackground = sampledCornerBackground(source, width, height);
    const edgeThreshold = mode === "photo" ? 45 : 32;
    for (let y = 0; y < height - step; y += step) {
      for (let x = 0; x < width - step; x += step) {
        const index = (y * width + x) * 4;
        const rightIndex = (y * width + Math.min(width - 1, x + step)) * 4;
        const downIndex = (Math.min(height - 1, y + step) * width + x) * 4;
        const alpha = source[index + 3] / 255;
        if (alpha < .04) continue;
        sourcePalette.add(`${source[index] >> 5},${source[index + 1] >> 5},${source[index + 2] >> 5}`);
        colorError += (Math.abs(source[index] - target[index]) + Math.abs(source[index + 1] - target[index + 1]) + Math.abs(source[index + 2] - target[index + 2])) / 765;
        samples += 1;
        const sourceMagnitude = edgeMagnitude(source, index, rightIndex, downIndex);
        const sourceEdge = sourceMagnitude >= edgeThreshold;
        const targetEdge = edgeMagnitude(target, index, rightIndex, downIndex) >= edgeThreshold;
        // White margins dominate packaging proof sheets and can make a poor
        // reconstruction look 95% similar. Score ink, artwork and structural
        // edges separately so the selected candidate is the one that preserves
        // actual printable content.
        const sourceInk = rgbDistanceFrom(source, index, sourceBackground) >= 30 || sourceEdge;
        if (sourceInk) {
          contentError += (
            Math.abs(source[index] - target[index])
            + Math.abs(source[index + 1] - target[index + 1])
            + Math.abs(source[index + 2] - target[index + 2])
          ) / 765;
          contentSamples += 1;
        }
        if (sourceEdge) sourceEdges += 1;
        if (targetEdge) targetEdges += 1;
        if (sourceEdge && targetEdge) matchingEdges += 1;
      }
    }
    const visual = samples ? Math.max(0, 1 - colorError / samples) : 0;
    const contentVisual = contentSamples ? Math.max(0, 1 - contentError / contentSamples) : visual;
    const edge = sourceEdges + targetEdges ? 2 * matchingEdges / (sourceEdges + targetEdges) : 1;
    const nodes = Number(result.metrics?.nodes || 0);
    const paths = Number(result.metrics?.paths || 0);
    const nodesPerPath = paths ? nodes / paths : 0;
    const pathCoherence = paths
      ? Math.max(0, Math.min(1, (nodesPerPath - 4) / 8))
      : 1;
    const fragmentedPathStorm = paths >= 1200 && nodesPerPath < 10;
    const complexityBudget = Math.max(600, Math.sqrt(width * height) * (mode === "photo" ? 16 : mode === "illustration" ? 13 : 10));
    const complexity = 1 / (1 + (nodes + paths * 4) / complexityBudget);
    const artworkLayers = (result.layers || []).filter(layer => !isStructureLayer(layer));
    const expectedPalette = mode === "packaging"
      ? Math.max(2, Number(result.structure?.suggestedArtworkColors || 2))
      : mode === "illustration"
        ? Math.max(2, Math.min(12, sourcePalette.size))
        : Math.max(1, Math.min(artworkLayers.length, sourcePalette.size));
    const paletteRetention = Math.min(1, artworkLayers.length / expectedPalette);
    const contours = artworkLayers.flatMap(layer => Array.isArray(layer.contours) ? layer.contours : []);
    const tinyLimit = Math.max(2, width * height * .000015);
    const tinyContours = contours.filter(contour => Number(contour.area || 0) <= tinyLimit).length;
    const fragmentation = contours.length ? Math.max(0, 1 - tinyContours / contours.length) : 1;
    const weights = goal === "compact"
      ? { visual: .38, edge: .22, complexity: .4 }
      : goal === "balanced"
        ? { visual: .52, edge: .3, complexity: .18 }
        : { visual: .56, edge: .38, complexity: .06 };
    const edgeBoost = ["line", "logo", "packaging"].includes(mode) ? .06 : 0;
    const paletteWeight = ["packaging", "illustration"].includes(mode) ? .08 : 0;
    const rankingVisual = mode === "packaging" ? visual * .55 + contentVisual * .45 : visual;
    const rawScore = rankingVisual * (weights.visual - edgeBoost - paletteWeight)
      + edge * (weights.edge + edgeBoost)
      + complexity * weights.complexity
      + paletteRetention * paletteWeight;
    const editabilityPenalty = fragmentedPathStorm
      ? .68 + pathCoherence * .16
      : .94 + pathCoherence * .06;
    const score = 100 * rawScore * (.9 + fragmentation * .1) * editabilityPenalty;
    const sourceMaxEdge = Math.max(state.sourceWidth || width, state.sourceHeight || height);
    const processingMaxEdge = Math.max(width, height);
    const resolutionRetention = Math.min(1, processingMaxEdge / Math.max(1, sourceMaxEdge));
    const printSheet = Boolean(state.sourceAnalysis?.isPrintSheet);
    const absoluteResolutionTarget = Math.min(sourceMaxEdge, printSheet ? 2400 : 1600);
    const resolutionAdequate = !printSheet || (
      processingMaxEdge >= absoluteResolutionTarget
    );
    const structureAdequate = !printSheet || Number(result.metrics?.dielinePaths || 0) >= 4;
    const sourceFineInkRisk = printSheet && Boolean(state.sourceAnalysis?.denseFineInk);
    const productionReady = visual >= .64
      && edge >= .28
      && paletteRetention >= .7
      && fragmentation >= .18
      && resolutionAdequate
      && structureAdequate
      && !state.sourceAnalysis?.complexArtwork
      && !sourceFineInkRisk;
    const semanticInk = result?.structure?.semanticSpotSeparation
      ? scoreSemanticInkFidelity(sourceImageData, rendered)
      : {};
    return {
      score: roundTo(score, 1),
      visual: roundTo(visual * 100, 1),
      contentVisual: roundTo(contentVisual * 100, 1),
      edge: roundTo(edge * 100, 1),
      complexity: roundTo(complexity * 100, 1),
      pathCoherence: roundTo(pathCoherence * 100, 1),
      nodesPerPath: roundTo(nodesPerPath, 1),
      fragmentedPathStorm,
      paletteRetention: roundTo(paletteRetention * 100, 1),
      fragmentation: roundTo(fragmentation * 100, 1),
      resolutionRetention: roundTo(resolutionRetention * 100, 1),
      sourceMaxEdge,
      processingMaxEdge,
      absoluteResolutionTarget,
      resolutionAdequate,
      structureAdequate,
      productionReady,
      ...semanticInk
    };
  }

  function evaluateProfessionalReconstruction(result, sourceAnalysis = state.sourceAnalysis) {
    const quality = result?.quality || {};
    const layers = Array.isArray(result?.layers) ? result.layers : [];
    // Text restored from a real font and deliberately isolated Logo/artwork
    // layers can legitimately contain many small counters and holes.  Only
    // residual all-page trace layers are evidence that bitmap type was
    // shredded into thousands of tiny polygons.
    const suspiciousLayers = layers.filter(layer => {
      const role = String(layer?.role || "").toLowerCase();
      if (["text", "dieline", "cutline", "crease", "fold", "bleed"].includes(role)) return false;
      // A generic full-page bitmap trace is also labelled "artwork" by the
      // vector engine.  Exempt only the Logo/illustration regions that were
      // deliberately cropped, traced and marked as independent artwork.
      if (layer?.semanticInkLayer === true) return false;
      if (["logo", "artwork"].includes(role) && layer?.artworkIsolated === true) return false;
      return true;
    });
    const pathCount = suspiciousLayers.reduce((sum, layer) => sum + Number(layer?.paths || 0), 0);
    const nodeCount = suspiciousLayers.reduce((sum, layer) => sum + Number(layer?.nodes || 0), 0);
    const totalNodeCount = layers.reduce((sum, layer) => sum + Number(layer?.nodes || 0), 0);
    const originalGlyphLayers = layers.filter(layer => layer?.originalGlyphTrace === true);
    const originalGlyphPathCount = originalGlyphLayers.reduce((sum, layer) => sum + Number(layer?.paths || 0), 0);
    const originalGlyphNodeCount = originalGlyphLayers.reduce((sum, layer) => sum + Number(layer?.nodes || 0), 0);
    const nodesPerPath = pathCount ? nodeCount / pathCount : 0;
    const outputWidth = Math.max(1, Number(result?.outputWidth || result?.width || 1600));
    const outputHeight = Math.max(1, Number(result?.outputHeight || result?.height || 1200));
    // A production reconstruction must remain editable. High similarity does
    // not excuse an all-page bitmap trace with tens of thousands of redundant
    // points. Scale the budget with the final document size, not the temporary
    // processing canvas.
    const residualNodeBudget = Math.min(60000, Math.max(12000, Math.round(Math.sqrt(outputWidth * outputHeight) * 16)));
    const totalNodeBudget = Math.min(80000, Math.max(20000, Math.round(Math.sqrt(outputWidth * outputHeight) * 24)));
    const residualTraceTooDense = nodeCount > residualNodeBudget;
    const originalGlyphTooDense = originalGlyphPathCount > 18000 || originalGlyphNodeCount > 180000;
    // The total document can legitimately exceed the generic node budget in
    // fidelity mode after dense copy and Logos have been deliberately split
    // into isolated text/artwork layers.  Reject a large document only when a
    // meaningful share still belongs to an unclassified full-page trace.
    // Otherwise extreme-fidelity glyph curves would be mistaken for the exact
    // bitmap-shredding failure this gate is intended to catch.
    const suspiciousNodeShare = totalNodeCount ? nodeCount / totalNodeCount : 0;
    const documentTooDense = totalNodeCount > totalNodeBudget
      && (nodeCount > residualNodeBudget * .35 || suspiciousNodeShare > .28);
    const reconstructedTextLines = Number(quality.reconstructionTextLines || 0);
    const fragmentedPathStorm = pathCount >= 1200 && nodesPerPath < 10;
    const structureLayers = layers.filter(layer => [
      "dieline", "cutline", "crease", "fold"
    ].includes(String(layer?.role || "").toLowerCase()));
    const structureReady = Number(result?.metrics?.dielinePaths || 0) >= 4
      && structureLayers.length > 0
      && layers.some(layer => !structureLayers.includes(layer) && Number(layer.paths || 0) > 0);
    const denseFineInk = Boolean(sourceAnalysis?.denseFineInk);
    const semanticSpotSeparation = quality.semanticSpotSeparation === true
      || result?.structure?.semanticSpotSeparation === true;
    const semanticInkRecall = Number(quality.semanticInkRecall || 0);
    const neutralInkRecall = Number(quality.neutralInkRecall || 0);
    const redInkRecall = Number(quality.redInkRecall || 0);
    const semanticInkPrecision = Number(quality.semanticInkPrecision || 0);
    const semanticInkReady = !semanticSpotSeparation || (
      semanticInkRecall >= 86
      && neutralInkRecall >= 88
      && redInkRecall >= 84
      && semanticInkPrecision >= 82
    );
    const denseFineInkRebuilt = !denseFineInk
      || reconstructedTextLines >= 3
      || (semanticSpotSeparation && quality.semanticFineInkPreserved === true);
    const visual = Number(quality.visual || 0);
    const contentVisual = Number(quality.contentVisual || visual);
    const edge = Number(quality.edge || 0);
    const palette = Number(quality.paletteRetention || 0);
    const fragmentation = Number(quality.fragmentation || 0);
    const pathCoherence = Number(quality.pathCoherence ?? fragmentation);
    // A packaging sheet can legitimately contain many small punctuation,
    // counters and illustration details.  Treat them as broken fragments only
    // when their paths are also structurally incoherent; coherent curves with
    // healthy nodes-per-path must not be rejected merely for being small.
    const editableIntegrity = Math.max(fragmentation, pathCoherence * .7);
    // Dense instructions, ingredients, barcodes and other micro type cannot be
    // reconstructed faithfully from a flattened bitmap by tracing alone.
    // Never let "some paths exist" turn a shredded preview into a production file.
    const artworkReady = denseFineInkRebuilt
      && semanticInkReady
      && !fragmentedPathStorm
      && !residualTraceTooDense
      && !originalGlyphTooDense
      && !documentTooDense
      && visual >= 78
      && contentVisual >= 62
      && edge >= 56
      && palette >= 78
      && editableIntegrity >= 48
      && quality.resolutionAdequate !== false;
    const failures = [];
    if (visual < 78) failures.push(`整体相似度${visual}%（需≥78%）`);
    if (contentVisual < 62) failures.push(`印刷内容相似度${contentVisual}%（需≥62%）`);
    if (edge < 56) failures.push(`边缘还原${edge}%（需≥56%）`);
    if (palette < 78) failures.push(`色彩层保留${palette}%（需≥78%）`);
    if (editableIntegrity < 48) failures.push(`可编辑路径完整度${roundTo(editableIntegrity, 1)}%（需≥48%）`);
    if (semanticSpotSeparation && neutralInkRecall < 88) failures.push(`黑字与条码召回${neutralInkRecall}%（需≥88%）`);
    if (semanticSpotSeparation && redInkRecall < 84) failures.push(`红色刀线召回${redInkRecall}%（需≥84%）`);
    if (semanticSpotSeparation && semanticInkPrecision < 82) failures.push(`专色路径准确度${semanticInkPrecision}%（需≥82%）`);
    if (residualTraceTooDense) failures.push(`整页残留描摹${nodeCount}个节点（上限${residualNodeBudget}）`);
    if (originalGlyphTooDense) failures.push(`原字形曲线${originalGlyphPathCount}条路径、${originalGlyphNodeCount}个节点（需≤18000条/180000点）`);
    if (documentTooDense) failures.push(`总节点${totalNodeCount}个（上限${totalNodeBudget}）`);
    if (quality.resolutionAdequate === false) {
      failures.push(`处理清晰度${Number(quality.processingMaxEdge || 0)}px（需≥${Number(quality.absoluteResolutionTarget || 0)}px）`);
    }
    let reason = "";
    if (residualTraceTooDense || originalGlyphTooDense || documentTooDense) {
      reason = originalGlyphTooDense
        ? `原字形被拆成${originalGlyphPathCount}条路径、${originalGlyphNodeCount}个节点，超过商用可编辑上限`
        : `检测到整页高节点像素描摹（残留${nodeCount}个、总计${totalNodeCount}个节点），没有完成文字、Logo与底板的独立重建`;
    } else if (fragmentedPathStorm) {
      reason = `检测到碎片路径（${pathCount}条路径、平均${roundTo(nodesPerPath, 1)}个节点），已拒绝把破碎文字当成矢量稿`;
    } else if (!denseFineInkRebuilt) {
      const detected = Number(quality.reconstructionOcrDetected || 0);
      const accepted = Number(quality.reconstructionOcrAccepted || reconstructedTextLines || 0);
      reason = `检测到密集小字、条码或成分说明，但普通文字重建不足（识别${detected}行，可安全重建${accepted}行）`;
    } else if (!semanticInkReady) {
      reason = `专色分层未达标：黑字/条码召回${neutralInkRecall}%，红色刀线召回${redInkRecall}%，路径准确度${semanticInkPrecision}%`;
    } else if (!structureReady) {
      reason = "刀线或印刷轮廓没有完整重建";
    } else if (!artworkReady) {
      reason = failures.length
        ? `画面还原未达生产标准：${failures.join("；")}`
        : "画面还原未达生产标准";
    }
    return {
      structureReady,
      artworkReady,
      productionReady: structureReady && artworkReady,
      denseFineInk,
      denseFineInkRebuilt,
      semanticSpotSeparation,
      semanticInkReady,
      semanticInkRecall,
      neutralInkRecall,
      redInkRecall,
      semanticInkPrecision,
      fragmentedPathStorm,
      residualTraceTooDense,
      originalGlyphTooDense,
      documentTooDense,
      originalGlyphPathCount,
      originalGlyphNodeCount,
      pathCount,
      nodeCount,
      totalNodeCount,
      residualNodeBudget,
      totalNodeBudget,
      nodesPerPath: roundTo(nodesPerPath, 1),
      contentVisual,
      pathCoherence,
      editableIntegrity: roundTo(editableIntegrity, 1),
      failedChecks: failures,
      reason
    };
  }

  function isStructureLayer(layer) {
    return ["dieline", "cutline", "crease", "fold", "bleed"].includes(String(layer?.role || "").toLowerCase());
  }

  function normalizedSvgColor(value, fallback = "#000000") {
    const text = String(value || "").trim();
    const shortHex = text.match(/^#([0-9a-f]{3})$/i);
    if (shortHex) return `#${[...shortHex[1]].map(character => character.repeat(2)).join("")}`.toLowerCase();
    if (/^#[0-9a-f]{6}$/i.test(text)) return text.toLowerCase();
    const rgb = text.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
    if (rgb) return `#${rgb.slice(1, 4).map(channel => Math.round(clampNumber(channel, 0, 255, 0)).toString(16).padStart(2, "0")).join("")}`;
    return fallback;
  }

  function inheritedSvgFill(path) {
    let node = path;
    while (node?.nodeType === 1) {
      const direct = node.getAttribute("fill");
      if (direct && direct !== "none") return normalizedSvgColor(direct);
      const style = String(node.getAttribute("style") || "").match(/(?:^|;)\s*fill\s*:\s*([^;]+)/i);
      if (style && style[1] !== "none") return normalizedSvgColor(style[1]);
      node = node.parentElement;
    }
    return "#000000";
  }

  function svgColorDistance(left, right) {
    const channels = color => (String(color).match(/[0-9a-f]{2}/gi) || ["00", "00", "00"]).map(value => parseInt(value, 16));
    const a = channels(left);
    const b = channels(right);
    return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2);
  }

  function sampledBackgroundHex(imageData) {
    const color = sampledCornerBackground(imageData.data, imageData.width, imageData.height);
    return `#${color.map(channel => Math.round(clampNumber(channel, 0, 255, 0)).toString(16).padStart(2, "0")).join("")}`;
  }

  function measuredPathBounds(svgRoot, count) {
    if (!document.body || !count) return [];
    const measuring = document.importNode(svgRoot, true);
    measuring.setAttribute("aria-hidden", "true");
    measuring.style.cssText = "position:fixed;left:-100000px;top:-100000px;width:auto;height:auto;visibility:hidden;pointer-events:none";
    document.body.appendChild(measuring);
    const bounds = [...measuring.querySelectorAll("path")].map(path => {
      try {
        const box = path.getBBox();
        return { x: box.x, y: box.y, width: box.width, height: box.height };
      } catch (_) {
        return null;
      }
    });
    measuring.remove();
    return bounds;
  }

  function pixelPathRings(pathData) {
    const tokens = String(pathData || "").match(
      /[MmLlHhVvZz]|[-+]?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?/g
    ) || [];
    const rings = [];
    let index = 0;
    let command = "";
    let x = 0, y = 0, startX = 0, startY = 0;
    let ring = null;
    const number = () => Number(tokens[index++]);
    const finish = () => {
      if (ring?.length >= 3) rings.push(ring);
      ring = null;
    };
    while (index < tokens.length) {
      if (/^[A-Za-z]$/.test(tokens[index])) {
        command = tokens[index++];
        if (/[Zz]/.test(command)) {
          finish();
          x = startX;
          y = startY;
          continue;
        }
      }
      if (!command) return null;
      if (/[Mm]/.test(command)) {
        let nextX = number(), nextY = number();
        if (command === "m") { nextX += x; nextY += y; }
        finish();
        x = nextX; y = nextY; startX = x; startY = y;
        ring = [{ x, y }];
        command = command === "m" ? "l" : "L";
      } else if (/[Ll]/.test(command)) {
        let nextX = number(), nextY = number();
        if (command === "l") { nextX += x; nextY += y; }
        x = nextX; y = nextY; ring?.push({ x, y });
      } else if (/[Hh]/.test(command)) {
        let nextX = number();
        if (command === "h") nextX += x;
        x = nextX; ring?.push({ x, y });
      } else if (/[Vv]/.test(command)) {
        let nextY = number();
        if (command === "v") nextY += y;
        y = nextY; ring?.push({ x, y });
      } else {
        // Pixel-fit output is deliberately restricted to straight segments.
        // If a future runtime returns curves, preserve them byte-for-byte.
        return null;
      }
    }
    finish();
    return rings.length ? rings : null;
  }

  function pointSegmentDistanceSquared(point, first, last) {
    let x = first.x, y = first.y;
    let dx = last.x - x, dy = last.y - y;
    if (dx || dy) {
      const projection = ((point.x - x) * dx + (point.y - y) * dy) / (dx * dx + dy * dy);
      if (projection > 1) { x = last.x; y = last.y; }
      else if (projection > 0) { x += dx * projection; y += dy * projection; }
    }
    dx = point.x - x;
    dy = point.y - y;
    return dx * dx + dy * dy;
  }

  function simplifyPixelPoints(points, tolerance) {
    if (points.length <= 2) return points;
    let maximum = 0;
    let split = 0;
    for (let index = 1; index < points.length - 1; index += 1) {
      const distance = pointSegmentDistanceSquared(points[index], points[0], points[points.length - 1]);
      if (distance > maximum) { maximum = distance; split = index; }
    }
    if (maximum <= tolerance * tolerance) return [points[0], points[points.length - 1]];
    const left = simplifyPixelPoints(points.slice(0, split + 1), tolerance);
    const right = simplifyPixelPoints(points.slice(split), tolerance);
    return left.slice(0, -1).concat(right);
  }

  function simplifyClosedPixelRing(ring, tolerance) {
    if (ring.length <= 4) return ring;
    const distanceBetween = (first, second) => {
      const dx = first.x - second.x;
      const dy = first.y - second.y;
      return dx * dx + dy * dy;
    };
    // A closed contour cannot be simplified reliably by appending its first
    // point and treating that zero-length chord as an open polyline. Split it
    // across two well-separated contour points, simplify both real arcs and
    // then join them again. This removes JPEG stair steps without turning
    // round counters such as O/R/8 into angular or broken glyphs.
    let firstIndex = 0;
    let secondIndex = 0;
    let maximumDistance = -1;
    ring.forEach((point, index) => {
      const distance = distanceBetween(point, ring[firstIndex]);
      if (distance > maximumDistance) {
        maximumDistance = distance;
        secondIndex = index;
      }
    });
    maximumDistance = -1;
    ring.forEach((point, index) => {
      const distance = distanceBetween(point, ring[secondIndex]);
      if (distance > maximumDistance) {
        maximumDistance = distance;
        firstIndex = index;
      }
    });
    if (firstIndex > secondIndex) [firstIndex, secondIndex] = [secondIndex, firstIndex];
    if (firstIndex === secondIndex) return ring;
    const forward = ring.slice(firstIndex, secondIndex + 1);
    const returning = ring.slice(secondIndex).concat(ring.slice(0, firstIndex + 1));
    const simplifiedForward = simplifyPixelPoints(forward, tolerance);
    const simplifiedReturning = simplifyPixelPoints(returning, tolerance);
    const joined = simplifiedForward
      .slice(0, -1)
      .concat(simplifiedReturning.slice(0, -1));
    return joined.length >= 3 ? joined : ring;
  }

  function optimizePixelPathData(pathData, tolerance = .75) {
    const rings = pixelPathRings(pathData);
    if (!rings) return pathData;
    return rings.map(ring => {
      const simplified = simplifyClosedPixelRing(ring, tolerance);
      const retained = simplified.length >= 3 ? simplified : ring;
      return `M${retained.map((point, index) => (
        `${index ? "L" : ""}${roundTo(point.x, 2)} ${roundTo(point.y, 2)}`
      )).join("")}Z`;
    }).join("");
  }

  function svgPathTranslation(transform) {
    const source = String(transform || "").trim();
    if (!source) return { x: 0, y: 0 };
    const translate = source.match(/^translate\(\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*(?:[,\s]\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?\s*\)$/i);
    if (translate) return { x: Number(translate[1]), y: Number(translate[2] || 0) };
    const matrix = source.match(/^matrix\(\s*1(?:\.0+)?\s*[,\s]\s*0(?:\.0+)?\s*[,\s]\s*0(?:\.0+)?\s*[,\s]\s*1(?:\.0+)?\s*[,\s]\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*[,\s]\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)\s*\)$/i);
    if (matrix) return { x: Number(matrix[1]), y: Number(matrix[2]) };
    return null;
  }

  function translatedSvgPathData(pathData, translation) {
    const dx = Number(translation?.x || 0);
    const dy = Number(translation?.y || 0);
    if (!dx && !dy) return pathData;
    const tokens = String(pathData || "").match(/[AaCcHhLlMmQqSsTtVvZz]|[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/g);
    if (!tokens?.length) return pathData;
    const arity = { A: 7, C: 6, H: 1, L: 2, M: 2, Q: 4, S: 4, T: 2, V: 1, Z: 0 };
    const xSlots = { A: [5], C: [0, 2, 4], H: [0], L: [0], M: [0], Q: [0, 2], S: [0, 2], T: [0], V: [] };
    const ySlots = { A: [6], C: [1, 3, 5], H: [], L: [1], M: [1], Q: [1, 3], S: [1, 3], T: [1], V: [0] };
    const output = [];
    let index = 0;
    let command = "";
    let firstMove = true;
    while (index < tokens.length) {
      if (/^[A-Za-z]$/.test(tokens[index])) {
        command = tokens[index++];
        if (/[Zz]/.test(command)) {
          output.push(command);
          continue;
        }
      }
      const upper = command.toUpperCase();
      const count = arity[upper];
      if (!command || !count || index + count > tokens.length) return pathData;
      const values = tokens.slice(index, index + count).map(Number);
      if (values.some(value => !Number.isFinite(value))) return pathData;
      const absolute = command === upper;
      if (absolute) {
        xSlots[upper].forEach(slot => { values[slot] += dx; });
        ySlots[upper].forEach(slot => { values[slot] += dy; });
      } else if (upper === "M" && firstMove) {
        values[0] += dx;
        values[1] += dy;
      }
      output.push(command, ...values.map(value => String(roundTo(value, 5))));
      index += count;
      if (upper === "M") {
        firstMove = false;
        command = command === "M" ? "L" : "l";
      }
    }
    return output.join(" ");
  }

  function vtracerSvgToResult(svgSource, imageData, options, traceInfo = {}) {
    if (typeof DOMParser !== "function") throw new Error("当前环境不支持解析专业曲线路径");
    if (typeof svgSource !== "string" || svgSource.length > 30 * 1024 * 1024) throw new Error("专业曲线结果为空或过大");
    const documentSvg = new DOMParser().parseFromString(svgSource, "image/svg+xml");
    const root = documentSvg.documentElement;
    if (root?.localName !== "svg" || documentSvg.querySelector("parsererror")) throw new Error("专业曲线结果不是有效 SVG");
    if (root.querySelector("script,image,foreignObject,iframe,object,embed,use")) throw new Error("专业曲线结果包含不允许的外部内容");
    const width = Math.max(1, Number.parseFloat(root.getAttribute("width")) || imageData.width);
    const height = Math.max(1, Number.parseFloat(root.getAttribute("height")) || imageData.height);
    const sourcePaths = [...root.querySelectorAll("path")];
    const bounds = options.removeBackground ? measuredPathBounds(root, sourcePaths.length) : [];
    const background = sampledBackgroundHex(imageData);
    const tracedBackground = normalizedSvgColor(traceInfo.backgroundReplacement || background, background);
    const backgroundTolerance = Math.max(18, Number(options.backgroundTolerance || 30) * 1.5);
    const grouped = new Map();
    let removedBackgroundPaths = 0;
    sourcePaths.forEach((path, index) => {
      const rawPathData = String(path.getAttribute("d") || "").trim();
      const pathTranslation = svgPathTranslation(path.getAttribute("transform"));
      if (!pathTranslation) return;
      const sourcePathData = translatedSvgPathData(rawPathData, pathTranslation);
      const d = options.traceFit === "pixel"
        ? optimizePixelPathData(
          sourcePathData,
          clampNumber(options.pixelOptimizeTolerance ?? .75, 0, 4, .75)
        )
        : sourcePathData;
      if (!d || d.length > 12 * 1024 * 1024 || !/^[0-9eE+.,\-\sMmLlHhVvCcSsQqTtAaZz]+$/.test(d)) return;
      const fill = inheritedSvgFill(path);
      const measuredBox = bounds[index];
      const box = measuredBox ? {
        x: measuredBox.x + pathTranslation.x,
        y: measuredBox.y + pathTranslation.y,
        width: measuredBox.width,
        height: measuredBox.height
      } : null;
      const coversCanvas = box
        && box.x <= width * .015
        && box.y <= height * .015
        && box.x + box.width >= width * .985
        && box.y + box.height >= height * .985;
      if (
        options.removeBackground
        && coversCanvas
        && (options.dropCanvasCoveringPaths === true || svgColorDistance(fill, tracedBackground) <= backgroundTolerance)
      ) {
        removedBackgroundPaths += 1;
        return;
      }
      const nodes = (d.match(/[MmLlHhVvCcSsQqTtAaZz]/g) || []).length;
      const group = grouped.get(fill) || { fill, pathData: [], nativeSubpaths: [], paths: 0, nodes: 0 };
      group.pathData.push(d);
      group.nativeSubpaths.push({
        d,
        bbox: box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null,
        nodes
      });
      group.paths += 1;
      group.nodes += nodes;
      grouped.set(fill, group);
    });
    const layers = [...grouped.values()].map((group, index) => ({
      id: `professional-color-${index + 1}`,
      name: `专业曲线颜色 ${index + 1}`,
      role: options.mode === "packaging" ? "artwork" : "color",
      color: group.fill,
      smoothing: 0,
      pixels: 0,
      area: 0,
      paths: group.paths,
      nodes: group.nodes,
      bbox: group.nativeSubpaths.filter(item => item.bbox).reduce((combined, item) => {
        const box = item.bbox;
        if (!combined) return { ...box };
        const right = Math.max(combined.x + combined.width, box.x + box.width);
        const bottom = Math.max(combined.y + combined.height, box.y + box.height);
        const x = Math.min(combined.x, box.x);
        const y = Math.min(combined.y, box.y);
        return { x, y, width: right - x, height: bottom - y };
      }, null) || { x: 0, y: 0, width, height },
      contours: [],
      nativePathD: group.pathData.join(" "),
      nativeSubpaths: group.nativeSubpaths,
      nativeTransform: [1, 0, 0, 1, 0, 0],
      colorGroup: group.fill,
      sourceModel: "vtracer-1.0-seamless-spline",
      visible: true
    })).filter(layer => layer.paths > 0);
    if (!layers.length) throw new Error("背景处理后没有留下可用的矢量路径，请关闭去背景或降低背景容差");
    return {
      width,
      height,
      outputWidth: Math.round(clampNumber(options.outputWidth || width, 1, 100000, 0)),
      outputHeight: Math.round(clampNumber(options.outputHeight || height, 1, 100000, 0)),
      background,
      options: { ...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: 0,
        dielinePaths: 0,
        removedBackgroundPaths,
        removedBackgroundPixels: Number(traceInfo.backgroundPixels || 0)
      },
      structure: options.mode === "packaging" ? { kind: "packaging", artworkColors: layers.length } : null,
      tracer: {
        engine: traceInfo.engine || "vtracer-1.0",
        version: window.JianglaiProfessionalTracer?.version || "",
        boundaryMode: options.boundaryMode || "cutout",
        spline: true,
        pathOptimization: Number(traceInfo.options?.optimize || 2)
      }
    };
  }

  function prepareProfessionalTraceImage(imageData, options) {
    if (!options.removeBackground) return { imageData, backgroundPixels: 0, backgroundReplacement: "" };
    const width = imageData.width;
    const height = imageData.height;
    const pixelCount = width * height;
    const source = imageData.data;
    const data = new Uint8ClampedArray(source);
    const visited = new Uint8Array(pixelCount);
    const queue = new Int32Array(pixelCount);
    const backgroundHex = sampledBackgroundHex(imageData);
    const backgroundRgb = (backgroundHex.match(/[0-9a-f]{2}/gi) || []).map(value => parseInt(value, 16));
    const tolerance = Math.max(8, Number(options.backgroundTolerance || 30));
    const globalLimit = Math.max(24, tolerance * 2.8);
    const neighborLimit = Math.max(18, tolerance * .85);
    const distanceAt = (pixel, reference) => {
      const offset = pixel * 4;
      return Math.sqrt(
        (source[offset] - reference[0]) ** 2
        + (source[offset + 1] - reference[1]) ** 2
        + (source[offset + 2] - reference[2]) ** 2
      );
    };
    const pixelColor = pixel => {
      const offset = pixel * 4;
      return [source[offset], source[offset + 1], source[offset + 2]];
    };
    let head = 0;
    let tail = 0;
    const enqueue = pixel => {
      if (pixel < 0 || pixel >= pixelCount || visited[pixel]) return;
      if (source[pixel * 4 + 3] < 8 || distanceAt(pixel, backgroundRgb) <= globalLimit) {
        visited[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++];
      const x = pixel % width;
      const y = Math.floor(pixel / width);
      const current = pixelColor(pixel);
      const neighbors = [
        x > 0 ? pixel - 1 : -1,
        x + 1 < width ? pixel + 1 : -1,
        y > 0 ? pixel - width : -1,
        y + 1 < height ? pixel + width : -1
      ];
      for (const neighbor of neighbors) {
        if (neighbor < 0 || visited[neighbor]) continue;
        if (source[neighbor * 4 + 3] < 8 || (
          distanceAt(neighbor, backgroundRgb) <= globalLimit
          && distanceAt(neighbor, current) <= neighborLimit
        )) {
          visited[neighbor] = 1;
          queue[tail++] = neighbor;
        }
      }
    }
    for (let pixel = 0; pixel < pixelCount; pixel += 1) {
      if (!visited[pixel]) continue;
      const offset = pixel * 4;
      data[offset] = 255;
      data[offset + 1] = 255;
      data[offset + 2] = 255;
      data[offset + 3] = 255;
    }
    return {
      imageData: { data, width, height },
      backgroundPixels: tail,
      backgroundReplacement: "#ffffff"
    };
  }

  async function vectorizeCandidate(imageData, options) {
    const tracer = window.JianglaiProfessionalTracer;
    if (tracer?.trace) {
      try {
        const prepared = prepareProfessionalTraceImage(imageData, options);
        const traced = await tracer.trace(prepared.imageData, options);
        traced.backgroundPixels = prepared.backgroundPixels;
        traced.backgroundReplacement = prepared.backgroundReplacement;
        return vtracerSvgToResult(traced.svg, imageData, options, traced);
      } catch (error) {
        console.warn("Professional vector tracer failed; using compatibility engine", error);
      }
    }
    return window.JianglaiVectorEngine.vectorizePixels(imageData, options);
  }

  function semanticCoreColor(samples, familyId) {
    if (familyId === "neutral" || !samples?.size) return "#000000";
    let selected = null;
    for (const bin of samples.values()) {
      if (!selected || bin.count > selected.count) selected = bin;
    }
    if (!selected?.count) {
      return SEMANTIC_INK_FAMILIES.find(family => family.id === familyId)?.color || "#000000";
    }
    return `#${[selected.r, selected.g, selected.b]
      .map(channel => Math.round(channel / selected.count).toString(16).padStart(2, "0"))
      .join("")}`;
  }

  function eraseSemanticStructureRules(data, width, height, imageData, familyId) {
    if (!["neutral", "red"].includes(familyId)) return { pixels: 0, rules: 0 };
    const rules = semanticRuleRecord(imageData, familyId).rules;
    const radius = Math.max(2, Math.round(Math.max(width, height) / 1800));
    let pixels = 0;
    for (const rule of rules) {
      const axis = Math.round(rule.axis);
      for (let offset = -radius; offset <= radius; offset += 1) {
        const fixed = axis + offset;
        if (rule.horizontal) {
          if (fixed < 0 || fixed >= height) continue;
          for (let x = Math.max(0, rule.start - radius); x <= Math.min(width - 1, rule.end + radius); x += 1) {
            const index = (fixed * width + x) * 4;
            if (data[index] !== 0) continue;
            data[index] = data[index + 1] = data[index + 2] = 255;
            pixels += 1;
          }
        } else {
          if (fixed < 0 || fixed >= width) continue;
          for (let y = Math.max(0, rule.start - radius); y <= Math.min(height - 1, rule.end + radius); y += 1) {
            const index = (y * width + fixed) * 4;
            if (data[index] !== 0) continue;
            data[index] = data[index + 1] = data[index + 2] = 255;
            pixels += 1;
          }
        }
      }
    }
    return { pixels, rules: rules.length };
  }

  function semanticInkMask(imageData, familyId, options = {}) {
    const { data: source, width, height } = imageData;
    const data = new Uint8ClampedArray(width * height * 4);
    const colorSamples = new Map();
    let pixels = 0;
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const sourceIndex = pixel * 4;
      const targetIndex = sourceIndex;
      const family = semanticInkCoreFamily(
        source[sourceIndex],
        source[sourceIndex + 1],
        source[sourceIndex + 2],
        source[sourceIndex + 3]
      );
      const ink = family === familyId;
      const value = ink ? 0 : 255;
      data[targetIndex] = value;
      data[targetIndex + 1] = value;
      data[targetIndex + 2] = value;
      data[targetIndex + 3] = 255;
      if (ink) {
        pixels += 1;
        const r = source[sourceIndex], g = source[sourceIndex + 1], b = source[sourceIndex + 2];
        const key = `${r >> 4},${g >> 4},${b >> 4}`;
        const bin = colorSamples.get(key) || { count: 0, r: 0, g: 0, b: 0 };
        bin.count += 1;
        bin.r += r;
        bin.g += g;
        bin.b += b;
        colorSamples.set(key, bin);
      }
    }
    // Full-page separation must remove dielines before tracing. An OCR crop is
    // already a tightly bounded text object: running the same long-rule
    // detector there mistakes Chinese horizontals, uppercase crossbars and
    // underlines for dielines and visibly amputates the customer's glyphs.
    const removedStructure = options.preserveGlyphStrokes === true
      ? { pixels: 0, rules: 0 }
      : eraseSemanticStructureRules(data, width, height, imageData, familyId);
    pixels = Math.max(0, pixels - removedStructure.pixels);
    return {
      imageData: { data, width, height },
      pixels,
      color: semanticCoreColor(colorSamples, familyId),
      structureRules: removedStructure.rules,
      structurePixels: removedStructure.pixels
    };
  }

  function rgbDistance(left, right) {
    return Math.hypot(
      Number(left?.[0] || 0) - Number(right?.[0] || 0),
      Number(left?.[1] || 0) - Number(right?.[1] || 0),
      Number(left?.[2] || 0) - Number(right?.[2] || 0)
    );
  }

  function cropBoundaryMedianRgb(imageData) {
    const { data, width, height } = imageData || {};
    if (!data || !width || !height) return [255, 255, 255];
    const samples = [];
    const strideX = Math.max(1, Math.floor(width / 48));
    const strideY = Math.max(1, Math.floor(height / 48));
    const add = (x, y) => {
      const index = (y * width + x) * 4;
      if (data[index + 3] > 24) samples.push([data[index], data[index + 1], data[index + 2]]);
    };
    for (let x = 0; x < width; x += strideX) {
      add(x, 0);
      add(x, height - 1);
    }
    for (let y = 0; y < height; y += strideY) {
      add(0, y);
      add(width - 1, y);
    }
    if (!samples.length) return [255, 255, 255];
    const median = channel => samples
      .map(sample => sample[channel])
      .sort((first, second) => first - second)[Math.floor(samples.length / 2)];
    return [median(0), median(1), median(2)];
  }

  function removeSmallMaskComponents(assignments, clusterIndex, width, height, minimumArea) {
    const visited = new Uint8Array(width * height);
    const retained = new Uint8Array(width * height);
    const stack = [];
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      if (assignments[pixel] !== clusterIndex || visited[pixel]) continue;
      const component = [];
      stack.push(pixel);
      visited[pixel] = 1;
      while (stack.length) {
        const current = stack.pop();
        component.push(current);
        const x = current % width;
        const y = Math.floor(current / width);
        for (let nextY = Math.max(0, y - 1); nextY <= Math.min(height - 1, y + 1); nextY += 1) {
          for (let nextX = Math.max(0, x - 1); nextX <= Math.min(width - 1, x + 1); nextX += 1) {
            const next = nextY * width + nextX;
            if (visited[next] || assignments[next] !== clusterIndex) continue;
            visited[next] = 1;
            stack.push(next);
          }
        }
      }
      if (component.length < minimumArea) continue;
      component.forEach(index => { retained[index] = 1; });
    }
    return retained;
  }

  function refineForegroundMaskCoverage(retained, source, width, height, foreground, background) {
    const output = new Uint8Array(retained);
    const visited = new Uint8Array(width * height);
    const vector = foreground.map((value, channel) => value - background[channel]);
    const vectorNorm = vector.reduce((sum, value) => sum + value * value, 0);
    if (vectorNorm < 576) return output;
    const stack = [];
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      if (!retained[pixel] || visited[pixel]) continue;
      const component = [];
      let top = height;
      let bottom = -1;
      stack.push(pixel);
      visited[pixel] = 1;
      while (stack.length) {
        const current = stack.pop();
        component.push(current);
        const x = current % width;
        const y = Math.floor(current / width);
        top = Math.min(top, y);
        bottom = Math.max(bottom, y);
        for (let nextY = Math.max(0, y - 1); nextY <= Math.min(height - 1, y + 1); nextY += 1) {
          for (let nextX = Math.max(0, x - 1); nextX <= Math.min(width - 1, x + 1); nextX += 1) {
            const next = nextY * width + nextX;
            if (visited[next] || !retained[next]) continue;
            visited[next] = 1;
            stack.push(next);
          }
        }
      }
      const componentHeight = bottom - top + 1;
      // A single OCR crop often contains both a display logo and a much
      // smaller descriptor. Large glyphs need a 54% antialias midline to
      // avoid visibly fat strokes; applying that threshold to microtype
      // breaks its counters and terminals, so fine copy stays near the 50%
      // antialias midline instead of using the display-letter contraction.
      const coverageFloor = componentHeight <= Math.max(10, height * .16) ? .50 : .54;
      component.forEach(index => {
        const sourceIndex = index * 4;
        const coverage = vector.reduce((sum, value, channel) => (
          sum + (source[sourceIndex + channel] - background[channel]) * value
        ), 0) / vectorNorm;
        if (coverage < coverageFloor) output[index] = 0;
      });
    }
    return output;
  }

  function foregroundDirectionCosine(firstColor, secondColor, background) {
    const first = firstColor.map((value, channel) => value - background[channel]);
    const second = secondColor.map((value, channel) => value - background[channel]);
    const firstNorm = Math.sqrt(first.reduce((sum, value) => sum + value * value, 0));
    const secondNorm = Math.sqrt(second.reduce((sum, value) => sum + value * value, 0));
    if (firstNorm < 1 || secondNorm < 1) return -1;
    return first.reduce((sum, value, channel) => sum + value * second[channel], 0)
      / (firstNorm * secondNorm);
  }

  function originalForegroundInkMasks(imageData) {
    const { data: source, width, height } = imageData || {};
    if (!source || !width || !height) return [];
    const background = cropBoundaryMedianRgb(imageData);
    const histogram = new Uint32Array(443);
    let opaquePixels = 0;
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const index = pixel * 4;
      if (source[index + 3] <= 24) continue;
      const distance = Math.min(442, Math.round(rgbDistance(
        [source[index], source[index + 1], source[index + 2]],
        background
      )));
      histogram[distance] += 1;
      opaquePixels += 1;
    }
    if (!opaquePixels) return [];
    const contrastTarget = Math.max(1, Math.floor(opaquePixels * .96));
    let cumulative = 0;
    let contrast = 0;
    for (let distance = 0; distance < histogram.length; distance += 1) {
      cumulative += histogram[distance];
      if (cumulative >= contrastTarget) {
        contrast = distance;
        break;
      }
    }
    if (contrast < 24) return [];
    const coreThreshold = Math.max(18, contrast * .7);
    const bins = new Map();
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const index = pixel * 4;
      if (source[index + 3] <= 24) continue;
      const color = [source[index], source[index + 1], source[index + 2]];
      const distance = rgbDistance(color, background);
      if (distance < coreThreshold) continue;
      const key = `${color[0] >> 5},${color[1] >> 5},${color[2] >> 5}`;
      const bin = bins.get(key) || { count: 0, sum: [0, 0, 0] };
      bin.count += 1;
      bin.sum[0] += color[0];
      bin.sum[1] += color[1];
      bin.sum[2] += color[2];
      bins.set(key, bin);
    }
    const rawClusters = [...bins.values()]
      .map(bin => ({
        count: bin.count,
        color: bin.sum.map(value => value / Math.max(1, bin.count))
      }))
      .sort((first, second) => second.count - first.count);
    const clusters = [];
    rawClusters.forEach(bin => {
      const existing = clusters.find(cluster => rgbDistance(cluster.color, bin.color) <= 82);
      if (!existing) {
        clusters.push({ count: bin.count, sum: bin.color.map(value => value * bin.count), color: [...bin.color] });
        return;
      }
      existing.count += bin.count;
      existing.sum[0] += bin.color[0] * bin.count;
      existing.sum[1] += bin.color[1] * bin.count;
      existing.sum[2] += bin.color[2] * bin.count;
      existing.color = existing.sum.map(value => value / existing.count);
    });
    clusters.sort((first, second) => second.count - first.count);
    const primaryCount = Number(clusters[0]?.count || 0);
    const activeClusters = clusters
      .filter(cluster => cluster.count >= Math.max(4, primaryCount * .16))
      .filter(cluster => rgbDistance(cluster.color, background) >= 24)
      .slice(0, 4);
    if (!activeClusters.length) return [];
    const assignments = new Int16Array(width * height);
    assignments.fill(-1);
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const index = pixel * 4;
      if (source[index + 3] <= 24) continue;
      const color = [source[index], source[index + 1], source[index + 2]];
      let best = { cluster: -1, score: Infinity };
      activeClusters.forEach((cluster, clusterIndex) => {
        const vector = cluster.color.map((value, channel) => value - background[channel]);
        const norm = vector.reduce((sum, value) => sum + value * value, 0);
        if (norm < 576) return;
        const delta = color.map((value, channel) => value - background[channel]);
        const coverage = delta.reduce((sum, value, channel) => sum + value * vector[channel], 0) / norm;
        if (coverage < .48 || coverage > 1.5) return;
        const projected = background.map((value, channel) => value + vector[channel] * coverage);
        const residual = rgbDistance(color, projected);
        const residualLimit = Math.max(18, Math.sqrt(norm) * .18);
        if (residual > residualLimit) return;
        const score = residual + Math.abs(1 - Math.min(1, coverage)) * 2;
        if (score < best.score) best = { cluster: clusterIndex, score };
      });
      assignments[pixel] = best.cluster;
    }
    // JPEG antialiasing commonly turns one white glyph into several brightness
    // clusters. Filtering each cluster independently disconnects fine serifs
    // and produces transparent checkerboard holes inside the finished letter.
    // Merge clusters that point in the same colour direction from the local
    // background before component filtering. Different printing inks (black,
    // red, yellow, etc.) remain separate because their direction differs.
    const directionalGroups = [];
    activeClusters.forEach((cluster, clusterIndex) => {
      let group = directionalGroups.find(candidate => (
        foregroundDirectionCosine(cluster.color, candidate.anchorColor, background) >= .985
      ));
      if (!group) {
        group = {
          anchorColor: [...cluster.color],
          clusterIndexes: [],
          representativeColor: [...cluster.color],
          contrast: 0
        };
        directionalGroups.push(group);
      }
      group.clusterIndexes.push(clusterIndex);
      const clusterContrast = rgbDistance(cluster.color, background);
      if (clusterContrast >= group.contrast) {
        group.contrast = clusterContrast;
        group.representativeColor = [...cluster.color];
      }
    });
    const clusterToGroup = new Int16Array(activeClusters.length);
    directionalGroups.forEach((group, groupIndex) => {
      group.clusterIndexes.forEach(clusterIndex => {
        clusterToGroup[clusterIndex] = groupIndex;
      });
    });
    const groupedAssignments = new Int16Array(width * height);
    groupedAssignments.fill(-1);
    for (let pixel = 0; pixel < width * height; pixel += 1) {
      const clusterIndex = assignments[pixel];
      if (clusterIndex >= 0) groupedAssignments[pixel] = clusterToGroup[clusterIndex];
    }
    const minimumComponentArea = Math.max(
      2,
      Math.round(Math.sqrt(width * height) * .008)
    );
    return directionalGroups.map((group, groupIndex) => {
      const retained = removeSmallMaskComponents(
        groupedAssignments,
        groupIndex,
        width,
        height,
        minimumComponentArea
      );
      const refined = refineForegroundMaskCoverage(
        retained,
        source,
        width,
        height,
        group.representativeColor,
        background
      );
      const data = new Uint8ClampedArray(width * height * 4);
      let pixels = 0;
      for (let pixel = 0; pixel < width * height; pixel += 1) {
        const value = refined[pixel] ? 0 : 255;
        const index = pixel * 4;
        data[index] = value;
        data[index + 1] = value;
        data[index + 2] = value;
        data[index + 3] = 255;
        if (value === 0) pixels += 1;
      }
      const color = `#${group.representativeColor
        .map(channel => Math.round(clampNumber(channel, 0, 255, 0)).toString(16).padStart(2, "0"))
        .join("")}`;
      return {
        id: `foreground-${groupIndex + 1}`,
        imageData: { data, width, height },
        pixels,
        color,
        background,
        contrast: Math.round(group.contrast)
      };
    }).filter(mask => mask.pixels >= 4);
  }

  async function traceOriginalForegroundMask(mask, options) {
    const traceOptions = {
      ...options,
      mode: "line",
      colors: 2,
      simplify: 0,
      smoothing: 0,
      speckle: 0,
      semanticInkMask: true,
      preserveGlyphs: true,
      dropCanvasCoveringPaths: true,
      removeBackground: true,
      backgroundTolerance: 18,
      boundaryMode: "cutout",
      traceFit: "spline",
      outputWidth: mask.imageData.width,
      outputHeight: mask.imageData.height
    };
    const splineTracer = window.JianglaiSplineTracer;
    if (splineTracer?.traceBinary && typeof ImageData === "function") {
      const binaryImage = new ImageData(
        new Uint8ClampedArray(mask.imageData.data),
        mask.imageData.width,
        mask.imageData.height
      );
      const svg = await splineTracer.traceBinary(binaryImage, {
        cornerThreshold: 58,
        lengthThreshold: 1.2,
        maxIterations: 16,
        spliceThreshold: 45,
        filterSpeckle: 0,
        pathPrecision: 6
      });
      return {
        svg,
        traceInfo: {},
        traceOptions,
        sourceModel: "full-resolution-adaptive-midline-bezier-outline"
      };
    }
    const tracer = window.JianglaiProfessionalTracer;
    if (!tracer?.trace) throw new Error("原字形高精度贝塞尔引擎尚未加载");
    const fallbackOptions = {
      ...traceOptions,
      traceFit: "pixel",
      // Half a source pixel is still below the uncertainty of an antialiased
      // JPEG edge. Together with true closed-ring simplification it removes
      // redundant stair steps while retaining counters, serifs and punctuation.
      pixelOptimizeTolerance: .5
    };
    const traced = await tracer.trace(mask.imageData, fallbackOptions);
    return {
      svg: traced.svg,
      traceInfo: traced,
      traceOptions: fallbackOptions,
      sourceModel: "full-resolution-contrast-aware-pixel-outline-fallback"
    };
  }

  async function traceSemanticInkMask(mask, family, options) {
    const sharedOptions = {
      ...options,
      mode: "line",
      colors: 2,
      simplify: family.id === "neutral" ? 0 : .2,
      smoothing: family.id === "neutral" ? 0 : .28,
      speckle: 0,
      semanticInkMask: true,
      semanticInkFamily: family.id,
      dropCanvasCoveringPaths: true,
      removeBackground: true,
      backgroundTolerance: 18,
      boundaryMode: "cutout",
      outputWidth: mask.imageData.width,
      outputHeight: mask.imageData.height
    };
    if (family.id === "neutral") {
      const splineTracer = window.JianglaiSplineTracer;
      if (!splineTracer?.traceBinary || typeof ImageData !== "function") {
        throw new Error("文字高精度贝塞尔引擎尚未加载");
      }
      const binaryImage = new ImageData(
        new Uint8ClampedArray(mask.imageData.data),
        mask.imageData.width,
        mask.imageData.height
      );
      const svg = await splineTracer.traceBinary(binaryImage, {
        // Packaging body copy needs substantially more curvature than dielines.
        // These values were verified at 1:1 on accented Latin microtype: they
        // retain counters, terminals and diacritics without guessing a font.
        cornerThreshold: 52,
        lengthThreshold: 1.35,
        maxIterations: 14,
        spliceThreshold: 45,
        filterSpeckle: 0,
        pathPrecision: 5
      });
      return {
        svg,
        traceOptions: { ...sharedOptions, traceFit: "spline" },
        sourceModel: "semantic-neutral-high-fidelity-bezier"
      };
    }
    const tracer = window.JianglaiProfessionalTracer;
    if (!tracer?.trace) throw new Error("专色刀线追踪引擎尚未加载");
    const traceOptions = { ...sharedOptions, traceFit: "pixel" };
    const traced = await tracer.trace(mask.imageData, traceOptions);
    return {
      svg: traced.svg,
      traceInfo: traced,
      traceOptions,
      sourceModel: "semantic-spot-core-pixel-outline"
    };
  }

  async function traceSemanticInkLayers(imageData, options, progress = () => {}) {
    const tracer = window.JianglaiProfessionalTracer;
    if (!tracer?.trace) throw new Error("专色文字与刀线追踪引擎尚未加载");
    const profile = analyzeSemanticInkProfile(imageData);
    const activeFamilies = SEMANTIC_INK_FAMILIES.filter(family => profile.active.includes(family.id));
    if (!activeFamilies.some(family => family.id === "neutral") || activeFamilies.length < 2) {
      throw new Error("当前原稿不适合专色分层，将改用普通专业曲线重建");
    }
    const layers = [];
    const familyPixels = {};
    for (let familyIndex = 0; familyIndex < activeFamilies.length; familyIndex += 1) {
      const family = activeFamilies[familyIndex];
      progress(family, familyIndex + 1, activeFamilies.length);
      await new Promise(resolve => window.setTimeout(resolve, 12));
      const mask = semanticInkMask(imageData, family.id);
      familyPixels[family.id] = mask.pixels;
      if (mask.pixels < 4) continue;
      const traced = await traceSemanticInkMask(mask, family, options);
      const local = vtracerSvgToResult(
        traced.svg,
        mask.imageData,
        traced.traceOptions,
        traced.traceInfo || {}
      );
      const nativeSubpaths = (local.layers || []).flatMap(layer => layer.nativeSubpaths || []);
      const layer = nativeLayerWithSubpaths({
        id: `semantic-ink-${family.id}`,
        name: family.name,
        role: "artwork",
        color: mask.color || family.color,
        cmyk: family.id === "neutral" ? [0, 0, 0, 100] : null,
        spotName: family.spotName,
        contours: [],
        strokes: [],
        smoothing: 0,
        pixels: mask.pixels,
        area: mask.pixels,
        semanticInkLayer: true,
        // This is a full-page colour separation, not an isolated logo.  It
        // must remain eligible for per-region cleanup before original glyphs
        // are inserted, otherwise the source copy and rebuilt copy overlap.
        artworkIsolated: false,
        sourceModel: traced.sourceModel,
        nativeTransform: [1, 0, 0, 1, 0, 0],
        visible: true
      }, nativeSubpaths);
      if (layer) layers.push(layer);
    }
    if (layers.length < 2) throw new Error("专色分层没有形成完整的文字、条码与刀线路径");
    return {
      width: imageData.width,
      height: imageData.height,
      outputWidth: Math.round(clampNumber(options.outputWidth || imageData.width, 1, 100000, 0)),
      outputHeight: Math.round(clampNumber(options.outputHeight || imageData.height, 1, 100000, 0)),
      background: "#ffffff",
      options: { ...options, semanticSpotSeparation: true },
      layers,
      metrics: {
        colors: layers.length,
        paths: layers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0),
        nodes: layers.reduce((sum, layer) => sum + Number(layer.nodes || 0), 0),
        foregroundPixels: Object.values(familyPixels).reduce((sum, pixels) => sum + pixels, 0),
        dielinePaths: 0,
        semanticFamilyPixels: familyPixels
      },
      structure: {
        kind: "packaging",
        semanticSpotSeparation: true,
        semanticFamilies: layers.map(layer => layer.id.replace("semantic-ink-", ""))
      },
      tracer: {
        engine: "vtracer-hybrid-semantic-core",
        version: window.JianglaiProfessionalTracer?.version || "",
        boundaryMode: "cutout",
        spline: true,
        traceFit: "hybrid",
        typographyFit: "high-fidelity-bezier",
        pathOptimization: 3
      }
    };
  }

  function semanticRuleCandidates(imageData, familyId, horizontal) {
    const { data, width, height } = imageData;
    const major = horizontal ? width : height;
    const minor = horizontal ? height : width;
    const minimumLength = Math.max(18, Math.round(major * .045));
    const gapTolerance = Math.max(1, Math.round(major * .0007));
    const candidates = [];
    const familyAt = (axis, position) => {
      const x = horizontal ? position : axis;
      const y = horizontal ? axis : position;
      const index = (y * width + x) * 4;
      return semanticInkFamily(data[index], data[index + 1], data[index + 2], data[index + 3]) === familyId;
    };
    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 && familyAt(axis, position);
        if (active) {
          if (start < 0) start = position;
          last = position;
          hits += 1;
        }
        if (start >= 0 && ((!active && position - last > gapTolerance) || position === major)) {
          const length = last - start + 1;
          if (length >= minimumLength && hits >= length * .7) {
            candidates.push({ horizontal, axis, start, end: last, hits });
          }
          start = -1;
          last = -1;
          hits = 0;
        }
      }
    }
    return candidates;
  }

  function mergeSemanticRuleCandidates(candidates) {
    const merged = [];
    const sorted = [...candidates].sort((left, right) => (
      Number(left.horizontal) - Number(right.horizontal)
      || left.axis - right.axis
      || left.start - right.start
    ));
    for (const candidate of sorted) {
      const length = candidate.end - candidate.start + 1;
      const match = merged.find(existing => {
        if (existing.horizontal !== candidate.horizontal || Math.abs(existing.axis - candidate.axis) > 4) return false;
        const overlap = Math.max(0, Math.min(existing.end, candidate.end) - Math.max(existing.start, candidate.start) + 1);
        return overlap >= Math.min(length, existing.end - existing.start + 1) * .72;
      });
      if (!match) {
        merged.push({ ...candidate, samples: 1 });
        continue;
      }
      match.axis = (match.axis * match.samples + candidate.axis) / (match.samples + 1);
      match.samples += 1;
      match.start = Math.min(match.start, candidate.start);
      match.end = Math.max(match.end, candidate.end);
      match.hits += candidate.hits;
    }
    return merged;
  }

  function semanticRuleRecord(imageData, familyId) {
    if (!imageData || !semanticRuleCache) return { rules: [], mask: new Uint8Array(0) };
    let familyRecords = semanticRuleCache.get(imageData);
    if (!familyRecords) {
      familyRecords = new Map();
      semanticRuleCache.set(imageData, familyRecords);
    }
    if (familyRecords.has(familyId)) return familyRecords.get(familyId);
    const rules = mergeSemanticRuleCandidates([
      ...semanticRuleCandidates(imageData, familyId, true),
      ...semanticRuleCandidates(imageData, familyId, false)
    ]);
    const { width, height } = imageData;
    const mask = new Uint8Array(width * height);
    const radius = Math.max(2, Math.round(Math.max(width, height) / 1800));
    for (const rule of rules) {
      const axis = Math.round(rule.axis);
      for (let offset = -radius; offset <= radius; offset += 1) {
        const fixed = axis + offset;
        if (rule.horizontal) {
          if (fixed < 0 || fixed >= height) continue;
          for (let x = Math.max(0, rule.start - radius); x <= Math.min(width - 1, rule.end + radius); x += 1) {
            mask[fixed * width + x] = 1;
          }
        } else {
          if (fixed < 0 || fixed >= width) continue;
          for (let y = Math.max(0, rule.start - radius); y <= Math.min(height - 1, rule.end + radius); y += 1) {
            mask[y * width + fixed] = 1;
          }
        }
      }
    }
    const record = { rules, mask };
    familyRecords.set(familyId, record);
    return record;
  }

  function semanticStructureLayer(imageData, familyId, role, color, name, spotName) {
    const candidates = semanticRuleRecord(imageData, familyId).rules;
    const strokes = candidates.map((candidate, index) => {
      const axis = roundTo(candidate.axis, 2);
      const first = candidate.horizontal
        ? { x: candidate.start, y: axis }
        : { x: axis, y: candidate.start };
      const last = candidate.horizontal
        ? { x: candidate.end, y: axis }
        : { x: axis, y: candidate.end };
      return {
        id: `semantic-${role}-${index + 1}`,
        source: "semantic-spot-rule",
        points: [
          { id: `semantic-${role}-${index + 1}-start`, ...first },
          { id: `semantic-${role}-${index + 1}-end`, ...last }
        ]
      };
    });
    if (!strokes.length) return null;
    return {
      id: `semantic-structure-${role}`,
      name,
      role,
      color,
      sourceColor: color,
      cmyk: role === "cutline" ? [0, 0, 0, 100] : [0, 100, 100, 0],
      spotName,
      strokeWidth: 1,
      strokeWidthMm: .25,
      contours: [],
      strokes,
      paths: strokes.length,
      nodes: strokes.length * 2,
      sourceModel: "semantic-spot-rule-reconstruction",
      visible: true
    };
  }

  function mergeSemanticPackagingStructure(result, imageData) {
    const cutline = semanticStructureLayer(imageData, "neutral", "cutline", "#111111", "刀线（DIELINE）", "DIELINE");
    const crease = semanticStructureLayer(imageData, "red", "crease", "#ff3b30", "折线（CREASE）", "CREASE");
    const structureLayers = [cutline, crease].filter(Boolean);
    if (!structureLayers.length) return result;
    result.layers = (result.layers || []).filter(layer => !isStructureLayer(layer)).concat(structureLayers);
    result.metrics = {
      ...(result.metrics || {}),
      paths: (result.layers || []).reduce((sum, layer) => sum + Number(layer.paths || 0), 0),
      nodes: (result.layers || []).reduce((sum, layer) => sum + Number(layer.nodes || 0), 0),
      dielinePaths: structureLayers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0),
      cutlinePaths: Number(cutline?.paths || 0),
      creasePaths: Number(crease?.paths || 0)
    };
    result.structure = {
      ...(result.structure || {}),
      separatedStructureLayers: true,
      semanticStructureReconstruction: true,
      cutlinePaths: Number(cutline?.paths || 0),
      creasePaths: Number(crease?.paths || 0),
      cutSpotName: "DIELINE",
      creaseSpotName: "CREASE"
    };
    return result;
  }

  function mergePackagingStructure(result, imageData, options) {
    if (!result || state.mode !== "packaging") return result;
    if (result?.structure?.semanticSpotSeparation) {
      return mergeSemanticPackagingStructure(result, imageData);
    }
    try {
      const legacy = window.JianglaiVectorEngine.vectorizePixels(imageData, {
        ...options,
        colors: Math.max(2, Math.min(8, Number(options.colors || 8)))
      });
      const structureLayers = (legacy.layers || []).filter(layer => String(layer?.role || "").toLowerCase() === "dieline");
      if (!structureLayers.length) return result;
      result.layers = (result.layers || []).filter(layer => !isStructureLayer(layer)).concat(structureLayers);
      result.metrics = {
        ...(result.metrics || {}),
        paths: Number(result.metrics?.paths || 0) + structureLayers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0),
        nodes: Number(result.metrics?.nodes || 0) + structureLayers.reduce((sum, layer) => sum + Number(layer.nodes || 0), 0),
        dielinePaths: structureLayers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0)
      };
      result.structure = { ...(result.structure || {}), ...(legacy.structure || {}), professionalArtworkTrace: true };
    } catch (error) {
      console.warn("Packaging structure extraction failed", error);
    }
    return result;
  }

  function structureStrokeIntersections(stroke, strokes, tolerance = 3) {
    const points = stroke?.points || [];
    if (points.length < 2) return 0;
    const first = points[0];
    const last = points[points.length - 1];
    const horizontal = Math.abs(last.y - first.y) <= Math.abs(last.x - first.x);
    const minX = Math.min(first.x, last.x) - tolerance;
    const maxX = Math.max(first.x, last.x) + tolerance;
    const minY = Math.min(first.y, last.y) - tolerance;
    const maxY = Math.max(first.y, last.y) + tolerance;
    return strokes.reduce((count, other) => {
      if (other === stroke || !Array.isArray(other?.points) || other.points.length < 2) return count;
      const otherFirst = other.points[0];
      const otherLast = other.points[other.points.length - 1];
      const otherHorizontal = Math.abs(otherLast.y - otherFirst.y) <= Math.abs(otherLast.x - otherFirst.x);
      if (horizontal === otherHorizontal) return count;
      const crossingX = horizontal ? otherFirst.x : first.x;
      const crossingY = horizontal ? first.y : otherFirst.y;
      return count + Number(crossingX >= minX && crossingX <= maxX && crossingY >= minY && crossingY <= maxY);
    }, 0);
  }

  function splitPackagingStructureLayers(result) {
    if (!result || state.mode !== "packaging") return result;
    const sourceLayers = Array.isArray(result.layers) ? result.layers : [];
    const legacyLayers = sourceLayers.filter(layer => String(layer?.role || "").toLowerCase() === "dieline");
    const strokes = legacyLayers.flatMap(layer => Array.isArray(layer?.strokes) ? layer.strokes : []);
    if (strokes.length < 2) return result;
    const bounds = strokes.flatMap(stroke => stroke.points || []);
    if (bounds.length < 4) return result;
    const minX = Math.min(...bounds.map(point => Number(point.x)));
    const maxX = Math.max(...bounds.map(point => Number(point.x)));
    const minY = Math.min(...bounds.map(point => Number(point.y)));
    const maxY = Math.max(...bounds.map(point => Number(point.y)));
    const tolerance = Math.max(2, Math.min(maxX - minX, maxY - minY) * .008);
    const strokeAxes = strokes.map(stroke => {
      const points = stroke?.points || [];
      if (points.length < 2) return null;
      const first = points[0];
      const last = points[points.length - 1];
      const horizontal = Math.abs(last.y - first.y) <= Math.abs(last.x - first.x);
      return {
        horizontal,
        axis: horizontal ? (first.y + last.y) / 2 : (first.x + last.x) / 2
      };
    }).filter(Boolean);
    const horizontalAxes = strokeAxes.filter(item => item.horizontal).map(item => item.axis);
    const verticalAxes = strokeAxes.filter(item => !item.horizontal).map(item => item.axis);
    const horizontalOuter = horizontalAxes.length
      ? [Math.min(...horizontalAxes), Math.max(...horizontalAxes)]
      : [minY, maxY];
    const verticalOuter = verticalAxes.length
      ? [Math.min(...verticalAxes), Math.max(...verticalAxes)]
      : [minX, maxX];
    const creaseStrokes = [];
    const cutStrokes = [];
    for (const stroke of strokes) {
      const points = stroke?.points || [];
      if (points.length < 2) continue;
      const first = points[0];
      const last = points[points.length - 1];
      const horizontal = Math.abs(last.y - first.y) <= Math.abs(last.x - first.x);
      const axis = horizontal ? (first.y + last.y) / 2 : (first.x + last.x) / 2;
      // 结构线常带有伸出网格的糊口或辅助段，不能拿全部端点的包围盒
      // 判断外边界，否则最外圈切线会被误当折线。应比较同方向线的轴位置。
      const outerAxes = horizontal ? horizontalOuter : verticalOuter;
      const nearOuter = Math.min(Math.abs(axis - outerAxes[0]), Math.abs(axis - outerAxes[1])) <= tolerance;
      const intersections = structureStrokeIntersections(stroke, strokes, tolerance * 1.5);
      // Internal lines that meet the surrounding structural network are fold
      // or crease lines.  Boundary and dangling segments remain knife cuts.
      if (!nearOuter && intersections > 0) creaseStrokes.push(stroke);
      else cutStrokes.push(stroke);
    }
    const template = legacyLayers[0];
    const sourceDisplayColor = [
      result?.structure?.dielineSourceColor,
      template?.sourceColor,
      template?.color
    ].find(color => /^#[0-9a-f]{6}$/i.test(String(color || ""))) || "#fff200";
    const makeLayer = (role, items) => {
      if (!items.length) return null;
      const crease = role === "crease";
      return rebuildGeometry({
        ...template,
        id: crease ? "packaging-crease" : "packaging-cutline",
        name: crease ? "折线 / 压痕线（专色）" : "刀线 / 切线（专色）",
        role,
        // 视觉颜色保留客户原稿；DIELINE / CREASE 的生产含义由专色
        // 名称和 CMYK 元数据区分，不能污染屏幕预览和通用 SVG。
        color: sourceDisplayColor,
        sourceColor: sourceDisplayColor,
        cmyk: crease ? [0, 1, 1, 0] : [0, 0, 0, 1],
        spotName: crease ? "CREASE" : "DIELINE",
        contours: [],
        strokes: items,
        strokeWidthMm: 0.25,
        visible: true
      });
    };
    const separated = [makeLayer("cutline", cutStrokes), makeLayer("crease", creaseStrokes)].filter(Boolean);
    if (!separated.length) return result;
    result.layers = sourceLayers.filter(layer => !legacyLayers.includes(layer)).concat(separated);
    if (typeof engineApi().recalculateMetrics === "function") result = engineApi().recalculateMetrics(result);
    result.metrics = {
      ...(result.metrics || {}),
      dielinePaths: strokes.length,
      cutlinePaths: cutStrokes.length,
      creasePaths: creaseStrokes.length
    };
    result.structure = {
      ...(result.structure || {}),
      separatedStructureLayers: true,
      cutlinePaths: cutStrokes.length,
      creasePaths: creaseStrokes.length,
      cutSpotName: "DIELINE",
      creaseSpotName: "CREASE",
      dielineDisplayColor: sourceDisplayColor
    };
    return result;
  }

  async function optimizeVectorResult(imageData, runId) {
    if (state.mode === "packaging" && state.sourceAnalysis?.semanticSpotCandidate) {
      const options = {
        ...currentOptions(),
        colors: Math.max(2, Number(state.sourceAnalysis?.semanticInk?.active?.length || 2)),
        simplify: .12,
        smoothing: .16,
        speckle: 0,
        removeBackground: true,
        boundaryMode: "cutout",
        semanticSpotSeparation: true
      };
      let result = await traceSemanticInkLayers(imageData, options, (family, index, total) => {
        if (runId !== state.runId) throw new Error("生成任务已更新");
        setProcessing(
          true,
          "正在按印刷专色分层重建…",
          `第 ${index}/${total} 层 · ${family.name} · 小字和条码不删点`,
          .01 + index / Math.max(1, total) * .07
        );
        setStatus(`正在分离${family.name}，避免黑字被拆成彩色碎片…`);
      });
      // Structure rules were deliberately removed from the artwork masks.
      // Put their straight production geometry back before raster scoring so
      // quality metrics measure the complete document, not a temporary
      // artwork-only intermediate.
      result = mergeSemanticPackagingStructure(result, imageData);
      const scoring = await scoreVectorCandidate(result, imageData, state.qualityGoal, state.mode);
      state.optimization = {
        automatic: true,
        goal: state.qualityGoal,
        candidateCount: 1,
        selected: "印刷专色语义分层",
        score: scoring.score,
        visual: scoring.visual,
        edge: scoring.edge,
        complexity: scoring.complexity,
        paletteRetention: scoring.paletteRetention,
        fragmentation: scoring.fragmentation,
        resolutionRetention: scoring.resolutionRetention,
        resolutionAdequate: scoring.resolutionAdequate,
        structureAdequate: scoring.structureAdequate,
        semanticInkRecall: scoring.semanticInkRecall,
        neutralInkRecall: scoring.neutralInkRecall,
        redInkRecall: scoring.redInkRecall,
        semanticInkPrecision: scoring.semanticInkPrecision,
        productionReady: false
      };
      result.quality = {
        ...scoring,
        semanticSpotSeparation: true,
        semanticFineInkPreserved: Number(scoring.neutralInkRecall || 0) >= 88,
        semanticFamilyCount: Number(result.layers?.length || 0)
      };
      applyOptimizedOptions(options);
      updateAutoSummary(
        `采用“印刷专色语义分层” · 黑字/条码召回 ${Number(scoring.neutralInkRecall || 0)}% · 红色刀线召回 ${Number(scoring.redInkRecall || 0)}%`
      );
      return result;
    }
    const candidates = state.autoOptimize
      ? optimizationCandidates(state.mode, state.qualityGoal)
      : [{ label: "专业参数", options: currentOptions() }];
    const evaluated = [];
    let lastError = null;
    for (let index = 0; index < candidates.length; index += 1) {
      if (runId !== state.runId) throw new Error("生成任务已更新");
      const candidate = candidates[index];
      const detail = state.autoOptimize
        ? `正在比较第 ${index + 1}/${candidates.length} 组：${candidate.label}`
        : "正在按专业参数生成";
      setProcessing(true, "正在自动寻找最优方案…", `专业样条曲线 · ${candidate.options?.boundaryMode === "stacked" ? "叠印边界" : "无缝共享边界"} · ${detail}`);
      setStatus(detail);
      await new Promise(resolve => window.setTimeout(resolve, 18));
      try {
        const options = { ...currentOptions(), ...candidate.options };
        const result = await vectorizeCandidate(imageData, options);
        const scoring = await scoreVectorCandidate(result, imageData, state.qualityGoal, state.mode);
        evaluated.push({ ...candidate, options, result, scoring });
      } catch (error) {
        lastError = error;
        if (error?.code !== "VECTOR_COMPLEXITY_LIMIT") console.warn("Vector optimization candidate failed", candidate.label, error);
      }
    }
    if (!evaluated.length) throw lastError || new Error("没有形成可用的矢量方案");
    evaluated.sort((left, right) => right.scoring.score - left.scoring.score);
    const best = evaluated[0];
    state.optimization = {
      automatic: state.autoOptimize,
      goal: state.qualityGoal,
      candidateCount: evaluated.length,
      selected: best.label,
      score: best.scoring.score,
      visual: best.scoring.visual,
      edge: best.scoring.edge,
      complexity: best.scoring.complexity,
      paletteRetention: best.scoring.paletteRetention,
      fragmentation: best.scoring.fragmentation,
      resolutionRetention: best.scoring.resolutionRetention,
      resolutionAdequate: best.scoring.resolutionAdequate,
      structureAdequate: best.scoring.structureAdequate,
      productionReady: best.scoring.productionReady
    };
    best.result.quality = { ...best.scoring };
    applyOptimizedOptions(best.options);
    updateAutoSummary(state.autoOptimize
      ? `已比较 ${evaluated.length} 组，采用“${best.label}” · 候选评分 ${best.scoring.score}（仅用于本次选优）`
      : "已按专业参数生成，本次未进行自动比较。");
    return best.result;
  }

  function roundTo(value, digits = 2) {
    const factor = 10 ** digits;
    return Math.round(Number(value || 0) * factor) / factor;
  }

  function parseSvgPhysicalLength(value) {
    const match = String(value || "").trim().match(/^([+-]?(?:\d+\.?\d*|\.\d+))(mm|cm|in|pt|pc)$/i);
    if (!match) return null;
    const amount = Number(match[1]);
    const unit = match[2].toLowerCase();
    const multiplier = { mm: 1, cm: 10, in: 25.4, pt: 25.4 / 72, pc: 25.4 / 6 }[unit];
    return Number.isFinite(amount) && multiplier ? amount * multiplier : null;
  }

  function sanitizeAndInspectSvg(markup) {
    const source = String(markup || "");
    if (!source.trim() || source.length > 24 * 1024 * 1024) throw new Error("SVG 文件为空或内容过大");
    if (/<!doctype|<!entity/i.test(source)) throw new Error("SVG 包含不安全的外部实体声明");
    const documentNode = new DOMParser().parseFromString(source, "image/svg+xml");
    if (documentNode.querySelector("parsererror") || documentNode.documentElement?.localName !== "svg") {
      throw new Error("SVG 结构无法读取");
    }
    const root = documentNode.documentElement;
    root.querySelectorAll("script,foreignObject,iframe,object,embed,audio,video,canvas").forEach(node => node.remove());
    root.querySelectorAll("style").forEach(node => {
      if (/@import|url\s*\(/i.test(node.textContent || "")) node.remove();
    });
    root.querySelectorAll("*").forEach(node => {
      [...node.attributes].forEach(attribute => {
        const name = attribute.name.toLowerCase();
        const value = String(attribute.value || "").trim();
        if (name.startsWith("on") || /javascript\s*:/i.test(value)) node.removeAttribute(attribute.name);
        if ((name === "href" || name.endsWith(":href")) && value && !value.startsWith("#")) node.removeAttribute(attribute.name);
        if (name === "style" && /@import|url\s*\(/i.test(value)) node.removeAttribute(attribute.name);
      });
    });
    const drawableSelector = "path,rect,circle,ellipse,line,polyline,polygon";
    const drawables = [...root.querySelectorAll(drawableSelector)];
    const paths = [...root.querySelectorAll("path")];
    const pathCount = paths.reduce((sum, path) => sum + Math.max(1, (path.getAttribute("d") || "").match(/[Mm](?=[\s,+.\-0-9])/g)?.length || 0), 0)
      + drawables.filter(node => node.localName !== "path").length;
    const nodeCount = drawables.reduce((sum, node) => {
      if (node.localName === "path") return sum + ((node.getAttribute("d") || "").match(/[AaCcHhLlMmQqSsTtVvZz]/g)?.length || 0);
      if (["polyline", "polygon"].includes(node.localName)) return sum + ((node.getAttribute("points") || "").trim().split(/[\s,]+/).length / 2 || 0);
      return sum + (["rect", "line"].includes(node.localName) ? 4 : 8);
    }, 0);
    const colors = new Set();
    drawables.forEach(node => {
      for (const property of ["fill", "stroke"]) {
        const direct = String(node.getAttribute(property) || "").trim();
        if (direct && !/^(none|transparent|currentcolor)$/i.test(direct)) colors.add(direct.toLowerCase());
        const styleMatch = String(node.getAttribute("style") || "").match(new RegExp(`${property}\\s*:\\s*([^;]+)`, "i"));
        if (styleMatch && !/^(none|transparent|currentcolor)$/i.test(styleMatch[1].trim())) colors.add(styleMatch[1].trim().toLowerCase());
      }
    });
    const widthAttribute = root.getAttribute("width") || "";
    const heightAttribute = root.getAttribute("height") || "";
    let widthMm = parseSvgPhysicalLength(widthAttribute);
    let heightMm = parseSvgPhysicalLength(heightAttribute);
    const viewBox = (root.getAttribute("viewBox") || "").trim().split(/[\s,]+/).map(Number);
    const viewWidth = viewBox.length === 4 && viewBox.every(Number.isFinite) ? Math.abs(viewBox[2]) : 0;
    const viewHeight = viewBox.length === 4 && viewBox.every(Number.isFinite) ? Math.abs(viewBox[3]) : 0;
    if (widthMm && !heightMm && viewWidth && viewHeight) heightMm = widthMm * viewHeight / viewWidth;
    if (heightMm && !widthMm && viewWidth && viewHeight) widthMm = heightMm * viewWidth / viewHeight;
    const imageCount = root.querySelectorAll("image").length;
    const textCount = root.querySelectorAll("text,tspan").length;
    const strokeCount = drawables.filter(node => {
      const stroke = String(node.getAttribute("stroke") || node.getAttribute("style")?.match(/stroke\s*:\s*([^;]+)/i)?.[1] || "").trim();
      return stroke && !/^(none|transparent)$/i.test(stroke);
    }).length;
    const safeMarkup = `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(root)}`;
    return {
      kind: "svg",
      safeMarkup,
      widthMm: widthMm ? roundTo(widthMm) : null,
      heightMm: heightMm ? roundTo(heightMm) : null,
      displaySize: widthAttribute && heightAttribute ? `${widthAttribute} × ${heightAttribute}` : (viewWidth && viewHeight ? `${viewWidth} × ${viewHeight} viewBox` : "未声明"),
      colors: [...colors].slice(0, 16),
      pathCount: Math.round(pathCount),
      nodeCount: Math.round(nodeCount),
      imageCount,
      textCount,
      strokeCount,
      warnings: [
        imageCount ? `包含 ${imageCount} 个内嵌位图对象` : "",
        textCount ? `包含 ${textCount} 个未转曲文字对象` : "",
        !widthMm || !heightMm ? "没有可靠的毫米尺寸" : "",
        colors.size <= 1 && pathCount > 10 ? "文件只有一个颜色层，刀模线与文字可能已混合" : ""
      ].filter(Boolean)
    };
  }

  async function inspectPdfFile(file) {
    const bytes = new Uint8Array(await file.arrayBuffer());
    if (bytes.length < 8 || String.fromCharCode(...bytes.slice(0, 5)) !== "%PDF-") throw new Error("PDF 文件头无效");
    const source = new TextDecoder("latin1").decode(bytes);
    const pageCount = Math.max(1, source.match(/\/Type\s*\/Page\b/g)?.length || 0);
    const boxMatch = source.match(/\/(?:MediaBox|CropBox)\s*\[\s*([+-]?[\d.]+)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)\s*\]/);
    let widthMm = null;
    let heightMm = null;
    if (boxMatch) {
      const values = boxMatch.slice(1).map(Number);
      if (values.every(Number.isFinite)) {
        widthMm = roundTo(Math.abs(values[2] - values[0]) * 25.4 / 72);
        heightMm = roundTo(Math.abs(values[3] - values[1]) * 25.4 / 72);
      }
    }
    const imageCount = source.match(/\/Subtype\s*\/Image\b/g)?.length || 0;
    const separationCount = source.match(/\/Separation\b/g)?.length || 0;
    return {
      kind: "pdf",
      pageCount,
      widthMm,
      heightMm,
      displaySize: widthMm && heightMm ? `${widthMm} × ${heightMm} mm` : "页面尺寸未识别",
      imageCount,
      separationCount,
      colors: [],
      pathCount: null,
      nodeCount: null,
      warnings: [
        imageCount ? `PDF 包含 ${imageCount} 个图像对象，仍需检查分辨率` : "",
        pageCount > 1 ? `文件共有 ${pageCount} 页，预览默认显示第一页` : "",
        !widthMm || !heightMm ? "没有读取到可靠的页面毫米尺寸" : ""
      ].filter(Boolean)
    };
  }

  function revokeUrl(key) {
    if (state[key]) URL.revokeObjectURL(state[key]);
    state[key] = "";
  }

  function resetResult() {
    state.result = null;
    state.svg = "";
    state.outputKind = "";
    state.fidelityDataUrl = "";
    state.fidelityReason = "";
    state.processingMaxEdge = 0;
    state.layerState = {};
    state.commercialTextRebuilt = false;
    state.rebuiltTextRects = [];
    state.reconstructionReviewRequired = false;
    state.optimization = null;
    state.editor.selectedLayerId = "";
    state.editor.selectedContourId = "";
    state.editor.selectedNodeId = "";
    state.editor.dirty = false;
    state.editor.undo = [];
    state.editor.redo = [];
    state.editor.drag = null;
    state.editor.retraceStart = null;
    state.editor.retraceRect = null;
    state.editor.lastTextRect = null;
    state.editor.lastTextSpecs = [];
    state.textRebuildApplying = false;
    state.editor.lastEditTool = "";
    state.editor.tool = "browse";
    revokeUrl("resultUrl");
    const image = byId("jlVectorResultImage");
    if (image) image.removeAttribute("src");
    const palette = byId("jlVectorPalette");
    if (palette) palette.innerHTML = '<div class="jl-vector-quality warning">生成后可逐色隐藏或替换颜色。</div>';
    const layerSelect = byId("jlVectorEditLayer");
    if (layerSelect) layerSelect.innerHTML = '<option value="">生成后选择图层</option>';
    const editorSvg = byId("jlVectorEditorSvg");
    if (editorSvg) editorSvg.replaceChildren();
    const autoTextButton = byId("jlVectorAutoReplaceText");
    if (autoTextButton) { autoTextButton.disabled = true; autoTextButton.textContent = "重新识别并分层文字 / Logo"; }
    const autoTextNote = byId("jlVectorAutoTextNote");
    if (autoTextNote) autoTextNote.textContent = "普通文字重排为独立文字层；艺术字、Logo和图形隔离为独立描摹层。";
    const resultTab = document.querySelector('[data-vector-view="result"]');
    if (resultTab) resultTab.textContent = "矢量图";
    all("[data-vector-tool]").forEach(button => { button.disabled = false; });
    const manualButton = byId("jlVectorManualReview");
    if (manualButton) { manualButton.hidden = true; manualButton.setAttribute("aria-hidden", "true"); manualButton.tabIndex = -1; }
    const runNote = byId("jlVectorRunNote");
    if (runNote) runNote.textContent = "系统会比较多组方案，只按最终生成结果扣一次点数";
    setEditorTool("browse");
    setResultButtons(false);
    setPreviewAvailability(false);
    renderMetrics();
  }

  function readFileDataUrl(file) {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onload = () => resolve(String(reader.result || ""));
      reader.onerror = () => reject(new Error("原图保真数据读取失败"));
      reader.readAsDataURL(file);
    });
  }

  function buildFidelitySvg(dataUrl, widthMm = 0) {
    const width = Math.max(1, Number(state.sourceWidth || state.image?.naturalWidth || state.image?.width || 1));
    const height = Math.max(1, Number(state.sourceHeight || state.image?.naturalHeight || state.image?.height || 1));
    const physicalWidth = Number(widthMm) > 0 ? Number(widthMm) : 0;
    const physicalHeight = physicalWidth ? physicalWidth * height / width : 0;
    const widthAttribute = physicalWidth ? `${roundTo(physicalWidth, 3)}mm` : width;
    const heightAttribute = physicalHeight ? `${roundTo(physicalHeight, 3)}mm` : height;
    const metadata = escapeXml(JSON.stringify({
      generator: "Jianglai Vector Studio",
      outputKind: "fidelity-reference",
      embeddedRaster: true,
      sourceWidth: width,
      sourceHeight: height,
      productionVector: false,
      note: "保留原图画面，非全路径生产矢量"
    }));
    return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${widthAttribute}" height="${heightAttribute}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(safeFilename(state.file?.name))} 保真参考稿">\n  <title>${escapeXml(safeFilename(state.file?.name))} 保真参考稿</title>\n  <metadata>${metadata}</metadata>\n  <image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none" href="${escapeXml(dataUrl)}" xlink:href="${escapeXml(dataUrl)}"/>\n</svg>`;
  }

  async function activateFidelityFallback(reason) {
    resetResult();
    state.outputKind = "fidelity-preview";
    state.fidelityReason = String(reason || "复杂印刷整版包含照片、小字或刀模，自动全路径描摹会明显失真");
    state.fidelityDataUrl = await readFileDataUrl(state.file);
    state.svg = buildFidelitySvg(state.fidelityDataUrl);
    state.result = {
      width: state.sourceWidth,
      height: state.sourceHeight,
      outputWidth: state.sourceWidth,
      outputHeight: state.sourceHeight,
      layers: [],
      metrics: { colors: 0, paths: 0, nodes: 0, foregroundPixels: 0, dielinePaths: 0 },
      quality: {
        productionReady: false,
        fidelityPreview: true,
        visual: 100,
        edge: 100,
        paletteRetention: 100,
        resolutionRetention: 100
      }
    };
    revokeUrl("resultUrl");
    state.resultUrl = URL.createObjectURL(new Blob([state.svg], { type: "image/svg+xml" }));
    const resultImage = byId("jlVectorResultImage");
    if (resultImage) resultImage.src = state.resultUrl;
    const resultTab = document.querySelector('[data-vector-view="result"]');
    if (resultTab) resultTab.textContent = "保真参考";
    all("[data-vector-tool]").forEach(button => { button.disabled = true; });
    const palette = byId("jlVectorPalette");
    if (palette) palette.innerHTML = `<div class="jl-vector-quality warning">自动矢量重建未达到生产标准，已保留高清原稿供核对。本次不扣点；请点击“转人工精修”，不要把当前参考稿直接用于生产。</div>`;
    const manualButton = byId("jlVectorManualReview");
    if (manualButton) { manualButton.hidden = false; manualButton.setAttribute("aria-hidden", "false"); manualButton.tabIndex = 0; }
    setPreviewAvailability(true);
    setResultButtons(true);
    renderMetrics();
    setPreviewMode("compare");
    updatePhysicalSize();
  }

  async function showFidelityReference() {
    if (!state.file || !state.sourceAnalysis?.complexArtwork) return;
    const button = byId("jlVectorFidelityOnly");
    if (button) { button.disabled = true; button.textContent = "正在准备高清原稿…"; }
    setProcessing(true, "正在准备高清原稿…", "只保留原始像素供放大核对，不生成路径，也不扣点。 ");
    try {
      await activateFidelityFallback("用户选择只查看高清原稿，不执行矢量重建");
      state.reconstructionRequested = false;
      setStatus("高清原稿已准备好 · 图片没有被压缩 · 本次不扣点；需要可编辑路径时点击左侧“专业重建”", "warning");
      notifyUser("高清原稿已准备好；需要可编辑矢量时点击“专业重建”");
    } catch (error) {
      setStatus(`高清原稿读取失败：${error.message}`, "error");
      notifyUser(error.message);
    } finally {
      setProcessing(false);
      if (button) { button.disabled = false; button.textContent = "只查看高清原稿 · 免费"; }
    }
  }

  function setNativeMode(active) {
    const studio = byId("jlVectorStudio");
    studio?.classList.toggle("is-native-vector", Boolean(active));
    const notice = byId("jlVectorNativeNotice");
    if (notice) notice.hidden = !active;
    const originalButton = byId("jlVectorDownloadOriginal");
    if (originalButton) originalButton.hidden = !active;
    const pdfPreview = byId("jlVectorPdfPreview");
    if (pdfPreview && !active) { pdfPreview.hidden = true; pdfPreview.removeAttribute("src"); }
    const source = byId("jlVectorSourceImage");
    if (source && !active) source.hidden = false;
    const runButton = byId("jlVectorRun");
    if (runButton && active) { runButton.disabled = true; runButton.textContent = "原文件已保留，无需重新描摹"; }
    if (active) setComplexReconstructionUi(false);
  }

  function setComplexReconstructionUi(active) {
    const enabled = Boolean(active);
    const notice = byId("jlVectorReconstructionNotice");
    const referenceButton = byId("jlVectorFidelityOnly");
    const reviewRow = byId("jlVectorReconstructionReviewRow");
    if (notice) notice.hidden = !enabled;
    if (referenceButton) referenceButton.disabled = false;
    if (reviewRow) reviewRow.hidden = !enabled;
    if (!enabled) {
      const confirmed = byId("jlVectorReconstructionConfirmed");
      if (confirmed) confirmed.checked = false;
    }
  }

  function renderNativePalette(info) {
    const palette = byId("jlVectorPalette");
    if (!palette) return;
    palette.replaceChildren();
    if (!info.colors?.length) {
      const message = document.createElement("div");
      message.className = "jl-vector-quality";
      message.textContent = info.kind === "pdf" ? "PDF 颜色与专色保持原文件结构，不在这里改写。" : "文件没有可安全归纳的颜色属性。";
      palette.appendChild(message);
      return;
    }
    info.colors.forEach((color, index) => {
      const row = document.createElement("div");
      row.className = "jl-vector-native-color";
      const chip = document.createElement("i");
      chip.style.background = color;
      const label = document.createElement("span");
      label.textContent = `${index + 1}. ${color}`;
      row.append(chip, label);
      palette.appendChild(row);
    });
  }

  function renderNativeSummary() {
    const info = state.nativeVector;
    if (!info) return;
    const values = {
      jlVectorMetricColors: info.kind === "pdf" ? "原稿" : (info.colors.length || "—"),
      jlVectorMetricPaths: info.pathCount == null ? "保留" : info.pathCount,
      jlVectorMetricNodes: info.nodeCount == null ? "保留" : info.nodeCount,
      jlVectorMetricSize: formatBytes(state.file?.size || 0)
    };
    Object.entries(values).forEach(([id, value]) => { const element = byId(id); if (element) element.textContent = value; });
    const quality = byId("jlVectorQuality");
    if (quality) {
      quality.classList.toggle("warning", Boolean(info.warnings.length));
      const base = info.kind === "pdf"
        ? `✓ 原生 PDF 已无损保留 · ${info.pageCount} 页 · ${info.displaySize}`
        : `✓ 原生 SVG 已无损保留 · ${info.pathCount} 条路径 · ${info.displaySize}`;
      quality.textContent = info.warnings.length ? `${base}；${info.warnings.join("；")}` : `${base} · 未重新描摹`;
    }
    const title = byId("jlVectorNativeTitle");
    const detail = byId("jlVectorNativeDetail");
    if (title) title.textContent = info.kind === "pdf" ? "检测到原生 PDF：不重新描摹" : "检测到原生 SVG：不重新描摹";
    if (detail) detail.textContent = info.kind === "pdf"
      ? `原页面、转曲文字、刀模专色和实际尺寸全部保留。${info.imageCount ? "文件含图像对象，请在制前检查中核对分辨率。" : "未检测到内嵌位图对象。"}`
      : `原路径、填色和描边全部保留。${info.warnings.length ? info.warnings.join("；") : "结构检查未发现明显风险。"}`;
    renderNativePalette(info);
    updatePhysicalSize();
  }

  async function loadNativeVectorFile(file, kind, loadId) {
    setProcessing(true, kind === "pdf" ? "正在检查 PDF…" : "正在检查 SVG…", "只读取本机文件结构，不会上传，也不会重新描摹。 ");
    let info;
    let previewUrl = "";
    try {
      if (kind === "svg") {
        info = sanitizeAndInspectSvg(await file.text());
        previewUrl = URL.createObjectURL(new Blob([info.safeMarkup], { type: "image/svg+xml" }));
      } else {
        info = await inspectPdfFile(file);
        previewUrl = URL.createObjectURL(file);
      }
      if (loadId !== state.runId) { URL.revokeObjectURL(previewUrl); return; }
      if (typeof state.image?.close === "function") state.image.close();
      state.image = null;
      state.imageData = null;
      revokeUrl("sourceUrl");
      resetResult();
      state.file = file;
      state.sourceKind = kind;
      state.nativeVector = info;
      state.sourceWidth = 0;
      state.sourceHeight = 0;
      state.sourceAnalysis = null;
      state.sourceUrl = previewUrl;
      state.chargeRequestId = "";
      state.chargeSettled = false;
      const source = byId("jlVectorSourceImage");
      const pdfPreview = byId("jlVectorPdfPreview");
      if (kind === "svg") {
        if (source) { source.src = previewUrl; source.hidden = false; }
        if (pdfPreview) { pdfPreview.hidden = true; pdfPreview.removeAttribute("src"); }
      } else {
        if (source) { source.removeAttribute("src"); source.hidden = true; }
        if (pdfPreview) { pdfPreview.src = previewUrl; pdfPreview.hidden = false; }
      }
      const fileInfo = byId("jlVectorFileInfo");
      if (fileInfo) {
        fileInfo.classList.add("is-visible");
        const label = fileInfo.querySelector("b");
        if (label) label.textContent = `${file.name} · ${formatBytes(file.size)}`;
      }
      byId("jlVectorStageClear").disabled = false;
      const empty = byId("jlVectorEmpty");
      if (empty) empty.style.display = "none";
      setNativeMode(true);
      setPreviewMode("original");
      setPreviewAvailability(false);
      renderNativeSummary();
      resetView();
      setStatus(info.kind === "pdf"
        ? `原生 PDF 已无损载入 · ${info.displaySize} · 不扣点、不重新描摹`
        : `原生 SVG 已无损载入 · ${info.pathCount} 条路径 · ${info.colors.length} 个颜色属性 · 不扣点、不重新描摹`);
    } catch (error) {
      if (previewUrl) URL.revokeObjectURL(previewUrl);
      if (loadId === state.runId) { setStatus(error.message, "error"); notifyUser(error.message); }
    } finally {
      if (loadId === state.runId) setProcessing(false);
    }
  }

  function clearFile() {
    state.runId += 1;
    window.clearTimeout(state.rerunTimer);
    state.rerunTimer = null;
    state.file = null;
    state.sourceKind = "";
    state.nativeVector = null;
    if (typeof state.image?.close === "function") state.image.close();
    state.image = null;
    state.imageData = null;
    state.sourceWidth = 0;
    state.sourceHeight = 0;
    state.sourceAnalysis = null;
    state.chargeRequestId = "";
    state.chargeSettled = false;
    state.reconstructionRequested = false;
    state.qualityGoal = "fidelity";
    state.autoOptimize = true;
    revokeUrl("sourceUrl");
    resetResult();
    const input = byId("jlVectorStudioFile");
    if (input) input.value = "";
    const source = byId("jlVectorSourceImage");
    if (source) { source.removeAttribute("src"); source.hidden = false; }
    setNativeMode(false);
    setComplexReconstructionUi(false);
    const stageClear = byId("jlVectorStageClear");
    if (stageClear) stageClear.disabled = true;
    byId("jlVectorFileInfo")?.classList.remove("is-visible");
    const empty = byId("jlVectorEmpty");
    if (empty) empty.style.display = "grid";
    const widthField = byId("jlVectorWidthMm");
    if (widthField) { widthField.readOnly = false; widthField.value = ""; widthField.placeholder = "必须按客户原稿填写"; }
    const sizeConfirmed = byId("jlVectorSizeConfirmed");
    if (sizeConfirmed) sizeConfirmed.checked = false;
    const reconstructionConfirmed = byId("jlVectorReconstructionConfirmed");
    if (reconstructionConfirmed) reconstructionConfirmed.checked = false;
    const ocrText = byId("jlVectorOcrText");
    if (ocrText) ocrText.value = "";
    const ocrConsent = byId("jlVectorOcrConsent");
    if (ocrConsent) ocrConsent.checked = false;
    renderTextRebuildPreview();
    const runButton = byId("jlVectorRun");
    if (runButton) { runButton.disabled = false; runButton.textContent = `自动生成最优矢量图 · ${VECTOR_POINTS}点`; }
    const runNote = byId("jlVectorRunNote");
    if (runNote) runNote.textContent = "系统会比较多组方案，只按最终生成结果扣一次点数";
    all("[data-vector-goal]").forEach(button => button.classList.toggle("is-active", button.dataset.vectorGoal === "fidelity"));
    updateAutoSummary();
    setProcessing(false);
    setStatus("等待图片 · 支持拖放、粘贴或点击选择");
  }

  async function readImage(file) {
    const url = URL.createObjectURL(file);
    if (typeof globalThis.createImageBitmap === "function") {
      try {
        const image = await globalThis.createImageBitmap(file, { imageOrientation: "from-image" });
        return { image, url };
      } catch (_) {
        // 某些旧版 WebView 不支持该图片格式的 ImageBitmap，继续使用 Image 解码。
      }
    }
    return new Promise((resolve, reject) => {
      const image = new Image();
      image.onload = () => resolve({ image, url });
      image.onerror = () => { URL.revokeObjectURL(url); reject(new Error("图片无法读取，请换用 JPG、PNG、WebP 或 BMP")); };
      image.src = url;
    });
  }

  async function probeImageDimensions(file) {
    const bytes = new Uint8Array(await file.slice(0, Math.min(file.size, 1024 * 1024)).arrayBuffer());
    const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    const ascii = (start, length) => String.fromCharCode(...bytes.slice(start, start + length));
    if (bytes.length >= 24 && bytes[0] === 0x89 && ascii(1, 3) === "PNG") {
      return { width: view.getUint32(16, false), height: view.getUint32(20, false) };
    }
    if (bytes.length >= 26 && ascii(0, 2) === "BM") {
      return { width: Math.abs(view.getInt32(18, true)), height: Math.abs(view.getInt32(22, true)) };
    }
    if (bytes.length >= 30 && ascii(0, 4) === "RIFF" && ascii(8, 4) === "WEBP" && ascii(12, 4) === "VP8X") {
      const width = 1 + bytes[24] + (bytes[25] << 8) + (bytes[26] << 16);
      const height = 1 + bytes[27] + (bytes[28] << 8) + (bytes[29] << 16);
      return { width, height };
    }
    if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
      const sofMarkers = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
      let offset = 2;
      while (offset + 8 < bytes.length) {
        if (bytes[offset] !== 0xff) { offset += 1; continue; }
        while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
        const marker = bytes[offset++];
        if (marker === 0xd8 || marker === 0xd9 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
        if (offset + 2 > bytes.length) break;
        const length = view.getUint16(offset, false);
        if (length < 2 || offset + length > bytes.length) break;
        if (sofMarkers.has(marker) && length >= 7) {
          return { width: view.getUint16(offset + 5, false), height: view.getUint16(offset + 3, false) };
        }
        offset += length;
      }
    }
    return null;
  }

  function validatePixelDimensions(dimensions) {
    if (!dimensions) return;
    const width = Number(dimensions.width || 0);
    const height = Number(dimensions.height || 0);
    if (!width || !height) throw new Error("图片尺寸无效");
    if (width * height > 80_000_000) throw new Error("图片像素过大，请先缩小到 8000 万像素以内");
  }

  function drawImageData(image, mode, requestedMaxEdge = 0) {
    const sourceWidth = image.naturalWidth || image.width;
    const sourceHeight = image.naturalHeight || image.height;
    if (!sourceWidth || !sourceHeight) throw new Error("图片尺寸无效");
    if (sourceWidth * sourceHeight > 80_000_000) throw new Error("图片像素过大，请先缩小到 8000 万像素以内");
    const maxEdge = requestedMaxEdge || (mode === "photo" ? 620 : mode === "illustration" ? 760 : mode === "packaging" ? 3200 : mode === "line" ? 3200 : 900);
    // Upscaling a raster does not create detail. It multiplies antialias
    // shades around every glyph and was the direct cause of 100k+ node
    // packaging "vectors". Work at source resolution and only downsample
    // genuinely oversized artwork; OCR/font reconstruction handles small type.
    const scale = Math.min(1, maxEdge / Math.max(sourceWidth, sourceHeight));
    const width = Math.max(2, Math.round(sourceWidth * scale));
    const height = Math.max(2, Math.round(sourceHeight * scale));
    const canvas = document.createElement("canvas");
    canvas.width = width; canvas.height = height;
    const context = canvas.getContext("2d", { willReadFrequently: true });
    context.imageSmoothingEnabled = true;
    context.imageSmoothingQuality = "high";
    context.clearRect(0, 0, width, height);
    context.drawImage(image, 0, 0, width, height);
    return context.getImageData(0, 0, width, height);
  }

  async function loadFile(file) {
    if (!file) return;
    const kind = supportedFileKind(file);
    if (!kind) { notifyUser("请选择 JPG、PNG、WebP、BMP、PDF 或 SVG 文件"); return; }
    if (file.size > 30 * 1024 * 1024) { notifyUser("文件不能超过 30MB"); return; }
    const loadId = ++state.runId;
    if (kind === "pdf" || kind === "svg") {
      await loadNativeVectorFile(file, kind, loadId);
      return;
    }
    let candidateUrl = "";
    setProcessing(true, "正在读取图片…", "图片只在本机浏览器内解码，不会自动上传。 ");
    try {
      validatePixelDimensions(await probeImageDimensions(file));
      const loaded = await readImage(file);
      candidateUrl = loaded.url;
      if (loadId !== state.runId) { URL.revokeObjectURL(loaded.url); return; }
      // HTMLImageElement exposes naturalWidth/naturalHeight, while the desktop
      // createImageBitmap path exposes width/height. Accept both decoded image
      // shapes so a valid upload is not rejected as having an invalid size.
      validatePixelDimensions({
        width: loaded.image.naturalWidth || loaded.image.width,
        height: loaded.image.naturalHeight || loaded.image.height,
      });
      const draft = drawImageData(loaded.image, "illustration", 640);
      const sourceAnalysis = analyzePrintSheet(draft);
      const detected = detectMode(draft);
      if (typeof state.image?.close === "function") state.image.close();
      revokeUrl("sourceUrl");
      resetResult();
      state.file = file;
      state.sourceKind = "raster";
      state.nativeVector = null;
      state.image = loaded.image;
      state.sourceWidth = loaded.image.naturalWidth || loaded.image.width;
      state.sourceHeight = loaded.image.naturalHeight || loaded.image.height;
      state.sourceAnalysis = sourceAnalysis;
      state.sourceUrl = loaded.url;
      state.chargeRequestId = newChargeRequestId();
      state.chargeSettled = false;
      state.reconstructionRequested = false;
      state.qualityGoal = "fidelity";
      state.autoOptimize = true;
      all("[data-vector-goal]").forEach(button => button.classList.toggle("is-active", button.dataset.vectorGoal === "fidelity"));
      const widthField = byId("jlVectorWidthMm");
      if (widthField) { widthField.readOnly = false; widthField.value = ""; widthField.placeholder = "必须按客户原稿填写"; }
      const sizeConfirmed = byId("jlVectorSizeConfirmed");
      if (sizeConfirmed) sizeConfirmed.checked = false;
      const reconstructionConfirmed = byId("jlVectorReconstructionConfirmed");
      if (reconstructionConfirmed) reconstructionConfirmed.checked = false;
      candidateUrl = "";
      // 选择图片只做本机识别；必须由客户明确点击后才生成并扣点。
      selectPreset(detected, true, false);
      state.imageData = draft;
      const source = byId("jlVectorSourceImage");
      if (source) { source.src = state.sourceUrl; source.hidden = false; }
      setNativeMode(false);
      const stageClear = byId("jlVectorStageClear");
      if (stageClear) stageClear.disabled = false;
      const empty = byId("jlVectorEmpty");
      if (empty) empty.style.display = "none";
      const fileInfo = byId("jlVectorFileInfo");
      if (fileInfo) {
        fileInfo.classList.add("is-visible");
        const label = fileInfo.querySelector("b");
        if (label) label.textContent = `${file.name} · ${formatBytes(file.size)}`;
      }
      resetView();
      const button = byId("jlVectorRun");
      const complexPrintSheet = sourceAnalysis.complexArtwork;
      setComplexReconstructionUi(complexPrintSheet);
      if (button) button.textContent = complexPrintSheet
        ? `尝试专业重建 · 达标才扣${VECTOR_POINTS}点`
        : `自动生成最优矢量图 · ${VECTOR_POINTS}点`;
      const runNote = byId("jlVectorRunNote");
      if (runNote) runNote.textContent = complexPrintSheet
        ? "密集小字、条码或照片还原不达标时自动拦截，保留高清原稿且不扣点"
        : "系统会比较多组方案，只按最终生成结果扣一次点数";
      updateAutoSummary(complexPrintSheet
        ? `识别到 ${sourceAnalysis.structuralRuns} 段结构线和密集细节，将使用分层重画与文字重建，不做整图低清描摹。`
        : `已自动识别，生成时将比较 ${optimizationCandidates(detected, state.qualityGoal).length} 组方案。`);
      setStatus(complexPrintSheet
        ? `已识别为复杂包装稿 · ${state.sourceWidth}×${state.sourceHeight}px · 可点击“专业重建”生成可编辑刀线、轮廓和转曲文字`
        : `已识别为${analysisLabel(detected)} · ${state.sourceWidth}×${state.sourceHeight}px · 点击后自动选优，成功扣${VECTOR_POINTS}点`);
    } catch (error) {
      if (candidateUrl) URL.revokeObjectURL(candidateUrl);
      if (loadId === state.runId) {
        setProcessing(false);
        setStatus(error.message, "error");
        notifyUser(error.message);
      }
    } finally {
      if (loadId === state.runId) setProcessing(false);
    }
  }

  function scheduleRerun() {
    updateRangeOutputs();
    window.clearTimeout(state.rerunTimer);
    if (!state.file) return;
    if (state.nativeVector) {
      setStatus("原生 PDF/SVG 已无损保留，不执行像素描摹。 ");
      return;
    }
    if (!state.result && !state.chargeSettled) {
      setStatus(state.sourceAnalysis?.complexArtwork
        ? `专业重建参数已更新 · 成功完成刀线、轮廓和文字曲线后扣${VECTOR_POINTS}点`
        : `${state.autoOptimize ? "智能目标" : "专业参数"}已更新 · 点击生成后扣${VECTOR_POINTS}点`);
      return;
    }
    if (state.editor.dirty) {
      setStatus("参数已改变，但不会覆盖手工编辑；点击“重新生成矢量图”确认后再重算。", "warning");
      return;
    }
    state.rerunTimer = window.setTimeout(() => runVectorization(), 380);
  }

  function scheduleManualRerun() {
    state.autoOptimize = false;
    updateAutoSummary("已启用专业参数；点击“恢复智能优化”可重新由系统自动选优。");
    scheduleRerun();
  }

  async function chargeVectorResultOnce() {
    if (state.chargeSettled) return null;
    if (typeof window.__chargeLocalVectorResult !== "function") {
      throw new Error("AI点数服务尚未连接，请稍后重试");
    }
    const charge = await window.__chargeLocalVectorResult({
      request_id: state.chargeRequestId || (state.chargeRequestId = newChargeRequestId()),
      source_name: state.file?.name || "本机图片",
      source_size: Number(state.file?.size || 0)
    });
    state.chargeSettled = true;
    if (charge?.points_charged && charge?.charged !== false) {
      notifyUser(`矢量图已生成，已扣${Number(charge.points_charged)}点。`);
    }
    return charge;
  }

  function assertEditableVectorResult(result) {
    const layers = Array.isArray(result?.layers) ? result.layers : [];
    const pathCount = Number(result?.metrics?.paths || 0);
    const nodeCount = Number(result?.metrics?.nodes || 0);
    const layerPathCount = layers.reduce((total, layer) => total + Number(layer?.paths || 0), 0);
    if (!result || !layers.length || pathCount < 1 || nodeCount < 3 || layerPathCount < 1) {
      throw new Error("没有生成可编辑路径，已停止输出；系统不会把原图冒充为矢量图");
    }
    const verificationSvg = window.JianglaiVectorEngine.renderSvg(result, {
      title: "vector-path-verification"
    });
    if (
      /<image\b|data:image\//i.test(verificationSvg)
      || !/<(?:path|polygon|polyline|rect|circle|ellipse|line)\b/i.test(verificationSvg)
    ) {
      throw new Error("生成结果仍包含原始位图或缺少路径，已停止输出");
    }
    return verificationSvg;
  }

  async function runVectorization(options = {}) {
    if (state.nativeVector) {
      notifyUser("这个文件已经是矢量文件，已保留原路径和尺寸，无需重新生成。 ");
      return;
    }
    if (!state.file || !state.image || !window.JianglaiVectorEngine) {
      if (!state.file) notifyUser("请先选择一张图片");
      return;
    }
    const professionalReconstruction = Boolean(state.sourceAnalysis?.complexArtwork);
    if (professionalReconstruction && !state.reconstructionRequested) {
      const accepted = window.confirm(`将按参考图重新绘制刀线、印刷轮廓和文字曲线。文字会优先在本机识别，只有本机无法识别时才临时发送图片，识别后立即删除；成功生成可编辑重建稿才扣${VECTOR_POINTS}点。是否开始？`);
      if (!accepted) {
        setStatus("已取消专业重建；你仍可选择“只查看高清原稿”。", "warning");
        return;
      }
      state.reconstructionRequested = true;
      const consent = byId("jlVectorOcrConsent");
      if (consent) consent.checked = true;
    }
    if (state.editor.dirty && state.result && options.confirmEdits !== false) {
      const accepted = window.confirm("重新生成会替换当前节点编辑、选区文字重建和文字转曲结果，是否继续？");
      if (!accepted) {
        setStatus("已保留当前手工编辑。", "warning");
        return;
      }
    }
    window.clearTimeout(state.rerunTimer);
    state.rerunTimer = null;
    if (professionalReconstruction && state.outputKind === "fidelity-preview") resetResult();
    if (professionalReconstruction) all("[data-vector-tool]").forEach(tool => { tool.disabled = false; });
    const runId = ++state.runId;
    const button = byId("jlVectorRun");
    if (button) { button.disabled = true; button.textContent = "正在生成…"; }
    setProcessing(true, state.autoOptimize ? "正在自动寻找最优方案…" : "正在生成矢量路径…", state.autoOptimize ? "将比较多组参数，只按最终结果扣一次点数。" : "正在按专业参数拟合轮廓。 ");
    setStatus("正在本机用专业样条曲线引擎处理，不会上传客户图片…");
    try {
      await new Promise(resolve => window.setTimeout(resolve, 40));
      let maxEdge = state.mode === "line" || state.mode === "packaging" ? 3200 : state.mode === "logo" ? 1200 : state.mode === "photo" ? 700 : 900;
      if (professionalReconstruction && state.sourceAnalysis?.semanticSpotCandidate) {
        // Structural/ink tracing does not need to carry millions of raster
        // letter-edge pixels now that ordinary copy is rebuilt from the
        // original file by Vision OCR + native font Bézier curves below.
        // Keeping the base sheet at 3200px prevents 5K–8K packaging scans from
        // freezing the desktop renderer, while OCR still reads the untouched
        // source and logos remain separately preserved.
        maxEdge = Math.min(3200, Math.max(state.sourceWidth || 0, state.sourceHeight || 0));
      }
      let result = null;
      for (let attempt = 0; attempt < 4; attempt += 1) {
        state.imageData = drawImageData(state.image, state.mode, maxEdge);
        state.processingMaxEdge = Math.max(state.imageData.width, state.imageData.height);
        try {
          result = await optimizeVectorResult(state.imageData, runId);
          break;
        } catch (error) {
          if (error?.code !== "VECTOR_COMPLEXITY_LIMIT" || maxEdge <= 240 || attempt === 3) throw error;
          maxEdge = Math.max(220, Math.round(maxEdge * 0.62));
          setStatus(`细节过密，正在自动降低处理解析度至 ${maxEdge}px…`);
          await new Promise(resolve => window.setTimeout(resolve, 30));
        }
      }
      if (!result) throw new Error("矢量化没有返回结果");
      if (runId !== state.runId) return;
      result = mergePackagingStructure(result, state.imageData, currentOptions());
      result = splitPackagingStructureLayers(result);
      assertEditableVectorResult(result);
      const reconstructionAcceptance = professionalReconstruction
        ? evaluateProfessionalReconstruction(result, state.sourceAnalysis)
        : null;
      const reconstructionStructureReady = Boolean(reconstructionAcceptance?.structureReady);
      if (professionalReconstruction) {
        result.quality = {
          ...(result.quality || {}),
          productionReady: false,
          reconstructionDraft: true,
          reconstructionStructureReady,
          reconstructionArtworkReady: Boolean(reconstructionAcceptance?.artworkReady),
          reconstructionFailureReason: reconstructionAcceptance?.reason || ""
        };
      }
      state.outputKind = professionalReconstruction ? "professional-reconstruction" : "pure-vector";
      state.result = result;
      state.commercialTextRebuilt = false;
      state.rebuiltTextRects = [];
      state.reconstructionReviewRequired = professionalReconstruction;
      setPreviewAvailability(true);
      state.layerState = Object.fromEntries(result.layers.map(layer => [layer.id, { color: layer.color, visible: true }]));
      state.editor.dirty = false;
      state.editor.undo = [];
      state.editor.redo = [];
      populateEditLayers();
      // Do not render the dense, uncleaned full-page semantic SVG. On large
      // packaging sheets that intermediate document can contain thousands of
      // source glyph fragments and make the renderer unresponsive before OCR
      // gets a chance to replace them. Professional reconstruction renders
      // once, after regional cleanup and full-resolution glyph insertion.
      if (!professionalReconstruction) {
        refreshSvg();
        renderPalette();
        renderMetrics();
        setPreviewMode("compare");
      } else {
        setPreviewMode("original");
        setProcessing(
          true,
          "正在准备原稿字形重建…",
          "先定位文字并清理对应旧路径，完成后一次性显示最终矢量结果。",
          .1
        );
      }

      let reconstructedTextLines = 0;
      if (professionalReconstruction) {
        // OCR supplies region geometry only. The complete source supplies the
        // customer's actual glyphs; no similar-font reflow is allowed here.
        const semanticTextAlreadyPreserved = state.result?.quality?.semanticSpotSeparation === true;
        const textRebuild = await runAutomaticTextReplacement({
          consentGranted: true,
          deferCharge: true,
          throwOnError: true
        });
        if (runId !== state.runId) return;
        reconstructedTextLines = Number(textRebuild?.count || 0);
        const reconstructedArtworkFailed = Number(textRebuild?.artworkFailed || 0);
        const finalAcceptance = evaluateProfessionalReconstruction(state.result, state.sourceAnalysis);
        const reconstructionPassed = Boolean(
          finalAcceptance.productionReady && reconstructedArtworkFailed === 0
        );
        state.result.quality = {
          ...(state.result.quality || {}),
          productionReady: reconstructionPassed,
          reconstructionDraft: !reconstructionPassed,
          reconstructionStructureReady: Boolean(finalAcceptance.structureReady),
          reconstructionArtworkReady: Boolean(
            finalAcceptance.artworkReady && reconstructedArtworkFailed === 0
          ),
          reconstructionFailureReason: reconstructedArtworkFailed
            ? `${reconstructedArtworkFailed} 处原字形未通过自动核对`
            : finalAcceptance.reason || "",
          reconstructionTextLines: reconstructedTextLines,
          textPreservationMode: semanticTextAlreadyPreserved
            ? "semantic-ink-plus-full-resolution-original-glyph-outline"
            : "full-resolution-original-glyph-outline",
          preservedArtworkText: true,
          reviewRequired: true
        };
        if (state.optimization) state.optimization.productionReady = state.result.quality.productionReady;
        refreshSvg();
        renderPalette();
        renderMetrics();
        setPreviewMode("compare");
        if (!state.result.quality.productionReady) {
          const reason = state.result.quality.reconstructionFailureReason
            || "自动重建没有达到生产标准";
          throw new Error(`自动重建未达可交付标准：${reason}。系统已拒绝输出碎片稿，本次不扣点`);
        }
      }

      const qualityPassed = state.result.quality?.productionReady !== false;
      if (qualityPassed && !state.chargeSettled) {
        await chargeVectorResultOnce();
        if (runId !== state.runId) return;
      }
      const metrics = state.result.metrics;
      const textGuidance = state.mode === "line"
        ? " · 已做高清曲线拟合；右侧可重新识别并分层普通文字、Logo和艺术字"
        : professionalReconstruction
          ? " · OCR只定位；所有文字与Logo按完整原稿原坐标转曲，未换字体、未重排，结构线独立分层"
          : "";
      const productionGuidance = state.mode === "packaging"
        ? ` · 结构已拆分：刀线 ${Number(metrics.cutlinePaths || 0)} 条（DIELINE）、折线 ${Number(metrics.creasePaths || 0)} 条（CREASE）· 0.25mm；核对文字、条码和页面总宽后方可生产导出`
        : state.mode === "illustration"
          ? " · 位图重建仅作制图参考，生产前须对照原稿核对刀模、专色和毫米尺寸"
          : "";
      const optimizationGuidance = state.optimization?.automatic
        ? ` · 自动比较${state.optimization.candidateCount}组 · 采用“${state.optimization.selected}” · 候选评分${state.optimization.score}（非原稿相似度）`
        : " · 使用专业参数";
      const qualityMessage = qualityPassed
        ? professionalReconstruction
          ? "专业矢量重建完成 · 刀线和印刷轮廓已分层 · 文字与Logo已按完整原稿原坐标转曲"
          : `完成 · 已通过自动质量检查 · 色彩保留${Number(state.result.quality?.paletteRetention || 100)}%`
        : `已生成纯路径可编辑重建稿（不含原图位图）· 质量检查未通过 · 相似度${Number(state.result.quality?.visual || 0)}% · 边缘${Number(state.result.quality?.edge || 0)}% · 可下载草稿继续精修，生产导出仍锁定`;
      const pointMessage = state.chargeSettled ? `已扣${VECTOR_POINTS}点` : "质量不合格，未扣点";
      setStatus(`${qualityMessage} · ${pointMessage}${optimizationGuidance} · ${metrics.colors} 色 · ${metrics.paths} 条路径 · ${metrics.nodes} 个节点 · 纯 SVG，无嵌入位图${textGuidance}${productionGuidance}`, qualityPassed ? "" : "error");
      setResultButtons(true);
      const autoTextButton = byId("jlVectorAutoReplaceText");
      if (autoTextButton) autoTextButton.disabled = false;
    } catch (error) {
      if (runId === state.runId) {
        resetResult();
        setStatus(`生成失败：${error.message}`, "error");
        notifyUser(error.message);
      }
    } finally {
      if (runId === state.runId) {
        setProcessing(false);
        if (button) {
          button.disabled = false;
          button.textContent = state.sourceAnalysis?.complexArtwork
            ? state.chargeSettled ? "重新专业重建（本次不再扣点）" : `尝试专业重建 · 达标才扣${VECTOR_POINTS}点`
            : state.chargeSettled ? "重新自动优化（本次不再扣点）" : `自动生成最优矢量图 · ${VECTOR_POINTS}点`;
        }
      }
    }
  }

  function refreshSvg() {
    if (!state.result) return;
    state.svg = window.JianglaiVectorEngine.renderSvg(state.result, {
      title: safeFilename(state.file?.name),
      layers: state.layerState
    });
    revokeUrl("resultUrl");
    state.resultUrl = URL.createObjectURL(new Blob([state.svg], { type: "image/svg+xml" }));
    const resultImage = byId("jlVectorResultImage");
    if (resultImage) resultImage.src = state.resultUrl;
    updatePhysicalSize();
    renderEditorOverlay();
    updateCompare();
  }

  function renderPalette() {
    const container = byId("jlVectorPalette");
    if (!container || !state.result) return;
    container.innerHTML = "";
    state.result.layers.forEach((layer, index) => {
      const row = document.createElement("label");
      row.className = `jl-vector-swatch${isStructureLayer(layer) ? " is-dieline" : ""}`;
      const visible = document.createElement("input");
      visible.type = "checkbox";
      visible.checked = state.layerState[layer.id]?.visible !== false;
      visible.setAttribute("aria-label", `显示颜色 ${index + 1}`);
      visible.addEventListener("change", () => {
        state.layerState[layer.id].visible = visible.checked;
        refreshSvg(); renderMetrics();
      });
      const color = document.createElement("input");
      color.type = "color";
      color.value = state.layerState[layer.id]?.color || layer.color;
      color.setAttribute("aria-label", `修改颜色 ${index + 1}`);
      color.addEventListener("input", () => {
        state.layerState[layer.id].color = color.value;
        label.textContent = `${layer.name || color.value.toUpperCase()} · ${layer.paths} 路径`;
        refreshSvg();
      });
      const label = document.createElement("span");
      label.textContent = `${layer.name || color.value.toUpperCase()} · ${layer.paths} 路径`;
      row.append(visible, color, label);
      container.appendChild(row);
    });
  }

  function renderMetrics() {
    if (state.nativeVector) {
      renderNativeSummary();
      return;
    }
    const visibleLayers = state.result?.layers?.filter(layer => state.layerState[layer.id]?.visible !== false) || [];
    const metrics = {
      colors: visibleLayers.length,
      paths: visibleLayers.reduce((sum, layer) => sum + layer.paths, 0),
      nodes: visibleLayers.reduce((sum, layer) => sum + layer.nodes, 0),
      bytes: state.svg ? new TextEncoder().encode(state.svg).length : 0
    };
    const values = {
      jlVectorMetricColors: metrics.colors || "—",
      jlVectorMetricPaths: metrics.paths || "—",
      jlVectorMetricNodes: metrics.nodes || "—",
      jlVectorMetricSize: metrics.bytes ? formatBytes(metrics.bytes) : "—"
    };
    Object.entries(values).forEach(([id, value]) => { const element = byId(id); if (element) element.textContent = value; });
    const quality = byId("jlVectorQuality");
    if (quality) {
      if (state.outputKind === "fidelity-preview") {
        quality.classList.add("warning");
        quality.textContent = `✓ 高清原稿 100% 保留 · ${state.sourceWidth}×${state.sourceHeight}px · 当前只是查看参考，不含可编辑路径且不扣点；需要矢量稿请点击左侧“专业重建”。`;
        return;
      }
      if (state.outputKind === "professional-reconstruction" && state.result) {
        const ready = state.result.quality?.productionReady !== false;
        const lines = Number(state.result.quality?.reconstructionTextLines || 0);
        const artworkRegions = Number(state.result.quality?.reconstructionArtworkRegions || 0);
        const cutlinePaths = Number(state.result.metrics?.cutlinePaths || 0);
        const creasePaths = Number(state.result.metrics?.creasePaths || 0);
        quality.classList.add("warning");
        quality.textContent = ready
          ? `✓ 专业重建稿 · 纯矢量、无 <image> 位图 · 刀线 ${cutlinePaths} 条 / 折线 ${creasePaths} 条已分层 · ${lines} 行普通文字为独立可编辑文字层 · ${artworkRegions} 处 Logo / 艺术字独立描摹；导出前必须滑动对比并核对文字、数字、条码和毫米尺寸`
          : `⚠ 专业重建草稿尚未完成 · 刀线/折线结构${state.result.quality?.reconstructionStructureReady ? "已分层" : "待补齐"} · 普通文字 ${lines} 行 · Logo / 艺术字 ${artworkRegions} 处 · 本次不扣点，可继续右侧手动修正`;
        return;
      }
      const qualityLocked = state.result?.quality?.productionReady === false;
      const packagingTextLocked = Boolean(
        state.result && state.mode === "packaging" && !state.commercialTextRebuilt && detectSmartTextBoxes(state.imageData).length
      );
      quality.classList.toggle("warning", !state.result || packagingTextLocked || qualityLocked);
      quality.textContent = state.result
        ? qualityLocked
          ? `⚠ 质量检查未通过 · 当前只可查看预览；相似度 ${Number(state.result.quality?.visual || 0)}% · 边缘 ${Number(state.result.quality?.edge || 0)}% · 色彩保留 ${Number(state.result.quality?.paletteRetention || 0)}% · 原图解析度保留 ${Number(state.result.quality?.resolutionRetention || 0)}% · 本次不扣点，请提高原图清晰度或转人工精修`
          : state.mode === "packaging"
          ? `${packagingTextLocked ? "⚠" : "✓"} 生产结构已分层 · 刀线 ${Number(state.result.metrics?.cutlinePaths || 0)} 条（DIELINE）/ 折线 ${Number(state.result.metrics?.creasePaths || 0)} 条（CREASE）· 0.25mm · ${state.result.structure?.artworkKind === "multicolor" ? `${Number(state.result.structure?.artworkColors || 0)} 个彩色印刷层` : "100%K/白色内容分版"} · 贝塞尔曲线 · 无 <image> 位图${packagingTextLocked ? "；检测到文字 / Logo 仍是普通描摹，生产导出已锁定" : " · 普通文字、Logo 与艺术字已独立处理"}${byId("jlVectorSizeConfirmed")?.checked ? " · 尺寸已确认" : "；尚未确认页面毫米尺寸，生产导出已锁定"}`
          : `✓ 纯矢量路径 · 无 <image> 位图 · ${metrics.nodes > 9000 ? "节点较多，建议提高路径简化" : "可继续编辑和无限放大"}；由位图重建，刀模、专色和毫米尺寸仍需对照原稿核对`
        : "上传图片后会检查颜色、路径、节点和内嵌位图。";
    }
  }

  function engineApi() {
    return window.JianglaiVectorEngine || {};
  }

  function contourPath(contour) {
    const engine = engineApi();
    const smoothing = Number(state.result?.options?.smoothing || 0);
    if (typeof engine.contourPathData === "function") {
      try { return engine.contourPathData(contour, smoothing); }
      catch (_) { return engine.contourPathData(contour?.points || [], smoothing); }
    }
    const points = contour?.points || [];
    if (!points.length) return "";
    return `M ${points.map(point => `${finiteNumber(point.x)} ${finiteNumber(point.y)}`).join(" L ")} Z`;
  }

  function selectedLayer() {
    return state.result?.layers?.find(layer => layer.id === state.editor.selectedLayerId) || null;
  }

  function selectedContour() {
    return selectedLayer()?.contours?.find(contour => contour.id === state.editor.selectedContourId) || null;
  }

  function rebuildGeometry(layer) {
    const engine = engineApi();
    return typeof engine.rebuildLayer === "function"
      ? engine.rebuildLayer(layer, Number(state.result?.options?.smoothing || 0))
      : layer;
  }

  function recalculateDocument() {
    if (state.result && typeof engineApi().recalculateMetrics === "function") {
      state.result = engineApi().recalculateMetrics(state.result);
    }
    return state.result;
  }

  function populateEditLayers() {
    const select = byId("jlVectorEditLayer");
    if (!select) return;
    const current = state.editor.selectedLayerId;
    select.replaceChildren();
    for (const [index, layer] of (state.result?.layers || []).entries()) {
      if (!Array.isArray(layer.contours) || !layer.contours.length) continue;
      const option = document.createElement("option");
      option.value = layer.id;
      option.textContent = `图层 ${index + 1} · ${state.layerState[layer.id]?.color || layer.color} · ${layer.paths} 条路径`;
      select.appendChild(option);
    }
    if (!select.options.length) {
      const option = document.createElement("option");
      option.value = "";
      option.textContent = "当前结果没有可编辑路径";
      select.appendChild(option);
      state.editor.selectedLayerId = "";
    } else {
      const hasCurrent = [...select.options].some(option => option.value === current);
      select.value = hasCurrent ? current : select.options[0].value;
      state.editor.selectedLayerId = select.value;
    }
    if (!selectedLayer()?.contours?.some(contour => contour.id === state.editor.selectedContourId)) {
      state.editor.selectedContourId = selectedLayer()?.contours?.[0]?.id || "";
      state.editor.selectedNodeId = "";
    }
    updateEditButtons();
    renderEditorOverlay();
  }

  function updateEditButtons() {
    const contour = selectedContour();
    const hasResult = Boolean(state.result?.layers?.length);
    const insert = byId("jlVectorInsertNode");
    const remove = byId("jlVectorDeleteNode");
    const retrace = byId("jlVectorApplyRetrace");
    const topInsert = byId("jlVectorTopInsertNode");
    const topRemove = byId("jlVectorTopDeleteNode");
    const topRetrace = byId("jlVectorTopApplyRetrace");
    const undo = byId("jlVectorUndo");
    const redo = byId("jlVectorRedo");
    const canInsert = hasResult && Boolean(contour);
    const canDelete = canInsert && Boolean(state.editor.selectedNodeId) && (contour.points?.length || 0) > 3;
    const canRetrace = hasResult && Boolean(state.editor.retraceRect) && !state.textRebuildApplying;
    if (insert) insert.disabled = !canInsert;
    if (remove) remove.disabled = !canDelete;
    if (retrace) retrace.disabled = !canRetrace;
    if (topInsert) { topInsert.hidden = state.editor.tool !== "node"; topInsert.disabled = !canInsert; }
    if (topRemove) { topRemove.hidden = state.editor.tool !== "node"; topRemove.disabled = !canDelete; }
    if (topRetrace) { topRetrace.hidden = state.editor.tool !== "retrace"; topRetrace.disabled = !canRetrace; }
    if (undo) undo.disabled = !state.editor.undo.length;
    if (redo) redo.disabled = !state.editor.redo.length;
    all("[data-vector-tool]").forEach(button => {
      if (button.dataset.vectorTool !== "browse") button.disabled = !hasResult;
    });

    const context = byId("jlVectorToolContext");
    const title = byId("jlVectorToolTitle");
    const hint = byId("jlVectorToolHint");
    const guide = byId("jlVectorCanvasGuide");
    const active = hasResult && ["node", "retrace"].includes(state.editor.tool);
    if (context) context.hidden = !active;
    if (guide) guide.hidden = !active;
    if (!active) return;
    if (state.editor.tool === "node") {
      const total = contour?.points?.length || 0;
      const visible = visibleContourPoints(contour).length;
      if (title) title.textContent = "节点编辑已开启";
      if (hint) hint.textContent = !contour
        ? "当前没有可编辑的轮廓，请先生成包含路径的矢量图。"
        : total > visible
          ? `点击路径或蓝色节点后拖动；当前路径共 ${total} 个节点，为保证流畅显示其中 ${visible} 个可编辑节点。`
          : "点击路径选择轮廓，拖动蓝色节点修改；选中节点后可删除。";
      if (guide) guide.textContent = state.editor.selectedNodeId ? "已选中节点 · 可拖动或点击顶部删除" : "点击蓝色轮廓或节点开始精修";
    } else {
      if (title) title.textContent = state.textRebuildApplying ? "正在把选区恢复成字体" : state.editor.retraceRect ? "文字区域已选好" : "选区文字重建已开启";
      if (hint) hint.textContent = state.editor.retraceRect
        ? "选区已就绪，点击“识别并重建字体”，系统会识字、匹配字体并替换像素轮廓。"
        : "按住鼠标框住一行或一组文字；不要把刀模线、图标和大面积背景一起框入。";
      if (guide) guide.textContent = state.textRebuildApplying ? "正在识别文字并匹配字体…" : state.editor.retraceRect ? "文字选区已就绪 · 点击顶部“识别并重建字体”" : "按住鼠标，框住需要恢复成字体的文字";
    }
  }

  function setEditorTool(tool) {
    if (!["idle", "browse", "node", "retrace"].includes(tool)) tool = "idle";
    if (["node", "retrace"].includes(tool) && !state.result) {
      notifyUser("请先生成矢量图，再进行路径编辑");
      return;
    }
    state.editor.tool = tool;
    if (["node", "retrace"].includes(tool)) state.editor.lastEditTool = tool;
    all("[data-vector-tool]").forEach(button => {
      const active = button.dataset.vectorTool === tool;
      button.classList.toggle("is-active", active);
      button.setAttribute("aria-pressed", active ? "true" : "false");
    });
    all("[data-vector-pan-toggle]").forEach(button => {
      const active = tool === "browse";
      button.classList.toggle("is-active", active);
      button.setAttribute("aria-pressed", active ? "true" : "false");
      button.textContent = button.id === "jlVectorPanMode" ? (active ? "✋ 移动（开）" : "✋ 移动") : "✋ 移动画布";
    });
    const stage = byId("jlVectorStage");
    if (stage) stage.dataset.vectorTool = tool;
    if (["node", "retrace"].includes(tool)) setPreviewMode("result");
    if (tool !== "retrace") {
      state.editor.retraceStart = null;
      state.editor.retraceRect = null;
    }
    const hint = byId("jlVectorEditHint");
    if (hint) hint.textContent = tool === "node"
      ? "点击轮廓后拖动蓝色节点；节点过多时系统会自动抽样显示，修改仍会同步到导出文件。"
      : tool === "retrace"
        ? "框住一行或一组文字，再点击“识别并重建字体”；系统会用排版文字轮廓替换原像素路径。"
        : "选择“节点编辑”可拖动轮廓；选择“选区文字重建”可把像素字恢复成排版字体。";
    renderEditorOverlay();
    updateEditButtons();
    if (tool === "node") setStatus("节点编辑已开启 · 点击轮廓或蓝色节点后拖动", "warning");
    if (tool === "retrace") setStatus("选区文字重建已开启 · 请框住需要恢复成字体的一行或一组文字", "warning");
    if (tool === "idle") setStatus("移动画布已关闭 · 可选择节点编辑或选区文字重建", "warning");
  }

  function visibleContourPoints(contour, limit = 800) {
    const points = Array.isArray(contour?.points) ? contour.points : [];
    if (points.length <= limit) return points;
    const step = Math.ceil(points.length / limit);
    const sampled = points.filter((_, index) => index % step === 0);
    const selected = points.find(point => point.id === state.editor.selectedNodeId);
    if (selected && !sampled.some(point => point.id === selected.id)) sampled.push(selected);
    return sampled;
  }

  function renderEditorOverlay() {
    const svg = byId("jlVectorEditorSvg");
    if (!svg) return;
    svg.replaceChildren();
    const active = ["node", "retrace"].includes(state.editor.tool) && Boolean(state.result);
    svg.classList.toggle("is-active", active);
    if (!active) return;
    svg.setAttribute("viewBox", `0 0 ${state.result.width} ${state.result.height}`);
    if (state.editor.tool === "node") {
      const layer = selectedLayer();
      if (!layer) return;
      for (const contour of layer.contours || []) {
        const path = svgElement("path");
        path.setAttribute("d", contourPath(contour));
        path.setAttribute("class", `jl-vector-edit-path${contour.id === state.editor.selectedContourId ? " is-selected" : ""}`);
        path.dataset.layerId = layer.id;
        path.dataset.contourId = contour.id;
        svg.appendChild(path);
      }
      const contour = selectedContour();
      if (contour) {
        for (const point of visibleContourPoints(contour)) {
          const circle = svgElement("circle");
          circle.setAttribute("cx", finiteNumber(point.x));
          circle.setAttribute("cy", finiteNumber(point.y));
          circle.setAttribute("r", Math.max(2.2, Math.min(state.result.width, state.result.height) / 180));
          circle.setAttribute("class", `jl-vector-node${point.id === state.editor.selectedNodeId ? " is-selected" : ""}`);
          circle.dataset.nodeId = point.id;
          circle.dataset.contourId = contour.id;
          circle.dataset.layerId = layer.id;
          svg.appendChild(circle);
        }
      }
    }
    if (state.editor.tool === "retrace" && state.editor.retraceRect) {
      const rect = svgElement("rect");
      const selection = state.editor.retraceRect;
      rect.setAttribute("x", selection.x);
      rect.setAttribute("y", selection.y);
      rect.setAttribute("width", selection.width);
      rect.setAttribute("height", selection.height);
      rect.setAttribute("class", "jl-vector-retrace-selection");
      svg.appendChild(rect);
    }
  }

  function resultViewportGeometry() {
    const stage = byId("jlVectorStage");
    if (!stage || !state.result) return null;
    const bounds = stage.getBoundingClientRect();
    const stageWidth = Math.max(1, bounds.width);
    const stageHeight = Math.max(1, bounds.height);
    const resultWidth = Math.max(1, Number(state.result.width || 1));
    const resultHeight = Math.max(1, Number(state.result.height || 1));
    const containScale = Math.min(stageWidth / resultWidth, stageHeight / resultHeight);
    const contentWidth = resultWidth * containScale;
    const contentHeight = resultHeight * containScale;
    return {
      bounds, stageWidth, stageHeight, resultWidth, resultHeight, containScale,
      contentLeft: (stageWidth - contentWidth) / 2,
      contentTop: (stageHeight - contentHeight) / 2
    };
  }

  function svgPointFromEvent(event) {
    const geometry = resultViewportGeometry();
    if (!geometry) return { x: 0, y: 0 };
    const zoom = Math.max(.01, Number(state.zoom || 1));
    const screenX = Number(event.clientX) - geometry.bounds.left;
    const screenY = Number(event.clientY) - geometry.bounds.top;
    // The artboard uses transform-origin:center with translate(...) scale(...).
    // Safari can omit or double-count this ancestor CSS transform in getScreenCTM(),
    // so invert the exact transform ourselves before removing object-fit:contain padding.
    const artboardX = geometry.stageWidth / 2 + (screenX - geometry.stageWidth / 2 - Number(state.panX || 0)) / zoom;
    const artboardY = geometry.stageHeight / 2 + (screenY - geometry.stageHeight / 2 - Number(state.panY || 0)) / zoom;
    const resultX = (artboardX - geometry.contentLeft) / geometry.containScale;
    const resultY = (artboardY - geometry.contentTop) / geometry.containScale;
    return {
      x: Math.max(0, Math.min(geometry.resultWidth, resultX)),
      y: Math.max(0, Math.min(geometry.resultHeight, resultY))
    };
  }

  function recordHistory(entry) {
    state.editor.undo.push(entry);
    if (state.editor.undo.length > 30) state.editor.undo.shift();
    state.editor.redo = [];
    state.editor.dirty = true;
    state.editor.revision += 1;
    updateEditButtons();
  }

  function applyHistoryEntry(entry, direction) {
    if (!entry || !state.result) return;
    const value = cloneJson(entry[direction]);
    if (entry.kind === "contour") {
      const layer = state.result.layers.find(item => item.id === entry.layerId);
      const contour = layer?.contours?.find(item => item.id === entry.contourId);
      if (!layer || !contour) return;
      contour.points = value;
      const layerIndex = state.result.layers.findIndex(item => item.id === layer.id);
      state.result.layers[layerIndex] = rebuildGeometry(layer);
      recalculateDocument();
      state.editor.selectedLayerId = layer.id;
      state.editor.selectedContourId = contour.id;
    } else if (entry.kind === "document") {
      state.result.layers = value.layers;
      state.layerState = value.layerState;
      if (Object.prototype.hasOwnProperty.call(value, "commercialTextRebuilt")) {
        state.commercialTextRebuilt = Boolean(value.commercialTextRebuilt);
      }
      if (Array.isArray(value.rebuiltTextRects)) state.rebuiltTextRects = value.rebuiltTextRects;
      recalculateDocument();
    }
    state.editor.dirty = true;
    populateEditLayers();
    refreshSvg();
    renderPalette();
    renderMetrics();
  }

  function undoEdit() {
    const entry = state.editor.undo.pop();
    if (!entry) return;
    applyHistoryEntry(entry, "before");
    state.editor.redo.push(entry);
    updateEditButtons();
    setStatus("已撤销上一步路径编辑。", "warning");
  }

  function redoEdit() {
    const entry = state.editor.redo.pop();
    if (!entry) return;
    applyHistoryEntry(entry, "after");
    state.editor.undo.push(entry);
    updateEditButtons();
    setStatus("已重做路径编辑。", "warning");
  }

  function commitContourChange(before, message) {
    const layer = selectedLayer();
    const contour = selectedContour();
    if (!layer || !contour) return;
    const layerIndex = state.result.layers.findIndex(item => item.id === layer.id);
    state.result.layers[layerIndex] = rebuildGeometry(layer);
    recalculateDocument();
    const after = cloneJson(contour.points);
    if (JSON.stringify(before) !== JSON.stringify(after)) {
      recordHistory({ kind: "contour", layerId: layer.id, contourId: contour.id, before, after });
      refreshSvg();
      renderMetrics();
      setStatus(message, "warning");
    }
    renderEditorOverlay();
  }

  function insertNode() {
    const layer = selectedLayer();
    const contour = selectedContour();
    if (!layer || !contour) return;
    const before = cloneJson(contour.points);
    try {
      if (typeof engineApi().insertNodeAtLongestSegment !== "function") throw new Error("当前引擎不支持节点插入");
      state.result = engineApi().insertNodeAtLongestSegment(state.result, layer.id, contour.id);
      commitContourChange(before, "已在线段中加入节点，导出文件已同步更新。 ");
    } catch (error) { notifyUser(error.message); }
  }

  function deleteNode() {
    const layer = selectedLayer();
    const contour = selectedContour();
    if (!layer || !contour || !state.editor.selectedNodeId) return;
    const before = cloneJson(contour.points);
    try {
      if (typeof engineApi().removeNode !== "function") throw new Error("当前引擎不支持节点删除");
      state.result = engineApi().removeNode(state.result, layer.id, contour.id, state.editor.selectedNodeId);
      state.editor.selectedNodeId = "";
      commitContourChange(before, "已删除节点，轮廓仍保持闭合。 ");
    } catch (error) { notifyUser(error.message); }
  }

  function bindEditorPointerEvents() {
    const svg = byId("jlVectorEditorSvg");
    if (!svg) return;
    svg.addEventListener("pointerdown", event => {
      if (!state.result || !["node", "retrace"].includes(state.editor.tool) || state.textRebuildApplying) return;
      if (state.spacePan || event.button === 1) return;
      event.preventDefault();
      const point = svgPointFromEvent(event);
      if (state.editor.tool === "node") {
        const node = event.target.closest?.(".jl-vector-node");
        const path = event.target.closest?.(".jl-vector-edit-path");
        if (node) {
          state.editor.selectedLayerId = node.dataset.layerId;
          state.editor.selectedContourId = node.dataset.contourId;
          state.editor.selectedNodeId = node.dataset.nodeId;
          state.editor.drag = { pointerId: event.pointerId, before: cloneJson(selectedContour()?.points || []) };
          svg.setPointerCapture(event.pointerId);
        } else if (path) {
          state.editor.selectedLayerId = path.dataset.layerId;
          state.editor.selectedContourId = path.dataset.contourId;
          state.editor.selectedNodeId = "";
          renderEditorOverlay();
          updateEditButtons();
        }
      } else if (state.editor.tool === "retrace") {
        state.editor.retraceStart = point;
        state.editor.retraceRect = { x: point.x, y: point.y, width: 0, height: 0 };
        svg.setPointerCapture(event.pointerId);
        renderEditorOverlay();
      }
      updateEditButtons();
    });
    svg.addEventListener("pointermove", event => {
      if (!state.result) return;
      const point = svgPointFromEvent(event);
      if (state.editor.tool === "node" && state.editor.drag && state.editor.selectedNodeId) {
        try {
          state.result = engineApi().moveNode(state.result, state.editor.selectedLayerId, state.editor.selectedContourId, state.editor.selectedNodeId, point.x, point.y);
          renderEditorOverlay();
        } catch (_) {}
      } else if (state.editor.tool === "retrace" && state.editor.retraceStart) {
        const start = state.editor.retraceStart;
        state.editor.retraceRect = {
          x: Math.min(start.x, point.x),
          y: Math.min(start.y, point.y),
          width: Math.abs(point.x - start.x),
          height: Math.abs(point.y - start.y)
        };
        renderEditorOverlay();
      }
    });
    const finish = event => {
      if (state.editor.tool === "node" && state.editor.drag) {
        const before = state.editor.drag.before;
        state.editor.drag = null;
        commitContourChange(before, "节点位置已更新，导出文件已同步。 ");
      }
      if (state.editor.tool === "retrace" && state.editor.retraceStart) {
        state.editor.retraceStart = null;
        const rect = state.editor.retraceRect;
        if (!rect || rect.width < 3 || rect.height < 3) state.editor.retraceRect = null;
        renderEditorOverlay();
        updateEditButtons();
        if (state.editor.retraceRect) setStatus("文字区域已选好 · 点击顶部“识别并重建字体”完成处理", "warning");
        else setStatus("选区太小，请按住鼠标拖出一个更大的矩形区域", "warning");
      }
      try { if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId); } catch (_) {}
    };
    svg.addEventListener("pointerup", finish);
    svg.addEventListener("pointercancel", finish);
  }

  function cropImageData(source, rect) {
    const x = Math.max(0, Math.floor(rect.x));
    const y = Math.max(0, Math.floor(rect.y));
    const width = Math.max(2, Math.min(source.width - x, Math.ceil(rect.width)));
    const height = Math.max(2, Math.min(source.height - y, Math.ceil(rect.height)));
    if (width * height > 4_000_000) throw new Error("文字选区不能超过 400 万像素，请缩小选区");
    const canvas = document.createElement("canvas");
    canvas.width = width; canvas.height = height;
    const context = canvas.getContext("2d", { willReadFrequently: true });
    context.putImageData(source, -x, -y);
    return { imageData: context.getImageData(0, 0, width, height), x, y, width, height };
  }

  function cropOriginalSourceAtResultRect(rect, maxPixels = 12_000_000) {
    if (!state.image || !state.imageData) throw new Error("完整原稿尚未加载");
    const resultWidth = Math.max(1, Number(state.imageData.width || 1));
    const resultHeight = Math.max(1, Number(state.imageData.height || 1));
    const sourceWidth = Math.max(1, Number(state.sourceWidth || state.image.naturalWidth || state.image.width || 1));
    const sourceHeight = Math.max(1, Number(state.sourceHeight || state.image.naturalHeight || state.image.height || 1));
    const sourcePerResultX = sourceWidth / resultWidth;
    const sourcePerResultY = sourceHeight / resultHeight;
    const sourceX = Math.max(0, Math.floor(Number(rect.x || 0) * sourcePerResultX));
    const sourceY = Math.max(0, Math.floor(Number(rect.y || 0) * sourcePerResultY));
    const sourceRight = Math.min(sourceWidth, Math.ceil((Number(rect.x || 0) + Number(rect.width || 0)) * sourcePerResultX));
    const sourceBottom = Math.min(sourceHeight, Math.ceil((Number(rect.y || 0) + Number(rect.height || 0)) * sourcePerResultY));
    const cropSourceWidth = Math.max(2, sourceRight - sourceX);
    const cropSourceHeight = Math.max(2, sourceBottom - sourceY);
    // Mature logo tracers fit curves against a supersampled coverage field
    // instead of the source pixel staircase. Allow small and medium OCR crops
    // to use up to 3x linear precision while respecting the same hard memory
    // ceiling. This adds no invented artwork; it gives the Bézier fitter
    // subpixel edge positions for thin serif and microtype contours.
    const precisionScale = Math.min(3, Math.sqrt(maxPixels / Math.max(1, cropSourceWidth * cropSourceHeight)));
    const width = Math.max(2, Math.round(cropSourceWidth * precisionScale));
    const height = Math.max(2, Math.round(cropSourceHeight * precisionScale));
    const canvas = document.createElement("canvas");
    canvas.width = width;
    canvas.height = height;
    const context = canvas.getContext("2d", { willReadFrequently: true });
    context.imageSmoothingEnabled = true;
    context.imageSmoothingQuality = "high";
    context.clearRect(0, 0, width, height);
    context.drawImage(
      state.image,
      sourceX, sourceY, cropSourceWidth, cropSourceHeight,
      0, 0, width, height
    );
    const x = sourceX / sourcePerResultX;
    const y = sourceY / sourcePerResultY;
    const resultCropWidth = cropSourceWidth / sourcePerResultX;
    const resultCropHeight = cropSourceHeight / sourcePerResultY;
    return {
      imageData: context.getImageData(0, 0, width, height),
      x, y, width, height,
      sourceX, sourceY,
      sourceWidth: cropSourceWidth,
      sourceHeight: cropSourceHeight,
      scaleX: resultCropWidth / width,
      scaleY: resultCropHeight / height,
      fullResolution: precisionScale >= 1,
      supersampled: precisionScale > 1,
      precisionScale
    };
  }

  async function selectedTextFile(rect) {
    const crop = cropImageData(state.imageData, rect);
    const canvas = document.createElement("canvas");
    canvas.width = crop.width;
    canvas.height = crop.height;
    canvas.getContext("2d", { willReadFrequently: true }).putImageData(crop.imageData, 0, 0);
    const blob = await new Promise((resolve, reject) => canvas.toBlob(value => {
      if (value) resolve(value);
      else reject(new Error("无法读取框选文字，请重新框选后再试"));
    }, "image/png"));
    return { crop, file: new File([blob], "框选文字.png", { type: "image/png" }) };
  }

  function clearEditorSelection() {
    state.editor.drag = null;
    state.editor.retraceStart = null;
    state.editor.retraceRect = null;
    state.editor.selectedNodeId = "";
    setEditorTool("idle");
    setStatus("已退出路径精修；矢量结果和之前的修改均已保留。", "warning");
  }

  async function runVectorOcr() {
    if (!state.file) { notifyUser("请先选择一张包含文字的图片"); return; }
    if (!byId("jlVectorOcrConsent")?.checked) { notifyUser("请先确认临时发送图片用于文字识别"); return; }
    if (typeof window.__vectorOcrBridge !== "function") { notifyUser("当前版本没有连接文字识别服务，你仍可手动输入文字后转曲"); return; }
    const button = byId("jlVectorRunOcr");
    if (button) { button.disabled = true; button.textContent = "正在识别…"; }
    setProcessing(true, "正在识别图片文字…", "临时文件会在识别结束后立即删除。 ");
    try {
      const response = await window.__vectorOcrBridge(state.file);
      if (response?.unavailable === true || response?.ok === false) {
        throw new Error(String(response?.message || "图片文字识别服务暂时不可用，请稍后原地重试"));
      }
      const text = String(response?.text || response || "").trim();
      if (!text) throw new Error("没有识别到清晰文字，可以手动输入后转曲");
      byId("jlVectorOcrText").value = text.slice(0, 500);
      setStatus("文字已识别，请核对错字、换行和字体后再转曲。", "warning");
      return text;
    } catch (error) {
      notifyUser(error.message);
      setStatus(`文字识别失败：${error.message}；可以手动输入后转曲。`, "error");
      return "";
    } finally {
      setProcessing(false);
      if (button) { button.disabled = false; button.textContent = "识别图片文字"; }
    }
  }

  const TEXT_FONT_CATALOG = [
    { group: "中文黑体", label: "苹方", family: "PingFang SC", value: "'PingFang SC', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "冬青黑体", family: "Hiragino Sans GB", value: "'Hiragino Sans GB', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "黑体-简", family: "Heiti SC", value: "'Heiti SC', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "思源黑体", family: "Source Han Sans SC", value: "'Source Han Sans SC', 'Noto Sans CJK SC', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "Noto Sans CJK", family: "Noto Sans CJK SC", value: "'Noto Sans CJK SC', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "微软雅黑", family: "Microsoft YaHei", value: "'Microsoft YaHei', sans-serif", script: "cjk" },
    { group: "中文黑体", label: "Windows 黑体", family: "SimHei", value: "SimHei, sans-serif", script: "cjk" },
    { group: "中文黑体", label: "华文黑体", family: "STHeiti", value: "STHeiti, 'Heiti SC', sans-serif", script: "cjk" },
    { group: "中文宋体与书法", label: "宋体-简", family: "Songti SC", value: "'Songti SC', serif", script: "cjk" },
    { group: "中文宋体与书法", label: "华文宋体", family: "STSong", value: "STSong, serif", script: "cjk" },
    { group: "中文宋体与书法", label: "思源宋体", family: "Source Han Serif SC", value: "'Source Han Serif SC', 'Noto Serif CJK SC', serif", script: "cjk" },
    { group: "中文宋体与书法", label: "Noto Serif CJK", family: "Noto Serif CJK SC", value: "'Noto Serif CJK SC', serif", script: "cjk" },
    { group: "中文宋体与书法", label: "Windows 宋体", family: "SimSun", value: "SimSun, serif", script: "cjk" },
    { group: "中文宋体与书法", label: "楷体-简", family: "Kaiti SC", value: "'Kaiti SC', serif", script: "cjk" },
    { group: "中文宋体与书法", label: "华文楷体", family: "STKaiti", value: "STKaiti, serif", script: "cjk" },
    { group: "中文宋体与书法", label: "仿宋-简", family: "Fangsong SC", value: "'Fangsong SC', STFangsong, serif", script: "cjk" },
    { group: "中文宋体与书法", label: "行楷-简", family: "Xingkai SC", value: "'Xingkai SC', 'Kaiti SC', serif", script: "cjk" },
    { group: "中文宋体与书法", label: "魏碑-简", family: "Weibei SC", value: "'Weibei SC', 'Kaiti SC', serif", script: "cjk" },
    { group: "英文衬线与品牌", label: "Didot", family: "Didot", value: "Didot, 'Times New Roman', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Bodoni 72", family: "Bodoni 72", value: "'Bodoni 72', Didot, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Bodoni 72 Smallcaps", family: "Bodoni 72 Smallcaps", value: "'Bodoni 72 Smallcaps', 'Bodoni 72', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Big Caslon", family: "Big Caslon", value: "'Big Caslon', Caslon, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Baskerville", family: "Baskerville", value: "Baskerville, Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Hoefler Text", family: "Hoefler Text", value: "'Hoefler Text', Baskerville, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Iowan Old Style", family: "Iowan Old Style", value: "'Iowan Old Style', Baskerville, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "New York", family: "New York", value: "'New York', Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Athelas", family: "Athelas", value: "Athelas, Baskerville, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Charter", family: "Charter", value: "Charter, 'Iowan Old Style', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Cochin", family: "Cochin", value: "Cochin, Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Palatino", family: "Palatino", value: "Palatino, 'Times New Roman', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Georgia", family: "Georgia", value: "Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Times New Roman", family: "Times New Roman", value: "'Times New Roman', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "American Typewriter", family: "American Typewriter", value: "'American Typewriter', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Academy Engraved", family: "Academy Engraved LET", value: "'Academy Engraved LET', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Kefa", family: "Kefa", value: "Kefa, Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Marion", family: "Marion", value: "Marion, Georgia, serif", script: "latin" },
    { group: "英文衬线与品牌", label: "Rockwell", family: "Rockwell", value: "Rockwell, 'American Typewriter', serif", script: "latin" },
    { group: "英文衬线与品牌", label: "STIX Two Text", family: "STIX Two Text", value: "'STIX Two Text', 'Times New Roman', serif", script: "latin" },
    { group: "英文无衬线", label: "Helvetica Neue", family: "Helvetica Neue", value: "'Helvetica Neue', Helvetica, Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Helvetica", family: "Helvetica", value: "Helvetica, Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Avenir Next", family: "Avenir Next", value: "'Avenir Next', Avenir, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Avenir Next Condensed", family: "Avenir Next Condensed", value: "'Avenir Next Condensed', 'Arial Narrow', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Futura", family: "Futura", value: "Futura, 'Century Gothic', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Gill Sans", family: "Gill Sans", value: "'Gill Sans', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Optima", family: "Optima", value: "Optima, 'Gill Sans', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "DIN Alternate", family: "DIN Alternate", value: "'DIN Alternate', Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "DIN Condensed", family: "DIN Condensed", value: "'DIN Condensed', 'Arial Narrow', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Arial", family: "Arial", value: "Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Arial Narrow", family: "Arial Narrow", value: "'Arial Narrow', Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Arial Rounded", family: "Arial Rounded MT Bold", value: "'Arial Rounded MT Bold', Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Arial Black", family: "Arial Black", value: "'Arial Black', Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Impact", family: "Impact", value: "Impact, 'Arial Black', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Lucida Grande", family: "Lucida Grande", value: "'Lucida Grande', Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Geneva", family: "Geneva", value: "Geneva, Arial, sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Seravek", family: "Seravek", value: "Seravek, 'Helvetica Neue', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Skia", family: "Skia", value: "Skia, 'Helvetica Neue', sans-serif", script: "latin" },
    { group: "英文无衬线", label: "Microsoft Sans Serif", family: "Microsoft Sans Serif", value: "'Microsoft Sans Serif', Arial, sans-serif", script: "latin" },
    { group: "手写与装饰", label: "Apple Chancery", family: "Apple Chancery", value: "'Apple Chancery', cursive", script: "latin" },
    { group: "手写与装饰", label: "Brush Script", family: "Brush Script MT", value: "'Brush Script MT', cursive", script: "latin" },
    { group: "手写与装饰", label: "Bradley Hand", family: "Bradley Hand", value: "'Bradley Hand', cursive", script: "latin" },
    { group: "手写与装饰", label: "Marker Felt", family: "Marker Felt", value: "'Marker Felt', fantasy", script: "latin" },
    { group: "手写与装饰", label: "Noteworthy", family: "Noteworthy", value: "Noteworthy, cursive", script: "latin" },
    { group: "手写与装饰", label: "Copperplate", family: "Copperplate", value: "Copperplate, serif", script: "latin" },
    { group: "手写与装饰", label: "Chalkboard SE", family: "Chalkboard SE", value: "'Chalkboard SE', fantasy", script: "latin" },
    { group: "手写与装饰", label: "Chalkduster", family: "Chalkduster", value: "Chalkduster, fantasy", script: "latin" },
    { group: "手写与装饰", label: "Comic Sans MS", family: "Comic Sans MS", value: "'Comic Sans MS', cursive", script: "latin" },
    { group: "手写与装饰", label: "Corsiva", family: "Corsiva", value: "Corsiva, cursive", script: "latin" },
    { group: "手写与装饰", label: "Herculanum", family: "Herculanum", value: "Herculanum, fantasy", script: "latin" },
    { group: "手写与装饰", label: "Luminari", family: "Luminari", value: "Luminari, fantasy", script: "latin" },
    { group: "手写与装饰", label: "Papyrus", family: "Papyrus", value: "Papyrus, fantasy", script: "latin" },
    { group: "手写与装饰", label: "Phosphate", family: "Phosphate", value: "Phosphate, Impact, fantasy", script: "latin" },
    { group: "手写与装饰", label: "SignPainter", family: "SignPainter", value: "SignPainter, cursive", script: "latin" },
    { group: "手写与装饰", label: "Snell Roundhand", family: "Snell Roundhand", value: "'Snell Roundhand', cursive", script: "latin" },
    { group: "手写与装饰", label: "Trattatello", family: "Trattatello", value: "Trattatello, fantasy", script: "latin" },
    { group: "手写与装饰", label: "Zapfino", family: "Zapfino", value: "Zapfino, cursive", script: "latin" },
    { group: "等宽字体", label: "Menlo", family: "Menlo", value: "Menlo, monospace", script: "latin" },
    { group: "等宽字体", label: "Monaco", family: "Monaco", value: "Monaco, monospace", script: "latin" },
    { group: "等宽字体", label: "Courier New", family: "Courier New", value: "'Courier New', monospace", script: "latin" },
    { group: "等宽字体", label: "Andale Mono", family: "Andale Mono", value: "'Andale Mono', Menlo, monospace", script: "latin" },
    { group: "多语言字体", label: "Arial Unicode", family: "Arial Unicode MS", value: "'Arial Unicode MS', Arial, sans-serif", script: "mixed" },
    { group: "多语言字体", label: "AppleGothic", family: "AppleGothic", value: "AppleGothic, sans-serif", script: "mixed" }
  ];

  const SMART_TEXT_FONTS = TEXT_FONT_CATALOG.map(font => font.value);

  function fontFamilyAvailable(family) {
    if (!family) return false;
    const context = document.createElement("canvas").getContext("2d");
    const samples = ["Hamburgefontsiv 012345", "字体包装印刷 ABCxyz"];
    return ["serif", "sans-serif", "monospace"].some(fallback => samples.some(sample => {
      context.font = `72px ${fallback}`;
      const baseline = context.measureText(sample).width;
      context.font = `72px "${String(family).replace(/["\\]/g, "")}", ${fallback}`;
      return Math.abs(context.measureText(sample).width - baseline) > .5;
    }));
  }

  function fontCatalogKey(font) {
    return String(font?.family || "").toLowerCase().replace(/[^a-z0-9\u3400-\u9fff]+/g, "");
  }

  function mergeTextFontCatalog(systemFonts = []) {
    const merged = [];
    const seen = new Set();
    [...TEXT_FONT_CATALOG, ...systemFonts.map(font => ({
      group: font.script === "cjk" ? "本机中文字体" : "本机英文字体",
      label: String(font.label || font.family || "").trim(),
      family: String(font.family || "").trim(),
      value: `"${String(font.family || "").replace(/["\\]/g, "")}", ${font.script === "cjk" ? "sans-serif" : "serif"}`,
      script: font.script === "cjk" ? "cjk" : "latin",
      style: String(font.style || "").trim(),
      weight: Number(font.weight || 400),
      baseFamily: String(font.base_family || font.family || "").trim(),
      source: "system"
    }))].forEach(font => {
      const key = fontCatalogKey(font);
      if (!key || seen.has(key)) return;
      seen.add(key);
      merged.push(font);
    });
    return merged;
  }

  function renderTextFontOptions(catalog, systemCount = 0) {
    const select = byId("jlVectorTextFont");
    if (!select) return 0;
    const available = catalog.filter(font => font.source === "system" || fontFamilyAvailable(font.family));
    const visible = available.length >= 8 ? available : TEXT_FONT_CATALOG.filter(font => ["Arial", "Times New Roman", "PingFang SC", "Songti SC"].includes(font.family));
    const previous = select.value;
    select.replaceChildren();
    const groups = new Map();
    visible.forEach(font => {
      if (!groups.has(font.group)) {
        const group = document.createElement("optgroup");
        group.label = font.group;
        groups.set(font.group, group);
        select.appendChild(group);
      }
      const option = document.createElement("option");
      option.value = font.value;
      option.textContent = font.label;
      option.dataset.script = font.script;
      option.dataset.family = font.family;
      option.style.fontFamily = font.value;
      groups.get(font.group).appendChild(option);
    });
    const retained = visible.find(font => font.value === previous);
    const preferred = retained || visible.find(font => font.family === "Didot") || visible.find(font => font.family === "Songti SC") || visible[0];
    if (preferred) select.value = preferred.value;
    const count = byId("jlVectorFontCount");
    if (count) count.textContent = systemCount
      ? `已加载 ${visible.length} 款可转曲字体（本机识别 ${systemCount} 个字体家族）`
      : `已加载 ${visible.length} 款本机可用字体`;
    return visible.length;
  }

  async function populateTextFonts() {
    renderTextFontOptions(mergeTextFontCatalog());
    if (typeof window.__vectorFontCatalogBridge !== "function") return;
    try {
      const systemFonts = await window.__vectorFontCatalogBridge();
      if (Array.isArray(systemFonts) && systemFonts.length) {
        renderTextFontOptions(mergeTextFontCatalog(systemFonts), systemFonts.length);
      }
    } catch (error) {
      const count = byId("jlVectorFontCount");
      if (count) count.textContent += " · 本机完整字体库稍后重试";
    }
  }

  function smartWhitePixel(data, pixel, threshold = 112) {
    const offset = pixel * 4;
    if (data[offset + 3] <= 16) return false;
    const r = data[offset], g = data[offset + 1], b = data[offset + 2];
    if (Math.max(r, g, b) - Math.min(r, g, b) > 52) return false;
    return r * .299 + g * .587 + b * .114 >= threshold;
  }

  function detectSmartTextBoxes(imageData) {
    const { width, height, data } = imageData || {};
    if (!width || !height || !data) return [];
    const darkRows = new Uint32Array(height);
    for (let y = 0; y < height; y += 1) {
      let hits = 0;
      for (let x = 0; x < width; x += 1) {
        const offset = (y * width + x) * 4;
        if (data[offset + 3] > 16 && data[offset] * .299 + data[offset + 1] * .587 + data[offset + 2] * .114 < 72) hits += 1;
      }
      darkRows[y] = hits;
    }
    const panelRows = [];
    for (let y = 0; y < height; y += 1) if (darkRows[y] >= width * .3) panelRows.push(y);
    if (panelRows.length < height * .12) return [];
    const top = panelRows[0], bottom = panelRows[panelRows.length - 1] + 1;
    const columns = [];
    for (let x = 0; x < width; x += 1) {
      let hits = 0;
      for (let y = top; y < bottom; y += 1) {
        const offset = (y * width + x) * 4;
        if (data[offset] * .299 + data[offset + 1] * .587 + data[offset + 2] * .114 < 72) hits += 1;
      }
      if (hits >= (bottom - top) * .42) columns.push(x);
    }
    if (columns.length < width * .12) return [];
    const left = columns[0], right = columns[columns.length - 1] + 1;
    const activeRows = [];
    for (let y = top; y < bottom; y += 1) {
      let hits = 0;
      for (let x = left; x < right; x += 1) if (smartWhitePixel(data, y * width + x)) hits += 1;
      if (hits >= 3) activeRows.push(y);
    }
    const bands = [];
    for (const y of activeRows) {
      const current = bands[bands.length - 1];
      if (!current || y - current.bottom > 3) bands.push({ top: y, bottom: y });
      else current.bottom = y;
    }
    const boxes = [];
    for (const band of bands) {
      const bandHeight = band.bottom - band.top + 1;
      if (bandHeight < 3 || bandHeight > (bottom - top) * .24) continue;
      const activeColumns = [];
      for (let x = left; x < right; x += 1) {
        let hits = 0;
        for (let y = band.top; y <= band.bottom; y += 1) if (smartWhitePixel(data, y * width + x)) hits += 1;
        if (hits) activeColumns.push(x);
      }
      const groups = [];
      const joinGap = Math.max(6, Math.round(bandHeight * .72));
      for (const x of activeColumns) {
        const current = groups[groups.length - 1];
        if (!current || x - current.right > joinGap) groups.push({ left: x, right: x });
        else current.right = x;
      }
      for (const group of groups) {
        const boxWidth = group.right - group.left + 1;
        // 文字至少包含数个笔画，宽高比通常大于图标；借此排除回收标志。
        if (boxWidth < bandHeight * 1.45) continue;
        boxes.push({
          x: Math.max(left, group.left - 2),
          y: Math.max(top, band.top - 2),
          width: Math.min(right - group.left, boxWidth + 4),
          height: Math.min(bottom - band.top, bandHeight + 4)
        });
      }
    }
    return boxes.sort((a, b) => a.y - b.y || a.x - b.x).slice(0, 12);
  }

  function rebuiltRectCoversBox(rect, box) {
    const left = Math.max(rect.x, box.x);
    const top = Math.max(rect.y, box.y);
    const right = Math.min(rect.x + rect.width, box.x + box.width);
    const bottom = Math.min(rect.y + rect.height, box.y + box.height);
    const overlap = Math.max(0, right - left) * Math.max(0, bottom - top);
    return overlap / Math.max(1, box.width * box.height) >= .72;
  }

  function allDetectedTextRebuilt() {
    const boxes = detectSmartTextBoxes(state.imageData);
    return boxes.length > 0 && boxes.every(box => state.rebuiltTextRects.some(rect => rebuiltRectCoversBox(rect, box)));
  }

  function smartTextLines(value) {
    return String(value || "").replace(/\r/g, "").split(/\n+/).map(line => line.trim()).filter(line => {
      if (!line || !/[A-Za-z]/.test(line)) return false;
      if (/[\u3400-\u9fff]/.test(line) || /\b\d+(?:\.\d+)?\s*(?:mm|cm|g)\b/i.test(line)) return false;
      return true;
    }).slice(-12);
  }

  function tightTextCanvas(text, fontFamily, width, height, oversample = 1, preserveAspect = false) {
    const targetWidth = Math.max(4, Math.round(width * oversample));
    const targetHeight = Math.max(4, Math.round(height * oversample));
    const fontSize = 240;
    const probe = document.createElement("canvas");
    const probeContext = probe.getContext("2d");
    probeContext.font = `${fontSize}px ${fontFamily}`;
    const measured = probeContext.measureText(text || " ");
    // TextMetrics gives the real ink box without rasterizing and scanning a
    // several-thousand-pixel temporary line for every font candidate.  On a
    // dense packaging sheet that old scan multiplied into hundreds of
    // millions of main-thread pixel reads and made the interface look frozen.
    const left = Math.max(0, Number(measured.actualBoundingBoxLeft || 0));
    const right = Math.max(1, Number(measured.actualBoundingBoxRight || measured.width || fontSize));
    const ascent = Math.max(1, Number(measured.actualBoundingBoxAscent || fontSize * .82));
    const descent = Math.max(0, Number(measured.actualBoundingBoxDescent || fontSize * .22));
    const sourceWidth = Math.max(1, left + right);
    const sourceHeight = Math.max(1, ascent + descent);
    const target = document.createElement("canvas");
    target.width = targetWidth; target.height = targetHeight;
    const targetContext = target.getContext("2d", { willReadFrequently: true });
    targetContext.imageSmoothingEnabled = true;
    targetContext.imageSmoothingQuality = "high";
    targetContext.clearRect(0, 0, targetWidth, targetHeight);
    const naturalAspect = sourceWidth / Math.max(1, sourceHeight);
    const scaleX = preserveAspect
      ? Math.min(targetWidth / sourceWidth, targetHeight / sourceHeight)
      : targetWidth / sourceWidth;
    const scaleY = preserveAspect ? scaleX : targetHeight / sourceHeight;
    const drawWidth = sourceWidth * scaleX;
    const drawHeight = sourceHeight * scaleY;
    targetContext.save();
    targetContext.translate((targetWidth - drawWidth) / 2, (targetHeight - drawHeight) / 2);
    targetContext.scale(scaleX, scaleY);
    targetContext.fillStyle = "#000";
    targetContext.font = `${fontSize}px ${fontFamily}`;
    targetContext.textBaseline = "alphabetic";
    targetContext.fillText(text, left, ascent);
    targetContext.restore();
    target.__naturalAspect = naturalAspect;
    return target;
  }

  function scoreSmartFont(text, box, fontFamily) {
    const rendered = tightTextCanvas(text, fontFamily, box.width, box.height, 1);
    if (!rendered) return 0;
    const candidate = rendered.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, rendered.width, rendered.height).data;
    const source = state.imageData;
    let intersection = 0, expected = 0, actual = 0;
    for (let y = 0; y < box.height; y += 1) {
      for (let x = 0; x < box.width; x += 1) {
        const sourcePixel = (Math.round(box.y + y) * source.width + Math.round(box.x + x));
        const sourceWhite = smartWhitePixel(source.data, sourcePixel, 128);
        const candidateOn = candidate[(y * rendered.width + x) * 4 + 3] > 96;
        if (sourceWhite) expected += 1;
        if (candidateOn) actual += 1;
        if (sourceWhite && candidateOn) intersection += 1;
      }
    }
    if (!expected || !actual) return 0;
    const overlapScore = intersection * 2 / (expected + actual);
    const sourceAspect = Math.max(.05, box.width / Math.max(1, box.height));
    const candidateAspect = Math.max(.05, Number(rendered.__naturalAspect || sourceAspect));
    const aspectScore = Math.exp(-Math.abs(Math.log(candidateAspect / sourceAspect)) * 1.8);
    const densityScore = Math.max(0, 1 - Math.abs(actual - expected) / Math.max(actual, expected));
    return overlapScore * .68 + aspectScore * .2 + densityScore * .12;
  }

  function colorDistance(first, second) {
    return Math.hypot(first[0] - second[0], first[1] - second[1], first[2] - second[2]);
  }

  function selectedBackgroundColor(rect) {
    const source = state.imageData;
    if (!source || !rect) return [255, 255, 255];
    const left = Math.max(0, Math.floor(rect.x));
    const top = Math.max(0, Math.floor(rect.y));
    const right = Math.min(source.width - 1, Math.ceil(rect.x + rect.width) - 1);
    const bottom = Math.min(source.height - 1, Math.ceil(rect.y + rect.height) - 1);
    const samples = [];
    const add = (x, y) => {
      const offset = (y * source.width + x) * 4;
      if (source.data[offset + 3] > 16) samples.push([source.data[offset], source.data[offset + 1], source.data[offset + 2]]);
    };
    const strideX = Math.max(1, Math.floor((right - left + 1) / 18));
    const strideY = Math.max(1, Math.floor((bottom - top + 1) / 18));
    for (let x = left; x <= right; x += strideX) { add(x, top); add(x, bottom); }
    for (let y = top; y <= bottom; y += strideY) { add(left, y); add(right, y); }
    if (!samples.length) return [255, 255, 255];
    const channel = index => samples.map(value => value[index]).sort((a, b) => a - b)[Math.floor(samples.length / 2)];
    return [channel(0), channel(1), channel(2)];
  }

  function selectedInkPixel(data, pixel, background, threshold = 52) {
    const offset = pixel * 4;
    if (data[offset + 3] <= 24) return false;
    return colorDistance([data[offset], data[offset + 1], data[offset + 2]], background) >= threshold;
  }

  function selectedInkBounds(rect, threshold = 52) {
    const source = state.imageData;
    if (!source || !rect) return null;
    const background = selectedBackgroundColor(rect);
    const left = Math.max(0, Math.floor(rect.x));
    const top = Math.max(0, Math.floor(rect.y));
    const right = Math.min(source.width, Math.ceil(rect.x + rect.width));
    const bottom = Math.min(source.height, Math.ceil(rect.y + rect.height));
    let minX = right, minY = bottom, maxX = -1, maxY = -1;
    for (let y = top; y < bottom; y += 1) {
      for (let x = left; x < right; x += 1) {
        if (!selectedInkPixel(source.data, y * source.width + x, background, threshold)) continue;
        minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
      }
    }
    if (maxX < minX || maxY < minY) return { ...rect, background };
    return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, background };
  }

  function detectSelectedTextLineBoxes(rect) {
    const source = state.imageData;
    if (!source || !rect) return [];
    const background = selectedBackgroundColor(rect);
    const left = Math.max(0, Math.floor(rect.x));
    const top = Math.max(0, Math.floor(rect.y));
    const right = Math.min(source.width, Math.ceil(rect.x + rect.width));
    const bottom = Math.min(source.height, Math.ceil(rect.y + rect.height));
    const width = Math.max(1, right - left);
    const height = Math.max(1, bottom - top);
    const minimumRowHits = Math.max(2, Math.ceil(width * .008));
    const activeRows = [];
    for (let y = top; y < bottom; y += 1) {
      let hits = 0;
      for (let x = left; x < right; x += 1) {
        if (selectedInkPixel(source.data, y * source.width + x, background, 46)) hits += 1;
      }
      if (hits >= minimumRowHits) activeRows.push(y);
    }
    const bands = [];
    const joinGap = Math.max(2, Math.round(height * .04));
    for (const y of activeRows) {
      const band = bands[bands.length - 1];
      if (!band || y - band.bottom > joinGap + 1) bands.push({ top: y, bottom: y });
      else band.bottom = y;
    }
    const minimumBandHeight = Math.max(2, Math.round(height * .012));
    const boxes = [];
    for (const band of bands) {
      const bandHeight = band.bottom - band.top + 1;
      if (bandHeight < minimumBandHeight) continue;
      let minX = right, maxX = -1, minY = band.bottom, maxY = band.top;
      for (let y = band.top; y <= band.bottom; y += 1) for (let x = left; x < right; x += 1) {
        if (!selectedInkPixel(source.data, y * source.width + x, background, 46)) continue;
        minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
      }
      if (maxX < minX || maxY < minY) continue;
      const padding = Math.max(1, Math.round(bandHeight * .035));
      boxes.push({
        x: Math.max(left, minX - padding),
        y: Math.max(top, minY - padding),
        width: Math.min(right, maxX + padding + 1) - Math.max(left, minX - padding),
        height: Math.min(bottom, maxY + padding + 1) - Math.max(top, minY - padding),
        background
      });
    }
    return boxes.filter(box => box.width >= 3 && box.height >= 3).sort((a, b) => a.y - b.y).slice(0, 12);
  }

  function mapRecognizedTextToBoxes(text, boxes) {
    const lines = String(text || "").replace(/\r/g, "").split(/\n+/).map(line => line.trim()).filter(Boolean);
    if (!boxes.length) return [];
    if (boxes.length === 1) return [{ text: lines.join(" "), box: boxes[0] }];
    let values = lines;
    if (values.length !== boxes.length) {
      const words = lines.join(" ").split(/\s+/).filter(Boolean);
      if (words.length === boxes.length) values = words;
      else if (values.length > boxes.length) values = boxes.map((_, index) => index === boxes.length - 1 ? values.slice(index).join(" ") : values[index]);
    }
    return boxes.map((box, index) => ({ text: String(values[index] || "").trim(), box })).filter(item => item.text);
  }

  function prepareFontMatchSample(box, maxWidth = 360, maxHeight = 112) {
    const source = state.imageData;
    const originalWidth = Math.max(4, Math.round(box.width));
    const originalHeight = Math.max(4, Math.round(box.height));
    const scale = Math.min(1, maxWidth / originalWidth, maxHeight / originalHeight);
    const width = Math.max(4, Math.round(originalWidth * scale));
    const height = Math.max(4, Math.round(originalHeight * scale));
    const background = box.background || selectedBackgroundColor(box);
    const soft = new Uint8Array(width * height);
    const core = new Uint8Array(width * height);
    for (let y = 0; y < height; y += 1) {
      const sourceY = Math.min(source.height - 1, Math.max(0, Math.round(box.y + (y + .5) * originalHeight / height - .5)));
      for (let x = 0; x < width; x += 1) {
        const sourceX = Math.min(source.width - 1, Math.max(0, Math.round(box.x + (x + .5) * originalWidth / width - .5)));
        const pixel = sourceY * source.width + sourceX;
        const offset = y * width + x;
        soft[offset] = selectedInkPixel(source.data, pixel, background, 48) ? 1 : 0;
        core[offset] = selectedInkPixel(source.data, pixel, background, 88) ? 1 : 0;
      }
    }
    return {
      __fontMatchSample: true,
      box,
      width,
      height,
      sourceAspect: originalWidth / Math.max(1, originalHeight),
      soft,
      core
    };
  }

  function fontMaskSimilarity(sourceMask, candidate, alphaThreshold) {
    let intersection = 0;
    let expected = 0;
    let actual = 0;
    for (let index = 0; index < sourceMask.length; index += 1) {
      const sourceOn = sourceMask[index] === 1;
      const candidateOn = candidate[index * 4 + 3] > alphaThreshold;
      if (sourceOn) expected += 1;
      if (candidateOn) actual += 1;
      if (sourceOn && candidateOn) intersection += 1;
    }
    if (!expected || !actual) return { overlap: 0, density: 0 };
    return {
      overlap: intersection * 2 / (expected + actual),
      density: Math.max(0, 1 - Math.abs(actual - expected) / Math.max(actual, expected))
    };
  }

  function scoreSelectedFont(text, box, fontFamily) {
    const sample = box?.__fontMatchSample ? box : prepareFontMatchSample(box);
    const width = sample.width;
    const height = sample.height;
    // Score the same uniformly fitted geometry that will be exported.  The
    // previous comparison stretched every candidate to fill the OCR box, so
    // DIN Condensed, Regular and Bold could all appear deceptively similar.
    const rendered = tightTextCanvas(text, fontFamily, width, height, 1, true);
    if (!rendered) return 0;
    const candidate = rendered.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, width, height).data;
    // Compare both the antialiased envelope and solid stroke core. This keeps
    // small counters/serifs while preventing screenshot blur from winning the
    // font search merely because it creates more dark pixels.
    const soft = fontMaskSimilarity(sample.soft, candidate, 72);
    const core = fontMaskSimilarity(sample.core, candidate, 148);
    const overlapScore = soft.overlap * .42 + core.overlap * .58;
    const densityScore = soft.density * .35 + core.density * .65;
    const sourceAspect = Math.max(.05, sample.sourceAspect);
    const candidateAspect = Math.max(.05, Number(rendered.__naturalAspect || sourceAspect));
    const aspectScore = Math.exp(-Math.abs(Math.log(candidateAspect / sourceAspect)) * 2.8);
    return overlapScore * .67 + aspectScore * .21 + densityScore * .12;
  }

  function availableTextFonts(text = "") {
    const cjk = /[\u3400-\u9fff\uf900-\ufaff]/.test(String(text || ""));
    const options = [...(byId("jlVectorTextFont")?.options || [])];
    const matching = options.filter(option => !option.dataset.script || option.dataset.script === (cjk ? "cjk" : "latin"));
    const fromSelect = (matching.length ? matching : options).map(option => option.value).filter(Boolean);
    const candidates = [...new Set(fromSelect.length ? fromSelect : SMART_TEXT_FONTS)];
    // “精准还原” intentionally searches every installed face, including Bold,
    // Semibold and Condensed members. Other profiles keep the bounded preview
    // search for users who explicitly prefer speed over maximum fidelity.
    return state.qualityGoal === "fidelity" ? candidates : candidates.slice(0, cjk ? 140 : 180);
  }

  async function closestTextFont(text, rect, progress = () => {}) {
    const lines = String(text || "").replace(/\r/g, "").split(/\n+/).map(line => line.trim()).filter(Boolean);
    const sample = lines.sort((a, b) => b.length - a.length)[0] || String(text || "").trim();
    const box = selectedInkBounds(rect, 82) || { ...rect, background: selectedBackgroundColor(rect) };
    const fonts = availableTextFonts(sample);
    const coarseSample = prepareFontMatchSample(box, 360, 112);
    const ranked = [];
    for (let index = 0; index < fonts.length; index += 1) {
      ranked.push({ font: fonts[index], score: scoreSelectedFont(sample, coarseSample, fonts[index]) });
      if ((index + 1) % 8 === 0 || index === fonts.length - 1) {
        progress(index + 1, fonts.length, "全字体粗筛");
        await yieldToRenderer();
      }
    }
    ranked.sort((first, second) => second.score - first.score);
    const finalists = ranked.slice(0, Math.min(state.qualityGoal === "fidelity" ? 28 : 12, ranked.length));
    const fineSample = prepareFontMatchSample(box, state.qualityGoal === "fidelity" ? 1200 : 720, state.qualityGoal === "fidelity" ? 320 : 200);
    let best = { font: byId("jlVectorTextFont")?.value || "Arial, sans-serif", score: -1 };
    for (let index = 0; index < finalists.length; index += 1) {
      const fineScore = scoreSelectedFont(sample, fineSample, finalists[index].font);
      const score = fineScore * .78 + finalists[index].score * .22;
      if (score > best.score) best = { font: finalists[index].font, score };
      if ((index + 1) % 4 === 0 || index === finalists.length - 1) {
        progress(index + 1, finalists.length, "高分字体精细复核");
        await yieldToRenderer();
      }
    }
    return { ...best, box, candidatesCompared: fonts.length, finalistsCompared: finalists.length };
  }

  function sampleSelectedTextColor(rect) {
    const source = state.imageData;
    if (!source || !rect) return "#000000";
    const background = selectedBackgroundColor(rect);
    const samples = [];
    const left = Math.max(0, Math.floor(rect.x));
    const top = Math.max(0, Math.floor(rect.y));
    const right = Math.min(source.width, Math.ceil(rect.x + rect.width));
    const bottom = Math.min(source.height, Math.ceil(rect.y + rect.height));
    for (let y = top; y < bottom; y += 1) for (let x = left; x < right; x += 1) {
      const pixel = y * source.width + x;
      if (!selectedInkPixel(source.data, pixel, background, 64)) continue;
      const offset = pixel * 4;
      const rgb = [source.data[offset], source.data[offset + 1], source.data[offset + 2]];
      samples.push({ rgb, distance: colorDistance(rgb, background) });
    }
    if (!samples.length) return "#000000";
    samples.sort((a, b) => b.distance - a.distance);
    const core = samples.slice(0, Math.max(4, Math.ceil(samples.length * .3)));
    const median = index => core.map(value => value.rgb[index]).sort((a, b) => a - b)[Math.floor(core.length / 2)];
    return `#${[median(0), median(1), median(2)].map(value => value.toString(16).padStart(2, "0")).join("")}`;
  }

  function ensureFontOption(font) {
    const select = byId("jlVectorTextFont");
    if (!select || !font) return;
    if (![...select.options].some(option => option.value === font)) {
      const option = document.createElement("option");
      option.value = font;
      option.textContent = `自动匹配 · ${font.split(",")[0].replace(/["']/g, "")}`;
      select.appendChild(option);
    }
    select.value = font;
  }

  function traceSmartTextLine(text, fontFamily, box, serial) {
    const oversample = 3;
    const canvas = tightTextCanvas(text, fontFamily, box.width, box.height, oversample);
    if (!canvas) return null;
    const traced = engineApi().vectorizePixels(canvas.getContext("2d", { willReadFrequently: true }).getImageData(0, 0, canvas.width, canvas.height), {
      mode: "line", colors: 2, simplify: 1.1, smoothing: .62, speckle: 2,
      removeBackground: false, alphaThreshold: 22, outputWidth: canvas.width, outputHeight: canvas.height,
      title: "智能文字重建"
    });
    const contours = [];
    for (const layer of traced.layers) {
      for (const contour of (layer.contours || [])) {
        const id = `smart-text-${state.editor.revision + 1}-${serial}-${contours.length + 1}`;
        contour.id = id;
        contour.source = "smart-text-rebuild";
        contour.points.forEach((point, index) => {
          point.id = `${id}-node-${index + 1}`;
          point.x = point.x / oversample + box.x;
          point.y = point.y / oversample + box.y;
        });
        contours.push(contour);
      }
    }
    if (!contours.length) return null;
    return rebuildGeometry({
      id: `smart-text-layer-${state.editor.revision + 1}-${serial}`,
      name: `智能重建文字 · ${text.slice(0, 24)}`,
      role: "foil",
      color: "#ffffff",
      cmyk: [0, 0, 0, 0],
      spotName: "FOIL_WHITE",
      smoothing: .62,
      pixels: 0,
      contours,
      visible: true
    });
  }

  function localSplineInput(box) {
    const source = state.imageData;
    const x0 = Math.max(0, Math.floor(box.x));
    const y0 = Math.max(0, Math.floor(box.y));
    const width = Math.max(2, Math.min(source.width - x0, Math.ceil(box.width)));
    const height = Math.max(2, Math.min(source.height - y0, Math.ceil(box.height)));
    const data = new Uint8ClampedArray(width * height * 4);
    for (let y = 0; y < height; y += 1) {
      for (let x = 0; x < width; x += 1) {
        const target = (y * width + x) * 4;
        const white = smartWhitePixel(source.data, (y0 + y) * source.width + x0 + x, 112);
        const value = white ? 0 : 255;
        data[target] = value;
        data[target + 1] = value;
        data[target + 2] = value;
        data[target + 3] = 255;
      }
    }
    return { imageData: new ImageData(data, width, height), x: x0, y: y0, width, height };
  }

  function splineSvgContours(svgSource, crop, serial) {
    const parsed = new DOMParser().parseFromString(String(svgSource || ""), "image/svg+xml");
    if (parsed.querySelector("parsererror, script, foreignObject, image, iframe, object, embed")) {
      throw new Error("高质量曲线引擎返回了无效 SVG");
    }
    const holder = document.createElement("div");
    holder.style.cssText = "position:fixed;left:-100000px;top:-100000px;width:1px;height:1px;overflow:hidden;pointer-events:none";
    const svg = svgElement("svg");
    svg.setAttribute("width", crop.width);
    svg.setAttribute("height", crop.height);
    svg.setAttribute("viewBox", `0 0 ${crop.width} ${crop.height}`);
    holder.appendChild(svg);
    document.body.appendChild(holder);
    const contours = [];
    try {
      const pathSources = [...parsed.querySelectorAll("path")].map(path => path.getAttribute("d") || "").filter(Boolean);
      for (const pathSource of pathSources) {
        const subpaths = pathSource.match(/[Mm][^Mm]*/g) || [];
        for (const subpath of subpaths) {
          if (!/^[0-9eE+.,\-\sMmLlHhVvCcSsQqTtAaZz]+$/.test(subpath)) continue;
          const path = svgElement("path");
          path.setAttribute("d", subpath);
          svg.appendChild(path);
          const length = path.getTotalLength();
          if (!Number.isFinite(length) || length < 2) { path.remove(); continue; }
          const samples = Math.max(8, Math.min(1600, Math.ceil(length / 1.35)));
          const id = `commercial-spline-${state.editor.revision + 1}-${serial}-${contours.length + 1}`;
          const points = [];
          for (let index = 0; index < samples; index += 1) {
            const point = path.getPointAtLength(length * index / samples);
            points.push({ id: `${id}-node-${index + 1}`, x: crop.x + point.x, y: crop.y + point.y });
          }
          path.remove();
          if (points.length < 3) continue;
          contours.push({ id, closed: true, points, source: "vtracer-commercial-spline" });
        }
      }
    } finally { holder.remove(); }
    return contours;
  }

  async function traceCommercialTextBox(box, serial) {
    const tracer = window.JianglaiSplineTracer;
    if (!tracer?.traceBinary) throw new Error("商用级曲线引擎尚未加载，请重新启动软件后再试");
    const crop = localSplineInput(box);
    const svgSource = await tracer.traceBinary(crop.imageData, {
      cornerThreshold: 60,
      lengthThreshold: 2.5,
      spliceThreshold: 45,
      filterSpeckle: 2,
      pathPrecision: 3
    });
    const contours = splineSvgContours(svgSource, crop, serial);
    if (!contours.length) return null;
    return rebuildGeometry({
      id: `commercial-spline-layer-${state.editor.revision + 1}-${serial}`,
      name: `商用级文字 / Logo 曲线 ${serial}`,
      role: "foil",
      color: "#ffffff",
      cmyk: [0, 0, 0, 0],
      spotName: "FOIL_WHITE",
      smoothing: .42,
      pixels: 0,
      contours,
      visible: true
    });
  }

  async function runSmartTextRebuild() {
    if (!state.result || !state.imageData) { notifyUser("请先生成矢量图"); return; }
    const button = byId("jlVectorSmartTextRebuild");
    if (button) { button.disabled = true; button.textContent = "正在拟合商用级曲线…"; }
    const before = {
      layers: cloneJson(state.result.layers), layerState: cloneJson(state.layerState),
      commercialTextRebuilt: state.commercialTextRebuilt, rebuiltTextRects: cloneJson(state.rebuiltTextRects)
    };
    try {
      const boxes = detectSmartTextBoxes(state.imageData);
      if (!boxes.length) throw new Error("没有定位到黑底上的浅色 Logo，请放大原图或使用选区文字重建");
      const rebuilt = [];
      for (let index = 0; index < boxes.length; index += 1) {
        const box = boxes[index];
        const layer = await traceCommercialTextBox(box, index + 1);
        if (!layer) continue;
        removeContoursInsideTextRect({ x: box.x - 4, y: box.y - 4, width: box.width + 8, height: box.height + 8 });
        rebuilt.push(layer);
      }
      if (!rebuilt.length) throw new Error("文字 / Logo 曲线重建失败，请放大原图后重试");
      state.result.layers.push(...rebuilt);
      rebuilt.forEach(layer => { state.layerState[layer.id] = { color: "#ffffff", visible: true }; });
      state.rebuiltTextRects = boxes.map(box => ({ ...box }));
      state.commercialTextRebuilt = true;
      recalculateDocument();
      const after = {
        layers: cloneJson(state.result.layers), layerState: cloneJson(state.layerState),
        commercialTextRebuilt: state.commercialTextRebuilt, rebuiltTextRects: cloneJson(state.rebuiltTextRects)
      };
      recordHistory({ kind: "document", before, after });
      populateEditLayers(); refreshSvg(); renderPalette(); renderMetrics(); setPreviewMode("result");
      setStatus(`已用本机 VTracer 样条引擎重建 ${rebuilt.length} 个文字 / Logo 区域 · 保留原字形，不替换字体 · FOIL_WHITE 专色 · 生产导出已解锁。`);
      notifyUser(`已完成 ${rebuilt.length} 个文字 / Logo 区域的商用级曲线重建`);
    } catch (error) {
      state.result.layers = before.layers;
      state.layerState = before.layerState;
      state.commercialTextRebuilt = before.commercialTextRebuilt;
      state.rebuiltTextRects = before.rebuiltTextRects;
      recalculateDocument();
      notifyUser(error.message);
      setStatus(`智能文字重建失败：${error.message}`, "error");
    } finally {
      if (button) { button.disabled = false; button.textContent = "保留原字形重建 Logo（备用）"; }
    }
  }

  function rasterizeTextToResult(text, fontFamily, fontSize, color = "#000000", targetRect = null, serial = 1) {
    const scale = 8;
    const lines = String(text).replace(/\r/g, "").split("\n").slice(0, 12);
    const probe = document.createElement("canvas").getContext("2d");
    probe.font = `${fontSize * scale}px ${fontFamily}`;
    const width = Math.min(4096, Math.max(16, Math.ceil(Math.max(...lines.map(line => probe.measureText(line || " ").width)) + fontSize * scale)));
    const lineHeight = fontSize * scale * 1.3;
    const height = Math.min(4096, Math.max(16, Math.ceil(lines.length * lineHeight + fontSize * scale * .5)));
    const canvas = document.createElement("canvas");
    canvas.width = width; canvas.height = height;
    const context = canvas.getContext("2d", { willReadFrequently: true });
    context.clearRect(0, 0, width, height);
    context.fillStyle = "#000";
    context.font = `${fontSize * scale}px ${fontFamily}`;
    context.textBaseline = "top";
    lines.forEach((line, index) => context.fillText(line, fontSize * scale * .2, index * lineHeight));
    const pixels = context.getImageData(0, 0, width, height);
    let minX = width, minY = height, maxX = -1, maxY = -1;
    for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
      if (pixels.data[(y * width + x) * 4 + 3] <= 20) continue;
      minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y);
    }
    if (maxX < minX || maxY < minY) throw new Error("文字内容为空，无法生成轮廓");
    const glyphCanvas = document.createElement("canvas");
    glyphCanvas.width = maxX - minX + 1;
    glyphCanvas.height = maxY - minY + 1;
    const glyphContext = glyphCanvas.getContext("2d", { willReadFrequently: true });
    glyphContext.drawImage(canvas, minX, minY, glyphCanvas.width, glyphCanvas.height, 0, 0, glyphCanvas.width, glyphCanvas.height);
    const traced = engineApi().vectorizePixels(glyphContext.getImageData(0, 0, glyphCanvas.width, glyphCanvas.height), {
      mode: "line", colors: 2, simplify: 1.4, smoothing: .58, speckle: 2, removeBackground: false, alphaThreshold: 22,
      outputWidth: glyphCanvas.width, outputHeight: glyphCanvas.height, title: "文字转曲"
    });
    const naturalWidth = glyphCanvas.width / scale;
    const naturalHeight = glyphCanvas.height / scale;
    const hasTarget = targetRect && targetRect.width >= 3 && targetRect.height >= 3;
    // OCR boxes describe a position, not permission to stretch the typeface in
    // two directions independently.  Uniform fitting keeps ordinary copy from
    // becoming unnaturally wide/tall when a recognition box contains padding.
    const fitScale = hasTarget
      ? Math.min(targetRect.width / naturalWidth, targetRect.height / naturalHeight)
      : 1;
    const targetWidth = naturalWidth * fitScale;
    const targetHeight = naturalHeight * fitScale;
    const offsetX = hasTarget
      ? targetRect.x + Math.max(0, (targetRect.width - targetWidth) / 2)
      : Math.max(0, (state.result.width - targetWidth) / 2);
    const offsetY = hasTarget
      ? targetRect.y + Math.max(0, (targetRect.height - targetHeight) / 2)
      : Math.max(0, (state.result.height - targetHeight) / 2);
    traced.layers.forEach((layer, layerIndex) => {
      layer.id = `text-outline-${state.editor.revision + 1}-${serial}-layer-${layerIndex + 1}`;
      layer.name = `${primaryFontFamily(fontFamily)} · ${String(text).slice(0, 24)}`;
      layer.role = "text";
      layer.color = /^#[0-9a-f]{6}$/i.test(color) ? color : "#000000";
      layer.cmyk = [0, 0, 0, 100];
      layer.fontFamily = primaryFontFamily(fontFamily);
      layer.text = String(text);
      if (targetRect) layer.nativeTextRect = { ...targetRect };
      layer.contours.forEach((contour, contourIndex) => {
        contour.id = `${layer.id}-path-${contourIndex + 1}`;
        contour.source = "text-outline";
        contour.points.forEach((point, pointIndex) => {
          point.id = `${contour.id}-node-${pointIndex + 1}`;
          point.x = point.x / scale * fitScale + offsetX;
          point.y = point.y / scale * fitScale + offsetY;
        });
      });
      traced.layers[layerIndex] = rebuildGeometry(layer);
    });
    return traced.layers;
  }

  function primaryFontFamily(fontStack) {
    return String(fontStack || "").split(",", 1)[0].trim().replace(/^['"]|['"]$/g, "");
  }

  function fontOutlineToResult(outline, spec, serial = 1) {
    const source = String(outline?.d || "");
    if (String(outline?.missing_characters || "")) {
      throw new Error("所选字体不能完整显示这行文字，已停止替换以避免缺字方框");
    }
    const box = outline?.bbox || {};
    const sourceX = Number(box.x);
    const sourceY = Number(box.y);
    const sourceWidth = Number(box.width);
    const sourceHeight = Number(box.height);
    if (!source || !/^[0-9eE+.,\-\sMmLlHhVvCcSsQqTtAaZz]+$/.test(source)) throw new Error("字体曲线路径无效");
    if (![sourceX, sourceY, sourceWidth, sourceHeight].every(Number.isFinite) || sourceWidth <= 0 || sourceHeight <= 0) {
      throw new Error("字体曲线尺寸无效");
    }
    const target = spec.rect;
    // Fit the real glyph bounds uniformly inside the OCR line.  Vision line
    // boxes commonly contain vertical padding (especially mixed Chinese,
    // numbers and units); filling that height and separately squeezing the
    // width made headings overlap and visibly distorted the substitute font.
    const scale = Math.min(target.width / sourceWidth, target.height / sourceHeight);
    const renderedWidth = sourceWidth * scale;
    const renderedHeight = sourceHeight * scale;
    const subpaths = source.match(/[Mm][^Mm]*/g) || [];
    const commandCount = (source.match(/[MmLlHhVvCcSsQqTtAaZz]/g) || []).length;
    if (!subpaths.length || !commandCount) throw new Error("字体没有生成可用的贝塞尔轮廓");
    const translateX = target.x + (target.width - renderedWidth) / 2 - sourceX * scale;
    const translateY = target.y + (target.height - renderedHeight) / 2 - sourceY * scale;
    // 字体服务返回的本来就是原生 Bézier 曲线。旧流程又把曲线采样成
    // 折线节点，再用平滑算法猜回曲线，细宋体和小字号因此发虚。
    // 这里直接保留字体原始 path，并只施加矩阵变换；预览、SVG 与 PDF
    // 都使用同一条真实曲线，不再经过位图描边或二次平滑。
    return [{
      id: `native-font-layer-${state.editor.revision + 1}-${serial}`,
      name: `${primaryFontFamily(spec.font)} · ${spec.text.slice(0, 24)}`,
      role: "text",
      color: /^#[0-9a-f]{6}$/i.test(spec.color) ? spec.color : "#000000",
      cmyk: [0, 0, 0, 100],
      smoothing: 0,
      pixels: 0,
      contours: [],
      strokes: [],
      nativePathD: source,
      nativeTransform: [scale, 0, 0, scale, translateX, translateY],
      bbox: {
        x: target.x + (target.width - renderedWidth) / 2,
        y: target.y + (target.height - renderedHeight) / 2,
        width: renderedWidth,
        height: renderedHeight
      },
      paths: subpaths.length,
      nodes: commandCount,
      visible: true,
      fontFamily: outline.font_family || primaryFontFamily(spec.font),
      text: spec.text,
      nativeTextRect: { ...target },
      nativeFontBezier: true,
      sourceModel: "native-font-bezier"
    }];
  }

  async function textSpecToResultLayers(spec, serial) {
    if (typeof window.__vectorFontOutlineBridge === "function") {
      try {
        const outline = await window.__vectorFontOutlineBridge(spec.text, primaryFontFamily(spec.font));
        return { layers: fontOutlineToResult(outline, spec, serial), fallback: false };
      } catch (error) {
        console.warn("Native font outline unavailable, using raster fallback", error);
      }
    }
    return {
      layers: rasterizeTextToResult(spec.text, spec.font, spec.size, spec.color, spec.rect, serial),
      fallback: true
    };
  }

  function geometryBoxBelongsToRegion(box, rect, overlapFloor = .2) {
    if (!box || !rect) return false;
    const x = Number(box.x || 0);
    const y = Number(box.y || 0);
    const width = Math.max(0, Number(box.width || 0));
    const height = Math.max(0, Number(box.height || 0));
    if (!width || !height) return false;
    const left = Math.max(x, rect.x);
    const top = Math.max(y, rect.y);
    const right = Math.min(x + width, rect.x + rect.width);
    const bottom = Math.min(y + height, rect.y + rect.height);
    const overlap = Math.max(0, right - left) * Math.max(0, bottom - top);
    const area = Math.max(1, width * height);
    const centerX = x + width / 2;
    const centerY = y + height / 2;
    const centerInside = centerX >= rect.x && centerX <= rect.x + rect.width
      && centerY >= rect.y && centerY <= rect.y + rect.height;
    const localScale = width <= rect.width * 1.75 && height <= rect.height * 2.2;
    return overlap / area >= overlapFloor || (centerInside && localScale);
  }

  function nativeLayerWithSubpaths(layer, nativeSubpaths) {
    const retained = (nativeSubpaths || []).filter(item => item?.d);
    if (!retained.length) return null;
    const bbox = retained.filter(item => item.bbox).reduce((combined, item) => {
      const box = item.bbox;
      if (!combined) return { ...box };
      const right = Math.max(combined.x + combined.width, box.x + box.width);
      const bottom = Math.max(combined.y + combined.height, box.y + box.height);
      const x = Math.min(combined.x, box.x);
      const y = Math.min(combined.y, box.y);
      return { x, y, width: right - x, height: bottom - y };
    }, null) || layer.bbox;
    return {
      ...layer,
      nativeSubpaths: retained,
      nativePathD: retained.map(item => item.d).join(" "),
      bbox,
      paths: retained.length,
      nodes: retained.reduce((sum, item) => sum + Number(item.nodes || 0), 0)
    };
  }

  function removeContoursInsideTextRect(rect, options = {}) {
    if (!rect || rect.width < 3 || rect.height < 3) return 0;
    const preservedBackgroundHex = Array.isArray(options.preserveBackgroundColor)
      ? `#${options.preserveBackgroundColor
        .slice(0, 3)
        .map(channel => Math.round(clampNumber(channel, 0, 255, 0)).toString(16).padStart(2, "0"))
        .join("")}`
      : "";
    let removed = 0;
    state.result.layers = state.result.layers.flatMap(layer => {
      if (!Array.isArray(layer.contours)) return [layer];
      // OCR replacement may cover panels crossed by a dieline. Text cleanup must
      // never delete the dedicated production cutting/creasing geometry.
      if (isStructureLayer(layer)) return [layer];
      if (layer.artworkIsolated === true) return [layer];
      // A traced black carton contains small black "islands" inside the
      // counters of white e/d/o glyphs. Removing every contour in the OCR box
      // deletes those islands and exposes the transparent checkerboard through
      // otherwise correct letters. Preserve the locally sampled solid
      // background layer; only the contrasting original glyph layer is replaced.
      if (
        preservedBackgroundHex
        && svgColorDistance(layer.color, preservedBackgroundHex) <= 42
      ) return [layer];
      if (layer.nativeTextRect && rebuiltRectCoversBox(rect, layer.nativeTextRect)) {
        removed += Number(layer.paths || 1);
        return [];
      }
      // Native font layers deliberately have no sampled contour array. Keep
      // unrelated lines intact when another OCR region is rebuilt; otherwise
      // rebuilding line 2 would silently delete the crisp Bézier layer for
      // line 1 merely because its contours array is empty.
      if (layer.nativeFontBezier === true && layer.nativePathD) return [layer];
      if (Array.isArray(layer.nativeSubpaths) && layer.nativeSubpaths.length) {
        const retained = layer.nativeSubpaths.filter(item => {
          const replace = geometryBoxBelongsToRegion(item.bbox, rect, .2);
          if (replace) removed += 1;
          return !replace;
        });
        const rebuilt = nativeLayerWithSubpaths(layer, retained);
        return rebuilt ? [rebuilt] : [];
      }
      const retained = layer.contours.filter(contour => {
        const replace = geometryBoxBelongsToRegion(contour.bbox, rect, .2);
        if (replace) removed += 1;
        return !replace;
      });
      if (!retained.length) return [];
      return [rebuildGeometry({ ...layer, contours: retained })];
    });
    return removed;
  }

  function unionTextRects(rects) {
    const usable = (rects || []).filter(rect => rect?.width >= 3 && rect?.height >= 3);
    if (!usable.length) return null;
    const left = Math.min(...usable.map(rect => rect.x));
    const top = Math.min(...usable.map(rect => rect.y));
    const right = Math.max(...usable.map(rect => rect.x + rect.width));
    const bottom = Math.max(...usable.map(rect => rect.y + rect.height));
    return { x: left, y: top, width: right - left, height: bottom - top };
  }

  async function replaceSelectedTextSpecs(specs, eraseRect, progress = () => {}) {
    const eraseRects = (Array.isArray(eraseRect) ? eraseRect : [eraseRect]).filter(rect => rect?.width >= 3 && rect?.height >= 3);
    const eraseBounds = unionTextRects(eraseRects);
    if (!state.result || !eraseBounds) {
      throw new Error("请先框选需要恢复成字体的文字区域");
    }
    const usable = (specs || []).filter(spec => spec?.text && spec?.rect?.width >= 3 && spec?.rect?.height >= 3);
    if (!usable.length) throw new Error("没有识别到可替换的文字行");
    const before = {
      layers: cloneJson(state.result.layers),
      layerState: cloneJson(state.layerState),
      commercialTextRebuilt: state.commercialTextRebuilt,
      rebuiltTextRects: cloneJson(state.rebuiltTextRects)
    };
    try {
      const removed = eraseRects.reduce((total, rect) => total + removeContoursInsideTextRect(rect), 0);
      const converted = [];
      for (let index = 0; index < usable.length; index += 1) {
        converted.push(await textSpecToResultLayers(usable[index], index + 1));
        progress(index + 1, usable.length);
        await yieldToRenderer();
      }
      const layers = converted.flatMap(item => item.layers).filter(layer => (
        (Array.isArray(layer.contours) && layer.contours.length)
        || (layer.nativeFontBezier === true && Boolean(layer.nativePathD))
      ));
      const fallbackCount = converted.filter(item => item.fallback).length;
      if (!layers.length) throw new Error("没有生成可用的文字轮廓，请修改文字或字体后再试");
      state.result.layers = state.result.layers.concat(layers);
      layers.forEach(layer => { state.layerState[layer.id] = { color: layer.color, visible: true }; });
      state.rebuiltTextRects = state.rebuiltTextRects.filter(rect => !eraseRects.some(erase => rebuiltRectCoversBox(erase, rect)));
      state.rebuiltTextRects.push(...eraseRects.map(rect => ({ ...rect })));
      if (state.mode === "packaging") state.commercialTextRebuilt = allDetectedTextRebuilt();
      recalculateDocument();
      const after = {
        layers: cloneJson(state.result.layers),
        layerState: cloneJson(state.layerState),
        commercialTextRebuilt: state.commercialTextRebuilt,
        rebuiltTextRects: cloneJson(state.rebuiltTextRects)
      };
      recordHistory({ kind: "document", before, after });
      state.editor.lastTextRect = { ...eraseBounds };
      state.editor.lastTextSpecs = cloneJson(usable);
      state.editor.retraceRect = null;
      state.editor.selectedLayerId = layers[0]?.id || "";
      populateEditLayers();
      refreshSvg(); renderPalette(); renderMetrics();
      setPreviewMode("result");
      setEditorTool("node");
      return { removed, layers, fallbackCount };
    } catch (error) {
      state.result.layers = before.layers;
      state.layerState = before.layerState;
      state.commercialTextRebuilt = before.commercialTextRebuilt;
      state.rebuiltTextRects = before.rebuiltTextRects;
      recalculateDocument();
      throw error;
    }
  }

  async function replaceSelectedTextWithFont(text, font, size, color, targetRect) {
    return replaceSelectedTextSpecs([{ text, font, size, color, rect: targetRect }], targetRect);
  }

  function normalizeAutomaticTextLayout(layout) {
    const imageWidth = Number(state.imageData?.width || 0);
    const imageHeight = Number(state.imageData?.height || 0);
    if (!imageWidth || !imageHeight) return [];
    const sourceLines = Array.isArray(layout?.lines) ? layout.lines : [];
    const numericSpace = Number(layout?.coordinate_space);
    const allNumbers = sourceLines.flatMap(line => [line?.x, line?.y, line?.width, line?.height, line?.x1, line?.y1, line?.x2, line?.y2])
      .map(Number).filter(Number.isFinite);
    const maximum = allNumbers.length ? Math.max(...allNumbers) : 0;
    const normalizedSpace = Number.isFinite(numericSpace) && numericSpace > 0
      ? numericSpace
      : maximum <= 1.05 ? 1 : maximum <= 1000 ? 1000 : 0;
    return sourceLines.map(line => {
      const text = String(line?.text || "").replace(/\s+/g, " ").trim().slice(0, 180);
      let x = finiteNumber(line?.x ?? line?.left ?? line?.x1);
      let y = finiteNumber(line?.y ?? line?.top ?? line?.y1);
      let width = finiteNumber(line?.width);
      let height = finiteNumber(line?.height);
      if (!width && Number.isFinite(Number(line?.x2))) width = Number(line.x2) - x;
      if (!height && Number.isFinite(Number(line?.y2))) height = Number(line.y2) - y;
      const scaleX = normalizedSpace ? imageWidth / normalizedSpace : 1;
      const scaleY = normalizedSpace ? imageHeight / normalizedSpace : 1;
      x *= scaleX; y *= scaleY; width *= scaleX; height *= scaleY;
      x = Math.max(0, Math.min(imageWidth - 1, x));
      y = Math.max(0, Math.min(imageHeight - 1, y));
      width = Math.max(0, Math.min(imageWidth - x, width));
      height = Math.max(0, Math.min(imageHeight - y, height));
      const confidenceValue = Number(line?.confidence);
      return {
        text,
        rect: { x, y, width, height },
        confidence: Number.isFinite(confidenceValue) ? confidenceValue : null,
        replaceMode: String(line?.replace_mode || line?.replaceMode || "").trim().toLowerCase()
      };
    }).filter(item => item.text && item.rect.width >= 3 && item.rect.height >= 3)
      .slice(0, state.qualityGoal === "fidelity" ? 200 : 120);
  }

  function nearestInkColumn(rect, expectedX) {
    const source = state.imageData;
    if (!source || !rect) return expectedX;
    const background = selectedBackgroundColor(rect);
    const start = Math.max(Math.ceil(rect.x + rect.width * .12), Math.floor(expectedX - rect.width * .1));
    const end = Math.min(Math.floor(rect.x + rect.width * .88), Math.ceil(expectedX + rect.width * .1));
    let best = { x: Math.round(expectedX), hits: Infinity, distance: Infinity };
    for (let x = start; x <= end; x += 1) {
      let hits = 0;
      for (let y = Math.max(0, Math.floor(rect.y)); y < Math.min(source.height, Math.ceil(rect.y + rect.height)); y += 1) {
        if (selectedInkPixel(source.data, y * source.width + x, background, 82)) hits += 1;
      }
      const distance = Math.abs(x - expectedX);
      if (hits < best.hits || (hits === best.hits && distance < best.distance)) best = { x, hits, distance };
    }
    return best.x;
  }

  function splitMixedWeightTextItem(item) {
    const text = String(item?.text || "").trim();
    const rect = item?.rect;
    if (!rect || rect.height > rect.width * .3) return [item];
    // Packaging copy commonly uses “HEADING | body copy” on one physical
    // line.  Rebuilding that whole line with one face destroys either the
    // heading weight or the body text.  Split only this strong typographic
    // pattern, then match each run independently against real local faces.
    const match = text.match(/^([A-ZÀ-Þ][A-ZÀ-Þ0-9 /&+\-]{2,28})(\s+[|I]\s+)(.{5,})$/);
    if (!match) return [item];
    const firstText = `${match[1]}${match[2].trimEnd()}`;
    const secondText = match[3].trim();
    const totalUnits = Math.max(1, firstText.length + secondText.length);
    const expectedX = rect.x + rect.width * (firstText.length / totalUnits);
    const splitX = nearestInkColumn(rect, expectedX);
    if (splitX <= rect.x + 3 || splitX >= rect.x + rect.width - 3) return [item];
    const firstRect = selectedInkBounds({
      x: rect.x, y: rect.y, width: splitX - rect.x, height: rect.height
    }, 82) || { x: rect.x, y: rect.y, width: splitX - rect.x, height: rect.height };
    const secondRect = selectedInkBounds({
      x: splitX, y: rect.y, width: rect.x + rect.width - splitX, height: rect.height
    }, 82) || { x: splitX, y: rect.y, width: rect.x + rect.width - splitX, height: rect.height };
    return [
      { ...item, text: firstText, rect: firstRect, mixedWeightRun: "heading" },
      { ...item, text: secondText, rect: secondRect, mixedWeightRun: "body" }
    ];
  }

  function textRectHasMultipleInkColors(rect) {
    const source = state.imageData;
    if (!source || !rect) return false;
    const background = selectedBackgroundColor(rect);
    const left = Math.max(0, Math.floor(rect.x));
    const top = Math.max(0, Math.floor(rect.y));
    const right = Math.min(source.width, Math.ceil(rect.x + rect.width));
    const bottom = Math.min(source.height, Math.ceil(rect.y + rect.height));
    const area = Math.max(1, (right - left) * (bottom - top));
    const stride = Math.max(1, Math.floor(Math.sqrt(area / 3200)));
    const clusters = [];
    let samples = 0;
    for (let y = top; y < bottom; y += stride) for (let x = left; x < right; x += stride) {
      const offset = (y * source.width + x) * 4;
      if (source.data[offset + 3] <= 24) continue;
      const color = [source.data[offset], source.data[offset + 1], source.data[offset + 2]];
      if (colorDistance(color, background) < 64) continue;
      samples += 1;
      let cluster = clusters.find(item => colorDistance(item.color, color) < 68);
      if (!cluster) {
        cluster = { color: [...color], count: 0, sum: [0, 0, 0] };
        clusters.push(cluster);
      }
      cluster.count += 1;
      cluster.sum[0] += color[0]; cluster.sum[1] += color[1]; cluster.sum[2] += color[2];
      cluster.color = cluster.sum.map(value => value / cluster.count);
    }
    if (samples < 12) return false;
    const meaningful = clusters.filter(cluster => cluster.count >= Math.max(5, samples * .055));
    return meaningful.some((first, index) => meaningful.slice(index + 1).some(second => colorDistance(first.color, second.color) > 88));
  }

  function automaticTextReplacementDecision(item) {
    const text = String(item?.text || "").trim();
    const rect = item?.rect || {};
    const imageWidth = Number(state.imageData?.width || 0);
    const imageHeight = Number(state.imageData?.height || 0);
    const explicitStandard = item.replaceMode === "standard";
    if (!text || !imageWidth || !imageHeight) return { replace: false, reason: "invalid" };
    if (["artwork", "logo", "decorative", "handwriting", "preserve"].includes(item.replaceMode)) {
      return { replace: false, reason: "artwork" };
    }
    if (rect.height > rect.width * 1.15 && text.length > 2) return { replace: false, reason: "vertical-or-rotated" };
    if (rect.width > imageWidth * .94 || rect.height > imageHeight * .11 || rect.width * rect.height > imageWidth * imageHeight * .09) {
      return { replace: false, reason: "unsafe-box" };
    }
    const hasCjk = /[\u3400-\u9fff\uf900-\ufaff]/.test(text);
    const hasDigit = /\d/.test(text);
    const latinOnly = /^[A-Za-zÀ-ÿ&'’.\-\s]+$/.test(text);
    const bodyCopyGeometry = rect.height <= imageHeight * .052
      && rect.width <= imageWidth * .9
      && (text.length >= 10 || /\s/.test(text));
    const productionCode = /^(?:UV|CMYK|PANTONE|LOT|DATE|NO\.?|QTY)$/i.test(text);
    const dimension = /\d+(?:\.\d+)?\s*(?:mm|cm|m|g|kg|x|×)/i.test(text);
    const longOrdinaryCopy = text.length >= 18
      && (bodyCopyGeometry || dimension || /[,.;:，。；：+＋]/.test(text));
    // macOS Vision 对整行中英混排、尺寸和细字说明有时只返回 0.5，
    // 但文字内容与整行坐标仍然准确。不能因为统一的高阈值留下破碎像素字。
    // 长说明/尺寸行允许以版面证据降低到 0.48；短品牌词仍由下方 Logo
    // 规则保护，不会因为高置信度而被换成普通字体。
    const confidenceFloor = explicitStandard ? .48 : longOrdinaryCopy ? .48 : .82;
    if (item.confidence !== null && item.confidence < confidenceFloor) {
      return { replace: false, reason: "low-confidence" };
    }
    // The managed vision route explicitly separates ordinary copy from
    // artwork. Trust a high-confidence "standard" result after the geometric
    // safety checks above; otherwise English ingredients/instructions made of
    // letters only would be incorrectly preserved as hundreds of fragments.
    if (explicitStandard) return { replace: true, reason: "explicit-standard-copy" };
    // A fold/cut line often crosses an otherwise ordinary paragraph. That
    // introduces a second ink colour inside the OCR rectangle, but it must not
    // turn the whole paragraph into protected artwork. Only preserve
    // multi-colour regions when their geometry also looks like display text.
    if (textRectHasMultipleInkColors(rect) && !bodyCopyGeometry && !dimension && !productionCode) {
      return { replace: false, reason: "multicolor-styled-text" };
    }
    // macOS Vision provides excellent boxes but no artwork/standard label.
    // Small paragraph-like rows are ordinary copy even when they contain no
    // number. Large or short display wording is still protected below.
    if (bodyCopyGeometry && (text.length >= 14 || /[,.;:，。；：]/.test(text))) {
      return { replace: true, reason: "body-copy-geometry" };
    }
    // Short Latin wording on packaging is usually a brand, logo, script mark
    // or designed headline.  Preserve its existing vector outline unless the
    // content is clearly a dimension or production code.
    if (
      latinOnly
      && !hasDigit
      && !productionCode
      && (text.length <= 24 || rect.height > imageHeight * .035)
    ) {
      return { replace: false, reason: "likely-brand-artwork" };
    }
    if (hasCjk && [...text].filter(character => /[\u3400-\u9fff]/.test(character)).length < 4 && !hasDigit) {
      return { replace: false, reason: "short-title" };
    }
    if (!hasCjk && !hasDigit && !dimension && !productionCode) return { replace: false, reason: "not-standard-copy" };
    return { replace: true, reason: "standard-copy" };
  }

  function textRectOverlapRatio(first, second) {
    const left = Math.max(first.x, second.x);
    const top = Math.max(first.y, second.y);
    const right = Math.min(first.x + first.width, second.x + second.width);
    const bottom = Math.min(first.y + first.height, second.y + second.height);
    const overlap = Math.max(0, right - left) * Math.max(0, bottom - top);
    return overlap / Math.max(1, Math.min(first.width * first.height, second.width * second.height));
  }

  function safeAutomaticTextCandidates(items) {
    const accepted = [];
    const preserved = [];
    [...(items || [])]
      .sort((first, second) => (Number(second.confidence ?? 1) - Number(first.confidence ?? 1)) || (first.rect.y - second.rect.y))
      .forEach(item => {
        const decision = automaticTextReplacementDecision(item);
        if (!decision.replace || accepted.some(existing => textRectOverlapRatio(existing.rect, item.rect) > .28)) {
          preserved.push({ ...item, preserveReason: decision.replace ? "overlap" : decision.reason });
          return;
        }
        accepted.push(item);
      });
    return { accepted: accepted.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x), preserved };
  }

  function isArtworkPreservationCandidate(item) {
    const reason = String(item?.preserveReason || "");
    return ["artwork", "logo", "outline"].includes(item?.replaceMode) || [
      "artwork", "font-mismatch", "multicolor-styled-text", "likely-brand-artwork", "short-title", "not-standard-copy"
    ].includes(reason);
  }

  function distinctArtworkCandidates(items) {
    const selected = [];
    [...(items || [])]
      .filter(isArtworkPreservationCandidate)
      .sort((first, second) => (second.rect.width * second.rect.height) - (first.rect.width * first.rect.height))
      .forEach(item => {
        if (selected.some(existing => textRectOverlapRatio(existing.rect, item.rect) >= .72)) return;
        selected.push(item);
      });
    const limit = state.outputKind === "professional-reconstruction"
      ? 240
      : state.qualityGoal === "fidelity" ? 64 : 24;
    return selected.slice(0, limit);
  }

  function originalGlyphMask(rect) {
    const crop = state.outputKind === "professional-reconstruction" && state.qualityGoal === "fidelity"
      ? cropOriginalSourceAtResultRect(rect)
      : cropImageData(state.imageData, rect);
    const ink = selectedInkBounds(rect, 24);
    if (!ink || ink.width * ink.height < 4) throw new Error("原字形区域没有足够的清晰像素");
    // Crop the original antialiased pixels unchanged. VTracer's colour layers
    // preserve the light/dark edge coverage far better than a binary mask,
    // particularly for small serif copy and dense CJK strokes.
    return {
      ...crop,
      background: selectedBackgroundColor(rect),
      color: sampleSelectedTextColor(rect)
    };
  }

  async function traceOriginalGlyphRegion(item, serial) {
    const rect = paddedAutomaticTextRect(item.rect, .16, .018);
    const crop = originalGlyphMask(rect);
    const fineText = Number(item?.rect?.height || 0) <= Number(state.imageData?.height || 0) * .045;
    const extremeFidelity = state.qualityGoal === "fidelity";
    const baseOptions = {
      mode: "packaging",
      speckle: fineText ? 0 : 1,
      dropCanvasCoveringPaths: true,
      removeBackground: true,
      backgroundTolerance: 24,
      boundaryMode: "cutout",
      outputWidth: crop.width,
      outputHeight: crop.height
    };
    const role = isolatedRegionRole(item);
    const scaleX = Number(crop.scaleX || 1);
    const scaleY = Number(crop.scaleY || 1);
    const layers = [];
    const translateLayer = (layer, layerIndex, family, sourceModel) => {
      const translatedSubpaths = (layer.nativeSubpaths || []).map(subpath => ({
        ...subpath,
        bbox: subpath.bbox ? {
          x: subpath.bbox.x * scaleX + crop.x,
          y: subpath.bbox.y * scaleY + crop.y,
          width: subpath.bbox.width * scaleX,
          height: subpath.bbox.height * scaleY
        } : null
      }));
      return nativeLayerWithSubpaths({
        ...layer,
        id: `original-glyph-layer-${state.editor.revision + 1}-${serial}-${family?.id || "fallback"}-${layerIndex + 1}`,
        name: `${role === "logo" ? "Logo / 艺术字" : "原字形文字"} · ${String(item.text || "").slice(0, 24) || serial}`,
        role,
        color: family?.traceColor || layer.color,
        cmyk: family?.id === "neutral" ? [0, 0, 0, 100] : null,
        nativeTransform: [scaleX, 0, 0, scaleY, crop.x, crop.y],
        sourceText: String(item.text || ""),
        sourceRect: { ...rect },
        artworkIsolated: true,
        outlinePreserved: true,
        originalGlyphTrace: true,
        semanticInkFamily: family?.id || "",
        sourceModel,
        visible: true
      }, translatedSubpaths);
    };
    // Each OCR crop determines its own boundary background and foreground
    // polarity. This is essential for both black-on-white copy and white-on-
    // black packaging: absolute "dark pixel = ink" thresholds turn a black
    // carton into one giant glyph and leave only broken white counters.
    // Foreground colours are traced at the midpoint of their antialiased edge
    // with pixel-fit geometry. OCR still supplies coordinates only.
    const familyFailures = [];
    let foregroundPixelCount = 0;
    const foregroundMasks = originalForegroundInkMasks(crop.imageData);
    for (const mask of foregroundMasks) {
      try {
        if (mask.pixels < 4) continue;
        foregroundPixelCount += Number(mask.pixels || 0);
        const traced = await traceOriginalForegroundMask(mask, {
          ...baseOptions,
          speckle: 0
        });
        const local = vtracerSvgToResult(
          traced.svg,
          mask.imageData,
          traced.traceOptions,
          traced.traceInfo || {}
        );
        const inkFamily = {
          id: mask.id,
          traceColor: mask.color,
          spotName: "",
          contrast: mask.contrast
        };
        (local.layers || []).forEach((layer, layerIndex) => {
          const translated = translateLayer(
            layer,
            layerIndex,
            inkFamily,
            crop.fullResolution === false
              ? `bounded-full-source-${traced.sourceModel}`
              : `full-resolution-${traced.sourceModel}`
          );
          if (translated) layers.push(translated);
        });
      } catch (foregroundError) {
        familyFailures.push({ family: mask.id, error: foregroundError });
        console.warn(`Original glyph ${mask.id} ink skipped`, foregroundError);
      }
    }
    if (!layers.length && foregroundPixelCount === 0) {
      // Vision occasionally labels a bare dieline segment or registration
      // mark as text. The semantic masks erase those rules, so an empty
      // remainder is a verified OCR false positive rather than missing copy.
      const emptyRegion = new Error("OCR 区域只有结构线，没有可恢复文字");
      emptyRegion.code = "VECTOR_EMPTY_OCR_REGION";
      throw emptyRegion;
    }
    if (!layers.length) {
      const tracer = window.JianglaiProfessionalTracer;
      if (!tracer?.trace) throw new Error("原字形高保真曲线引擎尚未加载");
      const fallbackOptions = {
        ...baseOptions,
        colors: extremeFidelity ? 4 : 3,
        simplify: extremeFidelity ? .04 : .08,
        smoothing: extremeFidelity ? .92 : .82
      };
      const traced = await tracer.trace(crop.imageData, fallbackOptions);
      const local = vtracerSvgToResult(traced.svg, crop.imageData, fallbackOptions, traced);
      (local.layers || []).forEach((layer, layerIndex) => {
        const translated = translateLayer(
          layer,
          layerIndex,
          null,
          crop.fullResolution === false
            ? "bounded-full-source-original-glyph-outline"
            : "full-resolution-original-glyph-outline"
        );
        if (translated) layers.push(translated);
      });
    }
    const pathCount = layers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0);
    const nodeCount = layers.reduce((sum, layer) => sum + Number(layer.nodes || 0), 0);
    if (!layers.length || !pathCount || (pathCount > 1200 && nodeCount / pathCount < 5)) {
      throw new Error("原字形区域仍然过于碎片化");
    }
    return {
      rect,
      layers,
      originalGlyph: true,
      familyFailures,
      preserveBackgroundColor: Array.isArray(crop.background) ? [...crop.background] : null
    };
  }

  async function traceIsolatedArtworkRegion(item, serial) {
    const rect = paddedAutomaticTextRect(item.rect, .38, .08);
    const crop = cropImageData(state.imageData, rect);
    const multicolor = textRectHasMultipleInkColors(rect);
    const traced = engineApi().vectorizePixels(crop.imageData, {
      mode: multicolor ? "illustration" : "logo",
      colors: multicolor ? 8 : 3,
      simplify: multicolor ? .72 : .92,
      smoothing: multicolor ? .78 : .62,
      speckle: Math.max(2, Math.round(Math.min(crop.width, crop.height) * .012)),
      removeBackground: true,
      backgroundTolerance: 30,
      alphaThreshold: 18,
      maxPaths: 4200,
      maxNodes: 90000,
      title: "Logo与艺术字独立描摹"
    });
    const translated = engineApi().translateResult(traced, crop.x, crop.y, {
      idPrefix: `isolated-artwork-${state.editor.revision + 1}-${serial}`,
      source: "isolated-logo-artwork-trace"
    });
    const preserveReason = String(item.preserveReason || "");
    const artworkRole = item.replaceMode === "artwork"
      ? "artwork"
      : item.replaceMode === "logo"
        ? "logo"
        : ["likely-brand-artwork", "short-title"].includes(preserveReason)
          ? "logo"
          : "artwork";
    const layers = (translated.layers || []).filter(layer => !isStructureLayer(layer)).map((layer, layerIndex) => rebuildGeometry({
      ...layer,
      id: `isolated-artwork-layer-${state.editor.revision + 1}-${serial}-${layerIndex + 1}`,
      name: `${artworkRole === "logo" ? "Logo / 艺术字" : "装饰图形"} · ${String(item.text || "").slice(0, 24) || serial}`,
      role: artworkRole,
      sourceText: String(item.text || ""),
      sourceRect: { ...rect },
      artworkIsolated: true,
      visible: true
    }));
    const pathCount = layers.reduce((sum, layer) => sum + Number(layer.paths || 0), 0);
    const nodeCount = layers.reduce((sum, layer) => sum + Number(layer.nodes || 0), 0);
    if (!layers.length || !pathCount || (pathCount > 900 && nodeCount / pathCount < 5)) {
      throw new Error("艺术字区域仍然过于碎片化");
    }
    return { rect, layers };
  }

  function isolatedRegionRole(item) {
    const preserveReason = String(item?.preserveReason || "");
    if (item?.replaceMode === "outline" || preserveReason === "font-mismatch") return "text";
    if (item?.replaceMode === "artwork") return "artwork";
    if (item?.replaceMode === "logo") return "logo";
    if (item?.replaceMode === "standard") return "text";
    return ["likely-brand-artwork", "short-title"].includes(preserveReason) ? "logo" : "artwork";
  }

  function contourBelongsToRegion(contour, rect) {
    // 大底板可能覆盖文字区域，但绝不能因中心点碰巧位于 OCR 框内而被移走。
    return geometryBoxBelongsToRegion(contour?.bbox, rect, .55);
  }

  function isolateExistingArtworkRegion(item, serial) {
    const rect = paddedAutomaticTextRect(item.rect, .2, .025);
    const role = isolatedRegionRole(item);
    const isolated = [];
    let moved = 0;
    const retainedLayers = [];
    for (const layer of state.result.layers || []) {
      if (
        isStructureLayer(layer)
        || layer?.artworkIsolated === true
      ) {
        retainedLayers.push(layer);
        continue;
      }
      if (Array.isArray(layer?.nativeSubpaths) && layer.nativeSubpaths.length) {
        const selectedSubpaths = layer.nativeSubpaths.filter(item => geometryBoxBelongsToRegion(item.bbox, rect, .55));
        if (!selectedSubpaths.length) {
          retainedLayers.push(layer);
          continue;
        }
        const remainingSubpaths = layer.nativeSubpaths.filter(item => !selectedSubpaths.includes(item));
        const remaining = nativeLayerWithSubpaths(layer, remainingSubpaths);
        if (remaining) retainedLayers.push(remaining);
        const isolatedLayer = nativeLayerWithSubpaths({
          ...layer,
          id: `isolated-source-layer-${state.editor.revision + 1}-${serial}-${isolated.length + 1}`,
          name: `${role === "text" ? "原字形文字" : role === "logo" ? "Logo / 艺术字" : "装饰图形"} · ${String(item.text || "").slice(0, 24) || serial}`,
          role,
          sourceText: String(item.text || ""),
          sourceRect: { ...rect },
          artworkIsolated: true,
          outlinePreserved: true,
          visible: true
        }, selectedSubpaths);
        if (isolatedLayer) {
          moved += selectedSubpaths.length;
          isolated.push(isolatedLayer);
        }
        continue;
      }
      if (!Array.isArray(layer?.contours) || !layer.contours.length) {
        retainedLayers.push(layer);
        continue;
      }
      const selectedContours = layer.contours.filter(contour => contourBelongsToRegion(contour, rect));
      if (!selectedContours.length) {
        retainedLayers.push(layer);
        continue;
      }
      const remainingContours = layer.contours.filter(contour => !selectedContours.includes(contour));
      if (remainingContours.length) retainedLayers.push(rebuildGeometry({ ...layer, contours: remainingContours }));
      moved += selectedContours.length;
      isolated.push(rebuildGeometry({
        ...layer,
        id: `isolated-source-layer-${state.editor.revision + 1}-${serial}-${isolated.length + 1}`,
        name: `${role === "text" ? "原字形文字" : role === "logo" ? "Logo / 艺术字" : "装饰图形"} · ${String(item.text || "").slice(0, 24) || serial}`,
        role,
        contours: selectedContours,
        strokes: [],
        sourceText: String(item.text || ""),
        sourceRect: { ...rect },
        artworkIsolated: true,
        outlinePreserved: true,
        visible: true
      }));
    }
    if (!isolated.length || !moved) throw new Error("现有矢量层中没有找到该区域的完整轮廓");
    state.result.layers = retainedLayers.concat(isolated);
    return { rect, layers: isolated, moved, sourcePreserved: true };
  }

  async function rebuildPreservedArtworkRegions(items, progress = () => {}) {
    const candidates = distinctArtworkCandidates(items);
    if (!candidates.length) return { count: 0, layerCount: 0, textCount: 0, failed: 0, ignored: 0, removed: 0 };
    const before = {
      layers: cloneJson(state.result.layers),
      layerState: cloneJson(state.layerState),
      commercialTextRebuilt: state.commercialTextRebuilt,
      rebuiltTextRects: cloneJson(state.rebuiltTextRects)
    };
    const rebuilt = [];
    let failed = 0;
    let ignored = 0;
    let removed = 0;
    let moved = 0;
    for (let index = 0; index < candidates.length; index += 1) {
      try {
        const originalGlyphCandidate = candidates[index]?.replaceMode === "outline"
          || isolatedRegionRole(candidates[index]) === "text"
          || (String(candidates[index]?.text || "").trim() && !textRectHasMultipleInkColors(candidates[index].rect));
        if (originalGlyphCandidate) {
          const traced = await traceOriginalGlyphRegion(candidates[index], index + 1);
          removed += removeContoursInsideTextRect(traced.rect, {
            preserveBackgroundColor: traced.preserveBackgroundColor
          });
          state.result.layers.push(...traced.layers);
          rebuilt.push(traced);
        } else {
          const preserved = isolateExistingArtworkRegion(candidates[index], index + 1);
          rebuilt.push(preserved);
          moved += Number(preserved.moved || 0);
        }
      } catch (preserveError) {
        if (preserveError?.code === "VECTOR_EMPTY_OCR_REGION") {
          ignored += 1;
          console.info("Ignored empty OCR structure candidate", {
            text: String(candidates[index]?.text || ""),
            rect: candidates[index]?.rect
          });
          progress(index + 1, candidates.length);
          await yieldToRenderer();
          continue;
        }
        try {
          const traced = await traceIsolatedArtworkRegion(candidates[index], index + 1);
          removed += removeContoursInsideTextRect(traced.rect);
          state.result.layers.push(...traced.layers);
          rebuilt.push(traced);
        } catch (traceError) {
          failed += 1;
          console.warn("Isolated artwork preservation failed", {
            text: String(candidates[index]?.text || ""),
            rect: candidates[index]?.rect
          }, preserveError, traceError);
        }
      }
      progress(index + 1, candidates.length);
      await yieldToRenderer();
    }
    const layers = rebuilt.flatMap(item => item.layers);
    if (layers.length) {
      layers.forEach(layer => {
        state.layerState[layer.id] = { color: layer.color, visible: true };
      });
      recalculateDocument();
      const after = {
        layers: cloneJson(state.result.layers),
        layerState: cloneJson(state.layerState),
        commercialTextRebuilt: state.commercialTextRebuilt,
        rebuiltTextRects: cloneJson(state.rebuiltTextRects)
      };
      recordHistory({ kind: "document", before, after });
    }
    return {
      count: rebuilt.length,
      layerCount: layers.length,
      textCount: rebuilt.filter(item => item.layers.some(layer => String(layer?.role || "") === "text")).length,
      failed,
      ignored,
      removed,
      moved
    };
  }

  function refineAutomaticTextRect(rect) {
    const imageWidth = Number(state.imageData?.width || 0);
    const imageHeight = Number(state.imageData?.height || 0);
    const padX = Math.max(2, rect.width * .06);
    const padY = Math.max(2, rect.height * .35);
    const expanded = {
      x: Math.max(0, rect.x - padX),
      y: Math.max(0, rect.y - padY),
      width: Math.min(imageWidth, rect.x + rect.width + padX) - Math.max(0, rect.x - padX),
      height: Math.min(imageHeight, rect.y + rect.height + padY) - Math.max(0, rect.y - padY)
    };
    const candidates = detectSelectedTextLineBoxes(expanded);
    if (!candidates.length) return selectedInkBounds(expanded) || rect;
    const targetX = rect.x + rect.width / 2;
    const targetY = rect.y + rect.height / 2;
    return [...candidates].sort((first, second) => {
      const firstDistance = Math.hypot(first.x + first.width / 2 - targetX, first.y + first.height / 2 - targetY);
      const secondDistance = Math.hypot(second.x + second.width / 2 - targetX, second.y + second.height / 2 - targetY);
      return firstDistance - secondDistance;
    })[0];
  }

  function paddedAutomaticTextRect(rect, verticalFactor = .32, horizontalFactor = .035) {
    const imageWidth = Number(state.imageData?.width || 0);
    const imageHeight = Number(state.imageData?.height || 0);
    const padX = Math.max(2, rect.width * horizontalFactor);
    const padY = Math.max(2, rect.height * verticalFactor);
    const left = Math.max(0, rect.x - padX);
    const top = Math.max(0, rect.y - padY);
    const right = Math.min(imageWidth, rect.x + rect.width + padX);
    const bottom = Math.min(imageHeight, rect.y + rect.height + padY);
    return { x: left, y: top, width: right - left, height: bottom - top };
  }

  async function runAutomaticTextReplacement(options = {}) {
    if (!state.result || !state.imageData || !state.file) { notifyUser("请先上传图片并生成矢量图"); return { ok: false }; }
    if (state.textRebuildApplying) return { ok: false };
    if (typeof window.__vectorTextLayoutBridge !== "function") {
      const error = new Error("当前版本未连接自动文字定位服务，请重新启动软件后再试");
      if (options.throwOnError) throw error;
      notifyUser(error.message);
      return { ok: false, error };
    }
    const consent = byId("jlVectorOcrConsent");
    if (!consent?.checked) {
      const accepted = options.consentGranted === true || window.confirm("系统会优先在本机识别整张图片的文字和位置；只有本机无法识别时才临时调用云端，完成后立即删除临时文件。只恢复高置信度的普通说明文字，艺术字、Logo、竖排字和不确定文字保持原样。是否继续？");
      if (!accepted) return { ok: false, cancelled: true };
      consent.checked = true;
    }
    const button = byId("jlVectorAutoReplaceText");
    state.textRebuildApplying = true;
    if (button) { button.disabled = true; button.textContent = "正在识别普通文字…"; }
    updateEditButtons();
    setProcessing(true, "第 1/4 步 · 正在识别全部文字…", "优先读取完整原稿；精准模式会逐行定位，不因耗时提前截断。", .1);
    try {
      const layoutStartedAt = Date.now();
      const layoutProgressTimer = window.setInterval(() => {
        const elapsedSeconds = Math.max(1, Math.round((Date.now() - layoutStartedAt) / 1000));
        const waitingProgress = .1 + Math.min(.09, elapsedSeconds / 180 * .09);
        setProcessing(
          true,
          "第 1/4 步 · 正在识别并核对全部文字…",
          `本机 OCR 已启动，正在结合完整原稿核对行位置与文字类别 · 已处理 ${elapsedSeconds} 秒`,
          waitingProgress
        );
      }, 1000);
      let layout;
      try {
        layout = await window.__vectorTextLayoutBridge(state.file);
      } finally {
        window.clearInterval(layoutProgressTimer);
      }
      const detected = normalizeAutomaticTextLayout(layout);
      if (!detected.length) throw new Error("没有定位到可自动替换的文字，请确认图片清晰后重试");
      const selection = safeAutomaticTextCandidates(detected);
      if (state.outputKind === "professional-reconstruction" && state.qualityGoal === "fidelity") {
        // Commercial packaging must retain the customer's exact typography.
        // OCR supplies geometry only; it must never reflow copy with a merely
        // similar local font.  Trace every located line from the full-resolution
        // source at its original coordinates, then remove the matching paths
        // from the full-page semantic separation before inserting it.
        const commercialOutlineItems = selection.accepted.concat(selection.preserved).map(item => ({
          ...item,
          replaceMode: "outline",
          preserveReason: "commercial-original-glyph"
        }));
        setProcessing(
          true,
          `第 2/3 步 · 原稿字形精细转曲 0/${commercialOutlineItems.length}`,
          "OCR 只用于定位；不换字体、不重排文字，正在从完整原稿按原坐标提取字形。",
          .2
        );
        const artwork = await rebuildPreservedArtworkRegions(commercialOutlineItems, (done, total) => {
          if (button) button.textContent = `原字形转曲 ${done}/${total}`;
          setProcessing(
            true,
            `第 2/3 步 · 原稿字形精细转曲 ${done}/${total}`,
            "逐行读取完整分辨率原稿，保留原字体、字号、字距、颜色和位置。",
            .2 + done / Math.max(1, total) * .7
          );
        });
        state.commercialTextRebuilt = artwork.count > 0 && artwork.failed === 0;
        state.reconstructionReviewRequired = artwork.failed > 0;
        state.result.quality = {
          ...(state.result.quality || {}),
          reconstructionTextLines: artwork.textCount,
          reconstructionOcrDetected: detected.length,
          reconstructionOcrAccepted: selection.accepted.length,
          reconstructionOcrProvider: String(layout?.provider || ""),
          reconstructionArtworkRegions: artwork.count,
          isolatedArtworkLayers: artwork.layerCount,
          preservedArtworkText: true,
          textPreservationMode: "full-resolution-original-glyph-outline",
          reviewRequired: artwork.failed > 0
        };
        const acceptance = evaluateProfessionalReconstruction(state.result, state.sourceAnalysis);
        state.result.quality = {
          ...(state.result.quality || {}),
          productionReady: Boolean(acceptance.productionReady && artwork.failed === 0),
          reconstructionDraft: !acceptance.productionReady || artwork.failed > 0,
          reconstructionStructureReady: Boolean(acceptance.structureReady),
          reconstructionArtworkReady: Boolean(acceptance.artworkReady && artwork.failed === 0),
          reconstructionFailureReason: artwork.failed
            ? `${artwork.failed} 处原字形需要人工核对`
            : acceptance.reason || ""
        };
        refreshSvg();
        renderPalette();
        renderMetrics();
        setEditorTool("browse");
        state.compare = 50;
        setPreviewMode("compare");
        updateCompare();
        const note = byId("jlVectorAutoTextNote");
        const resultLabel = `已按完整原稿原坐标转曲 ${artwork.count} 处文字 / Logo`;
        if (note) note.textContent = `${resultLabel}${artwork.failed ? `；${artwork.failed} 处未通过自动核对` : ""}。OCR 只负责定位，未替换字体、未重排文字。`;
        setStatus(
          `${resultLabel} · 未换字体、未重排${artwork.failed ? ` · ${artwork.failed} 处待核对` : ""}；请拖动中线核对。`,
          artwork.failed ? "warning" : ""
        );
        notifyUser(resultLabel);
        setProcessing(true, "第 3/3 步 · 原字形重建完成", "文字与 Logo 已按客户原稿位置独立转曲，刀线和折线仍保持独立生产层。", 1);
        await yieldToRenderer();
        return {
          ok: artwork.count > 0,
          count: artwork.textCount,
          preservedCount: commercialOutlineItems.length,
          artworkCount: artwork.count,
          artworkLayerCount: artwork.layerCount,
          artworkFailed: artwork.failed,
          artworkIgnored: artwork.ignored,
          specs: [],
          removed: artwork.removed,
          detectedCount: detected.length,
          acceptedCount: selection.accepted.length,
          provider: String(layout?.provider || "")
        };
      }
      // Manual/non-packaging reconstruction can still use OCR content and an
      // explicitly chosen or measured local font.
      const outlineFallback = [];
      const specs = [];
      const rebuildItems = selection.accepted.flatMap(splitMixedWeightTextItem);
      setProcessing(
        true,
        `第 2/4 步 · 极致字体匹配 0/${rebuildItems.length}`,
        `已识别 ${detected.length} 行；将对每行完整比较本机字体库，再对高分字体做高分辨率复核。`,
        .2
      );
      await yieldToRenderer();
      for (let itemIndex = 0; itemIndex < rebuildItems.length; itemIndex += 1) {
        const item = rebuildItems[itemIndex];
        // 专业整版重建必须直接使用系统返回的整行坐标。再次按像素连通域
        // 收紧会把大标题拆成单个字块，造成字体被错误放大和多行重叠。
        const rect = state.outputKind === "professional-reconstruction"
          ? { ...item.rect }
          : refineAutomaticTextRect(item.rect);
        const match = await closestTextFont(item.text, rect, (done, total, phase) => {
          const phaseRatio = phase === "全字体粗筛" ? (done / Math.max(1, total)) * .78 : .78 + (done / Math.max(1, total)) * .22;
          const overall = (itemIndex + phaseRatio) / Math.max(1, rebuildItems.length);
          const percent = .2 + overall * .5;
          if (button) button.textContent = `字体精配 ${itemIndex + 1}/${rebuildItems.length}`;
          setProcessing(
            true,
            `第 2/4 步 · 极致字体匹配 ${itemIndex + 1}/${rebuildItems.length}`,
            `${phase}：已比较 ${done}/${total} 个字体；当前行“${item.text.slice(0, 34)}${item.text.length > 34 ? "…" : ""}”`,
            percent
          );
        });
        // OCR candidate selection has already excluded logos, artistic type,
        // rotated labels and unsafe regions. For accepted ordinary copy,
        // prioritize clean native glyph curves instead of retaining damaged
        // raster edges. Keep a stricter threshold outside professional
        // packaging; inside it, long/explicit standard copy can use the best
        // measured local font even when the flattened source is noisy.
        const clearBodyCopy = item.replaceMode === "standard"
          || item.text.length >= 12
          || /[,.;:，。；：]/.test(item.text);
        const extremeFidelity = state.qualityGoal === "fidelity";
        const matchFloor = state.outputKind === "professional-reconstruction"
          ? (extremeFidelity ? (clearBodyCopy ? .46 : .58) : (clearBodyCopy ? .24 : .44))
          : (extremeFidelity ? .78 : .72);
        if (match.score < matchFloor) {
          outlineFallback.push({
            ...item,
            rect,
            replaceMode: "outline",
            preserveReason: "font-mismatch",
            fontMatchScore: match.score
          });
        } else {
          specs.push({
            text: item.text,
            font: match.font,
            fontMatchScore: match.score,
            candidatesCompared: match.candidatesCompared,
            finalistsCompared: match.finalistsCompared,
            size: Math.max(8, Math.min(300, Math.round(rect.height * .9))),
            color: sampleSelectedTextColor(rect),
            rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
          });
        }
        await yieldToRenderer();
      }
      const preservedItems = selection.preserved.concat(outlineFallback);
      if (!specs.length) {
        const artwork = await rebuildPreservedArtworkRegions(preservedItems, (done, total) => {
          setProcessing(
            true,
            `第 4/4 步 · 原字形精细转曲 ${done}/${total}`,
            "没有足够接近的本机字体，正在以原稿字形和抗锯齿边缘生成高精度曲线。",
            .7 + done / Math.max(1, total) * .29
          );
        });
        if (artwork.count) {
          state.commercialTextRebuilt = artwork.failed === 0;
          state.reconstructionReviewRequired = true;
          state.result.quality = {
            ...(state.result.quality || {}),
            reconstructionTextLines: artwork.textCount,
            reconstructionOcrDetected: detected.length,
            reconstructionOcrAccepted: selection.accepted.length,
            reconstructionOcrProvider: String(layout?.provider || ""),
            reconstructionArtworkRegions: artwork.count,
            isolatedArtworkLayers: artwork.layerCount,
            preservedArtworkText: true,
            reviewRequired: true
          };
          refreshSvg();
          renderPalette();
          renderMetrics();
        }
        const note = byId("jlVectorAutoTextNote");
        const preservedLabel = `已把 ${artwork.count} 处文字、Logo 或图形按客户原字形转为独立曲线层`;
        if (note) note.textContent = `${preservedLabel}${artwork.moved ? `，保留 ${artwork.moved} 条原始轮廓` : ""}${artwork.failed ? `；另有 ${artwork.failed} 处需要人工核对` : ""}。`;
        setStatus(`${preservedLabel}${artwork.failed ? ` · ${artwork.failed} 处待核对` : ""}`, artwork.failed ? "warning" : "");
        notifyUser(artwork.count ? preservedLabel : "没有安全可分层的文字或图形");
        return {
          ok: true, count: artwork.textCount, preservedCount: preservedItems.length, artworkCount: artwork.count,
          artworkLayerCount: artwork.layerCount, artworkFailed: artwork.failed, specs: [],
          detectedCount: detected.length, acceptedCount: selection.accepted.length, provider: String(layout?.provider || "")
        };
      }
      const eraseRects = specs.map(spec => state.outputKind === "professional-reconstruction"
        ? paddedAutomaticTextRect(spec.rect)
        : {
          x: Math.max(0, spec.rect.x - 2),
          y: Math.max(0, spec.rect.y - 2),
          width: Math.min(state.result.width, spec.rect.x + spec.rect.width + 2) - Math.max(0, spec.rect.x - 2),
          height: Math.min(state.result.height, spec.rect.y + spec.rect.height + 2) - Math.max(0, spec.rect.y - 2)
        });
      setProcessing(true, `第 3/4 步 · 生成原生字体曲线 0/${specs.length}`, "逐行读取真实字体文件，保留原始 Bézier，不进行像素描边。", .7);
      const result = await replaceSelectedTextSpecs(specs, eraseRects, (done, total) => {
        setProcessing(
          true,
          `第 3/4 步 · 生成原生字体曲线 ${done}/${total}`,
          "逐行读取真实字体文件，保留原始 Bézier，不进行像素描边。",
          .7 + done / Math.max(1, total) * .15
        );
      });
      const artwork = await rebuildPreservedArtworkRegions(preservedItems, (done, total) => {
        setProcessing(
          true,
          `第 4/4 步 · 原字形精细转曲 ${done}/${total}`,
          "Logo、艺术字和字体匹配不足的文字正在按原稿字形独立转曲。",
          .85 + done / Math.max(1, total) * .14
        );
      });
      const primary = [...specs].sort((a, b) => b.rect.height - a.rect.height)[0];
      byId("jlVectorOcrText").value = specs.map(spec => spec.text).join("\n").slice(0, 500);
      ensureFontOption(primary.font);
      byId("jlVectorTextSize").value = String(primary.size);
      byId("jlVectorTextColor").value = primary.color;
      renderTextRebuildPreview();
      setEditorTool("browse");
      state.compare = 50;
      setPreviewMode("compare");
      updateCompare();
      const note = byId("jlVectorAutoTextNote");
      const curveNote = result.fallbackCount
        ? `；其中 ${result.fallbackCount} 行未读取到字体文件，已使用兼容转曲`
        : "；全部使用字体原生贝塞尔曲线，不再沿像素描边";
      if (state.outputKind === "professional-reconstruction") {
        state.commercialTextRebuilt = true;
        state.reconstructionReviewRequired = true;
        state.result.quality = {
          ...(state.result.quality || {}),
          reconstructionTextLines: specs.length + artwork.textCount,
          reconstructionOcrDetected: detected.length,
          reconstructionOcrAccepted: selection.accepted.length,
          reconstructionOcrProvider: String(layout?.provider || ""),
          reconstructionArtworkRegions: artwork.count,
          isolatedArtworkLayers: artwork.layerCount,
          preservedArtworkText: true,
          reviewRequired: true
        };
        const acceptance = evaluateProfessionalReconstruction(state.result, state.sourceAnalysis);
        state.result.quality = {
          ...(state.result.quality || {}),
          productionReady: Boolean(acceptance.productionReady),
          reconstructionDraft: !acceptance.productionReady,
          reconstructionStructureReady: Boolean(acceptance.structureReady),
          reconstructionArtworkReady: Boolean(acceptance.artworkReady),
          reconstructionFailureReason: acceptance.reason || ""
        };
        refreshSvg();
        renderMetrics();
        if (state.result.quality.productionReady && options.deferCharge !== true) {
          await chargeVectorResultOnce();
        }
      }
      if (note) note.textContent = `已恢复 ${specs.length} 行高匹配普通文字；另有 ${artwork.count} 处文字 / Logo 按客户原字形转曲${artwork.failed ? `，${artwork.failed} 处待人工核对` : ""}${result.removed + artwork.removed ? `，清理 ${result.removed + artwork.removed} 条旧碎片路径` : ""}${curveNote}。`;
      setStatus(`高匹配普通文字 ${specs.length} 行已重排 · 原字形文字 / Logo ${artwork.count} 处已转曲 · 刀线与折线独立分层${curveNote}；请拖动中线核对。`, result.fallbackCount || artwork.failed ? "warning" : "");
      notifyUser(`已恢复 ${specs.length} 行高匹配文字，并按原字形转曲 ${artwork.count} 处文字 / Logo`);
      setProcessing(true, "极致精细重建完成", "所有文字、Logo、刀线和折线均已完成分层，可滑动中线逐项核对。", 1);
      await yieldToRenderer();
      return {
        ok: true, count: specs.length + artwork.textCount, preservedCount: preservedItems.length,
        artworkCount: artwork.count, artworkLayerCount: artwork.layerCount, artworkFailed: artwork.failed,
        specs, removed: result.removed + artwork.removed, fallbackCount: result.fallbackCount,
        detectedCount: detected.length, acceptedCount: selection.accepted.length,
        provider: String(layout?.provider || "")
      };
    } catch (error) {
      // 普通文字恢复是可选增强，失败时不得降级或锁住已经生成的矢量稿。
      // 尤其不能因为 OCR / 云服务暂不可用而把已保留的艺术字轮廓判成废稿。
      notifyUser(error.message);
      setStatus(`普通文字恢复未完成：${error.message}；当前矢量结果和艺术字原轮廓均已保留。`, "warning");
      if (options.throwOnError) throw error;
      return { ok: false, error };
    } finally {
      state.textRebuildApplying = false;
      setProcessing(false);
      if (button) { button.disabled = false; button.textContent = "重新识别并分层文字 / Logo"; }
      updateEditButtons();
    }
  }

  async function applySelectedTextRebuild() {
    const rect = state.editor.retraceRect;
    if (!state.result || !state.imageData) { notifyUser("请先生成矢量图"); return; }
    if (!rect || rect.width < 3 || rect.height < 3) { notifyUser("请先用“选区文字重建”框住一行或一组文字"); return; }
    if (state.textRebuildApplying) return;
    if (typeof window.__vectorOcrBridge !== "function") {
      notifyUser("当前版本未连接选区识字服务；可以在右侧输入文字和字体后点击替换");
      return;
    }
    const consent = byId("jlVectorOcrConsent");
    if (!consent?.checked) {
      const accepted = window.confirm("系统会优先在本机识别框选区域；只有本机无法识别时才临时调用云端，完成后立即删除临时文件。识别结果会用最接近的本机字体替换。生产前仍需核对字体授权。是否继续？");
      if (!accepted) { setStatus("已取消选区识字，框选区域仍然保留。", "warning"); return; }
      consent.checked = true;
    }
    state.textRebuildApplying = true;
    updateEditButtons();
    setProcessing(true, "正在识别框选文字…", "只处理框选区域：识字、完整字体库匹配、生成原生曲线。", .05);
    try {
      const { file } = await selectedTextFile(rect);
      const response = await window.__vectorOcrBridge(file);
      if (response?.unavailable === true || response?.ok === false) {
        throw new Error(String(response?.message || "选区文字识别服务暂时不可用，请稍后原地重试"));
      }
      const text = String(response?.text || response || "").trim().slice(0, 300);
      if (!text) throw new Error("框选区域没有识别到清晰文字，请缩小选区只保留文字后再试");
      let lineBoxes = detectSelectedTextLineBoxes(rect);
      if (!lineBoxes.length) lineBoxes = [selectedInkBounds(rect) || { ...rect }];
      let mapped = mapRecognizedTextToBoxes(text, lineBoxes);
      if (!mapped.length) mapped = [{ text: text.replace(/\s+/g, " "), box: selectedInkBounds(rect) || { ...rect } }];
      const specs = [];
      for (let index = 0; index < mapped.length; index += 1) {
        const item = mapped[index];
        const match = await closestTextFont(item.text, item.box, (done, total, phase) => {
          const phaseRatio = phase === "全字体粗筛" ? done / Math.max(1, total) * .78 : .78 + done / Math.max(1, total) * .22;
          setProcessing(
            true,
            `正在精配框选文字 ${index + 1}/${mapped.length}`,
            `${phase}：${done}/${total} 个字体`,
            .1 + (index + phaseRatio) / Math.max(1, mapped.length) * .68
          );
        });
        specs.push({
          text: item.text,
          font: match.font,
          size: Math.max(8, Math.min(300, Math.round(item.box.height * .9))),
          color: sampleSelectedTextColor(item.box),
          rect: { x: item.box.x, y: item.box.y, width: item.box.width, height: item.box.height }
        });
      }
      const primary = [...specs].sort((a, b) => b.rect.height - a.rect.height)[0];
      byId("jlVectorOcrText").value = text;
      ensureFontOption(primary.font);
      byId("jlVectorTextSize").value = String(primary.size);
      byId("jlVectorTextColor").value = primary.color;
      renderTextRebuildPreview();
      const result = await replaceSelectedTextSpecs(specs, { ...rect }, (done, total) => {
        setProcessing(
          true,
          `正在生成字体原生曲线 ${done}/${total}`,
          "直接读取字体 Bézier 数据，不进行像素描边。",
          .78 + done / Math.max(1, total) * .21
        );
      });
      const fontName = primary.font.split(",")[0].replace(/["']/g, "");
      setStatus(`已按原稿的 ${specs.length} 行位置、大小和颜色重建文字 · 主字体 ${fontName}${result.removed ? ` · 替换 ${result.removed} 条像素轮廓` : ""}${result.fallbackCount ? ` · ${result.fallbackCount} 行使用兼容转曲` : " · 已使用字体原生贝塞尔曲线"}；请用滑动对比核对。`, result.fallbackCount ? "warning" : "");
      notifyUser("框选文字已恢复为可编辑的字体轮廓");
      byId("jlVectorOcrText")?.focus({ preventScroll: true });
    } catch (error) {
      notifyUser(error.message);
      setStatus(`选区文字重建失败：${error.message}；框选区域已保留，可原地重试。`, "error");
    } finally {
      state.textRebuildApplying = false;
      setProcessing(false);
      updateEditButtons();
    }
  }

  function renderTextRebuildPreview() {
    const preview = byId("jlVectorTextPreview");
    if (!preview) return;
    const text = String(byId("jlVectorOcrText")?.value || "").trim();
    const font = byId("jlVectorTextFont")?.value || "serif";
    const size = Math.max(8, Math.min(300, Number(byId("jlVectorTextSize")?.value || 48)));
    preview.textContent = text || "freddi eyewear / 输入文字后预览";
    preview.style.fontFamily = font;
    preview.style.fontSize = `${Math.max(18, Math.min(54, size * .7))}px`;
    preview.style.color = byId("jlVectorTextColor")?.value || "#ffffff";
  }

  async function convertTextToOutlines() {
    if (!state.result) { notifyUser("请先生成矢量图，再加入文字路径"); return; }
    const text = String(byId("jlVectorOcrText")?.value || "").trim();
    if (!text) { notifyUser("请先识别或输入需要转曲的文字"); return; }
    if (!byId("jlVectorFontLicense")?.checked) { notifyUser("请先确认拥有所选字体的使用权"); return; }
    if (text.length > 300) { notifyUser("单次转曲最多 300 个字符，请分段处理"); return; }
    try {
      const font = byId("jlVectorTextFont")?.value || "sans-serif";
      const size = Math.max(8, Math.min(300, Number(byId("jlVectorTextSize")?.value || 48)));
      const color = byId("jlVectorTextColor")?.value || "#ffffff";
      const targetRect = state.editor.retraceRect && state.editor.retraceRect.width >= 3 && state.editor.retraceRect.height >= 3
        ? { ...state.editor.retraceRect }
        : state.editor.lastTextRect && state.editor.lastTextRect.width >= 3 && state.editor.lastTextRect.height >= 3
          ? { ...state.editor.lastTextRect }
          : null;
      if (!targetRect) throw new Error("请先用“选区文字重建”框住需要替换的文字");
      let result;
      if (!state.editor.retraceRect && state.editor.lastTextSpecs.length > 1) {
        const previous = state.editor.lastTextSpecs;
        const mapped = mapRecognizedTextToBoxes(text, previous.map(spec => spec.rect));
        const corrected = mapped.map((item, index) => ({
          ...previous[index], text: item.text, rect: item.box,
          font: index === 0 ? font : previous[index].font,
          color: index === 0 ? color : previous[index].color,
          size: index === 0 ? size : previous[index].size
        }));
        result = await replaceSelectedTextSpecs(corrected, targetRect);
      } else {
        result = await replaceSelectedTextWithFont(text, font, size, color, targetRect);
      }
      setStatus(`已用修正后的文字和字体重新替换选区${result.removed ? `，移除 ${result.removed} 条旧轮廓` : ""}${result.fallbackCount ? `；${result.fallbackCount} 行使用兼容转曲` : "；已使用字体原生贝塞尔曲线"}。`, result.fallbackCount ? "warning" : "");
    } catch (error) {
      notifyUser(`文字转曲失败：${error.message}`);
    }
  }

  function updatePhysicalSize() {
    if (state.nativeVector) {
      const widthField = byId("jlVectorWidthMm");
      const heightField = byId("jlVectorHeightMm");
      if (widthField) {
        widthField.readOnly = true;
        widthField.value = state.nativeVector.widthMm || "";
        widthField.placeholder = state.nativeVector.widthMm ? "" : "原文件未声明毫米尺寸";
      }
      if (heightField) heightField.value = state.nativeVector.heightMm || "—";
      return { widthMm: state.nativeVector.widthMm || 0, heightMm: state.nativeVector.heightMm || 0 };
    }
    const width = Number(byId("jlVectorWidthMm")?.value || 0);
    const height = state.result && width > 0 ? width * state.result.height / state.result.width : 0;
    const field = byId("jlVectorHeightMm");
    if (field) field.value = height ? height.toFixed(2) : "—";
    return { widthMm: width, heightMm: height };
  }

  function confirmedProductionSize() {
    const size = updatePhysicalSize();
    if (state.outputKind === "fidelity-preview") {
      notifyUser("当前是保真参考稿，不是全路径生产矢量；请提供 AI/CDR/PDF 原稿或转人工精修");
      setStatus("生产导出已锁定：系统不会把内嵌原图的保真参考稿冒充为可编辑生产矢量。", "error");
      return null;
    }
    if (state.result?.quality?.productionReady === false) {
      notifyUser("当前结果未通过色彩、边缘和碎片质量检查，不能作为生产文件导出；请使用更清晰原图或重新生成");
      setStatus("生产导出已锁定：质量检查未通过，系统不会再把明显失真的结果当作成品。", "error");
      return null;
    }
    if (state.mode === "packaging" && !state.commercialTextRebuilt && detectSmartTextBoxes(state.imageData).length) {
      notifyUser("检测到包装文字或 Logo 尚未做商用级曲线重建；请先点击“商用级文字 / Logo 重建”并放大核对");
      setStatus("生产导出已锁定：文字 / Logo 仍是普通像素描摹路径，未达到商用验收标准。", "error");
      return null;
    }
    if (!Number.isFinite(size.widthMm) || size.widthMm <= 0) {
      notifyUser(state.mode === "packaging" ? "请按客户原稿填写当前页面总宽（mm）" : "请填写正确的成品宽度（mm）");
      return null;
    }
    if (!byId("jlVectorSizeConfirmed")?.checked) {
      notifyUser("请先对照客户原稿确认页面总宽和刀模实际尺寸，再勾选尺寸确认");
      return null;
    }
    if (state.reconstructionReviewRequired && !byId("jlVectorReconstructionConfirmed")?.checked) {
      notifyUser("请先滑动对比原稿与重建稿，核对文字、数字、条码和刀线后再勾选重建稿确认");
      setStatus("生产导出已锁定：专业重建稿尚未完成人工核对。", "error");
      return null;
    }
    return size;
  }

  function setPreviewMode(mode) {
    if (!["original", "result", "compare"].includes(mode)) mode = "compare";
    if (state.nativeVector) mode = "original";
    if (mode !== "original" && !state.result) {
      mode = "original";
      setStatus(`图片已读取 · 请点击“自动生成最优矢量图 · ${VECTOR_POINTS}点”后查看结果`);
    }
    if (["node", "retrace"].includes(state.editor.tool) && mode !== "result") setEditorTool("idle");
    state.previewMode = mode;
    all("[data-vector-view]").forEach(button => button.classList.toggle("is-active", button.dataset.vectorView === mode));
    const source = byId("jlVectorSourceImage");
    const result = byId("jlVectorResultLayer");
    const slider = byId("jlVectorCompare");
    const compareDock = byId("jlVectorCompareDock");
    const line = byId("jlVectorCompareLine");
    if (source) source.style.display = mode === "result" ? "none" : "block";
    if (result) {
      result.style.display = mode === "original" ? "none" : "block";
      const previewBackground = /^#[0-9a-f]{6}$/i.test(String(state.result?.background || ""))
        ? state.result.background
        : "#ffffff";
      // 对比模式的矢量侧必须是独立、不透明的画布。透明 SVG 直接盖在
      // 原图上会把底层原字与上层曲线同时显示，造成并不存在的“双影”。
      result.style.backgroundColor = mode === "compare" ? previewBackground : "transparent";
      result.classList.toggle("is-compare", mode === "compare");
    }
    if (slider) slider.disabled = mode !== "compare";
    if (compareDock) compareDock.hidden = mode !== "compare";
    if (line) line.style.display = mode === "compare" ? "block" : "none";
    renderEditorOverlay();
    updateCompare();
  }

  function updateCompare() {
    const result = byId("jlVectorResultLayer");
    const line = byId("jlVectorCompareLine");
    if (!result || !line) return;
    const percent = Math.max(0, Math.min(100, Number(state.compare || 50)));
    // 原图始终位于底层；只把矢量结果显示在分隔线右侧，确保界面标注的
    // “左原图 / 右矢量图”与用户实际看到的内容一致。
    result.style.clipPath = state.previewMode === "compare" ? `inset(0 0 0 ${percent}%)` : "none";
    line.style.left = `${percent}%`;
  }

  function previewDocumentDimensions() {
    const width = Number(state.sourceWidth || state.result?.outputWidth || state.result?.width || 0);
    const height = Number(state.sourceHeight || state.result?.outputHeight || state.result?.height || 0);
    return width > 0 && height > 0 ? { width, height } : null;
  }

  function previewFitScale() {
    const stage = byId("jlVectorStage");
    const dimensions = previewDocumentDimensions();
    if (!stage || !dimensions) return 0;
    const bounds = stage.getBoundingClientRect();
    if (bounds.width <= 0 || bounds.height <= 0) return 0;
    return Math.min(bounds.width / dimensions.width, bounds.height / dimensions.height);
  }

  function applyView() {
    const artboard = byId("jlVectorArtboard");
    const zoom = Math.max(.25, Math.min(MAX_PREVIEW_ZOOM, Number(state.zoom) || 1));
    if (artboard) {
      // Enlarge the layout itself instead of scaling a fit-sized WebKit layer.
      // This makes the browser decode and paint the full-resolution source at high zoom.
      const offset = (1 - zoom) * 50;
      artboard.style.inset = "auto";
      artboard.style.left = `${offset}%`;
      artboard.style.top = `${offset}%`;
      artboard.style.width = `${zoom * 100}%`;
      artboard.style.height = `${zoom * 100}%`;
      artboard.style.transform = `translate(${state.panX}px, ${state.panY}px)`;
    }
    const value = byId("jlVectorZoomValue");
    const stageValue = byId("jlVectorStageZoomValue");
    const slider = byId("jlVectorZoomSlider");
    const percent = Math.round(zoom * 100);
    if (value) value.textContent = `${percent}%`;
    if (stageValue) {
      stageValue.textContent = `${percent}%`;
      const fitScale = previewFitScale();
      stageValue.title = fitScale > 0 ? `当前约为原图 ${Math.round(fitScale * zoom * 100)}% 像素` : "";
    }
    if (slider && Number(slider.value) !== percent) slider.value = String(percent);
  }

  function resetView() {
    state.zoom = 1; state.panX = 0; state.panY = 0; applyView();
  }

  function setZoom(nextZoom, anchor = null) {
    const stage = byId("jlVectorStage");
    const previous = state.zoom;
    const next = Math.max(.25, Math.min(MAX_PREVIEW_ZOOM, Number(nextZoom) || 1));
    if (stage && anchor && previous > 0) {
      const bounds = stage.getBoundingClientRect();
      const dx = Number(anchor.clientX) - (bounds.left + bounds.width / 2);
      const dy = Number(anchor.clientY) - (bounds.top + bounds.height / 2);
      const ratio = next / previous;
      state.panX = dx - ratio * (dx - state.panX);
      state.panY = dy - ratio * (dy - state.panY);
    }
    state.zoom = next;
    applyView();
  }

  function showActualPixels() {
    const fitScale = previewFitScale();
    if (!(fitScale > 0)) {
      notifyUser("请先放入图片，再查看原始像素");
      return;
    }
    const requestedZoom = 1 / fitScale;
    setZoom(requestedZoom);
    const actualPercent = Math.round(fitScale * state.zoom * 100);
    if (requestedZoom > MAX_PREVIEW_ZOOM) {
      setStatus(`原图过大，已放到预览上限 · 当前约为原始像素 ${actualPercent}%`, "warning");
      return;
    }
    setStatus("已按原图 1:1 像素显示 · 细字请在此比例检查", "warning");
  }

  function changeZoom(delta, anchor = null) {
    setZoom(state.zoom * delta, anchor);
  }

  function setSpacePan(active) {
    state.spacePan = Boolean(active);
    const stage = byId("jlVectorStage");
    stage?.classList.toggle("is-space-pan", state.spacePan);
  }

  function fallbackDownloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url; link.download = filename;
    document.body.appendChild(link); link.click(); link.remove();
    window.setTimeout(() => URL.revokeObjectURL(url), 1500);
  }

  function savePickerTypes(filename, blob) {
    const extension = String(filename || "").split(".").pop()?.toLowerCase() || "";
    const mime = String(blob?.type || "application/octet-stream").split(";")[0];
    if (!extension) return [];
    return [{
      description: `${extension.toUpperCase()} 文件`,
      accept: { [mime || "application/octet-stream"]: [`.${extension}`] }
    }];
  }

  async function saveBlobWithLocation(blob, filename) {
    const safeName = String(filename || "将莱矢量文件.svg").replace(/[\\/:*?"<>|]+/g, "-");
    if (typeof window.showSaveFilePicker === "function" && window.isSecureContext !== false) {
      try {
        const handle = await window.showSaveFilePicker({
          suggestedName: safeName,
          types: savePickerTypes(safeName, blob),
          excludeAcceptAllOption: false
        });
        const writable = await handle.createWritable();
        await writable.write(blob);
        await writable.close();
        return {
          saved: true,
          filename: String(handle.name || safeName),
          locationLabel: "你刚才选择的文件夹"
        };
      } catch (error) {
        if (error?.name === "AbortError") {
          notifyUser("用户已取消保存，文件没有被下载。");
          return { saved: false, cancelled: true, filename: safeName };
        }
        // Older WebView2/WKWebView versions may expose the picker but reject
        // writing. Keep download usable and tell the operator where to look.
      }
    }
    fallbackDownloadBlob(blob, safeName);
    return {
      saved: true,
      filename: safeName,
      locationLabel: "系统“下载”文件夹"
    };
  }

  function savedFileMessage(label, result, extra = "") {
    if (!result?.saved) return "";
    return `${label}已保存：${result.filename} · 位置：${result.locationLabel}${extra ? ` · ${extra}` : ""}`;
  }

  async function downloadSvg() {
    if (state.nativeVector?.kind === "svg") {
      await downloadOriginalVector();
      return;
    }
    if (!state.result) return;
    if (state.outputKind === "fidelity-preview") {
      const widthMm = Number(byId("jlVectorWidthMm")?.value || 0);
      const fidelitySvg = buildFidelitySvg(state.fidelityDataUrl, widthMm);
      const saved = await saveBlobWithLocation(
        new Blob([fidelitySvg], { type: "image/svg+xml;charset=utf-8" }),
        `${safeFilename(state.file?.name)}-保真参考稿.svg`
      );
      if (saved.saved) {
        notifyUser(savedFileMessage(
          "参考稿",
          saved,
          "画面保持原分辨率；它含内嵌原图，不是可编辑全路径生产矢量"
        ));
      }
      return;
    }
    if (
      state.outputKind === "professional-reconstruction"
      && state.result?.quality?.productionReady === false
    ) {
      const editableDraft = window.JianglaiVectorEngine.renderSvg(state.result, {
        title: `${safeFilename(state.file?.name)} 可编辑重建草稿`
      });
      if (
        /<image\b|data:image\//i.test(editableDraft)
        || !/<(?:path|polygon|polyline|rect|circle|ellipse|line)\b/i.test(editableDraft)
      ) {
        notifyUser("当前草稿没有通过真实路径校验，已停止下载");
        return;
      }
      const saved = await saveBlobWithLocation(
        new Blob([editableDraft], { type: "image/svg+xml;charset=utf-8" }),
        `${safeFilename(state.file?.name)}-可编辑重建草稿.svg`
      );
      if (saved.saved) {
        notifyUser(savedFileMessage(
          "可编辑重建草稿",
          saved,
          "纯路径、不含原图位图；请继续修正文字与节点，当前未标记为生产成品"
        ));
      }
      return;
    }
    const size = confirmedProductionSize();
    if (!size) return;
    const productionSvg = window.JianglaiVectorEngine.renderSvg(resultForProductionExport(), {
      title: safeFilename(state.file?.name),
      widthMm: size.widthMm
    });
    const saved = await saveBlobWithLocation(
      new Blob([productionSvg], { type: "image/svg+xml;charset=utf-8" }),
      `${safeFilename(state.file?.name)}.svg`
    );
    if (saved.saved) {
      notifyUser(savedFileMessage(
        "生产 SVG",
        saved,
        `${size.widthMm.toFixed(2)}×${size.heightMm.toFixed(2)}mm，刀模线固定 0.25mm`
      ));
    }
  }

  async function downloadOriginalVector() {
    if (!state.file || !state.nativeVector) return;
    const saved = await saveBlobWithLocation(
      state.file,
      state.file.name || `${safeFilename(state.file?.name)}.${state.nativeVector.kind}`
    );
    if (saved.saved) {
      notifyUser(savedFileMessage(
        `${state.nativeVector.kind.toUpperCase()} 原文件`,
        saved,
        "路径、颜色和页面尺寸没有被重新描摹或改写"
      ));
    }
  }

  function resultForProductionExport() {
    const result = cloneJson(state.result);
    result.layers = result.layers.map(layer => ({
      ...layer,
      color: state.layerState[layer.id]?.color || layer.color,
      visible: state.layerState[layer.id]?.visible !== false
    }));
    return result;
  }

  async function downloadProduction(format) {
    if (state.nativeVector) {
      if (state.nativeVector.kind === "pdf" && format === "pdf") await downloadOriginalVector();
      else notifyUser("原生矢量文件应下载原文件继续生产，避免格式转换破坏专色、刀模或尺寸。 ");
      return;
    }
    if (!state.result) { notifyUser("请先生成矢量图"); return; }
    if (state.outputKind === "fidelity-preview") {
      notifyUser("复杂印刷整版不能从 JPG 自动恢复成可靠的生产 PDF/EPS/DXF；请提供 AI/CDR/PDF 或转人工精修");
      return;
    }
    const exporters = window.JianglaiVectorExporters;
    if (!exporters) { notifyUser("生产格式导出模块还没有加载，请重新启动软件"); return; }
    const size = confirmedProductionSize();
    if (!size) return;
    try {
      const output = exporters.exportResult(resultForProductionExport(), format, {
        widthMm: size.widthMm,
        filename: safeFilename(state.file?.name)
      });
      const bytes = exporters.toUint8Array(output);
      const saved = await saveBlobWithLocation(new Blob([bytes], { type: output.mime }), output.filename);
      if (!saved.saved) return;
      const label = format === "dxf" ? "切割线 DXF" : format.toUpperCase();
      notifyUser(savedFileMessage(
        label,
        saved,
        `${output.widthMm.toFixed(2)}×${output.heightMm.toFixed(2)}mm，${output.nodeCount} 个节点`
      ));
    } catch (error) {
      notifyUser(`${format.toUpperCase()} 导出失败：${error.message}`);
    }
  }

  async function downloadPng() {
    if (!state.svg || !state.result) return;
    const image = new Image();
    const url = URL.createObjectURL(new Blob([state.svg], { type: "image/svg+xml" }));
    try {
      await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("PNG 预览渲染失败")); image.src = url; });
      const scale = Math.min(4, Math.max(1, 2048 / Math.max(state.result.width, state.result.height)));
      const canvas = document.createElement("canvas");
      canvas.width = Math.round(state.result.width * scale); canvas.height = Math.round(state.result.height * scale);
      const context = canvas.getContext("2d");
      context.clearRect(0, 0, canvas.width, canvas.height);
      context.drawImage(image, 0, 0, canvas.width, canvas.height);
      const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
      if (!blob) throw new Error("PNG 导出失败");
      const saved = await saveBlobWithLocation(blob, `${safeFilename(state.file?.name)}-透明预览.png`);
      if (saved.saved) notifyUser(savedFileMessage("透明 PNG", saved));
    } catch (error) { notifyUser(error.message); }
    finally { URL.revokeObjectURL(url); }
  }

  async function copySvg() {
    if (state.nativeVector?.kind === "svg") {
      try {
        await navigator.clipboard.writeText(state.nativeVector.safeMarkup);
        notifyUser("已复制经过安全检查的 SVG 源码；下载按钮仍保留客户原文件。 ");
      } catch (_) { notifyUser("浏览器不允许直接复制，请下载 SVG 原文件。 "); }
      return;
    }
    if (!state.result) return;
    if (state.outputKind === "fidelity-preview") {
      notifyUser("保真参考稿包含整张原图，源码体积很大，请直接下载 SVG；生产全路径请转人工精修。");
      return;
    }
    const size = confirmedProductionSize();
    if (!size) return;
    const productionSvg = window.JianglaiVectorEngine.renderSvg(resultForProductionExport(), {
      title: safeFilename(state.file?.name),
      widthMm: size.widthMm
    });
    try {
      await navigator.clipboard.writeText(productionSvg);
      notifyUser(`SVG 源码已复制 · ${size.widthMm.toFixed(2)}×${size.heightMm.toFixed(2)}mm。`);
    } catch (_) {
      notifyUser("浏览器不允许直接复制，请使用“下载 SVG”。 ");
    }
  }

  function sendToManualReview() {
    if (!state.file) { notifyUser("请先选择一张图片"); return; }
    const input = byId("vectorFile");
    if (!input) { notifyUser("人工精修入口暂不可用"); return; }
    try {
      const transfer = new DataTransfer();
      transfer.items.add(state.file);
      input.files = transfer.files;
      input.dispatchEvent(new Event("change", { bubbles: true }));
      const customer = byId("vectorCustomer");
      if (customer && typeof window.currentCustomerName === "function") customer.value = window.currentCustomerName();
      byId("vectorSubmitCard")?.scrollIntoView({ behavior: "smooth", block: "center" });
      notifyUser("原图已带入人工精修区，请确认客户授权后提交。 ");
    } catch (_) {
      input.click();
      notifyUser("请在人工精修区重新选择原图。 ");
    }
  }

  async function runCloudVectorization() {
    if (!state.file || state.nativeVector) {
      notifyUser("请先选择一张 JPG、PNG、WebP 或 BMP 图片");
      return;
    }
    if (typeof window.__vectorCloudBridge !== "function") {
      notifyUser("当前版本尚未连接服务器矢量服务");
      return;
    }
    if (!window.confirm("原图将加密发送到将莱云服务器完成专业矢量转换。确认已获得客户授权并继续吗？")) return;
    const button = byId("jlVectorCloudRun");
    if (button?.disabled) return;
    const original = button?.textContent || "服务器专业转换 · 30点";
    if (button) {
      button.disabled = true;
      button.textContent = "服务器正在转换…";
    }
    setProcessing(true, "服务器正在生成矢量文件…", "正在上传原图并执行专业转换，完成后会自动下载。");
    try {
      const data = await window.__vectorCloudBridge(state.file, "svg");
      const url = String(data?.result?.url || "");
      if (!url.startsWith("/generated/")) throw new Error("服务器没有返回有效矢量文件");
      const link = document.createElement("a");
      link.href = url;
      link.download = String(data?.result?.filename || `${safeFilename(state.file.name)}.svg`);
      document.body.appendChild(link);
      link.click();
      link.remove();
      setStatus("服务器专业转换完成 · SVG 已开始下载", "success");
      notifyUser("服务器矢量文件已生成并开始下载。");
    } catch (error) {
      setStatus(`服务器转换失败：${error.message}`, "danger");
      notifyUser(error.message);
    } finally {
      setProcessing(false);
      if (button) {
        button.disabled = false;
        button.textContent = original;
      }
    }
  }

  function bindEvents(studio) {
    const input = byId("jlVectorStudioFile");
    input?.addEventListener("change", () => loadFile(input.files?.[0]));
    const dropzone = byId("jlVectorDropzone");
    ["dragenter", "dragover"].forEach(type => dropzone?.addEventListener(type, event => { event.preventDefault(); dropzone.classList.add("is-dragging"); }));
    ["dragleave", "drop"].forEach(type => dropzone?.addEventListener(type, event => { event.preventDefault(); dropzone.classList.remove("is-dragging"); }));
    dropzone?.addEventListener("drop", event => loadFile([...event.dataTransfer.files].find(file => supportedFileKind(file))));
    studio.addEventListener("paste", event => {
      const file = [...(event.clipboardData?.files || [])].find(item => supportedFileKind(item));
      if (file) { event.preventDefault(); loadFile(file); }
    });
    all("[data-vector-goal]").forEach(button => button.addEventListener("click", () => setQualityGoal(button.dataset.vectorGoal, true)));
    all("[data-vector-preset]").forEach(button => button.addEventListener("click", () => {
      state.autoOptimize = true;
      selectPreset(button.dataset.vectorPreset, true);
    }));
    ["jlVectorColors", "jlVectorSimplify", "jlVectorSmoothing", "jlVectorSpeckle", "jlVectorTolerance", "jlVectorRemoveBackground", "jlVectorBoundaryMode"]
      .forEach(id => byId(id)?.addEventListener("input", scheduleManualRerun));
    byId("jlVectorRestoreAuto")?.addEventListener("click", () => setQualityGoal(state.qualityGoal, true));
    byId("jlVectorRun")?.addEventListener("click", () => runVectorization({ confirmEdits: true }));
    byId("jlVectorCloudRun")?.addEventListener("click", runCloudVectorization);
    byId("jlVectorFidelityOnly")?.addEventListener("click", showFidelityReference);
    byId("jlVectorClearFile")?.addEventListener("click", clearFile);
    byId("jlVectorStageClear")?.addEventListener("click", clearFile);
    all("[data-vector-view]").forEach(button => button.addEventListener("click", () => setPreviewMode(button.dataset.vectorView)));
    byId("jlVectorCompare")?.addEventListener("input", event => { state.compare = Number(event.target.value); updateCompare(); });
    byId("jlVectorZoomIn")?.addEventListener("click", () => changeZoom(1.25));
    byId("jlVectorZoomOut")?.addEventListener("click", () => changeZoom(.8));
    byId("jlVectorZoomReset")?.addEventListener("click", resetView);
    all("[data-vector-pan-toggle]").forEach(button => button.addEventListener("click", () => {
      if (state.editor.tool === "browse") {
        const restore = state.result && ["node", "retrace"].includes(state.editor.lastEditTool) ? state.editor.lastEditTool : "idle";
        setEditorTool(restore);
        setStatus(restore === "idle" ? "移动画布已关闭" : `移动画布已关闭 · 已恢复${restore === "node" ? "节点编辑" : "选区文字重建"}`, "warning");
      } else {
        if (["node", "retrace"].includes(state.editor.tool)) state.editor.lastEditTool = state.editor.tool;
        setEditorTool("browse");
        setStatus("移动画布已开启 · 再点一次关闭；在画布空白处按住拖动，滚轮缩放", "warning");
      }
    }));
    byId("jlVectorFitView")?.addEventListener("click", () => {
      resetView();
      setStatus("已适应窗口：整张图片已回到画布中央。", "warning");
    });
    byId("jlVectorActualPixels")?.addEventListener("click", showActualPixels);
    byId("jlVectorStageZoomIn")?.addEventListener("click", () => changeZoom(1.2));
    byId("jlVectorStageZoomOut")?.addEventListener("click", () => changeZoom(1 / 1.2));
    byId("jlVectorZoomSlider")?.addEventListener("input", event => setZoom(Number(event.target.value) / 100));
    all("[data-vector-tool]").forEach(button => button.addEventListener("click", () => setEditorTool(button.dataset.vectorTool)));
    byId("jlVectorEditLayer")?.addEventListener("change", event => {
      state.editor.selectedLayerId = event.target.value;
      state.editor.selectedContourId = selectedLayer()?.contours?.[0]?.id || "";
      state.editor.selectedNodeId = "";
      renderEditorOverlay(); updateEditButtons();
    });
    byId("jlVectorInsertNode")?.addEventListener("click", insertNode);
    byId("jlVectorDeleteNode")?.addEventListener("click", deleteNode);
    byId("jlVectorApplyRetrace")?.addEventListener("click", applySelectedTextRebuild);
    byId("jlVectorTopInsertNode")?.addEventListener("click", insertNode);
    byId("jlVectorTopDeleteNode")?.addEventListener("click", deleteNode);
    byId("jlVectorTopApplyRetrace")?.addEventListener("click", applySelectedTextRebuild);
    byId("jlVectorClearEditSelection")?.addEventListener("click", clearEditorSelection);
    byId("jlVectorUndo")?.addEventListener("click", undoEdit);
    byId("jlVectorRedo")?.addEventListener("click", redoEdit);
    byId("jlVectorRunOcr")?.addEventListener("click", runVectorOcr);
    byId("jlVectorAutoReplaceText")?.addEventListener("click", runAutomaticTextReplacement);
    byId("jlVectorSmartTextRebuild")?.addEventListener("click", runSmartTextRebuild);
    byId("jlVectorConvertText")?.addEventListener("click", convertTextToOutlines);
    ["jlVectorOcrText", "jlVectorTextFont", "jlVectorTextSize", "jlVectorTextColor"]
      .forEach(id => byId(id)?.addEventListener("input", renderTextRebuildPreview));
    byId("jlVectorWidthMm")?.addEventListener("input", () => { updatePhysicalSize(); renderMetrics(); });
    byId("jlVectorSizeConfirmed")?.addEventListener("change", renderMetrics);
    bindEditorPointerEvents();
    const stage = byId("jlVectorStage");
    stage?.addEventListener("wheel", event => {
      if (event.target?.closest?.(".jl-vector-viewport-controls, .jl-vector-compare-dock")) return;
      event.preventDefault();
      changeZoom(event.deltaY < 0 ? 1.12 : .89, { clientX: event.clientX, clientY: event.clientY });
    }, { passive: false });
    stage?.addEventListener("pointerdown", event => {
      if (event.target?.closest?.(".jl-vector-viewport-controls, .jl-vector-compare-dock, .jl-vector-stage-clear")) return;
      const wantsPan = state.editor.tool === "browse" || state.spacePan || event.button === 1;
      if (!wantsPan) return;
      event.preventDefault();
      stage.setPointerCapture(event.pointerId);
      state.dragStart = { x: event.clientX, y: event.clientY, panX: state.panX, panY: state.panY };
      stage.classList.add("is-panning");
    });
    stage?.addEventListener("pointermove", event => {
      if (!state.dragStart) return;
      state.panX = state.dragStart.panX + event.clientX - state.dragStart.x;
      state.panY = state.dragStart.panY + event.clientY - state.dragStart.y;
      applyView();
    });
    const stopDrag = event => {
      state.dragStart = null;
      stage?.classList.remove("is-panning");
      try { if (stage?.hasPointerCapture(event?.pointerId)) stage.releasePointerCapture(event.pointerId); } catch (_) {}
    };
    stage?.addEventListener("pointerup", stopDrag); stage?.addEventListener("pointercancel", stopDrag);
    stage?.addEventListener("dblclick", event => {
      if (event.target?.closest?.(".jl-vector-viewport-controls, .jl-vector-compare-dock")) return;
      resetView();
      setStatus("已双击适应窗口。", "warning");
    });
    byId("jlVectorDownloadSvg")?.addEventListener("click", downloadSvg);
    byId("jlVectorDownloadPdf")?.addEventListener("click", () => downloadProduction("pdf"));
    byId("jlVectorDownloadEps")?.addEventListener("click", () => downloadProduction("eps"));
    byId("jlVectorDownloadDxf")?.addEventListener("click", () => downloadProduction("dxf"));
    byId("jlVectorDownloadPng")?.addEventListener("click", downloadPng);
    byId("jlVectorCopySvg")?.addEventListener("click", copySvg);
    byId("jlVectorDownloadOriginal")?.addEventListener("click", downloadOriginalVector);
    byId("jlVectorManualReview")?.addEventListener("click", sendToManualReview);
    window.addEventListener("keydown", event => {
      const target = event.target;
      if (target?.matches?.("input, textarea, select, [contenteditable=true]")) return;
      if (event.code === "Space" && state.result) {
        event.preventDefault();
        setSpacePan(true);
        return;
      }
      if (!state.result || !["node", "retrace"].includes(state.editor.tool)) return;
      if ((event.metaKey || event.ctrlKey) && String(event.key).toLowerCase() === "z") {
        event.preventDefault();
        if (event.shiftKey) redoEdit(); else undoEdit();
      } else if ((event.key === "Delete" || event.key === "Backspace") && state.editor.selectedNodeId) {
        event.preventDefault(); deleteNode();
      } else if (event.key === "Escape") {
        clearEditorSelection();
      }
    });
    window.addEventListener("keyup", event => {
      if (event.code === "Space") setSpacePan(false);
    });
    window.addEventListener("blur", () => setSpacePan(false));
    window.addEventListener("pagehide", () => { revokeUrl("sourceUrl"); revokeUrl("resultUrl"); }, { once: true });
  }

  function init() {
    const studio = byId("jlVectorStudio");
    if (!studio || state.initialized) return;
    state.initialized = true;
    bindEvents(studio);
    updateRangeOutputs();
    selectPreset("logo", false);
    setQualityGoal("fidelity", false);
    setPreviewMode("original");
    setEditorTool("browse");
    setResultButtons(false);
    setPreviewAvailability(false);
    updatePhysicalSize();
    populateTextFonts();
    renderTextRebuildPreview();
    renderMetrics();
    setStatus("等待图片 · 支持拖放、粘贴或点击选择");
  }

  window.__initVectorStudio = init;
  window.JianglaiVectorStudio = {
    init, loadFile, clearFile, runVectorization, detectMode, optimizationCandidates, scoreVectorCandidate, evaluateProfessionalReconstruction, optimizeVectorResult, getState: () => state,
    analyzePrintSheet, analyzeSemanticInkProfile, semanticInkFamily, scoreSemanticInkFidelity,
    detectSmartTextBoxes, smartTextLines, applySelectedTextRebuild, runAutomaticTextReplacement,
    normalizeAutomaticTextLayout, automaticTextReplacementDecision, safeAutomaticTextCandidates,
    splitPackagingStructureLayers, isStructureLayer, closestTextFont, sampleSelectedTextColor,
    isolateExistingArtworkRegion, traceOriginalGlyphRegion, originalGlyphMask, cropOriginalSourceAtResultRect, removeContoursInsideTextRect, fontOutlineToResult,
    vtracerSvgToResult, vectorizeCandidate, traceSemanticInkLayers, mergePackagingStructure,
    cropBoundaryMedianRgb, foregroundDirectionCosine, originalForegroundInkMasks, traceOriginalForegroundMask
  };
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init, { once: true });
  else init();
})();
