    report(90, 'Phase 3: Structuring Data...');

    if (!coordsResult || coordsResult.length === 0) {
      console.warn('[KitabooFXL] No coordinate data from extraction; zone building will produce no zones.');
    }

    // Use Gemini to classify page types (cover, back, toc, chapter title, regular) so we can
    // apply cover-style word grouping and other special rules without hardcoding page numbers.
    const pageTypeByNumber = {};
    if (process.env.GEMINI_API_KEY) {
      try {
        for (const img of renderResult.images) {
          try {
            const imageBase64 = (await fs.readFile(img.path)).toString('base64');
            const prompt = `You are analyzing a single page image from a children's non-fiction PDF book.
Classify the VISUAL ROLE of this page into ONE of exactly these values:
- "cover" (front cover with big hero title)
- "back" (back cover)
- "toc" (table of contents)
- "chapterTitle" (chapter opener page with large chapter heading and mostly decorative content)
- "regular" (any normal content page).

Return ONLY a JSON object of the form: {"pageType":"cover"} with one of the values above. Page number: ${img.pageNumber}.`;

            const response = await GeminiService.generateContent([
              { text: prompt },
              { inlineData: { mimeType: 'image/png', data: imageBase64 } }
            ], { modelName: 'gemini-2.5-flash', priority: 'low' });

            const parsed = KitabooFxlService.safeParseJsonObject(response);
            const pt = (parsed && typeof parsed.pageType === 'string') ? parsed.pageType.trim() : '';
            if (pt) {
              pageTypeByNumber[img.pageNumber] = pt;
            }
          } catch (perPageErr) {
            console.warn(`[KitabooFXL] Gemini page-type classification failed for page ${img.pageNumber}:`, perPageErr.message);
          }
        }
      } catch (err) {
        console.warn('[KitabooFXL] Gemini page-type classification skipped:', err.message);
      }
    }

    // Fallback: if Gemini did not classify anything, treat page 1 as cover; others as regular.
    if (Object.keys(pageTypeByNumber).length === 0 && renderResult.images.length > 0) {
      pageTypeByNumber[1] = 'cover';
    }

    // Detect last TOC page: up to and including TOC → one zone per line (like copyright/credits/TOC); after TOC → sentence-level.
    // IMPORTANT: For glyph extraction (visual glyph replay + logical word/sentence grouping), we *do not* apply TOC special-casing.
    // Glyph jobs should keep true word-level zones even on TOC/credits pages so every logical word has its own zone.
    const tocEndPageDetected = (() => {
      let last = 0;
      for (const p of coordsResult || []) {
        const items = p.items || [];
        const pageText = items.map(it => (it.text || '').trim()).join(' ');
        const hasTocTitle = items.some(it => /^Table\s+of\s+Contents$/i.test((it.text || '').trim()) || /^Contents$/i.test((it.text || '').trim()));
        const hasTocLikeContent = pageText.length < 600 && /\b(Table\s+of\s+Contents|Contents)\b/i.test(pageText);
        if (hasTocTitle || hasTocLikeContent) {
          last = Math.max(last, p.page || 0);
        }
      }
      return last;
    })();
    // Sentence-level hi-fi: pages before and including TOC use rectangle zones and no sentence-level rules (one zone per line).
    // Word-level / non-sentence: for glyph extraction, disable TOC special handling.
    // When options.tocEndPage is set (e.g. when auto-detect fails), use it for sentence-level so user can force TOC range.
    const tocEndPage = (zoneLevel === 'sentence' && options.tocEndPage != null && options.tocEndPage > 0)
      ? Math.max(1, Math.floor(Number(options.tocEndPage)))
      : (zoneLevel === 'sentence') ? tocEndPageDetected : (extractionLevel === 'glyph' ? 0 : tocEndPageDetected);
    if (tocEndPage > 0) {
      console.log(`[KitabooFXL] TOC pages 1–${tocEndPage}: line-by-line zoning (rectangles); content pages ${tocEndPage + 1}+: sentence-level (polygons).`);
    }

    // Map coordsResult to pages format
    const pages = [];
    for (const img of renderResult.images) {
      const pageCoords = coordsResult.find(p => p.page === img.pageNumber);
      const tocPage = tocEndPage > 0 && img.pageNumber <= tocEndPage;
      const pageType = pageTypeByNumber[img.pageNumber] || (img.pageNumber === 1 ? 'cover' : 'regular');
      const isCoverStylePage = pageType === 'cover' || pageType === 'back' || pageType === 'chapterTitle';

      // Determine if we use the clean image (if cleanup succeeded and file exists)
      let useCleanImage = false;
      const cleanImgName = `page_${img.pageNumber}_clean.png`;
      const cleanImgPath = path.join(renderedDir, cleanImgName);

      if (cleanupSuccess) {
        try {
          await fs.access(cleanImgPath);
          useCleanImage = KitabooFxlService.shouldUseCleanImage(img.pageNumber, pageCoords?.items);
        } catch (e) {
          // Clean image doesn't exist, fall back to original
        }
      }

      if (useCleanImage) {
        console.log(`[KitabooFXL] Page ${img.pageNumber}: Using clean image (internal processing only).`);
      } else {
        console.log(`[KitabooFXL] Page ${img.pageNumber}: Using original image to preserve artistic integrity.`);
      }

      // STUDIO/FRONTEND: Always show the original image (img.fileName)
      // The clean image is only used during EPUB generation (assembleFxlEpub)
      const finalImgName = img.fileName;
      const finalImgPath = `/backend/html_intermediate/kitaboo_${jobId}/high_fidelity_render/${finalImgName}`;

      // Determine scale for coordinates (pts to pixels) if needed
      // PyMuPDF extraction returns pts, but image is 300 DPI
      const scaleX = pageCoords ? (img.width / pageCoords.width) : 1;
      const scaleY = pageCoords ? (img.height / pageCoords.height) : 1;

      // Convert coords to KitabooZone format (polygon: every zone has points from bbox)
      // Extraction is always glyph; group by word_id or sentence_id based on zoneLevel for Zoning Studio.
      // Cover-style pages (from Gemini pageType) use word-level grouping even when zoneLevel is 'sentence'
      // so hero titles like "Horses Up Close" have separate zones per word.
      const isCoverPage = zoneLevel === 'sentence' && isCoverStylePage;
      const useWordGroupingThisPage = isCoverPage || (zoneLevel === 'word');
      if (isCoverPage) {
        console.log(`[KitabooFXL] Page ${img.pageNumber} (${pageType}) using word-level grouping for hero titles.`);
      }
      // CRITICAL: Grouping is per-page only. sourceItems = this page's glyphs; byGroup is a new Map for this page.
      // (word_id in coords.json is scoped per page by the Python script; we never merge across pages.)
      let sourceItems = (pageCoords?.items || []);
      if (sourceItems.length === 0 && pageCoords) {
        console.warn(`[KitabooFXL] Page ${img.pageNumber}: coords have no items (empty page or extraction issue).`);
      }
      const idSuffix = zoneLevel === 'sentence' ? 's' : 'w';
      if (extractionLevel === 'glyph') {
        const groupKey = useWordGroupingThisPage ? (item) => item.word_id ?? item.wordId : (item) => item.sentence_id ?? item.sentenceId;
        const byGroup = new Map(); // New Map per page — do not move outside the page loop.
        sourceItems.forEach((item, idx) => {
          const key = groupKey(item);
          if (key == null) return;
          if (!byGroup.has(key)) byGroup.set(key, []);
          byGroup.get(key).push({ item, idx });
        });
        const grouped = [];
        // Join glyph text with space between words (glyph extraction does not emit space chars; word_id marks boundaries).
        // Do not add a space before sentence-ending punctuation (. ? ! and full-width ．？！) so "muscles. A" stays one space, not "muscles . A".
        const isSentenceEndChar = (c) => /^[.?!．？！]$/.test((c || '').trim());
        const isSpaceChar = (c) => c === ' ' || c === '\u00A0';
        // Abbreviation: do not add space between ".", "D"/"A" etc. so "Ph." + "D" + "." stays "Ph.D." not "Ph. D ."
        const isAbbrevLetterAfterPeriod = (prevChar, ch) => /^[.?!．？！]$/.test((prevChar || '').trim()) && /^[A-Za-z]$/.test((ch || '').trim());
        const joinGlyphsWithSpaces = (glyphList) => {
          let s = '';
          let prevWordId = null;
          let prevChar = '';
          for (const g of glyphList) {
            const wid = g.word_id ?? g.wordId;
            const ch = (g.text || '').trim();
            // Add space at word boundary only when the current glyph is NOT already a space (PDF may emit space glyphs; don't double).
            // Skip space when previous word ended with .?! and current is single letter (abbreviation: Ph.D., M.A.Ed.).
            const atWordBoundary = prevWordId != null && wid != null && wid !== prevWordId;
            const noSpaceForAbbrev = atWordBoundary && isAbbrevLetterAfterPeriod(prevChar, ch);
            if (atWordBoundary && !noSpaceForAbbrev && !isSentenceEndChar(ch) && !isSpaceChar(ch)) {
              s += ' ';
            }
            s += g.text || '';
            prevWordId = wid;
            prevChar = (g.text || '').trim() || prevChar;
          }
          // Normalize all whitespace: collapse any run (spaces, tabs, PDF artifacts) to single space; trim. Prevents large gaps in FXL.
          let out = s.replace(/\s+/g, ' ').trim();
          // RCA: Fix duplicate-letter abbreviation corruption from PDF layers (Ph.DD. -> Ph.D., M.AA. -> M.A., M.AA.Ed. -> M.A.Ed.)
          out = KitabooFxlService.normalizeAbbreviationCorruption(out);
          // RCA: Fix common last-glyph truncations in some credit-role words ("Directo" -> "Director", "Publishe" -> "Publisher").
          out = KitabooFxlService.normalizeCommonTruncations(out);
          return out;
        };
        // Build grouped items in PDF stream order first; we'll optionally re-sort by Y/X for word-level later.
        [...byGroup.entries()].sort((a, b) => Math.min(...a[1].map(x => x.idx)) - Math.min(...b[1].map(x => x.idx))).forEach(([, group]) => {
          group.sort((a, b) => a.idx - b.idx);
          const glyphs = group.map(g => g.item);
          const text = joinGlyphsWithSpaces(glyphs);
          if (!text.trim()) return;
          const bboxes = glyphs.map(g => g.bbox).filter(b => Array.isArray(b) && b.length >= 4);
          if (!bboxes.length) return;
          const x0 = Math.min(...bboxes.map(b => b[0]));
          const y0 = Math.min(...bboxes.map(b => b[1]));
          const x1 = Math.max(...bboxes.map(b => b[2]));
          const y1 = Math.max(...bboxes.map(b => b[3]));
          const first = glyphs[0];
          const fontSize = first.size || 12;
          const item = {
            text,
            bbox: [x0, y0, x1, y1],
            font: first.font,
            font_file: first.font_file,
            size: fontSize,
            color: first.color,
            ascender: first.ascender,
            descender: first.descender,
            flags: first.flags,
            rotation: first.rotation,
            align: first.align
          };
          // Sentence-level: build lines from glyph y-clustering. On TOC pages: one zone per line (rectangles only); on content pages: polygons.
          // Detect new line using BOTH Y difference AND X reset (next word significantly left = new line).
          if (zoneLevel === 'sentence' && glyphs.length > 0) {
            // Slightly tighter Y band on body pages so wrapped sentences become multiple lineGroups → item.lines → correct absolute HTML.
            const lineThresholdY = Math.max(fontSize * (tocPage ? 0.4 : 0.28), 3);
            const xResetThreshold = fontSize * 0.5; // if next glyph's X is this much left of previous, it's a new line
            const lineGroups = [];
            glyphs.forEach((g) => {
              const b = g.bbox && g.bbox.length >= 4 ? g.bbox : [g.x, g.y, (g.x || 0) + fontSize * 0.6, (g.y || 0) + fontSize * 1.2];
              const cy = (b[1] + b[3]) / 2;
              const cxLeft = b[0];
              const last = lineGroups[lineGroups.length - 1];
              const prevLeft = last ? last.bboxes[last.bboxes.length - 1][0] : null;
              const isNewLineByX = prevLeft != null && cxLeft < prevLeft - xResetThreshold;
              const sameY = last && Math.abs(cy - last.y) <= lineThresholdY;
              const sameLine = sameY && !isNewLineByX;
              if (sameLine) {
                last.glyphs.push(g);
                last.bboxes.push(b);
              } else {
                lineGroups.push({ y: cy, glyphs: [g], bboxes: [b] });
              }
            });
            // If everything stayed in one group but glyphs span multiple text rows, split by Y-band (fixes Thorium/epub.js stacked text on absolute-html export).
            if (!tocPage && lineGroups.length === 1 && lineGroups[0].glyphs.length > 2) {
              const g0 = lineGroups[0];
              const minY = Math.min(...g0.bboxes.map((bb) => bb[1]));
              const maxY = Math.max(...g0.bboxes.map((bb) => bb[3]));
              const spread = maxY - minY;
              if (spread > fontSize * 1.15) {
                const lineHeight = Math.max(fontSize * 1.05, spread / Math.max(2, Math.round(spread / (fontSize * 1.05))));
                const buckets = [];
                for (let i = 0; i < g0.glyphs.length; i++) {
                  const b = g0.bboxes[i];
                  const cy = (b[1] + b[3]) / 2;
                  let placed = false;
                  for (const bucket of buckets) {
                    if (Math.abs(cy - bucket.y) <= lineHeight * 0.42) {
                      bucket.glyphs.push(g0.glyphs[i]);
                      bucket.bboxes.push(b);
                      const n = bucket.glyphs.length;
                      bucket.y += (cy - bucket.y) / n;
                      placed = true;
                      break;
                    }
                  }
                  if (!placed) buckets.push({ y: cy, glyphs: [g0.glyphs[i]], bboxes: [b] });
                }
                buckets.sort((a, b) => a.y - b.y);
                if (buckets.length > 1) {
                  lineGroups.length = 0;
                  for (const bucket of buckets) {
                    lineGroups.push({ y: bucket.y, glyphs: bucket.glyphs, bboxes: bucket.bboxes });
                  }
                }
              }
            }
            lineGroups.sort((a, b) => a.y - b.y);
            if (tocPage && lineGroups.length > 1) {
              // TOC / pre-TOC: one zone per line (rectangle boxes); sentence-level rules do not apply.
              // Use the first glyph of each line for that line's style (color/bold/italic) so TOC title and entries get correct per-line styling.
              lineGroups.forEach((lg) => {
                const bx = lg.bboxes;
                const lx0 = Math.min(...bx.map(b => b[0]));
                const ly0 = Math.min(...bx.map(b => b[1]));
                const lx1 = Math.max(...bx.map(b => b[2]));
                const ly1 = Math.max(...bx.map(b => b[3]));
                const lineText = joinGlyphsWithSpaces(lg.glyphs);
                const lineFirst = lg.glyphs[0];
                const lineFontSize = lineFirst.size || fontSize;
                grouped.push({
                  text: lineText,
                  bbox: [lx0, ly0, lx1, ly1],
                  font: lineFirst.font,
                  font_file: lineFirst.font_file,
                  size: lineFontSize,
                  color: lineFirst.color,
                  ascender: lineFirst.ascender,
                  descender: lineFirst.descender,
                  flags: lineFirst.flags,
                  rotation: lineFirst.rotation,
                  align: (lineFirst.align || first.align || 'left')
                });
              });
              return;
            }
            if (lineGroups.length > 1) {
              item.lines = lineGroups.map((lg) => {
                const bx = lg.bboxes;
                const lx0 = Math.min(...bx.map(b => b[0]));
                const ly0 = Math.min(...bx.map(b => b[1]));
                const lx1 = Math.max(...bx.map(b => b[2]));
                const ly1 = Math.max(...bx.map(b => b[3]));
                const lineText = joinGlyphsWithSpaces(lg.glyphs);
                return {
                  text: lineText,
                  bbox: [lx0, ly0, lx1, ly1],
                  origin: [lx0, ly0],
                  align: first.align || 'left'
                };
              });
            }
            // Sentence-level hi-fi: per-word styleRuns from glyphs (same fidelity as word-level SVG layer + cover/title styles).
            const wordsInText = text.split(/\s+/).filter(Boolean);
            const wordsFromGlyphs = [];
            let currWordId = null;
            let currFirst = null;
            for (const g of glyphs) {
              const wid = g.word_id ?? g.wordId;
              if (wid !== currWordId) {
                if (currFirst != null) wordsFromGlyphs.push({ firstGlyph: currFirst });
                currWordId = wid;
                currFirst = g;
              }
            }
            if (currFirst != null) wordsFromGlyphs.push({ firstGlyph: currFirst });
            if (wordsFromGlyphs.length === wordsInText.length && wordsFromGlyphs.length > 0) {
              let charOffset = 0;
              item.styleRuns = wordsInText.map((word, i) => {
                const start = charOffset;
                const end = charOffset + word.length;
                charOffset = end + 1; // +1 for space to next word
                const gf = wordsFromGlyphs[i].firstGlyph;
                return {
                  start,
                  end,
                  bold: !!((gf.flags || 0) & 2) || /bold/i.test(String(gf.font || '')),
                  italic: !!((gf.flags || 0) & 1) || /italic/i.test(String(gf.font || '')),
                  color: gf.color || '#000000'
                };
              });
            }
          }
          grouped.push(item);
        });
        // For Hi-Fi word-level (and cover when sentence-level), reading order = visual layout (top-to-bottom, left-to-right).
        if (zoneLevel === 'word' || (zoneLevel === 'sentence' && isCoverPage)) {
          grouped.sort((a, b) => {
            const ay = Array.isArray(a.bbox) && a.bbox.length >= 4 ? a.bbox[1] : 0;
            const byY = Array.isArray(b.bbox) && b.bbox.length >= 4 ? b.bbox[1] : 0;
            if (Math.abs(ay - byY) > 0.5) return ay - byY;
            const ax = Array.isArray(a.bbox) && a.bbox.length >= 4 ? a.bbox[0] : 0;
            const bx = Array.isArray(b.bbox) && b.bbox.length >= 4 ? b.bbox[0] : 0;
            return ax - bx;
          });
        }
        sourceItems = grouped;
      }

      const zones = sourceItems.map((item, i) => {
        const zone = {
          id: `p${img.pageNumber}_${idSuffix}${i}`,
          type: 'text',
          content: item.text,
          x: parseFloat((item.bbox[0] * scaleX).toFixed(3)),
          y: parseFloat((item.bbox[1] * scaleY).toFixed(3)),
          w: parseFloat(((item.bbox[2] - item.bbox[0]) * scaleX).toFixed(3)),
          h: parseFloat(((item.bbox[3] - item.bbox[1]) * scaleY).toFixed(3)),
          readingOrder: i + 1,
          fontSize: item.size ? parseFloat((item.size * scaleY).toFixed(3)) : 12,
          fontFamily: item.font || 'Arial',
          fontFile: item.font_file || null,
          color: item.color || '#000000',
          origin: item.origin ? [
            parseFloat((item.origin[0] * scaleX).toFixed(3)),
            parseFloat((item.origin[1] * scaleY).toFixed(3)),
            item.rotation || 0
          ] : null,
          bold: !!((item.flags || 0) & 2) || /bold/i.test(String(item.font || '')),
          italic: !!((item.flags || 0) & 1) || /italic/i.test(String(item.font || '')),
          ascender: item.ascender || 0.8,
          descender: item.descender || -0.2,
          textAlign: (item.align === 'right' || item.align === 'center') ? item.align : 'left'
        };
        // TOC pages: always rectangle (no polygon from item.lines). Content pages: use polygon for multi-line sentence zones.
        const pad = tocPage ? 2 : 0;
        zone.points = KitabooFxlService.bboxToPoints(zone.x, zone.y, (zone.w || 0) + pad, (zone.h || 0) + pad);
        const contentLen = (item.text || '').length;
        if (contentLen > 0) {
          if (Array.isArray(item.styleRuns) && item.styleRuns.length > 0) {
            zone.styleRuns = item.styleRuns;
          } else {
            const bold = !!((item.flags || 0) & 2) || /bold/i.test(String(item.font || ''));
            const italic = !!((item.flags || 0) & 1) || /italic/i.test(String(item.font || ''));
            zone.styleRuns = [{ start: 0, end: contentLen, bold, italic, color: item.color || '#000000' }];
          }
        }
        if (!tocPage && Array.isArray(item.lines) && item.lines.length > 1) {
          zone.lines = item.lines.map(ln => ({
            text: ln.text,
            origin: [parseFloat((ln.origin[0] * scaleX).toFixed(3)), parseFloat((ln.origin[1] * scaleY).toFixed(3))],
            bbox: ln.bbox ? [ln.bbox[0] * scaleX, ln.bbox[1] * scaleY, ln.bbox[2] * scaleX, ln.bbox[3] * scaleY] : null,
            align: (ln.align === 'right' || ln.align === 'center') ? ln.align : 'left'
          }));
          zone.points = KitabooFxlService.linesToOutlinePoints(zone.lines, { defaultW: zone.w / zone.lines.length, defaultH: (zone.fontSize || 12) * 1.2 });
        }
        return zone;
      });

      // Collect unique font names for AI font mapping
      const pageFonts = [...new Set(zones.filter(z => z.fontFamily).map(z => z.fontFamily))];

      // tocPage set at start of page loop (pages before and including TOC get rectangle zones, no sentence-level rules)
      // Multi-column: compute column split before clustering so we don't merge "Consultant" + "Publishing Credits" into one zone
      const pageWidthForColumns = img.width || img.dimensions?.width || (pageCoords && pageCoords.width) || 0;
      const colResult = (tocPage && pageWidthForColumns > 0 && zones.length >= 2)
        ? KitabooFxlService.detectColumnSplitX(zones, pageWidthForColumns)
        : null;
      const columnSplitX = (colResult && colResult.splitX != null) ? colResult.splitX : null;

      // Sentence-level (except cover-style pages): split any zone that contains multiple sentences into one zone per sentence.
      let zonesToCluster = zones;
      if (zoneLevel === 'sentence' && !isCoverPage && zones.length > 0) {
        const before = zones.length;
        zonesToCluster = KitabooFxlService.splitMultiSentenceZones(zones, img.pageNumber);
        if (zonesToCluster.length !== before) {
          console.log(`[KitabooFXL] Page ${img.pageNumber}: split multi-sentence zones ${before} -> ${zonesToCluster.length}.`);
        }
      }
      // New: Cluster and Deduplicate Spans to fix character overlap and redundant PDF layers.
      // Word-level zones (and cover when sentence-level uses word grouping): no merge so we keep one zone per word.
      // Sentence-level zones: allow clustering/line merging.
      const effectiveExtractionLevel = useWordGroupingThisPage ? 'word' : zoneLevel;
      let clusteredZones = (effectiveExtractionLevel === 'word')
        ? zonesToCluster
        : KitabooFxlService.clusterAndDeduplicateSpans(zonesToCluster, { extractionLevel: effectiveExtractionLevel, tocPage, columnSplitX });
      // Rule: one single-line zone for URLs (merge "http://www." + "tcmpub." + "com" into one zone at extraction/grouping).
      if (effectiveExtractionLevel === 'sentence' && clusteredZones.length > 0) {
        clusteredZones = KitabooFxlService.mergeConsecutiveUrlZones(clusteredZones);
      }

      // For multi-line sentence zones: recompute bbox from lines so it's tight (prevents elongated/wrong bbox from PDF)
      clusteredZones.forEach((z) => {
        if (Array.isArray(z.lines) && z.lines.length > 1) {
          let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
          for (const ln of z.lines) {
            if (ln.bbox && ln.bbox.length >= 4) {
              minX = Math.min(minX, ln.bbox[0]);
              minY = Math.min(minY, ln.bbox[1]);
              maxX = Math.max(maxX, ln.bbox[2]);
              maxY = Math.max(maxY, ln.bbox[3]);
            } else if (ln.origin && ln.origin.length >= 2) {
              const ox = Number(ln.origin[0]);
              const oy = Number(ln.origin[1]);
              const estW = (z.w || 100) / z.lines.length;
              const estH = (z.fontSize || 12) * 1.2;
              minX = Math.min(minX, ox);
              minY = Math.min(minY, oy);
              maxX = Math.max(maxX, ox + estW);
              maxY = Math.max(maxY, oy + estH);
            }
          }
          if (minX !== Infinity) {
            z.x = minX;
            z.y = minY;
            z.w = Math.max(maxX - minX, 1);
            z.h = Math.max(maxY - minY, 1);
          }
        }
      });

      // Cap zone height to actual text only when we have explicit multi-line data (z.lines).
      // Glyph-built sentence zones have no .lines but span multiple lines; do NOT cap them to 1 line.
      clusteredZones.forEach((z) => {
        const fsize = z.fontSize || 12;
        const lineCount = (z.lines && z.lines.length > 1) ? z.lines.length : null;
        if (lineCount != null) {
          const maxH = fsize * (lineCount * 1.4);
          if (z.h > maxH) {
            z.h = maxH;
          }
        }
        // If no .lines (e.g. zones from glyph grouping), keep z.h as-is so full sentence is covered
      });

      // Preserve exact PDF position: do not shift z.x/z.y. Only optionally shrink w/h from right/bottom so bbox hugs text.
      // Skip shrink on TOC/intro pages to prevent text collapsing (clipping last character).
      const MIN_W = 4;
      const MIN_H = 4;
      if (!tocPage) {
        clusteredZones.forEach((z) => {
          if (z.w > MIN_W && z.h > MIN_H) {
            const dx = Math.min(2, (z.w - 1) / 2);
            const dy = Math.min(2, (z.h - 1) / 2);
            z.w = Math.max(z.w - 2 * dx, 1);
            z.h = Math.max(z.h - 2 * dy, 1);
          }
        });
      }

      // All zones as polygon: use text-outline polygon for multi-line (sentence) zones, else bbox. Till TOC we use rectangle boxes only.
      // Slightly larger right padding on TOC/pre-TOC pages so long right-column lines don't look clipped.
      const clipPadR = tocPage ? 10 : 0;
      const clipPadL = 6;
      const clipPadT = 6;
      const clipPadB = tocPage ? 2 : 0;
      clusteredZones.forEach((z) => {
        const useRectBbox = tocPage; // TOC pages: rectangle per line; content pages: polygon outline
        if (!useRectBbox && Array.isArray(z.lines) && z.lines.length >= 2) {
          const outline = KitabooFxlService.linesToOutlinePoints(z.lines, { defaultW: (z.w || 100) / z.lines.length, defaultH: (z.fontSize || 12) * 1.2, paddingLeft: clipPadL, paddingTop: clipPadT });
          if (outline) z.points = outline;
          else z.points = KitabooFxlService.bboxToPoints(z.x - clipPadL, z.y - clipPadT, (z.w || 0) + clipPadL + clipPadR, (z.h || 0) + clipPadT + clipPadB);
        } else {
          z.points = KitabooFxlService.bboxToPoints(z.x - clipPadL, z.y - clipPadT, (z.w || 0) + clipPadL + clipPadR, (z.h || 0) + clipPadT + clipPadB);
        }
      });

      // Multi-column layout (TOC/intro): first column all rows, then second column all rows; one block per row
      const pageWidth = img.width || img.dimensions?.width || (pageCoords && pageCoords.width) || 0;
      let finalZones = (tocPage && clusteredZones.length >= 2 && pageWidth > 0)
        ? KitabooFxlService.reorderZonesForMultiColumnLayout(clusteredZones, pageWidth)
        : clusteredZones;
      if (tocPage && finalZones !== clusteredZones) {
        console.log(`[KitabooFXL] Page ${img.pageNumber}: multi-column layout applied (${clusteredZones.length} zones, column-first order).`);
      }

      // RCA fix: Image Credits (and similar) have one line with multiple items separated by ";". Split into one zone per item (after reorder so full-width lines stay in order).
      if (tocPage && finalZones.length > 0) {
        finalZones = KitabooFxlService.splitSemicolonSeparatedZones(finalZones, `p${img.pageNumber}`);
      }

      // Till TOC we have rectangle boxes: on TOC/pre-TOC pages (pageNumber <= tocEndPage) every zone must be a rectangle (4-point bbox), never polygon outline.
      if (tocPage && finalZones.length > 0) {
        const clipPadL = 6;
        const clipPadT = 6;
        // Slightly larger right padding so the last glyph isn't "cut" by the zone box (common on right column lines).
        const clipPadR = 10;
        const clipPadB = 2;
        finalZones.forEach((z) => {
          z.points = KitabooFxlService.bboxToPoints(z.x - clipPadL, z.y - clipPadT, (z.w || 0) + clipPadL + clipPadR, (z.h || 0) + clipPadT + clipPadB);
        });
      }

      // Save zones to DB (and expose to Studio). Zoning Studio must receive ONLY grouped zones (word or sentence), never raw glyph items.
      if (finalZones.length > 0) {
        await KitabooZoneModel.saveZonesForJob(jobId, pdfId, img.pageNumber, finalZones);
      }

      pages.push({
        pageNumber: img.pageNumber,
        imagePath: finalImgPath,
        dimensions: { width: img.width, height: img.height }, // Pixel dimensions (300 DPI)
        pointsDimensions: pageCoords ? { width: pageCoords.width, height: pageCoords.height } : null, // Original PDF points (72 DPI)
        zones: finalZones, // Grouped only (p6_w0, p6_w1 or p6_s0, p6_s1). Never raw pageCoords.items.
        fonts: pageFonts
      });
    }

    // Phase 4: Proactive AI Font Mapping
    const allFonts = [...new Set(pages.flatMap(p => p.fonts || []))];
    if (allFonts.length > 0 && process.env.GEMINI_API_KEY) {
      console.log(`[KitabooFXL] Analyzing ${allFonts.length} unique fonts for Gemini mapping...`);
      try {
        const prompt = `Here are the font names extracted from a PDF via PyMuPDF:
${allFonts.map(f => `- ${f}`).join('\n')}

For each font, identify the closest matching Google Font. 
Return only a JSON mapping object: { "PDF Font Name": "Google Font Name" }.`;
        const mappingJson = await GeminiService.generateContent(prompt, { modelName: 'gemini-2.5-flash' });
        const mapping = KitabooFxlService.safeParseJsonObject(mappingJson);
        if (mapping && typeof mapping === 'object') {
          KitabooFxlService._fontMappingCache = KitabooFxlService._fontMappingCache || {};
          KitabooFxlService._fontMappingCache[jobId] = mapping;
          console.log('[KitabooFXL] AI Font Mapping stored.');
        }
      } catch (err) { }
    }

    // Phase 4b: Automatic Artistic Style Refinement (Logic step requested for "apply automatic")
    // Identify headers and apply their visual styles (color, stroke) automatically.
    if (process.env.GEMINI_API_KEY) {
      console.log(`[KitabooFXL] Phase 4b: Refining artistic styles via AI...`);
      try {
        for (const page of pages) {
          const pt = pageTypeByNumber[page.pageNumber] || (page.pageNumber === 1 ? 'cover' : 'regular');
          const isHeroPage = pt === 'cover' || pt === 'chapterTitle';
          const headers = page.zones.filter(z => {
            const fs = z.fontSize || 0;
            return fs >= 20 || isHeroPage;
          }); // Focus on likely titles; always include text on hero pages
          if (headers.length === 0) continue;

          const imagePath = path.join(intermediateDir, 'high_fidelity_render', `page_${page.pageNumber}.png`);
          const imageBase64 = (await fs.readFile(imagePath)).toString('base64');

          const prompt = `Analyze this page image. I have already extracted some text zones, but I need you to identify the ARTISTIC STYLES for the following titles:
${headers.map(h => `- "${h.content}" at approx x=${h.x}, y=${h.y}`).join('\n')}

For each title, identify fill color (hex), stroke/outline color (hex), and stroke width (pixels).
IMPORTANT: If a title has MULTIPLE WORDS (e.g. "Horses Up Close"), return SEPARATE entries for each word or phrase that has a DIFFERENT style, so "Horses" can have a bold outlined style and "Up Close" a simpler style.
Example for "Horses Up Close": { "Horses": { "color": "#hex", "strokeColor": "#hex", "strokeWidth": 2 }, "Up Close": { "color": "#hex" } }.
Single-word titles: one entry. Use the exact word or phrase as key.

Return a JSON mapping: { "exact text or word": { "color": "#hex", "strokeColor": "#hex", "strokeWidth": pixels } }.
Only return JSON.`;

          const response = await GeminiService.generateContent([
            { text: prompt },
            { inlineData: { mimeType: 'image/png', data: imageBase64 } }
          ], { modelName: 'gemini-2.5-flash' });

          const styleMap = KitabooFxlService.safeParseJsonObject(response);
          if (styleMap && typeof styleMap === 'object') {
            page.zones.forEach(z => {
              let style = styleMap[z.content];
              // When zones are word-level, AI often returns one key for the full title (e.g. "All About Horses").
              // Match by finding a key that contains this zone's content; prefer the LONGEST matching key so
              // "Horses" gets the full title style ("All About Horses" = white) not a shorter key with black.
              if (!style && (z.content || '').trim()) {
                const word = (z.content || '').trim();
                const matchingKeys = Object.keys(styleMap).filter((k) => {
                  if (!k) return false;
                  if (k === word) return true;
                  const asWord = k.indexOf(` ${word} `) >= 0 || k.startsWith(`${word} `) || k.endsWith(` ${word}`);
                  return asWord || k.includes(word);
                });
                const key = matchingKeys.length > 0
                  ? matchingKeys.reduce((a, b) => (a.length >= b.length ? a : b))
                  : null;
                if (key) style = styleMap[key];
              }
              if (style) {
                z.color = style.color || z.color;
                z.strokeColor = style.strokeColor || null;
                z.strokeWidth = style.strokeWidth || null;
                if (z.strokeColor) z.type = 'header'; // Auto-promote to header if it has artistic stroke
              }
              // Sentence-level: apply per-word artistic styles so "Horses" gets same outline/gradient as word-level.
              if (Array.isArray(z.styleRuns) && z.styleRuns.length > 0 && (z.content || '').trim().includes(' ')) {
                const content = (z.content || z.text || '').trim();
                z.styleRuns.forEach((run) => {
                  const segment = content.slice(run.start, run.end).trim();
                  if (!segment) return;
                  let runStyle = styleMap[segment];
                  if (!runStyle && segment.length > 0) {
                    const matchingKeys = Object.keys(styleMap).filter((k) => k === segment || k.includes(segment) || segment.includes(k));
                    const key = matchingKeys.length > 0 ? matchingKeys.reduce((a, b) => (a.length >= b.length ? a : b)) : null;
                    if (key) runStyle = styleMap[key];
                  }
                  if (runStyle) {
                    if (runStyle.color != null) run.color = runStyle.color;
                    if (runStyle.strokeColor != null) run.strokeColor = runStyle.strokeColor;
                    if (runStyle.strokeWidth != null) run.strokeWidth = runStyle.strokeWidth;
                  }
                });
              }
            });
            // Re-save refined zones
            await KitabooZoneModel.saveZonesForJob(jobId, pdfId, page.pageNumber, page.zones);
          }
        }
        report(98, 'Phase 4b: Artistic Styles Applied');
      } catch (err) {
        console.warn('[KitabooFXL] AI Style Refinement failed:', err.message);
      }
    }


    // Phase 5: Font Discovery
    const fontDir = path.join(renderedDir, 'fonts');
    const fontsJsonPath = path.join(renderedDir, 'fonts.json');
    let extractedFonts = [];
    try {
      const mapping = JSON.parse(await fs.readFile(fontsJsonPath, 'utf8'));
      extractedFonts = Object.entries(mapping).map(([rawName, filename]) => ({
        rawName,
        filename,
        name: rawName, // The raw name used in PDF coords and AI logic
        woff2Filename: KitabooFxlService.isWoff2ConvertibleFont(filename)
          ? KitabooFxlService.getWoff2FileName(filename)
          : null,
        path: `/backend/html_intermediate/kitaboo_${jobId}/high_fidelity_render/fonts/${filename}`
      }));
      console.log(`[KitabooFXL] Discovered ${extractedFonts.length} extracted fonts via mapping.`);
    } catch (e) {
      // Fallback: directory listing (will have messy names)
      try {
        const fontFiles = await fs.readdir(fontDir);
        extractedFonts = fontFiles.map(f => ({
          filename: f,
          rawName: f.substring(0, f.lastIndexOf('.')),
          name: f.substring(0, f.lastIndexOf('.')),
          woff2Filename: KitabooFxlService.isWoff2ConvertibleFont(f)
            ? KitabooFxlService.getWoff2FileName(f)
            : null,
          path: `/backend/html_intermediate/kitaboo_${jobId}/high_fidelity_render/fonts/${f}`
        }));
        console.log(`[KitabooFXL] Discovered ${extractedFonts.length} fonts via directory listing.`);
      } catch (err) {
        // No fonts
      }
    }

    // Final Step: Persist Job Metadata to disk so it survives server restart
    const metadataPath = path.join(renderedDir, 'job_metadata.json');
    const jobMetadata = {
      jobId,
      pdfId,
      extractionLevel: 'glyph', // Always glyph; coords and EPUB use glyph-level data
      zoneLevel, // word | sentence — how zones were built for Studio
      fontMapping: KitabooFxlService._fontMappingCache[jobId] || {},
      extractedFonts,
      pagesMetadata: pages.map(p => ({
        pageNumber: p.pageNumber,
        dimensions: p.dimensions,
        pointsDimensions: p.pointsDimensions,
        pageType: pageTypeByNumber[p.pageNumber] || (p.pageNumber === 1 ? 'cover' : 'regular')
      }))
    };
    await fs.writeFile(metadataPath, JSON.stringify(jobMetadata, null, 2));
    console.log(`[KitabooFXL] Job metadata persisted to ${metadataPath}`);

    report(100, 'Complete');
    return { jobId, pages, extractedFonts };