/**
 * md2hwpx — HwpxBuilder (v0.5.0)
 *
 * IR + 양식 .hwpx + 매핑 → 새 .hwpx Blob
 *
 * v0.5.0 범위:
 *   - 헤딩(H1~H6) + 단락 → <hp:p styleIDRef="..." paraPrIDRef="...">
 *   - 인라인 bold/italic/strike: charPr 동적 생성 (header.xml 에 추가 + itemCnt 갱신)
 *   - 인라인 link: charPr 동적 생성 (밑줄 + 색상 #0563C1) — 실제 하이퍼링크 컨테이너는 v0.6+
 *
 * 핵심 결정 (dev.md §5.3):
 *   - 본문(section0.xml)은 string concat — XMLSerializer 가 hp:/hh: prefix 망가뜨리는 함정 회피
 *   - header.xml 만 정규식·동적 삽입으로 갱신 (DOMParser 도 prefix 문제 있어서 string 조작)
 *   - 양식의 첫 단락에 있던 <hp:secPr> 는 추출해서 새 본문 첫 <hp:run> 자리에 보존
 *
 * 표준 출처: KS X 6101:2018 (OWPML), 한컴 HWP 5.0 PDF
 */
'use strict';

window.HwpxBuilder = (() => {

  // v1.1.1: 표 열폭 내용 최적화 on/off (변환 탭 체크박스). build 마다 ir.options 로 설정.
  let _fitColumns = true;

  /**
   * @param {Object} args
   *   ir: { blocks: Block[] }
   *   analysis: PresetManager.analyzeHwpx 결과 ({styles, headerXml, sectionXml, zip})
   *   mapping: { h1, h2, h3, h4, h5, h6, paragraph } — 각 값은 styleID 문자열
   *   hwpxArrayBuffer: 양식 .hwpx 원본 (zip 을 다시 로드해서 부수효과 회피)
   * @returns {Promise<Blob>} 변환된 .hwpx
   */
  async function buildHwpx({ ir, analysis, mapping, hwpxArrayBuffer, imageRegistry }) {
    if (typeof JSZip === 'undefined') throw new Error('JSZip 미로드');
    if (!ir || !ir.blocks) throw new Error('IR 누락');
    if (!analysis || !analysis.styles) throw new Error('양식 분석 결과 누락');
    if (!mapping) throw new Error('매핑 누락');
    if (!hwpxArrayBuffer) throw new Error('hwpxArrayBuffer 누락');
    // v1.1.1: 표 열폭 최적화 플래그 (기본 ON, 명시적으로 false 일 때만 균등 분배)
    _fitColumns = !(ir.options && ir.options.fitColumns === false);
    // v0.9.0: imageRegistry 는 옵션. 없으면 image run 은 alt 텍스트 fallback.

    // 1. 양식 zip 새로 로드 (analysis.zip 을 부수 변경하지 않기 위해)
    const zip = await JSZip.loadAsync(hwpxArrayBuffer);
    const headerXml  = await zip.file('Contents/header.xml').async('string');
    const sectionXml = await zip.file('Contents/section0.xml').async('string');

    // 2. styleID → {paraPrIDRef, charPrIDRef} 맵
    const styleMap = new Map(analysis.styles.map(s => [String(s.id), s]));

    // v0.5.10: 양식의 표 템플릿 (셀 위치별 borderFillID)
    const tableTemplate = analysis.tableTemplate || null;

    // v0.5.13: 양식에 존재하는 borderFillID 집합 — emission 시 sanitize 용
    //   (존재하지 않는 ID 참조 시 한컴이 거부)
    const validBfIds = new Set((analysis.borderFills || []).map(bf => String(bf.id)));
    const defaultBfId = (analysis.borderFills && analysis.borderFills.length > 0)
      ? String(analysis.borderFills[0].id)
      : '1';

    // 3. header 편집기 — charPr 동적 추가 + itemCnt 갱신용
    const editor = createHeaderEditor(headerXml);

    // 4. 양식의 첫 단락에서 <hp:secPr> 추출 (페이지 셋업 보존)
    const secPr = extractFirstSecPr(sectionXml);

    // v0.6.2: 리스트 깊이별 paraPr indent 옵션
    const listAutoIndent = !!(ir.options && ir.options.listAutoIndent);
    // v0.7.0: 표 스타일 spec (그래픽 편집기에서 변경)
    const tableStyle = (ir.options && ir.options.tableStyle) || null;

    // 5. 본문 단락들 string concat (각주 본문 footnoteBodies + 표 템플릿 전달)
    //    v0.9.0: 양식의 secPr 에서 본문 가용 폭 추출 → 이미지 자동 축소 기준값으로 사용
    const maxBodyWidthHwp = extractMaxBodyWidthHwp(sectionXml) || 42519;
    const bodyXml = buildBody(ir.blocks, styleMap, mapping, editor, secPr, ir.footnoteBodies || {}, tableTemplate, validBfIds, defaultBfId, listAutoIndent, tableStyle, imageRegistry, maxBodyWidthHwp);

    // 6. section0.xml 재조립 (시작 태그 + 새 본문 + 닫기)
    const newSectionXml = rebuildSectionXml(sectionXml, bodyXml);

    // 7. header.xml 최종 직렬화
    const newHeaderXml = editor.serialize();

    // 8. ZIP 갱신 → Blob
    zip.file('Contents/header.xml',  newHeaderXml);
    zip.file('Contents/section0.xml', newSectionXml);

    // v0.9.0: 이미지 임베드 — BinData/imageN.<ext> 추가 + content.hpf opf:manifest 갱신
    if (imageRegistry && imageRegistry.size && imageRegistry.size() > 0 && window.ImageEmbed) {
      window.ImageEmbed.emitBinData(zip, imageRegistry);
      await window.ImageEmbed.patchContentHpf(zip, imageRegistry);
    }

    return await zip.generateAsync({
      type: 'blob',
      mimeType: 'application/hwp+zip',
      compression: 'DEFLATE',
    });
  }

  /**
   * v0.9.0: 양식의 section0.xml 에서 첫 hp:pagePr/hp:margin 을 찾아
   * 본문 가용 가로 폭 (HWPUNIT) 계산.
   *   = page width − left margin − right margin − gutter
   * 추출 실패 시 null 반환 → caller 가 기본 150mm (42519) 사용.
   */
  function extractMaxBodyWidthHwp(sectionXml) {
    if (!sectionXml) return null;
    const pageM = /<hp:pagePr\b[^>]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/i.exec(sectionXml);
    if (!pageM) return null;
    const pageW = parseInt(pageM[1], 10);
    if (!Number.isFinite(pageW)) return null;
    const marM = /<hp:margin\b[^>]*\sleft="(\d+)"[^>]*\sright="(\d+)"/i.exec(sectionXml);
    let leftM = 8504, rightM = 8504; // 30mm default
    if (marM) { leftM = parseInt(marM[1], 10); rightM = parseInt(marM[2], 10); }
    let gutter = 0;
    const gM = /<hp:margin\b[^>]*\sgutter="(\d+)"/i.exec(sectionXml);
    if (gM) gutter = parseInt(gM[1], 10) || 0;
    const w = pageW - leftM - rightM - gutter;
    return w > 0 ? w : null;
  }

  // ────────────────────────────────────────────────────────────────────
  // header.xml 편집기 — charPr 동적 추가 + itemCnt 갱신
  // ────────────────────────────────────────────────────────────────────

  function createHeaderEditor(headerXmlText) {
    let xml = headerXmlText;
    const cache = new Map(); // `${baseCharPrId}|${formatKey}` → newCharPrId
    // 기존 charPr id 의 최댓값 추적 (새 id 부여 시작점)
    let maxCharPrId = -1;
    const idRe = /<hh:charPr\s+id="([^"]+)"/g;
    let m;
    while ((m = idRe.exec(xml)) !== null) {
      const n = parseInt(m[1], 10);
      if (Number.isFinite(n) && n > maxCharPrId) maxCharPrId = n;
    }

    // v0.5.17: borderFill 동적 추가용 — 기존 max id 추적
    let maxBorderFillId = 0;
    const bfIdRe = /<hh:borderFill\s+id="([^"]+)"/g;
    let bfm;
    while ((bfm = bfIdRe.exec(xml)) !== null) {
      const n = parseInt(bfm[1], 10);
      if (Number.isFinite(n) && n > maxBorderFillId) maxBorderFillId = n;
    }
    const borderFillCache = new Map(); // spec key → id

    /**
     * v0.5.17: borderFill 사양으로부터 header.xml 에 새 borderFill 추가하고 ID 반환.
     *   동일 사양 캐시 — 같은 spec 은 같은 ID 재사용.
     * @param {Object} sides — { left, top, right, bottom } 각각 { type, width }
     *   type: 'NONE' | 'SOLID' | 'DOUBLE'
     *   width: '0.1 mm' | '0.12 mm' | '0.4 mm' 등
     * @param {string|null} fillColor — '#EEEEEE' 같은 음영색, null 이면 음영 없음
     * @returns {string} new borderFill ID
     */
    function addOrCreateBorderFill(sides, fillColor) {
      const key = JSON.stringify({ sides, fillColor });
      if (borderFillCache.has(key)) return borderFillCache.get(key);

      const newId = String(++maxBorderFillId);
      const sideXml = (name, side) =>
        `<hh:${name} type="${side.type}" width="${side.width}" color="#000000"/>`;
      // v0.5.18: 네임스페이스 fix — hc:fillBrush (hh 아님). winBrush 구조 한컴 표준값.
      //   사용자 제공 표 샘플.hwpx ID 5 분석 결과 정확한 구조 확인됨.
      const fillXml = fillColor
        ? `<hc:fillBrush><hc:winBrush faceColor="${fillColor}" hatchColor="#999999" alpha="0"/></hc:fillBrush>`
        : '';

      const newBlock =
        `<hh:borderFill id="${newId}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0">` +
          `<hh:slash type="NONE" Crooked="0" isCounter="0"/>` +
          `<hh:backSlash type="NONE" Crooked="0" isCounter="0"/>` +
          sideXml('leftBorder',   sides.left) +
          sideXml('rightBorder',  sides.right) +
          sideXml('topBorder',    sides.top) +
          sideXml('bottomBorder', sides.bottom) +
          `<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>` +
          fillXml +
        `</hh:borderFill>`;

      // </hh:borderFills> 직전에 삽입
      xml = xml.replace(/<\/hh:borderFills>/, newBlock + '</hh:borderFills>');
      // itemCnt 갱신
      xml = xml.replace(
        /<hh:borderFills\s+itemCnt="(\d+)"/,
        (_, c) => `<hh:borderFills itemCnt="${parseInt(c, 10) + 1}"`
      );

      borderFillCache.set(key, newId);
      return newId;
    }

    /**
     * baseCharPrId 에 서식(bold/italic/strike/link) 을 적용한 새 charPrId 반환.
     * 동일 조합 캐시 — 같은 (base, format) 는 같은 newId 재사용.
     * @param {Set<string>} fmt — 'BOLD','ITALIC','STRIKE','LINK' 의 일부
     */
    function getOrCreateCharPr(baseCharPrId, fmt) {
      if (!fmt || fmt.size === 0) return String(baseCharPrId);
      const key = `${baseCharPrId}|${[...fmt].sort().join(',')}`;
      if (cache.has(key)) return cache.get(key);

      const baseBlock = extractCharPrBlock(xml, baseCharPrId);
      if (!baseBlock) {
        // base 못 찾으면 그대로 반환 (안전 fallback)
        cache.set(key, String(baseCharPrId));
        return String(baseCharPrId);
      }

      const newId = String(++maxCharPrId);
      const newBlock = mutateCharPrBlock(baseBlock, newId, fmt);

      // </hh:charProperties> 직전에 새 charPr 삽입
      xml = xml.replace(/<\/hh:charProperties>/, newBlock + '</hh:charProperties>');
      // itemCnt 갱신
      xml = xml.replace(
        /<hh:charProperties\s+itemCnt="(\d+)"/,
        (_, c) => `<hh:charProperties itemCnt="${parseInt(c, 10) + 1}"`
      );

      cache.set(key, newId);
      return newId;
    }

    // ────────────────────────────────────────────────────────────────────
    // v0.6.2: paraPr 동적 추가 — 리스트 깊이별 marginLeft indent
    // ────────────────────────────────────────────────────────────────────
    let maxParaPrId = -1;
    const paraIdRe = /<hh:paraPr\s+id="([^"]+)"/g;
    let pm;
    while ((pm = paraIdRe.exec(xml)) !== null) {
      const n = parseInt(pm[1], 10);
      if (Number.isFinite(n) && n > maxParaPrId) maxParaPrId = n;
    }
    const paraPrCache = new Map(); // `${baseId}|${extraIndent}` → newId

    /**
     * baseParaPrId 의 `<hc:left value="N"...>` 에 extraIndent 를 더한 새 paraPr 반환.
     *   case + default (HWPUNITCHAR 분기) 양쪽 모두 갱신.
     *   동일 (base, extra) 캐시 — 같은 조합은 같은 id 재사용.
     * @param {string|number} baseId
     * @param {number} extraIndent — HWPUNIT 단위. 양수면 더 깊이 들여쓰기
     */
    function addOrCreateParaPr(baseId, extraIndent) {
      if (!extraIndent) return String(baseId);
      const key = `${baseId}|${extraIndent}`;
      if (paraPrCache.has(key)) return paraPrCache.get(key);

      const baseBlock = extractBlockById(xml, 'hh:paraPr', baseId);
      if (!baseBlock) {
        // 못 찾으면 그대로 fallback
        paraPrCache.set(key, String(baseId));
        return String(baseId);
      }

      const newId = String(++maxParaPrId);
      let newBlock = baseBlock.replace(/\bid="[^"]*"/, `id="${newId}"`);

      // <hc:left value="N" unit="HWPUNIT"/> 의 value 를 base+extra 로 (case + default 둘 다)
      newBlock = newBlock.replace(
        /<hc:left\s+value="(\d+)"\s+unit="HWPUNIT"\s*\/>/g,
        (_, v) => `<hc:left value="${parseInt(v, 10) + extraIndent}" unit="HWPUNIT"/>`
      );

      // </hh:paraProperties> 직전에 삽입
      xml = xml.replace(/<\/hh:paraProperties>/, newBlock + '</hh:paraProperties>');
      // itemCnt 갱신
      xml = xml.replace(
        /<hh:paraProperties\s+itemCnt="(\d+)"/,
        (_, c) => `<hh:paraProperties itemCnt="${parseInt(c, 10) + 1}"`
      );

      paraPrCache.set(key, newId);
      return newId;
    }

    /**
     * v0.6.5: 그림 placeholder 용 paraPr —
     *   baseParaPr 복제 + align CENTER + border 를 dottedBfId 로 + 약간 위/아래 마진
     *   "[그림]", "[그림: 캡션]" 등의 paragraph 에 점선 박스 효과
     * @param {string|number} baseId
     * @param {string|number} dottedBfId — addOrCreateBorderFill 로 미리 만든 점선 4면 borderFill id
     */
    function addOrCreatePlaceholderParaPr(baseId, dottedBfId) {
      const key = `placeholder|${baseId}|${dottedBfId || 'noborder'}`;
      if (paraPrCache.has(key)) return paraPrCache.get(key);

      const baseBlock = extractBlockById(xml, 'hh:paraPr', baseId);
      if (!baseBlock) {
        paraPrCache.set(key, String(baseId));
        return String(baseId);
      }

      const newId = String(++maxParaPrId);
      let newBlock = baseBlock.replace(/\bid="[^"]*"/, `id="${newId}"`);

      // <hh:align horizontal="..."/> CENTER 로 교체
      newBlock = newBlock.replace(
        /<hh:align\s+horizontal="[^"]*"\s+vertical="([^"]*)"\s*\/>/,
        (_, v) => `<hh:align horizontal="CENTER" vertical="${v}"/>`
      );

      // v0.6.10: dottedBfId 있을 때만 border 교체 (paragraph border 사용 시).
      //   1×1 표 안 cell paragraph 에서는 border X (셀 자체 border 가 그림 자리).
      if (dottedBfId) {
        // v0.6.9: paragraph 사이 여백 (paragraph border 일 때만)
        newBlock = newBlock.replace(
          /<hc:prev\s+value="0"\s+unit="HWPUNIT"\s*\/>/g,
          '<hc:prev value="850" unit="HWPUNIT"/>'
        );
        newBlock = newBlock.replace(
          /<hc:next\s+value="0"\s+unit="HWPUNIT"\s*\/>/g,
          '<hc:next value="850" unit="HWPUNIT"/>'
        );

        const newBorderXml = `<hh:border borderFillIDRef="${dottedBfId}" offsetLeft="283" offsetRight="283" offsetTop="567" offsetBottom="567" connect="0" ignoreMargin="0"/>`;
        if (/<hh:border\b[^/]*\/>/.test(newBlock)) {
          newBlock = newBlock.replace(/<hh:border\b[^/]*\/>/, newBorderXml);
        } else {
          newBlock = newBlock.replace(/<\/hh:paraPr>/, `${newBorderXml}</hh:paraPr>`);
        }
      }

      xml = xml.replace(/<\/hh:paraProperties>/, newBlock + '</hh:paraProperties>');
      xml = xml.replace(
        /<hh:paraProperties\s+itemCnt="(\d+)"/,
        (_, c) => `<hh:paraProperties itemCnt="${parseInt(c, 10) + 1}"`
      );

      paraPrCache.set(key, newId);
      return newId;
    }

    return {
      getOrCreateCharPr,
      addOrCreateBorderFill,
      addOrCreateParaPr,
      addOrCreatePlaceholderParaPr,
      serialize: () => xml,
    };
  }

  /** 일반화된 블록 추출 — id 로 찾기 (자기닫기 / 일반 둘 다) */
  function extractBlockById(xml, tagName, id) {
    const escId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const escTag = tagName.replace(/[:.]/g, m => '\\' + m);
    const selfClose = new RegExp(`<${escTag}\\s+id="${escId}"[^>]*/>`);
    let m = xml.match(selfClose);
    if (m) return m[0];
    const open = new RegExp(`<${escTag}\\s+id="${escId}"[^>]*?>`);
    m = xml.match(open);
    if (!m) return null;
    const startIdx = m.index;
    const closeIdx = xml.indexOf(`</${tagName}>`, startIdx);
    if (closeIdx < 0) return null;
    return xml.slice(startIdx, closeIdx + `</${tagName}>`.length);
  }

  /** header.xml 안에서 특정 id 의 <hh:charPr ...>...</hh:charPr> 블록 추출 (자기닫기 포함) */
  function extractCharPrBlock(headerXml, charPrId) {
    const escId = String(charPrId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    // 자기닫기: <hh:charPr id="N" ... />
    const selfClose = new RegExp(`<hh:charPr\\s+id="${escId}"[^>]*/>`);
    let m = headerXml.match(selfClose);
    if (m) return m[0];
    // 일반: <hh:charPr id="N" ...>...</hh:charPr>
    const open = new RegExp(`<hh:charPr\\s+id="${escId}"[^>]*?>`);
    m = headerXml.match(open);
    if (!m) return null;
    const startIdx = m.index;
    const closeIdx = headerXml.indexOf('</hh:charPr>', startIdx);
    if (closeIdx < 0) return null;
    return headerXml.slice(startIdx, closeIdx + '</hh:charPr>'.length);
  }

  /**
   * baseBlock(원본 charPr XML 문자열) 을 복제하고 id 교체 + 서식 추가/덮어쓰기.
   * 자기닫기 였으면 일반 블록으로 변환 (자식 요소를 넣기 위해).
   */
  function mutateCharPrBlock(baseBlock, newId, fmt) {
    let block = baseBlock;

    // 자기닫기 → 일반 블록
    if (block.endsWith('/>')) {
      block = block.slice(0, -2) + '></hh:charPr>';
    }

    // id="..." 교체
    block = block.replace(/\bid="[^"]*"/, `id="${newId}"`);

    // 매핑 안 된 단락 → 빨간 글자 (사용자가 한컴에서 직접 검토·수정용)
    // v0.7.7: § 검증 표시 (REVIEW) 도 동일 색 #DC143C — 사용자가 한컴에서 검증·수정용
    // MISSED / REVIEW 가 LINK 보다 우선 — 매핑 안 된 / 검증 필요가 더 중요한 신호
    if (fmt.has('MISSED') || fmt.has('REVIEW')) {
      if (/\btextColor="[^"]*"/.test(block)) {
        block = block.replace(/\btextColor="[^"]*"/, `textColor="#DC143C"`);
      } else {
        block = block.replace(/<hh:charPr\s+id="([^"]+)"/, `<hh:charPr id="$1" textColor="#DC143C"`);
      }
    } else if (fmt.has('LINK')) {
      // 링크면 textColor + 밑줄 변경 (MISSED 가 아닐 때만)
      if (/\btextColor="[^"]*"/.test(block)) {
        block = block.replace(/\btextColor="[^"]*"/, `textColor="#0563C1"`);
      } else {
        block = block.replace(/<hh:charPr\s+id="([^"]+)"/, `<hh:charPr id="$1" textColor="#0563C1"`);
      }
    }

    // 자식 요소 (bold/italic/strike/underline) 처리 — open 태그 직후 삽입
    const openEnd = block.indexOf('>');
    if (openEnd < 0) return block;
    const openTag = block.slice(0, openEnd + 1);
    let body = block.slice(openEnd + 1, block.lastIndexOf('</hh:charPr>'));

    if (fmt.has('BOLD')) {
      body = ensureSelfClosingTag(body, 'hh:bold', '');
    }
    if (fmt.has('ITALIC')) {
      body = ensureSelfClosingTag(body, 'hh:italic', '');
    }
    if (fmt.has('STRIKE')) {
      // 기존 strikeout 이 있으면 type/shape 갱신, 없으면 추가
      body = ensureTagAttr(body, 'hh:strikeout',
        ' type="CONTINUOUS" shape="SOLID" color="#000000"');
    }
    if (fmt.has('LINK')) {
      // 밑줄 추가 (NONE 이 아닌 값으로)
      body = ensureTagAttr(body, 'hh:underline',
        ' type="BOTTOM" shape="SOLID" color="#0563C1"');
    } else if (fmt.has('UNDERLINE')) {
      // v1.5.0: 일반 밑줄 (docx <u> 보존) — 검정
      body = ensureTagAttr(body, 'hh:underline',
        ' type="BOTTOM" shape="SOLID" color="#000000"');
    }

    return `${openTag}${body}</hh:charPr>`;
  }

  /** body 에 <tag .../> 가 없으면 추가 (있으면 그대로) */
  function ensureSelfClosingTag(body, tag, attrs) {
    const re = new RegExp(`<${tag.replace(/[:.]/g, m => '\\' + m)}\\b[^>]*\\/>`);
    if (re.test(body)) return body;
    return body + `<${tag}${attrs}/>`;
  }

  /** body 에 <tag ...> 가 있으면 attribute 부분 교체, 없으면 추가 */
  function ensureTagAttr(body, tag, attrs) {
    const reAny = new RegExp(`<${tag.replace(/[:.]/g, m => '\\' + m)}\\b[^>]*\\/?>`, 'g');
    if (reAny.test(body)) {
      body = body.replace(reAny, `<${tag}${attrs}/>`);
      return body;
    }
    return body + `<${tag}${attrs}/>`;
  }

  // ────────────────────────────────────────────────────────────────────
  // section0.xml — body 조립
  // ────────────────────────────────────────────────────────────────────

  /** 양식 section0.xml 의 첫 <hp:p> 안에서 <hp:secPr> 추출 (없으면 null) */
  function extractFirstSecPr(sectionXml) {
    const firstP = sectionXml.match(/<hp:p\b[^>]*>([\s\S]*?)<\/hp:p>/);
    if (!firstP) return null;
    const inner = firstP[1];
    // self-closing
    const sc = inner.match(/<hp:secPr\b[^>]*\/>/);
    if (sc) return sc[0];
    // 일반
    const open = inner.match(/<hp:secPr\b[^>]*?>/);
    if (!open) return null;
    const startIdx = open.index;
    const closeIdx = inner.indexOf('</hp:secPr>', startIdx);
    if (closeIdx < 0) return null;
    return inner.slice(startIdx, closeIdx + '</hp:secPr>'.length);
  }

  /** 새 <hs:sec> 본문 조립 */
  function buildBody(blocks, styleMap, mapping, editor, secPr, footnoteBodies, tableTemplate, validBfIds, defaultBfId, listAutoIndent, tableStyle, imageRegistry, maxBodyWidthHwp) {
    const out = [];
    let placedSecPr = false;
    let paraId = 0;
    let tableInstanceId = 1091700000; // 표 instId 시작점
    const fnCtx = { footnoteBodies: footnoteBodies || {}, styleMap, mapping, editor };
    // v0.9.0: 모든 buildRuns 호출에 imageRegistry/maxBodyWidthHwp 옵션 전파 위해 wrapping
    const runOpts = { imageRegistry, maxBodyWidthHwp };

    if (blocks.length === 0) {
      // 빈 IR 도 빈 단락 1개는 필요 (한컴은 빈 문서를 싫어함)
      blocks = [{ type: 'paragraph', runs: [{ text: '' }] }];
    }

    // v0.9.1: code-block IR 을 line 별 paragraph 로 펼침 (기존 v0.5.x 동작 — ASCII art 변환되지 않은 일반 코드 블록)
    const expanded = [];
    for (const b of blocks) {
      if (b && b.type === 'code-block') {
        const lines = String(b.text || '').split(/\r?\n/);
        for (const line of lines) {
          expanded.push({
            type: 'paragraph',
            pattern: b.pattern || 'paragraph',
            runs: [{ text: line, code: true }],
          });
        }
      } else {
        expanded.push(b);
      }
    }
    blocks = expanded;

    for (const block of blocks) {
      // v0.5.8: GFM 표 → <hp:tbl>
      if (block.type === 'table') {
        const tblXml = buildTableXml(block, styleMap, mapping, editor, tableInstanceId++, tableTemplate, validBfIds, defaultBfId, tableStyle, imageRegistry, maxBodyWidthHwp);
        // 표는 <hp:p> 안 <hp:ctrl> 자식. 빈 단락으로 감쌈.
        const cellStyleId = mapping.tableCell || mapping.paragraph;
        const cellStyle = styleMap.get(String(cellStyleId));
        const wrapParaPrId = cellStyle ? String(cellStyle.paraPrIDRef) : '0';
        const wrapStyleId  = cellStyle ? String(cellStyle.id) : '0';
        const wrapCharPrId = cellStyle ? String(cellStyle.charPrIDRef) : '0';

        // v0.5.16: 한컴이 직접 만든 표 분석 결과 — wrapper paragraph 구조 정확히 따라가야:
        //   <hp:p>
        //     <hp:run>                                 ← 첫 run: secPr + colPr (있을 때)
        //       <hp:secPr>...</hp:secPr>
        //       <hp:ctrl><hp:colPr/></hp:ctrl>
        //     </hp:run>
        //     <hp:run>                                 ← 둘째 run: 표 + 빈 <hp:t/>
        //       <hp:tbl>...</hp:tbl>
        //       <hp:t/>
        //     </hp:run>
        //     <hp:linesegarray>...</hp:linesegarray>
        //   </hp:p>
        //   <hp:p>...빈 단락...</hp:p>                  ← 후행 빈 paragraph (표 뒤 필수)
        let leadingRun = '';
        if (!placedSecPr && secPr) {
          // secPr 뒤에 colPr 자동 추가 (한컴 표준 단일 컬럼 레이아웃)
          const colPrCtrl = `<hp:ctrl><hp:colPr id="" type="NEWSPAPER" layout="LEFT" colCount="1" sameSz="1" sameGap="0"/></hp:ctrl>`;
          leadingRun = `<hp:run charPrIDRef="${wrapCharPrId}">${secPr}${colPrCtrl}</hp:run>`;
          placedSecPr = true;
        }

        const tableRun = `<hp:run charPrIDRef="${wrapCharPrId}">${tblXml}<hp:t/></hp:run>`;

        const wrapLineseg =
          `<hp:linesegarray>` +
            `<hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="42520" flags="393216"/>` +
          `</hp:linesegarray>`;

        out.push(
          `<hp:p id="${paraId++}" paraPrIDRef="${wrapParaPrId}" styleIDRef="${wrapStyleId}" pageBreak="0" columnBreak="0" merged="0">` +
            leadingRun +
            tableRun +
            wrapLineseg +
          `</hp:p>`
        );

        // v0.5.16: 표 뒤 후행 빈 paragraph — 한컴이 표 다음 문맥을 위해 항상 요구
        const trailLineseg =
          `<hp:linesegarray>` +
            `<hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="42520" flags="393216"/>` +
          `</hp:linesegarray>`;
        out.push(
          `<hp:p id="${paraId++}" paraPrIDRef="${wrapParaPrId}" styleIDRef="${wrapStyleId}" pageBreak="0" columnBreak="0" merged="0">` +
            `<hp:run charPrIDRef="${wrapCharPrId}"/>` +
            trailLineseg +
          `</hp:p>`
        );
        continue;
      }

      const { styleId, matched } = pickStyleId(block, mapping);
      const style   = styleMap.get(String(styleId));
      if (!style) continue; // 매핑 불가 — skip (안전 fallback)

      let paraPrId = String(style.paraPrIDRef);
      const baseCharPrId = String(style.charPrIDRef);

      // v0.6.2: 리스트 깊이별 paraPr 동적 indent
      //   listAutoIndent 옵션 ON + block.listDepth > 1 이면 양식 paraPr 복제 + marginLeft 증가
      if (listAutoIndent && block.listDepth && block.listDepth > 1) {
        // depth 2: +2000 HWPUNIT (~3.5mm), depth 3: +4000 (~7mm)
        const extraIndent = (block.listDepth - 1) * 2000;
        paraPrId = editor.addOrCreateParaPr(paraPrId, extraIndent);
      }

      // v0.6.10: figure-placeholder 는 parser 에서 table 블록으로 변환됨.
      //   paragraph 분기 도달 시점엔 pattern 'figure-placeholder' 라도 type='paragraph' 면
      //   변환 누락된 경우 — 기존 paraPr 폴백.

      // 부호 중복 방지: 매핑된 양식이 자동으로 글머리 부호 추가하는 패턴인 경우
      // 본문 prefix 부호 제거 (bullet:1. / bullet:1) 는 예외 — 사용자 명시)
      // v0.7.7: heading 도 첫 글자가 알려진 위계 부호 (▮■❍○◯◆◇※●▶▷□◎ 등) 면 strip
      //         양식의 heading 스타일이 자동 prefix 박는 경우 (예: H4 ■) 사용자 입력 ■ 와 중복 → ■■ 방지
      let blockRuns = block.runs || [];
      if (block.type === 'heading') {
        blockRuns = tryStripKnownPrefix(blockRuns);
      }
      const stripSym = shouldStripBulletPrefix(block.pattern);
      if (stripSym) {
        blockRuns = stripBulletPrefix(blockRuns, stripSym);
      }

      // v0.7.7: § 검증 표시 — paragraph 전체 빨간색
      const needsReview = !!(block.flags && block.flags.needsReview);
      const runs = buildRuns(blockRuns, baseCharPrId, editor, { missed: !matched, needsReview, fnCtx, imageRegistry: runOpts.imageRegistry, maxBodyWidthHwp: runOpts.maxBodyWidthHwp });

      // 첫 단락에만 secPr 끼워넣기 (페이지 셋업 보존)
      let pInner = '';
      if (!placedSecPr && secPr) {
        pInner = `<hp:run charPrIDRef="${baseCharPrId}">${secPr}</hp:run>${runs}`;
        placedSecPr = true;
      } else {
        pInner = runs;
      }

      // v0.5.4: <hp:linesegarray> 제거 — 한컴이 파일 열 때 자동 재계산하도록 위임
      //         (이전 고정값 vertsize=2400/horzsize=42520 이 폰트·페이지와 안 맞아 줄 겹침 발생)
      out.push(
        `<hp:p id="${paraId++}" paraPrIDRef="${paraPrId}" styleIDRef="${styleId}" pageBreak="0" columnBreak="0" merged="0">` +
        pInner +
        `</hp:p>`
      );
    }

    // 비어있으면 빈 단락 1개 강제
    if (out.length === 0) {
      const fallbackStyle = styleMap.get(String(mapping.paragraph)) || styleMap.values().next().value;
      if (fallbackStyle) {
        out.push(
          `<hp:p id="0" paraPrIDRef="${fallbackStyle.paraPrIDRef}" styleIDRef="${fallbackStyle.id}" pageBreak="0" columnBreak="0" merged="0">` +
          `<hp:run charPrIDRef="${fallbackStyle.charPrIDRef}">${secPr || ''}<hp:t></hp:t></hp:run>` +
          `</hp:p>`
        );
      }
    }

    return out.join('');
  }

  /**
   * @returns {{ styleId: string, matched: boolean }}
   *   matched=false 면 그 단락은 매핑 정의가 없어 paragraph 폴백 — UI 에서 빨강 처리용 신호.
   */
  function pickStyleId(block, mapping) {
    if (block.pattern && mapping[block.pattern]) {
      return { styleId: mapping[block.pattern], matched: true };
    }
    if (block.type === 'heading') {
      const hKey = `h${block.depth}`;
      if (mapping[hKey]) return { styleId: mapping[hKey], matched: true };
    }
    // v0.6.6: figure-placeholder 는 명시 매핑 X 라도 자동 점선 박스로 처리되므로 matched=true
    //   (paragraph 폴백 styleId 사용하되 텍스트 빨강 처리 skip)
    if (block.pattern === 'figure-placeholder') {
      return { styleId: mapping.paragraph, matched: true };
    }
    return { styleId: mapping.paragraph, matched: false };
  }

  /** bullet 패턴이면서 부호가 자동 글머리기호와 중복될 가능성 있는 경우 prefix 제거 */
  function shouldStripBulletPrefix(pattern) {
    if (!pattern || !pattern.startsWith('bullet:')) return null;
    const sym = pattern.slice('bullet:'.length);
    // 사용자 명시: 1., 1) 는 위계 부호로 보존 (장/절 다음 단계)
    if (sym === '1.' || sym === '1)') return null;
    return sym;
  }

  /** 첫 run 의 텍스트 prefix 부호 + 공백 제거 (한컴 양식이 자동 부호 추가하는 경우 중복 방지) */
  function stripBulletPrefix(runs, sym) {
    if (!runs || runs.length === 0) return runs;
    const escSym = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const re = new RegExp(`^\\s*${escSym}\\s+`);
    const first = runs[0];
    const stripped = String(first.text || '').replace(re, '');
    if (stripped === first.text) return runs; // 일치 X
    const copy = runs.slice();
    copy[0] = { ...first, text: stripped };
    return copy;
  }

  // v0.7.7: 알려진 위계 부호 — heading 첫 글자가 이 부호면 양식 자동 prefix 와 중복 방지
  //   ▮ ■ (꽉찬 네모) / ❍ ○ ◯ (동그라미) / ◆ ◇ (마름모) / ※ (주석) /
  //   ● (꽉찬 원) / ▶ ▷ (화살표) / □ (빈 네모) / ◎ (이중원) / ◾ ▪ (작은 네모)
  const KNOWN_BULLET_PREFIX_RE = /^([▮■❍○◯◆◇※●▶▷□◎◾▪])\s+/;

  /** v0.7.7: heading 첫 글자가 알려진 위계 부호면 그 부호 + 공백 제거 */
  function tryStripKnownPrefix(runs) {
    if (!runs || runs.length === 0) return runs;
    const first = runs[0];
    if (!first || typeof first.text !== 'string') return runs;
    const stripped = first.text.replace(KNOWN_BULLET_PREFIX_RE, '');
    if (stripped === first.text) return runs; // 일치 X
    const copy = runs.slice();
    copy[0] = { ...first, text: stripped };
    return copy;
  }

  /** Run[] → <hp:run>...<hp:t>...</hp:t></hp:run> 들 */
  function buildRuns(runs, baseCharPrId, editor, opts) {
    const missed = !!(opts && opts.missed);
    const needsReview = !!(opts && opts.needsReview); // v0.7.7: § 검증 표시
    const fnCtx  = opts && opts.fnCtx;

    if (!runs || runs.length === 0) {
      // 빈 단락: 빈 run 한 개
      const fmt = new Set();
      if (missed) fmt.add('MISSED');
      if (needsReview) fmt.add('REVIEW');
      const cid = editor.getOrCreateCharPr(baseCharPrId, fmt);
      return `<hp:run charPrIDRef="${cid}"><hp:t></hp:t></hp:run>`;
    }
    const out = [];
    for (const r of runs) {
      // v0.5.6: 각주 마커 → <hp:ctrl><hp:footNote ...> 인라인 삽입 (한컴 실제 구조)
      //         페이지 하단 자동 배치 + 한컴이 번호 자동 매김
      if (r.type === 'fnmark' && fnCtx) {
        const fnXml = buildFootnoteElement(r.number, fnCtx, baseCharPrId);
        const wrapCid = editor.getOrCreateCharPr(baseCharPrId, new Set());
        out.push(`<hp:run charPrIDRef="${wrapCid}">${fnXml}</hp:run>`);
        continue;
      }

      // v0.9.0: 이미지 run → hp:pic XML inline 삽입 (한컴 진본 구조).
      //   preloader 가 _embedRef 박은 run 만 처리. _embedError 면 alt 텍스트 fallback.
      if (r.type === 'image') {
        const reg = opts && opts.imageRegistry;
        const entry = (reg && r._embedRef) ? reg.get(r._embedRef) : null;
        if (entry && entry.bytes && !entry.error) {
          const picXml = window.ImageEmbed.emitPicXml(entry, reg, {
            maxBodyWidth: (opts && opts.maxBodyWidthHwp) || undefined,
          });
          const wrapCid = editor.getOrCreateCharPr(baseCharPrId, new Set());
          out.push(`<hp:run charPrIDRef="${wrapCid}">${picXml}</hp:run>`);
        } else {
          // fallback — alt 텍스트만 표시 (이전 v0.8.x 동작 유지)
          const fmt = new Set(); if (missed) fmt.add('MISSED'); if (needsReview) fmt.add('REVIEW');
          const cid = editor.getOrCreateCharPr(baseCharPrId, fmt);
          const altTxt = xmlEscapeText(r.alt || r.title || '[그림]');
          out.push(`<hp:run charPrIDRef="${cid}"><hp:t>${altTxt}</hp:t></hp:run>`);
        }
        continue;
      }

      const fmt = new Set();
      if (r.bold)   fmt.add('BOLD');
      if (r.italic) fmt.add('ITALIC');
      if (r.strike) fmt.add('STRIKE');
      if (r.underline) fmt.add('UNDERLINE'); // v1.5.0: docx 밑줄 보존
      if (r.link)   fmt.add('LINK');
      if (missed)   fmt.add('MISSED');
      if (needsReview) fmt.add('REVIEW'); // v0.7.7

      const charPrId = editor.getOrCreateCharPr(baseCharPrId, fmt);
      const text = xmlEscapeText(r.text);
      out.push(`<hp:run charPrIDRef="${charPrId}"><hp:t>${text}</hp:t></hp:run>`);
    }
    return out.join('');
  }

  /**
   * 각주 element 생성 — 한컴 실제 hwpx 구조 (v0.5.6)
   *
   * 본문 마커 위치:
   *   <hp:run charPrIDRef="N">
   *     <hp:ctrl>
   *       <hp:footNote number="K" suffixChar="41" instId="...">
   *         <hp:subList ...많은 attr...>
   *           <hp:p paraPrIDRef="..." styleIDRef="...">
   *             <hp:run charPrIDRef="...">
   *               <hp:ctrl>
   *                 <hp:autoNum num="K" numType="FOOTNOTE">
   *                   <hp:autoNumFormat type="DIGIT" suffixChar=")" .../>
   *                 </hp:autoNum>
   *               </hp:ctrl>
   *               <hp:t>각주 본문</hp:t>
   *             </hp:run>
   *           </hp:p>
   *         </hp:subList>
   *       </hp:footNote>
   *     </hp:ctrl>
   *   </hp:run>
   *
   * 한컴이 페이지 하단 자동 배치 + 마커 자동 번호 매김.
   */
  // v0.5.14: 각주 본문 paragraph 고유 id 카운터 — 0xC0000000 시작 (본문·표 셀과 분리)
  //   본문 paragraph id 는 0~수천, 표 셀 id 는 0x80000000+,
  //   각주 본문 id 는 0xC0000000+ → 세 영역 절대 충돌 없음
  let footnoteParaIdCounter = 0xC0000000;

  function buildFootnoteElement(number, fnCtx, baseCharPrId) {
    const { footnoteBodies, styleMap, mapping, editor } = fnCtx;
    const bodies = footnoteBodies[number] || [];

    // 각주 본문에 적용할 스타일 — mapping.footnote 가 1순위
    const fnStyleId = mapping.footnote || mapping.paragraph;
    const fnStyle = styleMap.get(String(fnStyleId));

    const bodyParaPrId = fnStyle ? String(fnStyle.paraPrIDRef) : '0';
    const bodyStyleId  = fnStyle ? String(fnStyle.id) : '0';
    const bodyCharPrId = fnStyle ? String(fnStyle.charPrIDRef) : baseCharPrId;

    // autoNum: 각주 본문 시작에 한컴 자동 번호 마커
    const autoNumXml =
      `<hp:ctrl>` +
        `<hp:autoNum num="${number}" numType="FOOTNOTE">` +
          `<hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/>` +
        `</hp:autoNum>` +
      `</hp:ctrl>`;

    // 각주 본문 단락(들) 조립
    let bodyParas = '';
    if (bodies.length === 0) {
      // body 없음 — 빈 각주
      const pId = footnoteParaIdCounter++;
      bodyParas =
        `<hp:p id="${pId}" paraPrIDRef="${bodyParaPrId}" styleIDRef="${bodyStyleId}" pageBreak="0" columnBreak="0" merged="0">` +
          `<hp:run charPrIDRef="${bodyCharPrId}">${autoNumXml}<hp:t></hp:t></hp:run>` +
        `</hp:p>`;
    } else {
      bodies.forEach((body, idx) => {
        // body.runs 안에 또 fnmark 가 있을 수 있지만 중첩 각주는 비현실적 — fnCtx 전달 X
        const bodyRuns = buildRuns(body.runs || [], bodyCharPrId, editor, { missed: false });
        // 첫 단락의 첫 <hp:run charPrIDRef="..."> 안 시작에 autoNum 삽입
        let inner = bodyRuns;
        if (idx === 0) {
          const replaced = bodyRuns.replace(/^(<hp:run\s+charPrIDRef="[^"]*">)/, `$1${autoNumXml}`);
          if (replaced !== bodyRuns) {
            inner = replaced;
          } else {
            // bodyRuns 가 비어있음 — autoNum 만 포함된 run 생성
            inner = `<hp:run charPrIDRef="${bodyCharPrId}">${autoNumXml}<hp:t></hp:t></hp:run>`;
          }
        }
        const pId = footnoteParaIdCounter++;
        bodyParas +=
          `<hp:p id="${pId}" paraPrIDRef="${bodyParaPrId}" styleIDRef="${bodyStyleId}" pageBreak="0" columnBreak="0" merged="0">` +
            inner +
          `</hp:p>`;
      });
    }

    // <hp:subList> attribute set — 한컴 샘플 따라 완전 명시
    const subListAttrs =
      'id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="TOP" ' +
      'linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" ' +
      'hasTextRef="0" hasNumRef="0"';

    // instId — 한컴은 임의 32-bit 양수. 우리도 같은 식으로.
    const instId = String((Math.floor(Math.random() * 0x7FFFFFFF) >>> 0) || 1);

    return (
      `<hp:ctrl>` +
        `<hp:footNote number="${number}" suffixChar="41" instId="${instId}">` +
          `<hp:subList ${subListAttrs}>${bodyParas}</hp:subList>` +
        `</hp:footNote>` +
      `</hp:ctrl>`
    );
  }

  /** section0.xml 의 <hs:sec ...> 시작 태그 보존 + 본문 교체 + </hs:sec> 닫기 */
  function rebuildSectionXml(originalSectionXml, newBody) {
    const openMatch = originalSectionXml.match(/<hs:sec\b[^>]*>/);
    if (!openMatch) {
      throw new Error('section0.xml 의 <hs:sec> 시작 태그를 찾지 못했습니다 — 표준 hwpx 가 아닐 가능성');
    }
    // v0.7.15 (hotfix): 양식 본문이 결과에 새어 들어가는 버그 fix.
    //   이전엔 prefix(<hs:sec> 까지) + newBody + '</hs:sec>' 만 박았는데, 양식의 XML 닫는 태그
    //   뒤 트레일링 (예: PI, 주석) 이 사라지거나, 양식의 sec 본문 일부가 의도와 다르게 노출되는 케이스 보고.
    //   명시적으로:
    //     1) <hs:sec ...> 시작 태그까지 prefix
    //     2) newBody
    //     3) 원본의 마지막 </hs:sec> + 그 뒤 트레일링 그대로
    //   → 양식의 본문은 확실히 제거, XML 끝 구조는 원본 보존
    const prefix = originalSectionXml.slice(0, openMatch.index + openMatch[0].length);
    const closingIdx = originalSectionXml.lastIndexOf('</hs:sec>');
    const tail = (closingIdx >= 0)
      ? originalSectionXml.slice(closingIdx)   // </hs:sec> + 트레일링
      : '</hs:sec>';                            // closing 없으면 직접 박음 (안전 fallback)
    return `${prefix}${newBody}${tail}`;
  }

  // ────────────────────────────────────────────────────────────────────
  // 표 (table) 변환 — v0.5.8
  //   GFM 표 → <hp:tbl> + <hp:tr> + <hp:tc>
  //   헤더 행 셀: header="1" + 헤더 borderFillID (음영 + 아래 이중선)
  //   본문 행 셀: header="0" + 본문 borderFillID
  // ────────────────────────────────────────────────────────────────────

  // 본문 폭 (HWPUNIT). 한컴 표준 A4 좌·우 여백 제외 약 42520.
  const DEFAULT_BODY_WIDTH = 42520;
  const DEFAULT_CELL_HEIGHT = 1500;
  const PLACEHOLDER_CELL_HEIGHT = 14000; // ~25mm — 그림 자리 placeholder 세로 (v0.6.11)

  function buildTableXml(block, styleMap, mapping, editor, tableInstId, tableTemplate, validBfIds, defaultBfId, tableStyle, imageRegistry, maxBodyWidthHwp) {
    const headerCells = block.header || [];
    const rowsCells   = block.rows   || [];
    const colCnt = Math.max(1, headerCells.length || (rowsCells[0]?.length || 1));
    const rowCnt = (headerCells.length > 0 ? 1 : 0) + rowsCells.length;

    // ────────────────────────────────────────────────────────────────────
    // v0.5.17: 자동 borderFill 생성 (사용자 스펙)
    //   좌/우 외곽 = 투명 (NONE)
    //   상/하 외곽 = 0.4mm 검정
    //   내부 가로/세로 = 0.12mm 검정
    //   헤더 아래 = DOUBLE 0.4mm
    //   헤더 배경 = #EEEEEE 옅은 회색
    //   양식의 borderFill 무시하고 항상 자동 생성 (default.hwpx 처럼 4면 NONE 인 경우 시각적으로 안 보임 방지)
    // ────────────────────────────────────────────────────────────────────
    // v0.5.18: 한컴 진본 표 샘플 분석 결과 정확한 enum / 값으로 교체
    const SIDE = {
      NONE:    { type: 'NONE',        width: '0.1 mm'  },
      THIN:    { type: 'SOLID',       width: '0.12 mm' },
      THICK:   { type: 'SOLID',       width: '0.4 mm'  },
      DOUBLE:  { type: 'DOUBLE_SLIM', width: '0.5 mm'  }, // 한컴 표준: DOUBLE_SLIM + 0.5mm
    };

    // v0.7.0: 표 스타일 spec — UI 에서 사용자 그래픽 편집기로 변경. null 이면 기본값
    // v0.7.7: bodyEvenFill / bodyOddFill — 짝/홀 본문 행 alternating 음영
    const ts = tableStyle || {
      outerTop: 'THICK', outerBottom: 'THICK', outerLeft: 'NONE', outerRight: 'NONE',
      headerBottom: 'DOUBLE', innerHorizontal: 'THIN', innerVertical: 'THIN',
      headerFill: '#F2F2F2',
      bodyEvenFill: null, bodyOddFill: null,
    };
    const HEADER_FILL = ts.headerFill || null;
    const BODY_EVEN_FILL = ts.bodyEvenFill || null;
    const BODY_ODD_FILL  = ts.bodyOddFill  || null;

    // v0.6.10: 그림 자리 placeholder 표 — 1×1, 4면 모두 DASH 점선 0.4mm, 음영 X
    const isFigurePlaceholderTbl = (block.pattern === 'figure-placeholder');
    const PLACEHOLDER_SIDE = { type: 'DASH', width: '0.4 mm' };

    // v0.9.25: ASCII 박스 → 표 변환된 케이스 — 표 스타일 무시하고 모든 변 일반 실선 (THIN).
    //   사용자 요청 (2026-05-22): "그림에서 도입되는 표는 표양식을 따르지 않고 그냥 무조건
    //   일반 실선으로 표현되면 좋겠어". 이중선·헤더 음영 등 모두 X.
    const isFromAsciiBox = !!block.fromAsciiBox;

    function specForCell(rCat, cCat, bodyRowIdx) {
      if (isFromAsciiBox) {
        // ASCII 박스 → 표: 모든 변 THIN (0.12mm 실선), 음영 X
        return {
          sides: {
            left: SIDE.THIN, right: SIDE.THIN,
            top:  SIDE.THIN, bottom: SIDE.THIN,
          },
          fill: null,
        };
      }
      if (isFigurePlaceholderTbl) {
        // 그림 자리: 모든 변 점선, 음영 X
        return {
          sides: {
            left: PLACEHOLDER_SIDE, right: PLACEHOLDER_SIDE,
            top: PLACEHOLDER_SIDE,  bottom: PLACEHOLDER_SIDE,
          },
          fill: null,
        };
      }
      // v0.7.0: 사용자 스펙 매핑 — 각 변의 키를 ts 값 → SIDE
      // 좌측 변: 좌측 컬럼이면 outerLeft, 그 외 innerVertical
      const left  = SIDE[(cCat === 'left' || cCat === 'single') ? ts.outerLeft : ts.innerVertical] || SIDE.THIN;
      // 우측 변: 우측 컬럼이면 outerRight, 그 외 innerVertical (반대편 셀이 그릴 거지만 안전상 동일)
      const right = SIDE[(cCat === 'right' || cCat === 'single') ? ts.outerRight : ts.innerVertical] || SIDE.THIN;
      // 상단 변: 헤더면 outerTop / 헤더 바로 아래 본문 첫 행이면 headerBottom (헤더 아래와 거울) / 그 외 innerHorizontal
      let topKey;
      if (rCat === 'header')         topKey = ts.outerTop;
      else if (rCat === 'bodyFirst') topKey = ts.headerBottom;
      else                           topKey = ts.innerHorizontal;
      const top = SIDE[topKey] || SIDE.THIN;
      // 하단 변: 헤더면 headerBottom / 마지막 행이면 outerBottom / 그 외 innerHorizontal
      let bottomKey;
      if (rCat === 'header')        bottomKey = ts.headerBottom;
      else if (rCat === 'bodyLast') bottomKey = ts.outerBottom;
      else                          bottomKey = ts.innerHorizontal;
      const bottom = SIDE[bottomKey] || SIDE.THIN;
      // 음영: 헤더는 HEADER_FILL, 본문은 v0.7.7 alternating (bodyRowIdx 짝/홀)
      //   bodyRowIdx=0 (본문 첫 행) → 홀수 → bodyOddFill
      //   bodyRowIdx=1 → 짝수 → bodyEvenFill
      let fill = null;
      if (rCat === 'header') {
        fill = HEADER_FILL;
      } else if (bodyRowIdx != null && (BODY_EVEN_FILL || BODY_ODD_FILL)) {
        fill = (bodyRowIdx % 2 === 0) ? BODY_ODD_FILL : BODY_EVEN_FILL;
      }
      return { sides: { left, right, top, bottom }, fill };
    }

    // v0.8.2: 양식 borderFill 직접 재사용 — tableStyle.fromTemplate=true 일 때
    //   tableTemplate.cellMap 의 위치별 borderFillID 를 그대로 사용 (양식 풀의 ID 100% 보존).
    //   사용자가 그래픽 스타일러로 변경하지 않은 figure-placeholder 가 아닌 일반 표만 이 분기.
    const useTemplateBF = !!(
      ts && ts.fromTemplate &&
      !isFigurePlaceholderTbl &&
      tableTemplate && tableTemplate.cellMap &&
      Object.keys(tableTemplate.cellMap).length > 0
    );

    /** v0.8.2: 양식 cellMap 에서 매칭되는 ID 를 찾되 없으면 가까운 카테고리로 fallback */
    function pickTemplateCellBF(rCat, cCat) {
      const cellMap = tableTemplate.cellMap;
      // 1. 정확 매칭
      const exactKey = `${rCat}.${cCat}`;
      if (cellMap[exactKey] && validBfIds && validBfIds.has(String(cellMap[exactKey]))) {
        return String(cellMap[exactKey]);
      }
      // 2. 같은 row 카테고리의 다른 col fallback
      for (const k of Object.keys(cellMap)) {
        if (k.startsWith(rCat + '.')) {
          const v = cellMap[k];
          if (v && validBfIds && validBfIds.has(String(v))) return String(v);
        }
      }
      // 3. row 가 'bodyFirst' 인데 못 찾으면 'bodyMiddle' 로, bodyMiddle → bodyLast 순으로 fallback
      const rowFallback = (rCat === 'bodyFirst') ? ['bodyMiddle', 'bodyLast']
                        : (rCat === 'bodyLast') ? ['bodyMiddle', 'bodyFirst']
                        : (rCat === 'bodyMiddle') ? ['bodyFirst', 'bodyLast']
                        : (rCat === 'header') ? ['bodyFirst', 'bodyMiddle']
                        : [];
      for (const rf of rowFallback) {
        const k = `${rf}.${cCat}`;
        if (cellMap[k] && validBfIds && validBfIds.has(String(cellMap[k]))) {
          return String(cellMap[k]);
        }
        // 같은 rf 의 다른 col
        for (const k2 of Object.keys(cellMap)) {
          if (k2.startsWith(rf + '.')) {
            const v = cellMap[k2];
            if (v && validBfIds && validBfIds.has(String(v))) return String(v);
          }
        }
      }
      // 4. 그래도 못 찾으면 cellMap 의 첫 유효 ID
      for (const v of Object.values(cellMap)) {
        if (v && validBfIds && validBfIds.has(String(v))) return String(v);
      }
      return null;
    }

    function pickCellBF(rowIdx, colIdx, isHeaderRow, endsLast) {
      let rCat = rowCategoryOf(rowIdx, rowCnt, isHeaderRow);
      // v1.5.0: 세로 병합 셀이 마지막 행까지 닿으면 외곽 하단선을 받도록 bodyLast 로
      if (!isHeaderRow && endsLast) rCat = 'bodyLast';
      const cCat = colCategoryOf(colIdx, colCnt);
      // v0.8.2: 양식 그대로 모드 — cellMap 의 ID 직접 사용
      if (useTemplateBF) {
        const tplId = pickTemplateCellBF(rCat, cCat);
        if (tplId) return tplId;
      }
      // v0.7.7: 본문 행 패리티 계산 — 헤더 있으면 rowIdx 1 부터 본문
      let bodyRowIdx = null;
      if (!isHeaderRow) {
        const headerOffset = (headerCells.length > 0) ? 1 : 0;
        bodyRowIdx = rowIdx - headerOffset;
      }
      const spec = specForCell(rCat, cCat, bodyRowIdx);
      return editor.addOrCreateBorderFill(spec.sides, spec.fill);
    }

    // 표 외곽 borderFillIDRef — 한컴 진본은 셀과 같은 borderFill 중 하나 재사용. bodyMid.middle (또는 가장 흔한) 1개 만들어 그 ID 사용
    // v0.8.2: useTemplateBF=true 면 양식의 outerBF 우선 (없으면 cellMap 의 적절한 ID)
    let outerBF;
    if (useTemplateBF) {
      if (tableTemplate.outerBF && validBfIds && validBfIds.has(String(tableTemplate.outerBF))) {
        outerBF = String(tableTemplate.outerBF);
      } else {
        outerBF = pickTemplateCellBF('bodyMiddle', colCnt === 1 ? 'single' : 'middle')
               || pickTemplateCellBF('bodyFirst', colCnt === 1 ? 'single' : 'middle')
               || defaultBfId;
      }
    } else {
      const outerSpec = specForCell('bodyMiddle', colCnt === 1 ? 'single' : 'middle');
      outerBF = editor.addOrCreateBorderFill(outerSpec.sides, outerSpec.fill);
    }

    // 셀 안 단락 스타일 — 템플릿 cell 스타일이 더 정확 (양식의 표내용 직접)
    const tplCellStyle = tableTemplate ? styleMap.get(String(tableTemplate.cellStyleId)) : null;
    const userCellStyle = styleMap.get(String(mapping.tableCell || mapping.paragraph));
    const cellStyle = tplCellStyle || userCellStyle;
    let cellParaPrId = (tableTemplate && tableTemplate.cellParaPrId) || (cellStyle ? String(cellStyle.paraPrIDRef) : '0');
    const cellStyleRef = (tableTemplate && tableTemplate.cellStyleId)  || (cellStyle ? String(cellStyle.id) : '0');
    const cellCharPrId = (tableTemplate && tableTemplate.cellCharPrId) || (cellStyle ? String(cellStyle.charPrIDRef) : '0');

    // v1.1.1: 내용 기반 열 너비 (TableFit) — 양식 본문 가용폭 안에 배분 → 페이지 넘침 방지.
    //   기존 균등 분배(42520/colCnt, 양식 여백 무시)를 대체. figure-placeholder 표는 제외(균등 유지).
    const fitTotal = Math.max(10000, maxBodyWidthHwp || DEFAULT_BODY_WIDTH);
    // v1.2.5: 균등 폭도 양식 본문폭(fitTotal) 기준 — 표 전체폭(hp:sz)과 Σ셀폭이 항상 일치하게.
    //   (기존: 셀폭은 42520 고정 기준이라 열폭 최적화 OFF + 본문폭≠42520 양식에서 표가 본문폭을 넘침)
    const cellWidth = Math.floor(fitTotal / colCnt);
    let colWidths = null;
    const TF = (typeof window !== 'undefined' && window.TableFit) ? window.TableFit
             : (typeof TableFit !== 'undefined' ? TableFit : null);
    if (TF && !isFigurePlaceholderTbl && colCnt > 1 && _fitColumns) {
      const grid = [];
      if (headerCells.length) grid.push(headerCells.map(cellPlainText));
      for (const r of rowsCells) grid.push((r || []).map(cellPlainText));
      const cellFontHwp = (cellStyle && cellStyle.char && cellStyle.char.height) || 900;
      colWidths = TF.fit(grid, colCnt, fitTotal, {
        emUnit: Math.max(600, cellFontHwp),
        padEm: 2.2,                                    // 셀 좌우 여백(510×2) + 여유
        min: Math.max(2000, Math.floor(fitTotal / (colCnt * 4))),
        maxFrac: 0.55,                                 // 한 열이 표를 독차지하지 않게
      });
    }
    const tblWidthTotal = colWidths ? colWidths.reduce((a, b) => a + b, 0) : cellWidth * colCnt;   // = Σ 셀폭 (정확 일치)

    // v0.6.10: 그림 자리 placeholder 표 — 셀 안 paragraph 가운데 정렬
    if (isFigurePlaceholderTbl) {
      cellParaPrId = editor.addOrCreatePlaceholderParaPr(cellParaPrId, null);
    }

    // v0.6.11: placeholder 표는 셀 height 크게 (~25mm 그림 자리 느낌)
    const cellHeight = isFigurePlaceholderTbl ? PLACEHOLDER_CELL_HEIGHT : DEFAULT_CELL_HEIGHT;

    const rows = [];
    let rowIdx = 0;

    // 헤더 행 (GFM 표의 1행 헤더)
    if (headerCells.length > 0) {
      rows.push(buildTableRow({
        cells: headerCells,
        rowIdx,
        colCnt,
        rowCnt,
        cellWidth,
        colWidths,
        cellHeight,
        isHeader: true,
        paraPrId: cellParaPrId,
        styleRef: cellStyleRef,
        charPrId: cellCharPrId,
        bold: true,
        editor,
        pickCellBF,
        imageRegistry,        // v0.9.5: 표 셀 안 이미지 처리 위해 전파
        maxBodyWidthHwp,
      }));
      rowIdx++;
    }

    for (const row of rowsCells) {
      rows.push(buildTableRow({
        cells: row,
        rowIdx,
        colCnt,
        rowCnt,
        cellWidth,
        colWidths,
        cellHeight,
        isHeader: false,
        paraPrId: cellParaPrId,
        styleRef: cellStyleRef,
        charPrId: cellCharPrId,
        bold: false,
        editor,
        pickCellBF,
        imageRegistry,        // v0.9.5
        maxBodyWidthHwp,
      }));
      rowIdx++;
    }

    // <hp:tbl> attribute set
    const tblAttrs =
      `id="${tableInstId}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" ` +
      `textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="1" ` +
      `rowCnt="${rowCnt}" colCnt="${colCnt}" cellSpacing="0" borderFillIDRef="${outerBF}" noAdjust="0"`;

    // v0.5.16: <hp:tbl> 첫 자식 4개 — 한컴이 직접 만든 표 분석 결과 필수.
    const tblWidth  = tblWidthTotal; // v1.1.1: 내용 기반 열폭 합 (= 양식 본문폭, 폴백 42520)
    const tblHeight = rowCnt * cellHeight; // placeholder 면 25mm, 일반 표면 기본
    const tblPreamble =
      `<hp:sz width="${tblWidth}" widthRelTo="ABSOLUTE" height="${tblHeight}" heightRelTo="ABSOLUTE" protect="0"/>` +
      `<hp:pos treatAsChar="1" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/>` +
      `<hp:outMargin left="283" right="283" top="283" bottom="283"/>` +
      `<hp:inMargin left="510" right="510" top="141" bottom="141"/>`;

    return `<hp:tbl ${tblAttrs}>${tblPreamble}${rows.join('')}</hp:tbl>`;
  }

  /** 셀의 행 카테고리 — 'header', 'bodyFirst', 'bodyMiddle', 'bodyLast' */
  function rowCategoryOf(rowIdx, rowCnt, isHeaderRow) {
    if (isHeaderRow) return 'header';
    // 본문 첫 행 (헤더 바로 아래) = rowIdx 1 (헤더 1행 가정) 또는 rowIdx 0 (헤더 없음)
    const bodyStart = rowIdx === 0 ? 0 : 1;
    if (rowIdx === bodyStart) return 'bodyFirst';
    if (rowIdx === rowCnt - 1) return 'bodyLast';
    return 'bodyMiddle';
  }

  /** 셀의 열 카테고리 — 'single', 'left', 'right', 'middle' */
  function colCategoryOf(colIdx, colCnt) {
    if (colCnt === 1) return 'single';
    if (colIdx === 0) return 'left';
    if (colIdx === colCnt - 1) return 'right';
    return 'middle';
  }

  function buildTableRow({ cells, rowIdx, colCnt, rowCnt, cellWidth, colWidths, cellHeight, isHeader, paraPrId, styleRef, charPrId, bold, editor, pickCellBF, imageRegistry, maxBodyWidthHwp }) {
    const tcs = [];
    for (let col = 0; col < colCnt; col++) {
      const cell = cells[col] || { runs: [] };
      // ★ v1.5.0: 셀 병합 (docx 직접 입력의 span 사이드채널) — covered 칸은 tc 미출력,
      //   앵커 셀은 cellSpan + 폭(열폭 합)·높이(행높이×rs) 로 병합 재현.
      if (cell.covered) continue;
      const cs = Math.max(1, cell.colSpan | 0 || 1);
      const rs = Math.max(1, cell.rowSpan | 0 || 1);
      const endsLast = !isHeader && (rowIdx + rs - 1) >= (rowCnt - 1);
      const cellBF = pickCellBF(rowIdx, col, isHeader, endsLast);
      // v1.1.1: 내용 기반 열폭 (colWidths) 우선, 없으면 균등(cellWidth)
      let cw = 0;
      for (let i = 0; i < cs; i++) cw += (colWidths && colWidths[col + i] != null) ? colWidths[col + i] : cellWidth;
      tcs.push(buildTableCell({
        cell, rowIdx, col, cellWidth: cw, cellHeight, colSpan: cs, rowSpan: rs, isHeader, cellBF, paraPrId, styleRef, charPrId, bold, editor,
        imageRegistry, maxBodyWidthHwp,  // v0.9.5
      }));
    }
    return `<hp:tr>${tcs.join('')}</hp:tr>`;
  }

  /** 표 셀의 평문 텍스트 (run 텍스트 이어붙임, 이미지/각주 마커는 제외) — 열폭 측정용 */
  function cellPlainText(cell) {
    if (!cell || !cell.runs) return '';
    return cell.runs.map(r => (r && typeof r.text === 'string') ? r.text : '').join('');
  }

  // 표 셀 paragraph id 카운터 — 모든 셀이 같은 id="0" 이면 한컴이 거부 가능
  let cellParaIdCounter = 0x80000000;

  function buildTableCell({ cell, rowIdx, col, cellWidth, cellHeight, colSpan, rowSpan, isHeader, cellBF, paraPrId, styleRef, charPrId, bold, editor, imageRegistry, maxBodyWidthHwp }) {
    // 셀 안 paragraph 의 runs 처리
    let runs = cell.runs || [];
    if (bold) {
      runs = runs.map(r => r.type ? r : { ...r, bold: true });
    }

    // v0.7.7: 셀 안 § 검증 표시 — 셀 전체 빨간 글씨
    // v0.9.5: ReferenceError fix — buildTableCell 이 opts 매개변수를 안 받았는데 'opts && opts.imageRegistry' 참조 시 터짐.
    //         destructured args 에 imageRegistry / maxBodyWidthHwp 추가. 부수효과로 표 셀 안 이미지 처리 완성.
    const cellNeedsReview = !!(cell.flags && cell.flags.needsReview);
    const runsXml = buildRuns(runs, charPrId, editor, { missed: false, needsReview: cellNeedsReview, imageRegistry, maxBodyWidthHwp });

    const tcAttrs =
      `name="" header="${isHeader ? 1 : 0}" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="${cellBF}"`;

    const subListAttrs =
      'id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" ' +
      'linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" ' +
      'hasTextRef="0" hasNumRef="0"';

    // v0.5.12: 표 셀 paragraph 마다 고유 id (한컴이 id 충돌 거부 가능성 차단)
    const pId = cellParaIdCounter++;

    // v0.5.12: 표 셀 paragraph 에 linesegarray 명시 — 일반 본문 paragraph 와 달리
    //          한컴이 표 셀 안 paragraph 의 linesegarray 를 자동 재계산하지 못해 거부할 가능성
    const lineseg =
      `<hp:linesegarray><hp:lineseg textpos="0" vertpos="0" vertsize="1000" textheight="1000" baseline="850" spacing="600" horzpos="0" horzsize="${cellWidth - 1000}" flags="393216"/></hp:linesegarray>`;

    return (
      `<hp:tc ${tcAttrs}>` +
        `<hp:subList ${subListAttrs}>` +
          `<hp:p id="${pId}" paraPrIDRef="${paraPrId}" styleIDRef="${styleRef}" pageBreak="0" columnBreak="0" merged="0">` +
            runsXml +
            lineseg +
          `</hp:p>` +
        `</hp:subList>` +
        `<hp:cellAddr colAddr="${col}" rowAddr="${rowIdx}"/>` +
        `<hp:cellSpan colSpan="${colSpan || 1}" rowSpan="${rowSpan || 1}"/>` +
        `<hp:cellSz width="${cellWidth}" height="${(cellHeight || DEFAULT_CELL_HEIGHT) * (rowSpan || 1)}"/>` +
        `<hp:cellMargin left="510" right="510" top="141" bottom="141"/>` +
      `</hp:tc>`
    );
  }

  // ────────────────────────────────────────────────────────────────────
  // XML escape (텍스트 노드용)
  // ────────────────────────────────────────────────────────────────────

  function xmlEscapeText(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;');
  }

  return {
    buildHwpx,
  };
})();
