/**
 * md2hwpx — MdParser (v0.5.1)
 *
 * marked.js 13.0 의 lexer 결과를 우리 자체 IR(Intermediate Representation) 로 변환.
 *
 * IR 모델:
 *   IR = { blocks: Block[], patterns: { [key]: count } }
 *
 *   Block (블록 단위):
 *     { type: 'heading'|'paragraph', depth?: 1..6, pattern: string, runs: Run[] }
 *
 *   Run (인라인 한 조각):
 *     { text: string, bold?: bool, italic?: bool, strike?: bool, code?: bool, link?: string }
 *
 * pattern 키 규칙 (★ v0.5.1 신설 — 매핑 UI 의 동적 행 키):
 *   - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'  — 마크다운 헤딩 (#, ##, ...)
 *   - 'bullet:▮', 'bullet:❍', 'bullet:-', 'bullet:1.' 등 — 본문 prefix 부호
 *   - 'paragraph'                           — prefix 없는 일반 단락
 *
 * 위계 부호는 한국 보고서에서 흔히 쓰는 표현 — 마크다운 표준은 아니지만 사용자가
 * paragraph 의 첫 글자로 직접 입력하는 경우가 많아 자동 감지해 매핑 UI 에 노출.
 *
 * v0.5.0 → v0.5.1 변경: block.pattern 필드 추가, list 토큰의 원래 부호 보존.
 * v0.5.0 범위: heading + paragraph + 인라인(text/strong/em/link/del/codespan/br).
 */
'use strict';

window.MdParser = (() => {

  // 한 글자 본문 위계 부호 후보 (paragraph 의 첫 글자가 이 중 하나 + 공백이면 bullet 으로 인식)
  const BULLET_CHARS = '▮❍○◦●◯◎◉◆◇■□▢▶▷▸▹▪▫※*‣⁃◌◐◑◒◓◔◕';

  /**
   * @param {string} mdText
   * @param {Object} [options]
   * @param {boolean} [options.listAutoIndent=false] — 리스트 깊이별 paraPr marginLeft indent
   *   양식 paraPr 에 들여쓰기가 이미 정의된 경우 OFF (기본). 정의 X 양식이면 ON.
   *   v0.6.2: builder 가 IR.options.listAutoIndent 읽어 paraPr 동적 생성 결정.
   */
  function parse(mdText, options = {}) {
    if (typeof marked === 'undefined') throw new Error('marked.js 미로드');
    const tokens = marked.lexer(String(mdText || ''), { gfm: true, breaks: false });

    // 1차: 토큰 → blocks (이때 위첨자 + ⁾/) 는 fnmark run 으로 분리됨)
    const rawBlocks = [];
    for (const t of tokens) {
      const b = blockFromToken(t);
      if (Array.isArray(b)) rawBlocks.push(...b);
      else if (b) rawBlocks.push(b);
    }
    // v0.5.6: 모든 block 에 footnote 우선 재검사 (list 안의 footnote 도 잡힘)
    for (const b of rawBlocks) {
      const firstRun = b.runs?.[0];
      if (firstRun?.type === 'fnmark') {
        b.pattern = 'footnote';
        continue;
      }
      const firstText = firstRun?.text || '';
      if (/^\s*[⁰¹²³⁴⁵⁶⁷⁸⁹]+[⁾)]/.test(firstText)) {
        b.pattern = 'footnote';
        continue;
      }
      if (!b.pattern) b.pattern = derivePattern(b);
    }

    // v0.6.10: figure-placeholder paragraph → 1×1 표 블록 변환
    //   paragraph border 가 한컴에서 인접 paragraph 와 시각적으로 겹치고 선택 불가
    //   1×1 표로 변환하면 진짜 박스 + 한컴에서 선택 가능 + 표 변환 검증된 코드 재사용
    for (let i = 0; i < rawBlocks.length; i++) {
      const b = rawBlocks[i];
      if (b.pattern === 'figure-placeholder' && b.type === 'paragraph') {
        rawBlocks[i] = {
          type: 'table',
          pattern: 'figure-placeholder', // builder 가 점선 모드 분기
          header: [],
          rows: [[{ runs: b.runs }]],
        };
      }
    }

    // v0.5.7: 본문 안의 fnmark 마커 number 수집
    //   - footnote body 는 본문에 마커가 있는 number 만 진짜 각주로 분류
    //   - 마커 없는 위첨자 단락 (예: 참고문헌 섹션) 은 일반 paragraph 로 폴백
    //   - 같은 number 의 body 가 여러 개면 첫 번째만 각주, 나머지 paragraph
    const markedNumbers = new Set();
    for (const b of rawBlocks) {
      if (b.pattern === 'footnote') continue; // body 아닌 본문 paragraph 만
      for (const r of b.runs || []) {
        if (r.type === 'fnmark') markedNumbers.add(r.number);
      }
    }

    // 2차: footnote body 블록을 footnoteBodies 맵으로 이동 (blocks 에서 제거)
    //      한컴 <hp:footNote> 가 각주를 인라인 마커로 표시하고 페이지 하단에 자동 배치
    const footnoteBodies = {};
    const blocks = [];
    for (const b of rawBlocks) {
      if (b.pattern === 'footnote') {
        const num = extractFootnoteNumber(b);
        // v0.5.7: 본문에 마커가 있고, 같은 number 가 아직 footnote 로 등록되지 않은 경우만
        if (num != null && markedNumbers.has(num) && !footnoteBodies[num]) {
          const cleanRuns = removeLeadingFootnoteMarker(b.runs);
          footnoteBodies[num] = [{
            type: 'paragraph',
            pattern: 'footnote',
            runs: cleanRuns,
          }];
          continue;
        }
        // 폴백: 마커 없는 위첨자 단락 또는 중복 number — 일반 paragraph 로
        b.runs = convertFnmarkToText(b.runs);
        b.pattern = derivePatternWithoutFootnote(b);
      }
      blocks.push(b);
    }

    // 패턴 통계 — footnote 도 별도 표시 (UI 매핑 행)
    const patterns = {};
    for (const b of blocks) {
      patterns[b.pattern] = (patterns[b.pattern] || 0) + 1;
    }
    const fnTotal = Object.values(footnoteBodies).reduce((a, b) => a + b.length, 0);
    if (fnTotal > 0) patterns.footnote = (patterns.footnote || 0) + fnTotal;

    // v0.7.7: § 검증 표시 감지 — paragraph/cell 텍스트에 § 가 있으면
    //   block.flags.needsReview = true 부착 → builder 가 빨간색 (#DC143C) 적용
    for (const b of blocks) {
      attachReviewFlag(b);
    }
    for (const fnList of Object.values(footnoteBodies)) {
      for (const b of fnList) attachReviewFlag(b);
    }

    return { blocks, patterns, footnoteBodies, options };
  }

  /** v0.7.7: § 검증 표시 감지 — block 안의 모든 텍스트에 § 가 있는지 검사 */
  function attachReviewFlag(b) {
    if (!b) return;
    if (b.type === 'table') {
      // 표 — 각 셀 단위로 cell.flags.needsReview 부착
      const allRows = [];
      if (Array.isArray(b.header) && b.header.length > 0) allRows.push(b.header);
      if (Array.isArray(b.rows)) allRows.push(...b.rows);
      for (const row of allRows) {
        if (!Array.isArray(row)) continue;
        for (const cell of row) {
          if (cell && runsHaveReviewMarker(cell.runs)) {
            cell.flags = { ...(cell.flags || {}), needsReview: true };
          }
        }
      }
      return;
    }
    if (runsHaveReviewMarker(b.runs)) {
      b.flags = { ...(b.flags || {}), needsReview: true };
    }
  }

  function runsHaveReviewMarker(runs) {
    if (!Array.isArray(runs)) return false;
    for (const r of runs) {
      if (r && typeof r.text === 'string' && r.text.indexOf('§') !== -1) return true;
    }
    return false;
  }

  /** block 의 첫 fnmark 또는 text run prefix 에서 각주 번호 추출 */
  function extractFootnoteNumber(block) {
    for (const r of block.runs || []) {
      if (r.type === 'fnmark') return r.number;
      if (r.text) {
        const m = String(r.text).match(/^\s*([⁰¹²³⁴⁵⁶⁷⁸⁹]+)[⁾)]/);
        if (m) return parseSuperscriptNumber(m[1]);
        // text 가 빈 문자열 등은 계속
        if (r.text.trim()) return null;
      }
    }
    return null;
  }

  /** body 시작의 위첨자+⁾/) prefix 또는 fnmark run 제거 */
  function removeLeadingFootnoteMarker(runs) {
    if (!runs || runs.length === 0) return runs;
    let i = 0;
    while (i < runs.length) {
      const r = runs[i];
      if (r.type === 'fnmark') { i++; continue; } // fnmark 제거
      if (r.text != null) {
        const stripped = String(r.text).replace(/^\s*[⁰¹²³⁴⁵⁶⁷⁸⁹]+[⁾)]\s*/, '');
        if (stripped !== r.text) {
          return [{ ...r, text: stripped }, ...runs.slice(i + 1)];
        }
        // text 가 공백뿐이면 skip
        if (!r.text.trim()) { i++; continue; }
      }
      break;
    }
    return runs.slice(i);
  }

  /** 위첨자 숫자 문자열 → 일반 숫자 ("¹⁰" → 10) */
  function parseSuperscriptNumber(s) {
    const map = { '⁰':'0','¹':'1','²':'2','³':'3','⁴':'4','⁵':'5','⁶':'6','⁷':'7','⁸':'8','⁹':'9' };
    const ascii = String(s).split('').map(c => map[c] || '').join('');
    const n = parseInt(ascii, 10);
    return Number.isFinite(n) ? n : null;
  }

  /** 일반 숫자 → 위첨자 문자열 (10 → "¹⁰") */
  function toSuperscript(n) {
    const sup = ['⁰','¹','²','³','⁴','⁵','⁶','⁷','⁸','⁹'];
    return String(n).split('').map(d => sup[parseInt(d, 10)] || d).join('');
  }

  /** v0.5.7: footnote 로 분류되지 않은 body 의 fnmark run 을 위첨자 텍스트로 복원 */
  function convertFnmarkToText(runs) {
    if (!Array.isArray(runs)) return runs;
    return runs.map(r => {
      if (r.type === 'fnmark') {
        return { text: `${toSuperscript(r.number)}⁾ ` };
      }
      return r;
    });
  }

  /** footnote 검사를 제외한 derivePattern — 폴백된 paragraph 재분류용 */
  function derivePatternWithoutFootnote(block) {
    if (block.type === 'heading') return `h${block.depth || 1}`;
    if (block.type !== 'paragraph') return block.type;
    const firstText = block.runs?.[0]?.text || '';
    const bullet = detectBulletPrefix(firstText);
    if (bullet) return `bullet:${bullet}`;
    return 'paragraph';
  }

  function blockFromToken(t) {
    switch (t.type) {
      case 'heading':
        return {
          type: 'heading',
          depth: clamp(t.depth || 1, 1, 6),
          pattern: `h${clamp(t.depth || 1, 1, 6)}`,
          runs: flattenRuns(t.tokens || [{ type: 'text', text: t.text }], {}),
        };

      case 'paragraph': {
        // v0.5.4: paragraph 안에 여러 줄이 빈 줄 없이 있으면 marked 가 한 paragraph 로 묶음
        // 한컴 문서는 줄바꿈 = 단락 분리 이므로 raw 의 \n 으로 split 해서 각 줄을 별도 paragraph 로
        const raw = String(t.raw || t.text || '');
        const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
        if (lines.length <= 1) {
          // 한 줄 paragraph — 기존대로 tokens 사용 (인라인 보존)
          return {
            type: 'paragraph',
            runs: flattenRuns(t.tokens || [{ type: 'text', text: t.text }], {}),
          };
        }
        // 여러 줄 — 각 줄을 marked 로 다시 inline 파싱해 별도 paragraph 로 분리
        return lines.map(line => {
          let inlineTokens = [{ type: 'text', text: line }];
          try {
            const sub = marked.lexer(line, { gfm: true, breaks: false });
            const subPara = sub.find(x => x.type === 'paragraph' || x.type === 'heading');
            if (subPara && subPara.tokens) inlineTokens = subPara.tokens;
          } catch (_e) { /* fallback */ }
          return {
            type: 'paragraph',
            runs: flattenRuns(inlineTokens, {}),
          };
        });
      }

      case 'space':
        return null;

      // GFM 리스트 — v0.6.0: 1~3단계 중첩 지원, v0.6.2: prefix 부호만
      //   재귀로 nested list 처리. 각 item 은 paragraph + listDepth 메타.
      //   부호 (-, *, +, 1., 2., ...) 만 prefix. 들여쓰기는 builder 의 paraPr 동적 indent.
      case 'list':
        return processListItems(t, 1);

      // 인용 — v0.5.0 부터 평탄 paragraph 로
      case 'blockquote':
        return {
          type: 'paragraph',
          runs: flattenRuns(t.tokens || [], {}),
        };

      case 'code':
        // v0.9.1: 코드 블록 전체를 code-block IR 로 보존 (라인 분리 X) — ASCII art 변환 트리거 위해.
        //   md2hwpx.js (onConvert) 가 [그림 N] 인접 + 박스 문자 포함 코드 블록을 SVG→PNG image run 으로 치환.
        //   치환되지 않은 일반 코드 블록은 builder 가 line 별 paragraph 로 emit (기존 v0.5.x 동작).
        return {
          type: 'code-block',
          pattern: 'paragraph',
          text: String(t.text || ''),
          lang: String(t.lang || ''),
        };

      // v0.5.8: GFM 표 — IR table block 으로 변환
      case 'table': {
        const align = Array.isArray(t.align) ? t.align : [];
        // marked v13 의 table 토큰: t.header (Array<{tokens, header, align, text}>), t.rows (Array<Array<{...}>>)
        const headerCells = (t.header || []).map(c => ({
          runs: flattenRuns(c.tokens || [{ type: 'text', text: c.text || '' }], {}),
          align: c.align || null,
        }));
        const rowsCells = (t.rows || []).map(row =>
          row.map(c => ({
            runs: flattenRuns(c.tokens || [{ type: 'text', text: c.text || '' }], {}),
            align: c.align || null,
          }))
        );
        return {
          type: 'table',
          pattern: 'table',
          header: headerCells,
          rows:   rowsCells,
          align,
        };
      }

      case 'hr':
      case 'html':
      case 'def':
        return null;

      default:
        if (t.text) return { type: 'paragraph', runs: [{ text: String(t.text) }] };
        return null;
    }
  }

  /**
   * marked list item 의 inline 토큰 배열 추출.
   * v13 의 list item 구조는 보통:
   *   item.tokens = [{ type: 'text', text, tokens: [...inline] }]
   * 또는 (loose=true 일 때):
   *   item.tokens = [{ type: 'paragraph', tokens: [...] }, ...]
   */
  function collectListItemInline(item) {
    if (!item || !item.tokens || item.tokens.length === 0) {
      return [{ type: 'text', text: item?.text || '' }];
    }
    const first = item.tokens[0];
    if (first.tokens && Array.isArray(first.tokens)) return first.tokens;
    return item.tokens;
  }

  /**
   * v0.6.0: list token 의 items 를 재귀 처리. 1~3단계 중첩 지원.
   *   - 현재 item 의 inline → paragraph 한 개 (부호 prefix + depth 들여쓰기 포함)
   *   - item 내부의 nested list (item.tokens 중 type='list') → 재귀 호출, depth+1
   * @param {Object} listToken — marked list token { items, ordered, start, raw }
   * @param {number} depth — 1 부터 시작. 3 까지 들여쓰기, 4+ 는 3 단계로 클램프 (시각적 한계).
   * @returns {Array<Object>} paragraph blocks
   */
  function processListItems(listToken, depth) {
    const out = [];
    const clampDepth = Math.min(depth, 3); // 3단계 이상은 시각적으로 동일 처리

    (listToken.items || []).forEach((item, idx) => {
      const realBullet = pickListBullet(listToken, item, idx);
      const patternBullet = listToken.ordered ? '1.' : realBullet;

      // 현재 item 의 inline (첫 paragraph 만)
      const innerInline = collectListItemInline(item);
      const runs = flattenRuns(innerInline, {});

      // v0.5.6: list item 이 곧 각주 body 인 경우 — list 처리 X, footnote 로
      const firstRun = runs[0];
      const firstText = firstRun?.text || '';
      const isFootnote =
        firstRun?.type === 'fnmark' ||
        /^\s*[⁰¹²³⁴⁵⁶⁷⁸⁹]+[⁾)]/.test(firstText);

      if (isFootnote) {
        out.push({ type: 'paragraph', pattern: 'footnote', runs });
      } else {
        // v0.6.2: 부호 prefix 만 (들여쓰기는 builder 의 paraPr 동적 indent 로 처리)
        //   전각공백 prefix 는 한컴 렌더링에서 시각 효과 미미 → paraPr.marginLeft 가 정답
        const prefix = `${realBullet} `;
        if (runs.length > 0 && runs[0].text != null) {
          runs[0] = { ...runs[0], text: `${prefix}${runs[0].text}` };
        } else {
          runs.push({ text: prefix });
        }
        out.push({
          type: 'paragraph',
          pattern: `bullet:${patternBullet}`,
          listDepth: clampDepth,
          runs,
        });
      }

      // 재귀: item.tokens 중 nested list 처리
      for (const child of item.tokens || []) {
        if (child.type === 'list') {
          const sub = processListItems(child, depth + 1);
          out.push(...sub);
        }
      }
    });

    return out;
  }

  /** list item 의 부호 결정 — ordered 면 `1.`, unordered 면 raw 의 첫 부호 */
  function pickListBullet(listToken, item, idx) {
    if (listToken.ordered) {
      const start = listToken.start ? Number(listToken.start) : 1;
      const n = start + idx;
      return `${n}.`;
    }
    // unordered: item.raw 또는 listToken.raw 의 첫 부호 (-, *, +)
    const raw = String(item?.raw || listToken?.raw || '').trimStart();
    const m = raw.match(/^([-*+])\s/);
    return m ? m[1] : '-';
  }

  /** paragraph 의 첫 텍스트에서 위계 부호 prefix 자동 감지 */
  function derivePattern(block) {
    if (block.type === 'heading') return `h${block.depth || 1}`;
    if (block.type !== 'paragraph') return block.type;
    const firstText = block.runs?.[0]?.text || '';
    // v0.5.4: 각주 블록 — 줄 시작이 위첨자 + ⁾/) 패턴이면 'footnote'
    if (isFootnoteStart(firstText)) return 'footnote';
    // v0.5.9: 표 부속 패턴 (캡션·단위·주·자료) — 본문 위계 부호보다 우선
    if (isCaptionTableStart(firstText))  return 'caption-table';
    // v0.6.7: figure-placeholder 가 caption-figure 보다 우선
    //   [그림 1], [그림 1-1], <그림> 등 모두 placeholder 로 통합 (사용자 의도: 그림 자리)
    if (isFigurePlaceholder(firstText))  return 'figure-placeholder';
    if (isCaptionFigureStart(firstText)) return 'caption-figure'; // 폴백 (사실상 도달 X)
    if (isUnitStart(firstText))          return 'unit';
    if (isNoteStart(firstText))          return 'note';
    if (isSourceStart(firstText))        return 'source';
    const bullet = detectBulletPrefix(firstText);
    if (bullet) return `bullet:${bullet}`;
    return 'paragraph';
  }

  // v0.5.9: 표 부속 패턴 감지
  function isCaptionTableStart(text) {
    return /^\s*\[\s*표\s*[\d.\-–~]+\s*\]/.test(text || '');
  }
  function isCaptionFigureStart(text) {
    return /^\s*\[\s*그림\s*[\d.\-–~]+\s*\]/.test(text || '');
  }
  function isUnitStart(text) {
    return /^\s*\(\s*단위\s*[:：]/.test(text || '');
  }
  function isNoteStart(text) {
    // "주:" 시작 — 단 위첨자(¹⁾) 시작인 경우는 isFootnoteStart 가 먼저 처리됨
    return /^\s*주\s*[:：]/.test(text || '');
  }
  function isSourceStart(text) {
    return /^\s*자료\s*[:：]/.test(text || '');
  }
  // v0.6.5/v0.6.7: 그림 placeholder — 본문의 그림 자리 표시
  //   사용자 보고서 다양한 표기 모두 수용:
  //     [그림], [그림 1], [그림 1-1], [그림 1.1], [그림: 캡션], [그림 1: 캡션]
  //     <그림>, <그림 1>, <그림 1-1>, <그림 1.1>, <그림: 캡션>, <그림 1: 캡션>
  //     [차트], [도표], [사진], [이미지] 동일하게 (< > 형식도)
  //   v0.6.7: 숫자 있는 형식도 placeholder 로 통합 (이전 caption-figure 와 통합).
  //     캡션 의도면 사용자가 매핑 UI 에서 양식 캡션 스타일로 매핑 (override) 가능
  function isFigurePlaceholder(text) {
    return /^\s*[\[<]\s*(그림|차트|도표|사진|이미지)(?:\s*[\d.\-–~]+)?(?:\s*[:：][^\]>]*)?\s*[\]>]\s*$/.test(text || '');
  }

  // 위첨자 숫자 — Unicode U+2070 (⁰) + U+00B9·B2·B3 (¹²³) + U+2074~2079 (⁴⁵⁶⁷⁸⁹)
  const SUPERSCRIPT_DIGITS = '⁰¹²³⁴⁵⁶⁷⁸⁹';

  /**
   * 줄 시작이 "위첨자숫자+ + ⁾/) + 공백" 패턴이면 true → 각주 설명 블록
   * 예: `¹⁾ 가상자산이용자보호법 ...`, `²) EU MiCA ...`, `¹⁰⁾ ...`
   */
  function isFootnoteStart(text) {
    if (!text) return false;
    const trimmed = String(text).trimStart();
    const re = new RegExp(`^[${escapeRegexClass(SUPERSCRIPT_DIGITS)}]+[⁾)]\\s+`);
    return re.test(trimmed);
  }

  /** 텍스트 시작이 "{단일 부호} " 형태면 그 부호 반환, 아니면 null */
  function detectBulletPrefix(text) {
    if (!text) return null;
    const trimmed = String(text).trimStart();
    // 1. 단일 글자 부호 + 공백
    const re1 = new RegExp(`^([${escapeRegexClass(BULLET_CHARS)}])\\s+`);
    const m1 = trimmed.match(re1);
    if (m1) return m1[1];
    // 2. `- ` / `* ` / `+ ` (GFM 평탄화 결과 — 본문에 부호가 직접 들어간 경우)
    const m2 = trimmed.match(/^([-*+])\s+/);
    if (m2) return m2[1];
    // 3. 번호 패턴 `1. ` / `2. ` — 모두 `1.` 으로 묶음 (UI 단순화)
    if (/^\d+\.\s+/.test(trimmed)) return '1.';
    // 4. 번호 패턴 `1) ` 도 묶음
    if (/^\d+\)\s+/.test(trimmed)) return '1)';
    return null;
  }

  function escapeRegexClass(chars) {
    return chars.replace(/[-\]\\^]/g, ch => '\\' + ch);
  }

  // ────────────────────────────────────────────────────────────────────
  // 인라인 평탄화 (v0.5.0 그대로)
  // ────────────────────────────────────────────────────────────────────

  function flattenRuns(tokens, ctx) {
    const runs = [];
    for (const tok of tokens) {
      switch (tok.type) {
        case 'text': {
          if (Array.isArray(tok.tokens) && tok.tokens.length > 0) {
            runs.push(...flattenRuns(tok.tokens, ctx));
          } else {
            // v0.5.5: 본문 텍스트 안의 위첨자+⁾/) 패턴은 footnote marker 로 분리
            const decoded = decodeEntities(tok.text || '');
            runs.push(...splitFootnoteMarkers(decoded, ctx));
          }
          break;
        }
        case 'strong':
          runs.push(...flattenRuns(tok.tokens || [], { ...ctx, bold: true }));
          break;
        case 'em':
          runs.push(...flattenRuns(tok.tokens || [], { ...ctx, italic: true }));
          break;
        case 'del':
          runs.push(...flattenRuns(tok.tokens || [], { ...ctx, strike: true }));
          break;
        case 'link':
          runs.push(...flattenRuns(tok.tokens || [{ type: 'text', text: tok.text }], { ...ctx, link: tok.href || '' }));
          break;
        case 'codespan':
          runs.push(mkRun(decodeEntities(tok.text || ''), { ...ctx, code: true }));
          break;
        case 'br':
          runs.push(mkRun(' ', ctx));
          break;
        case 'escape':
          runs.push(mkRun(String(tok.text || ''), ctx));
          break;
        case 'html': {
          // v1.5.0: <u>…</u> 밑줄 지원 — docx 직접 입력이 밑줄을 이 표기로 보존.
          //   marked 는 인라인 html 을 여는/닫는 태그 별개 토큰으로 줌 → ctx 토글 불가하므로
          //   <u>내용</u> 이 한 토큰에 들어온 경우와 태그 단독 토큰 모두 처리.
          const raw = String(tok.text || '');
          const um = /^<u>([\s\S]*?)<\/u>$/i.exec(raw.trim());
          if (um) { runs.push(mkRun(decodeEntities(stripTags(um[1])), { ...ctx, underline: true })); break; }
          if (/^<u>$/i.test(raw.trim())) { ctx._u = true; break; }
          if (/^<\/u>$/i.test(raw.trim())) { ctx._u = false; break; }
          runs.push(mkRun(stripTags(raw), ctx));
          break;
        }
        case 'image':
          // v0.9.0: 이미지 토큰을 IR 의 image run 으로 보존 (이전: alt 텍스트만 추출)
          //   src = href (dataURI 또는 URL), alt = 설명, title = 캡션 (선택)
          //   ImagePreloader 가 비동기로 PNG bytes/크기 측정 → builder 가 hp:pic emit
          runs.push({
            type: 'image',
            src: String(tok.href || ''),
            alt: String(tok.text || tok.alt || ''),
            title: String(tok.title || ''),
          });
          break;
        default:
          if (tok.text) runs.push(mkRun(String(tok.text), ctx));
      }
    }
    return mergeAdjacent(runs);
  }

  /**
   * 텍스트 안의 위첨자 + ⁾/) 패턴을 fnmark run 으로 분리
   * 예: "...로 부상¹⁾ 또한 ²)..." → [text, fnmark(1), text, fnmark(2), text]
   */
  function splitFootnoteMarkers(text, ctx) {
    if (!text) return [];
    const runs = [];
    const re = /([⁰¹²³⁴⁵⁶⁷⁸⁹]+)([⁾)])/g;
    let last = 0;
    let m;
    while ((m = re.exec(text)) !== null) {
      if (m.index > last) {
        runs.push(mkRun(text.slice(last, m.index), ctx));
      }
      const num = parseSuperscriptNumber(m[1]);
      if (num != null) {
        runs.push({ type: 'fnmark', number: num });
      }
      last = m.index + m[0].length;
    }
    if (last < text.length) {
      runs.push(mkRun(text.slice(last), ctx));
    } else if (runs.length === 0) {
      // 매칭 X — 그냥 원본 텍스트
      runs.push(mkRun(text, ctx));
    }
    return runs;
  }

  function mkRun(text, ctx) {
    // paragraph 내부의 \n 은 한컴 <hp:t> 에서 무시되어 글자가 겹침
    // → 공백으로 치환 (marked 가 같은 단락 안의 줄바꿈을 strong 줄바꿈 의도로 보지 않음)
    const normalized = String(text || '').replace(/\r?\n/g, ' ').replace(/ {2,}/g, ' ');
    const r = { text: normalized };
    if (ctx.bold)   r.bold = true;
    if (ctx.italic) r.italic = true;
    if (ctx.strike) r.strike = true;
    if (ctx.underline || ctx._u) r.underline = true; // v1.5.0: <u> 밑줄
    if (ctx.code)   r.code = true;
    if (ctx.link)   r.link = ctx.link;
    return r;
  }

  function mergeAdjacent(runs) {
    const out = [];
    for (const r of runs) {
      // 특수 타입 run (fnmark 등) 은 그대로 보존
      if (r.type) { out.push(r); continue; }
      if (!r.text) continue;
      const last = out[out.length - 1];
      if (last && !last.type && sameFormat(last, r)) {
        last.text += r.text;
      } else {
        out.push({ ...r });
      }
    }
    return out;
  }

  function sameFormat(a, b) {
    return !!a.bold === !!b.bold
        && !!a.italic === !!b.italic
        && !!a.strike === !!b.strike
        && !!a.underline === !!b.underline
        && !!a.code === !!b.code
        && (a.link || null) === (b.link || null);
  }

  function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }

  function decodeEntities(s) {
    return String(s || '')
      .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
      .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
      .replace(/&amp;/g, '&');
  }

  function stripTags(s) { return String(s || '').replace(/<[^>]*>/g, ''); }

  return {
    parse,
    detectBulletPrefix, // UI 에서도 참조용
  };
})();
