CCF Log Package Exporter by Capybara_korea

Exports a CCFOLIA chat log package as ZIP with log.json, index.html, and image files.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         CCF Log Package Exporter by Capybara_korea
// @namespace    https://greatest.deepsurf.us/users/Capybara_korea/ccf-log-package
// @version      0.0.3
// @description  Exports a CCFOLIA chat log package as ZIP with log.json, index.html, and image files.
// @description:ko CCFOLIA 채팅 로그를 log.json, index.html, 이미지 파일이 포함된 ZIP 패키지로 내보냅니다.
// @license      Copyright @Capybara_korea. All rights reserved.
// @match        https://ccfolia.com/*
// @match        https://*.ccfolia.com/*
// @run-at       document-start
// @grant        none
// @noframes
// ==/UserScript==

(() => {
  "use strict";

  const STYLE_ID = "ccf-log-package-style";
  const EXPORT_BTN_ATTR = "data-ccf-log-package-btn";
  const EXPORT_BTN_SELECTOR = `[${EXPORT_BTN_ATTR}="1"]`;
  const EDITOR_SELECTOR = 'textarea, input[type="text"], [contenteditable="true"], [role="textbox"]';
  const MESSAGE_SCOPE_SELECTOR = '[role="log"], [aria-live="polite"], [aria-live="assertive"], .MuiDrawer-paper, ul.MuiList-root';
  const MESSAGE_TEXT_SELECTOR = [
    'p.MuiTypography-root.MuiTypography-body2',
    '.MuiListItemText-root > p',
    '[data-index] p',
    'li p'
  ].join(", ");
  const RAW_ATTR = "data-ccf-raw";
  const SAFE_UI_ATTR = "data-ccf-safe-markup";
  const PACKAGE_VERSION = 1;
  const INVIS_START = "\u2063\u2063\u2063";
  const INVIS_END = "\u2062\u2062\u2062";
  const INVIS_MAP = ["\u200B", "\u200C", "\u200D", "\u2060"];
  const INVIS_REVERSE = new Map(INVIS_MAP.map((char, index) => [char, index]));
  const LOCAL_IMAGE_TOKEN_PREFIX = "ccf-local://image/";
  const LOCAL_IMAGE_STORAGE_PREFIX = "ccf-inline-image:";
  const LOCAL_IMAGE_INDEX_KEY = "ccf-inline-image:index";
  const LOCAL_IMAGE_MAX_ENTRIES = 24;
  const FONT_SIZE_MIN = 1;
  const FONT_SIZE_MAX = 200;
  const DEFAULT_BLUR_VALUE = "4px";
  const CCF_SUITE_REGISTRY_KEY = "ccf-suite-registry-v1";
  const CCF_SUITE_SCRIPT_STATE_KEY = "ccf-suite-script-states-v1";
  const CCF_SUITE_REGISTER_EVENT = "ccf-suite:register";
  const CCF_SUITE_REQUEST_EVENT = "ccf-suite:request-register";
  const CCF_LOG_PACKAGE_SCRIPT_INFO = Object.freeze({
    id: "ccf-log-package",
    name: "CCF Log Package Exporter",
    version: "0.0.1",
    namespace: "https://greatest.deepsurf.us/users/Capybara_korea/ccf-log-package"
  });
  const buttonState = {
    scheduled: false,
    busy: false
  };
  const LOG_SCAN_MAX_ITERATIONS = 400;
  const LOG_SCAN_STEP_RATIO = 0.8;
  const LOG_SCAN_MIN_STEP = 160;
  const LOG_SETTLE_QUIET_MS = 140;
  const LOG_SETTLE_TIMEOUT_MS = 1200;
  const OFFICIAL_LOG_CAPTURE_TIMEOUT_MS = 12000;

  registerWithCcfSuite(CCF_LOG_PACKAGE_SCRIPT_INFO);
  window.addEventListener(CCF_SUITE_REQUEST_EVENT, handleCcfSuiteRegisterRequest);
  if (!isCcfSuiteScriptEnabled(CCF_LOG_PACKAGE_SCRIPT_INFO.id)) {
    return;
  }
  init();

  function handleCcfSuiteRegisterRequest(event) {
    const targetId = event?.detail?.targetId;
    if (targetId && targetId !== CCF_LOG_PACKAGE_SCRIPT_INFO.id) return;
    registerWithCcfSuite(CCF_LOG_PACKAGE_SCRIPT_INFO);
  }

  function registerWithCcfSuite(scriptInfo) {
    try {
      const registry = readCcfSuiteRegistry();
      const previous = registry.scripts[scriptInfo.id] && typeof registry.scripts[scriptInfo.id] === "object"
        ? registry.scripts[scriptInfo.id]
        : {};
      const now = new Date().toISOString();
      const sessionId = typeof window.__CCF_SUITE_MANAGER_SESSION_ID === "string"
        ? window.__CCF_SUITE_MANAGER_SESSION_ID
        : "";

      registry.scripts[scriptInfo.id] = {
        ...previous,
        ...scriptInfo,
        installedAt: previous.installedAt || now,
        lastSeenAt: now,
        lastSeenUrl: location.href,
        lastSeenSessionId: sessionId
      };

      window.localStorage.setItem(CCF_SUITE_REGISTRY_KEY, JSON.stringify(registry));
      window.dispatchEvent(
        new CustomEvent(CCF_SUITE_REGISTER_EVENT, {
          detail: registry.scripts[scriptInfo.id]
        })
      );
    } catch (error) {
      // Ignore suite registration failures.
    }
  }

  function readCcfSuiteRegistry() {
    try {
      const parsed = JSON.parse(window.localStorage.getItem(CCF_SUITE_REGISTRY_KEY) || "{}");
      return parsed && typeof parsed.scripts === "object"
        ? { scripts: parsed.scripts }
        : { scripts: {} };
    } catch (error) {
      return { scripts: {} };
    }
  }

  function isCcfSuiteScriptEnabled(scriptId) {
    try {
      const parsed = JSON.parse(window.localStorage.getItem(CCF_SUITE_SCRIPT_STATE_KEY) || "{}");
      return !parsed || typeof parsed !== "object" || parsed[scriptId] !== false;
    } catch (error) {
      return true;
    }
  }

  function init() {
    injectStyle();
    scheduleEnsureButtons();
    observeDom();
  }

  function injectStyle() {
    if (document.getElementById(STYLE_ID)) return;

    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      .ccf-log-package-menu-item {
        position: relative;
      }

      .ccf-log-package-menu-label {
        pointer-events: none;
      }

      .ccf-log-package-menu-item[data-busy="1"] {
        opacity: 0.68;
        pointer-events: none;
      }

      .ccf-log-package-menu-item[data-busy="1"]::after {
        content: "";
        position: absolute;
        top: 50%;
        right: 16px;
        width: 12px;
        height: 12px;
        margin-top: -6px;
        border-radius: 0;
        border: 2px solid currentColor;
        border-right-color: transparent;
        animation: ccf-log-package-spin 1s linear infinite;
      }

      @keyframes ccf-log-package-spin {
        from { transform: rotate(0deg); }
        to { transform: rotate(360deg); }
      }
    `;

    (document.head || document.documentElement).appendChild(style);
  }

  function observeDom() {
    const observer = new MutationObserver(() => {
      scheduleEnsureButtons();
    });

    observer.observe(document.documentElement || document.body, {
      childList: true,
      subtree: true
    });
  }

  function scheduleEnsureButtons() {
    if (buttonState.scheduled) return;
    buttonState.scheduled = true;
    requestAnimationFrame(() => {
      buttonState.scheduled = false;
      ensureExportButtons();
    });
  }

  function ensureExportButtons() {
    const menus = findTargetMenus();
    for (const menu of menus) {
      if (!(menu instanceof HTMLElement)) continue;

      const anchors = findMenuAnchors(menu);
      if (!anchors.exportLogsItem && !anchors.tabEditItem) continue;
      const existingButtons = cleanupDuplicateExportButtons(menu, anchors);
      if (existingButtons.length) {
        existingButtons.forEach((button) => syncExportButtonState(button));
        continue;
      }

      const referenceItem = anchors.tabEditItem || anchors.exportLogsItem;
      const button = createExportButton(referenceItem);
      const parent = referenceItem?.parentElement || menu;

      if (anchors.tabEditItem && anchors.tabEditItem.parentElement === parent) {
        parent.insertBefore(button, anchors.tabEditItem);
      } else if (anchors.exportLogsItem && anchors.exportLogsItem.parentElement === parent) {
        anchors.exportLogsItem.insertAdjacentElement("afterend", button);
      } else {
        parent.appendChild(button);
      }
    }
  }

  function cleanupDuplicateExportButtons(menu, anchors) {
    const buttons = [...menu.querySelectorAll(EXPORT_BTN_SELECTOR)]
      .filter((button) => button instanceof HTMLElement)
      .filter((button) => button.closest('[role="menu"]') === menu);

    if (buttons.length <= 1) return buttons;

    const parent =
      anchors.tabEditItem?.parentElement ||
      anchors.exportLogsItem?.parentElement ||
      buttons[0]?.parentElement ||
      menu;

    const sorted = buttons.slice().sort((left, right) => {
      if (left.parentElement !== parent) return 1;
      if (right.parentElement !== parent) return -1;
      return getNodeIndex(left) - getNodeIndex(right);
    });

    const keep = sorted[0];
    for (const button of sorted.slice(1)) {
      button.remove();
    }

    return keep ? [keep] : [];
  }

  function getNodeIndex(node) {
    if (!(node instanceof Node) || !node.parentNode) return Number.MAX_SAFE_INTEGER;
    return Array.prototype.indexOf.call(node.parentNode.childNodes, node);
  }

  function createExportButton(referenceItem = null) {
    const button = document.createElement("li");
    button.className = cleanupMenuItemClassName(referenceItem?.className || "MuiButtonBase-root MuiMenuItem-root MuiMenuItem-gutters");
    button.classList.add("ccf-log-package-menu-item");
    button.setAttribute(EXPORT_BTN_ATTR, "1");
    button.setAttribute("role", "menuitem");
    button.setAttribute("tabindex", "-1");
    button.setAttribute("aria-label", "카피바라 패키지");
    button.setAttribute("title", "카피바라 패키지");
    button.dataset.defaultLabel = "카피바라 패키지";

    const label = document.createElement("span");
    label.className = "ccf-log-package-menu-label";
    button.appendChild(label);

    const ripple = referenceItem?.querySelector?.(".MuiTouchRipple-root");
    if (ripple instanceof HTMLElement) {
      button.appendChild(ripple.cloneNode(false));
    }

    syncExportButtonState(button);

    button.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      void handleExport(button);
    });

    return button;
  }

  async function handleExport(originButton = null) {
    if (buttonState.busy) return;

    setButtonsBusy(true);
    try {
      const result = await buildLogPackage(originButton);
      downloadBlob(result.fileName, result.blob);
    } catch (error) {
      console.error("[CCF LOG PACKAGE] export failed", error);
      alert(error?.message || "로그 패키지를 만들지 못했습니다.");
    } finally {
      setButtonsBusy(false);
    }
  }

  function setButtonsBusy(nextBusy) {
    buttonState.busy = !!nextBusy;
    document.querySelectorAll(EXPORT_BTN_SELECTOR).forEach((button) => {
      syncExportButtonState(button);
      button.setAttribute("aria-disabled", buttonState.busy ? "true" : "false");
    });
  }

  function syncExportButtonState(button) {
    if (!(button instanceof HTMLElement)) return;
    button.dataset.busy = buttonState.busy ? "1" : "0";
    const label = button.querySelector(".ccf-log-package-menu-label");
    if (label instanceof HTMLElement) {
      label.textContent = buttonState.busy
        ? "카피바라 패키지 생성 중..."
        : (button.dataset.defaultLabel || "카피바라 패키지");
    }
  }

  async function buildLogPackage(originButton = null) {
    const exportedAt = new Date();
    const roomTitle = getRoomTitle();
    const roomAddress = getRoomAddressLabel();
    const officialLog = await captureOfficialLogHtml(originButton);
    const entries = officialLog?.html
      ? parseOfficialLogEntries(officialLog.html)
      : await collectLogEntries();
    if (!entries.length) {
      throw new Error("내보낼 채팅 로그를 찾지 못했습니다.");
    }

    await enrichEntriesWithLiveAvatars(entries);

    const assets = await buildAssetBundle(entries);
    const assetMap = new Map(assets.map((asset) => [asset.source, asset]));
    for (const entry of entries) {
      entry.packageHtml = rewriteEntryHtmlForPackage(entry.bodyHtml, assetMap);
    }
    const currentThemeDefinition = getPackageThemeDefinition();
    const themeOptionModel = getPackageThemeOptionModel(currentThemeDefinition.mode);
    const themeDefinition = themeOptionModel.definitions[themeOptionModel.selectedMode] || currentThemeDefinition;

    const logJson = buildLogJson({
      roomTitle,
      exportedAt,
      entries,
      assets
    });
    const tistoryContentHtmlByMode = themeOptionModel.options.reduce((out, option) => {
      const optionTheme = themeOptionModel.definitions[option.value] || themeDefinition;
      out[option.value] = buildTistoryContentHtml({
        roomTitle,
        exportedAt,
        entries,
        assets,
        themeDefinition: optionTheme
      });
      return out;
    }, {});
    const tistoryContentHtml = tistoryContentHtmlByMode[themeOptionModel.selectedMode] || "";
    const indexHtml = buildIndexHtml({
      roomTitle,
      exportedAt,
      entries,
      assets,
      tistoryContentHtml,
      themeDefinition,
      themeOptionModel,
      tistoryContentHtmlByMode
    });

    const zipEntries = [
      makeZipEntry("log.json", encodeUtf8(logJson), exportedAt),
      makeZipEntry("index.html", encodeUtf8(indexHtml), exportedAt)
    ];

    for (const asset of assets) {
      if (!asset.included || !(asset.bytes instanceof Uint8Array) || !asset.fileName) continue;
      zipEntries.push(makeZipEntry(asset.fileName, asset.bytes, exportedAt));
    }

    const zipBytes = buildStoredZip(zipEntries);
    return {
      fileName: buildPackageFileName(roomTitle, roomAddress, exportedAt),
      blob: new Blob([zipBytes], { type: "application/zip" })
    };
  }

  async function captureOfficialLogHtml(originButton = null) {
    const officialItem = findOfficialExportMenuItem(originButton);
    if (!(officialItem instanceof HTMLElement)) return null;

    try {
      return await interceptOfficialLogDownload(() => {
        officialItem.dispatchEvent(new MouseEvent("click", {
          bubbles: true,
          cancelable: true,
          view: window
        }));
      });
    } catch (error) {
      console.warn("[CCF LOG PACKAGE] official log capture failed; falling back to live DOM scan", error);
      return null;
    }
  }

  function findOfficialExportMenuItem(originButton = null) {
    const menu = originButton?.closest?.('[role="menu"]');
    if (menu instanceof HTMLElement) {
      const anchors = findMenuAnchors(menu);
      if (anchors.exportLogsItem instanceof HTMLElement) {
        return anchors.exportLogsItem;
      }
    }

    for (const candidateMenu of findTargetMenus()) {
      const anchors = findMenuAnchors(candidateMenu);
      if (anchors.exportLogsItem instanceof HTMLElement) {
        return anchors.exportLogsItem;
      }
    }

    return null;
  }

  function interceptOfficialLogDownload(trigger) {
    return new Promise((resolve, reject) => {
      const objectUrlMap = new Map();
      const originalCreateObjectURL = URL.createObjectURL.bind(URL);
      const originalRevokeObjectURL = URL.revokeObjectURL.bind(URL);
      const originalClick = HTMLAnchorElement.prototype.click;
      const originalDispatchEvent = HTMLAnchorElement.prototype.dispatchEvent;
      let timeoutId = 0;
      let settled = false;

      const restore = () => {
        URL.createObjectURL = originalCreateObjectURL;
        URL.revokeObjectURL = originalRevokeObjectURL;
        HTMLAnchorElement.prototype.click = originalClick;
        HTMLAnchorElement.prototype.dispatchEvent = originalDispatchEvent;
        clearTimeout(timeoutId);
      };

      const finish = (result, error = null) => {
        if (settled) return;
        settled = true;
        restore();
        if (error) {
          reject(error);
          return;
        }
        resolve(result);
      };

      const tryCaptureAnchor = (anchor) => {
        if (!(anchor instanceof HTMLAnchorElement)) return false;

        const href = anchor.href || anchor.getAttribute("href") || "";
        const download = anchor.download || anchor.getAttribute("download") || "";
        const blob = objectUrlMap.get(href);
        if (!blob || !looksLikeOfficialLogBlob(blob, download)) return false;

        blob.text()
          .then((html) => finish({ html, fileName: download || "" }))
          .catch((error) => finish(null, error));
        return true;
      };

      URL.createObjectURL = function createObjectURLPatched(blob) {
        const url = originalCreateObjectURL(blob);
        objectUrlMap.set(url, blob);
        return url;
      };

      URL.revokeObjectURL = function revokeObjectURLPatched(url) {
        objectUrlMap.delete(url);
        return originalRevokeObjectURL(url);
      };

      HTMLAnchorElement.prototype.click = function clickPatched(...args) {
        if (tryCaptureAnchor(this)) return;
        return originalClick.apply(this, args);
      };

      HTMLAnchorElement.prototype.dispatchEvent = function dispatchEventPatched(event) {
        if (event?.type === "click" && tryCaptureAnchor(this)) return true;
        return originalDispatchEvent.call(this, event);
      };

      timeoutId = window.setTimeout(() => {
        finish(null, new Error("공식 로그 HTML을 가로채는 시간이 초과되었습니다."));
      }, OFFICIAL_LOG_CAPTURE_TIMEOUT_MS);

      Promise.resolve()
        .then(() => trigger())
        .catch((error) => finish(null, error));
    });
  }

  function looksLikeOfficialLogBlob(blob, downloadName = "") {
    const type = String(blob?.type || "").toLowerCase();
    const name = String(downloadName || "").toLowerCase();
    if (type.includes("html")) return true;
    return /\.html?$/.test(name);
  }

  async function collectLogEntries() {
    const scope = findPrimaryLogScope();
    if (!scope) return [];

    const scroller = findLogScrollContainer(scope);
    if (!scroller) {
      const elements = findLogMessageElements([scope]);
      return elements.map((element, index) => buildLogEntry(element, index));
    }

    const collected = await collectAllLogEntriesFromScroller(scope, scroller);
    if (collected.length) return collected;

    const fallbackElements = findLogMessageElements([scope]);
    return fallbackElements.map((element, index) => buildLogEntry(element, index));
  }

  function parseOfficialLogEntries(html) {
    const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
    const paragraphs = [...doc.body.querySelectorAll("p")];

    return paragraphs
      .map((paragraph, index) => parseOfficialLogParagraph(paragraph, index))
      .filter(Boolean);
  }

  function parseOfficialLogParagraph(paragraph, index) {
    if (!(paragraph instanceof HTMLElement)) return null;

    const spans = [...paragraph.querySelectorAll(":scope > span")];
    const channel = normalizeSpace(spans[0]?.textContent || "");
    const sender = normalizeSpace(spans[1]?.textContent || "");
    const messageSpan = spans[spans.length - 1] || paragraph;
    const rawText = normalizeText(readNodeTextWithBreaks(messageSpan));
    const extracted = extractEnvelope(rawText);
    const text = extracted?.envelope?.text != null
      ? normalizeText(String(extracted.envelope.text))
      : normalizeText(stripInvisibleEnvelope(rawText));
    const baseColor = normalizeCssColor(paragraph.style?.color || "");
    const bodyHtml = buildRenderedMessageHtml({
      text,
      formatRuns: extracted?.envelope?.formatRuns || [],
      alignRuns: extracted?.envelope?.alignRuns || [],
      blockStyle: extracted?.envelope?.blockStyle || {},
      baseColor
    });
    const assetSources = collectAssetSourcesFromHtml(bodyHtml);

    return {
      index: index + 1,
      id: `official-${index + 1}`,
      sender,
      avatarSource: "",
      timestamp: "",
      metaTexts: channel ? [channel, sender].filter(Boolean) : [sender].filter(Boolean),
      channel,
      text,
      visibleText: text,
      rawText,
      baseColor,
      formatEnvelopeVersion: extracted?.envelope?.v ?? null,
      formatRuns: cloneJson(extracted?.envelope?.formatRuns || []),
      alignRuns: cloneJson(extracted?.envelope?.alignRuns || []),
      blockStyle: cloneJson(extracted?.envelope?.blockStyle || {}),
      assetSources,
      bodyHtml,
      packageHtml: ""
    };
  }

  function readNodeTextWithBreaks(node) {
    if (!node) return "";
    if (node.nodeType === Node.TEXT_NODE) {
      return node.textContent || "";
    }
    if (node.nodeName === "BR") {
      return "\n";
    }
    return [...node.childNodes].map((child) => readNodeTextWithBreaks(child)).join("");
  }

  function buildRenderedMessageHtml({ text, formatRuns, alignRuns, blockStyle, baseColor }) {
    const wrapper = document.createElement("div");
    wrapper.className = "ccf-render-root";

    renderStyledText(wrapper, text || "", formatRuns || [], getEffectiveAlignRuns(text || "", alignRuns || [], blockStyle || {}));
    return wrapper.innerHTML;
  }

  function clamp(value, min, max) {
    const numeric = Number(value);
    if (!Number.isFinite(numeric)) return min;
    if (numeric < min) return min;
    if (numeric > max) return max;
    return numeric;
  }

  function normalizeCssColor(value) {
    if (value == null) return "";
    const trimmed = String(value).trim();
    if (!trimmed) return "";

    const probe = document.createElement("span");
    probe.style.color = "";
    probe.style.color = trimmed;
    return probe.style.color || "";
  }

  function renderStyledText(container, text, runs, alignRuns = []) {
    if (!container) return;

    if (!text) {
      container.style.textAlign = "";
      container.textContent = "";
      return;
    }

    const normalizedRuns = normalizeRuns(runs, text.length);
    const normalizedAlignRuns = getEffectiveAlignRuns(text, alignRuns);
    if (!normalizedRuns.length && !normalizedAlignRuns.length) {
      container.style.textAlign = "";
      container.textContent = text;
      return;
    }

    container.innerHTML = "";
    container.style.textAlign = "";

    const lines = getTextLines(text);
    let activeCodeGroup = null;
    let activeCodeGroupKey = "";

    for (const line of lines) {
      const lineEl = document.createElement("span");
      lineEl.className = "ccf-line";
      lineEl.dataset.ccfLine = "1";
      lineEl.dataset.lineIndex = String(line.index);
      lineEl.dataset.start = String(line.start);
      lineEl.dataset.end = String(line.end);
      lineEl.style.textAlign = getLineAlign(normalizedAlignRuns, line.index);

      const lineRuns = normalizedRuns
        .filter((run) => run.start < line.end && run.end > line.start)
        .map((run) => ({
          start: clamp(run.start - line.start, 0, line.text.length),
          end: clamp(run.end - line.start, 0, line.text.length),
          style: { ...run.style }
        }))
        .filter((run) => run.end > run.start);

      if (!line.text.length) {
        const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns);
        lineEl.appendChild(document.createElement("br"));
        if (blockCodeGroupKey) {
          if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
            activeCodeGroup = document.createElement("span");
            activeCodeGroup.className = "ccf-frag ccf-code-frag is-block ccf-code-block-group";
            activeCodeGroupKey = blockCodeGroupKey;
            container.appendChild(activeCodeGroup);
          }
          activeCodeGroup.appendChild(lineEl);
          continue;
        }
        activeCodeGroup = null;
        activeCodeGroupKey = "";
      } else if (!lineRuns.length) {
        lineEl.textContent = line.text;
        activeCodeGroup = null;
        activeCodeGroupKey = "";
      } else {
        const fragments = buildFragments(line.text, lineRuns);
        const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns, fragments);
        if (blockCodeGroupKey) {
          if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
            activeCodeGroup = document.createElement("span");
            activeCodeGroup.className = "ccf-frag ccf-code-frag is-block ccf-code-block-group";
            activeCodeGroupKey = blockCodeGroupKey;
            container.appendChild(activeCodeGroup);
          }

          for (const frag of fragments) {
            appendStyledFragment(lineEl, {
              ...frag,
              style: stripCodeModeFromStyle(frag.style)
            });
          }
          activeCodeGroup.appendChild(lineEl);
          continue;
        }

        activeCodeGroup = null;
        activeCodeGroupKey = "";
        for (const frag of fragments) {
          appendStyledFragment(lineEl, frag);
        }
      }

      container.appendChild(lineEl);
    }
  }

  function normalizeRuns(runs, textLength) {
    if (!Array.isArray(runs)) return [];

    const cleaned = runs
      .map((run) => ({
        start: clamp(Number(run.start) || 0, 0, textLength),
        end: clamp(Number(run.end) || 0, 0, textLength),
        style: cleanupStyle(run.style || {})
      }))
      .filter((run) => run.end > run.start && Object.keys(run.style).length > 0)
      .sort((a, b) => a.start - b.start || a.end - b.end);

    const merged = [];
    for (const cur of cleaned) {
      const prev = merged[merged.length - 1];
      if (prev && prev.end === cur.start && JSON.stringify(prev.style) === JSON.stringify(cur.style)) {
        prev.end = cur.end;
      } else {
        merged.push(cur);
      }
    }

    return merged;
  }

  function cleanupStyle(style) {
    const out = {};
    if (style.bold) out.bold = true;
    if (style.italic) out.italic = true;
    if (style.underline) out.underline = true;
    if (style.strike) out.strike = true;
    const rubyText = normalizeRubyText(style.rubyText);
    if (rubyText) out.rubyText = rubyText;
    const tooltipText = normalizeTooltipText(style.tooltipText);
    if (tooltipText) out.tooltipText = tooltipText;
    const codeMode = normalizeCodeMode(style.codeMode);
    if (codeMode) out.codeMode = codeMode;
    const blur = normalizeBlurValue(style.blur);
    if (blur) out.blur = blur;
    if (style.color && style.color !== "#ffffff") out.color = style.color;
    if (style.backgroundColor && style.backgroundColor !== "#000000") out.backgroundColor = style.backgroundColor;
    const imageUrl = normalizeImageUrl(style.imageUrl);
    if (imageUrl) out.imageUrl = imageUrl;
    const imageAlt = normalizeImageAlt(style.imageAlt);
    if (imageAlt) out.imageAlt = imageAlt;
    if (style.backgroundImage) out.backgroundImage = String(style.backgroundImage).trim();
    const fontSize = normalizeFontSizeValue(style.fontSize);
    if (fontSize != null) out.fontSize = fontSize;
    const display = String(style.display || "").trim().toLowerCase();
    if (["inline", "inline-block", "block"].includes(display)) out.display = display;
    const padding = String(style.padding || "").trim();
    if (padding) out.padding = padding;
    const margin = String(style.margin || "").trim();
    if (margin) out.margin = margin;
    const border = String(style.border || "").trim();
    if (border) out.border = border;
    const letterSpacing = String(style.letterSpacing || "").trim();
    if (letterSpacing) out.letterSpacing = letterSpacing;
    const lineHeight = String(style.lineHeight || "").trim();
    if (lineHeight) out.lineHeight = lineHeight;
    const textAlign = cleanupAlign(style.textAlign);
    if (textAlign) out.textAlign = textAlign;
    const textShadow = String(style.textShadow || "").trim();
    if (textShadow) out.textShadow = textShadow;
    const opacity = Number(style.opacity);
    if (Number.isFinite(opacity)) out.opacity = clamp(opacity, 0, 1);
    return out;
  }

  function normalizeRubyText(value) {
    if (value == null) return "";
    return String(value).trim().slice(0, 120);
  }

  function normalizeTooltipText(value) {
    if (value == null) return "";
    return String(value)
      .replace(/\r\n?/g, "\n")
      .split("\n")
      .map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
      .join("\n")
      .replace(/\n{3,}/g, "\n\n")
      .trim()
      .slice(0, 240);
  }

  function normalizeCodeMode(value) {
    if (value === true) return "inline";
    const normalized = String(value ?? "").trim().toLowerCase();
    if (normalized === "inline" || normalized === "block") return normalized;
    if (normalized === "true" || normalized === "1" || normalized === "code") return "inline";
    return "";
  }

  function normalizeBlurValue(value) {
    if (value == null || value === false) return "";
    let trimmed = String(value).trim();
    if (!trimmed) return "";

    const blurMatch = trimmed.match(/blur\(([^)]+)\)/i);
    if (blurMatch) {
      trimmed = blurMatch[1].trim();
    }

    if (/^(?:\d+|\d*\.\d+)$/.test(trimmed)) {
      trimmed = `${trimmed}px`;
    }

    const match = trimmed.match(/^(-?(?:\d+|\d*\.\d+))(px|em|rem)$/i);
    if (!match) return "";

    const amount = Number.parseFloat(match[1]);
    if (!Number.isFinite(amount) || amount <= 0) return "";
    return `${Number(amount.toFixed(2))}${match[2].toLowerCase()}`;
  }

  function normalizeImageUrl(value) {
    if (typeof value !== "string") return "";
    let trimmed = value.trim();
    if (!trimmed) return "";

    if (trimmed.startsWith(LOCAL_IMAGE_TOKEN_PREFIX)) {
      return trimmed;
    }

    if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) {
      return trimmed.replace(/\s+/g, "");
    }

    if (/^\/\//.test(trimmed)) {
      trimmed = `https:${trimmed}`;
    } else if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && /^[\w.-]+\.[a-z]{2,}(?:\/|$)/i.test(trimmed)) {
      trimmed = `https://${trimmed}`;
    }

    try {
      const parsed = new URL(trimmed, location.href);
      if (!/^https?:$/i.test(parsed.protocol)) return "";
      return parsed.toString();
    } catch (error) {
      return "";
    }
  }

  function normalizeImageAlt(value) {
    if (value == null) return "";
    return String(value).trim().slice(0, 200);
  }

  function isLocalImageToken(value) {
    return typeof value === "string" && value.startsWith(LOCAL_IMAGE_TOKEN_PREFIX);
  }

  function getLocalImageTokenId(value) {
    return isLocalImageToken(value) ? value.slice(LOCAL_IMAGE_TOKEN_PREFIX.length) : "";
  }

  function getLocalImageStorageKey(id) {
    return `${LOCAL_IMAGE_STORAGE_PREFIX}${id}`;
  }

  function resolveStoredLocalImageUrl(value) {
    const id = getLocalImageTokenId(value);
    if (!id) return "";

    try {
      const stored = window.localStorage.getItem(getLocalImageStorageKey(id));
      return /^data:image\/[a-z0-9.+-]+;base64,/i.test(stored || "")
        ? String(stored).replace(/\s+/g, "")
        : "";
    } catch (error) {
      return "";
    }
  }

  function resolveRenderableImageUrl(value) {
    const normalized = normalizeImageUrl(value);
    if (!normalized) return "";
    if (isLocalImageToken(normalized)) {
      return resolveStoredLocalImageUrl(normalized);
    }
    return normalized;
  }

  function normalizeFontSizeValue(value) {
    if (value == null) return null;
    const trimmed = String(value).trim();
    if (!trimmed) return null;
    const numeric = Math.round(Number(trimmed));
    if (!Number.isFinite(numeric)) return null;
    return clamp(numeric, FONT_SIZE_MIN, FONT_SIZE_MAX);
  }

  function getTextLines(text) {
    const normalized = typeof text === "string" ? text : "";
    if (!normalized.length) {
      return [{ index: 0, start: 0, end: 0, text: "", hasBreak: false }];
    }

    const out = [];
    let start = 0;
    let lineIndex = 0;
    for (let i = 0; i <= normalized.length; i += 1) {
      if (i !== normalized.length && normalized[i] !== "\n") continue;
      out.push({
        index: lineIndex,
        start,
        end: i,
        text: normalized.slice(start, i),
        hasBreak: i < normalized.length
      });
      start = i + 1;
      lineIndex += 1;
    }
    return out;
  }

  function getTextLineCount(text) {
    return getTextLines(text).length;
  }

  function cleanupAlign(value) {
    return value === "center" || value === "right" ? value : null;
  }

  function normalizeAlignRuns(runs, lineCount) {
    if (!Array.isArray(runs)) return [];

    const cleaned = runs
      .map((run) => ({
        start: clamp(Number(run.start) || 0, 0, lineCount),
        end: clamp(Number(run.end) || 0, 0, lineCount),
        align: cleanupAlign(run.align)
      }))
      .filter((run) => run.end > run.start && !!run.align)
      .sort((a, b) => a.start - b.start || a.end - b.end);

    const merged = [];
    for (const cur of cleaned) {
      const prev = merged[merged.length - 1];
      if (prev && prev.end >= cur.start) {
        if (prev.align === cur.align) {
          prev.end = Math.max(prev.end, cur.end);
          continue;
        }
        if (prev.end > cur.start) {
          cur.start = prev.end;
        }
      }
      if (cur.end > cur.start) {
        merged.push(cur);
      }
    }

    return merged;
  }

  function cleanupBlockStyle(style) {
    const out = {};
    if (style && ["center", "right"].includes(style.align)) {
      out.align = style.align;
    }
    return out;
  }

  function getLegacyAlignRuns(text, blockStyle) {
    const legacy = cleanupBlockStyle(blockStyle);
    const align = cleanupAlign(legacy.align);
    if (!align) return [];
    return [{ start: 0, end: getTextLineCount(text), align }];
  }

  function getEffectiveAlignRuns(text, alignRuns, blockStyle = null) {
    const normalized = normalizeAlignRuns(alignRuns, getTextLineCount(text));
    if (normalized.length) return normalized;
    return getLegacyAlignRuns(text, blockStyle);
  }

  function getLineAlign(alignRuns, lineIndex) {
    const run = alignRuns.find((item) => item.start <= lineIndex && item.end > lineIndex);
    return run?.align || "";
  }

  function buildFragments(text, runs) {
    const points = new Set([0, text.length]);
    for (const run of runs) {
      points.add(run.start);
      points.add(run.end);
    }

    const sorted = [...points].sort((a, b) => a - b);
    const out = [];
    for (let i = 0; i < sorted.length - 1; i += 1) {
      const start = sorted[i];
      const end = sorted[i + 1];
      if (start === end) continue;
      out.push({
        text: text.slice(start, end),
        style: mergeStyles(
          runs
            .filter((run) => run.start <= start && run.end >= end)
            .map((run) => run.style)
        )
      });
    }
    return out;
  }

  function stripCodeModeFromStyle(style) {
    if (!style || !Object.prototype.hasOwnProperty.call(style, "codeMode")) {
      return style ? { ...style } : style;
    }

    const nextStyle = { ...style };
    delete nextStyle.codeMode;
    return nextStyle;
  }

  function getBlockCodeGroupKeyForLine(line, runs, fragments = null) {
    const coveringRun = runs.find((run) =>
      normalizeCodeMode(run.style?.codeMode) === "block" &&
      run.start <= line.start &&
      run.end >= line.end
    );

    if (!coveringRun) return "";
    if (!line.text.length) return `${coveringRun.start}:${coveringRun.end}`;
    if (!Array.isArray(fragments) || !fragments.length) return "";
    return fragments.every((frag) => normalizeCodeMode(frag.style?.codeMode) === "block")
      ? `${coveringRun.start}:${coveringRun.end}`
      : "";
  }

  function mergeStyles(styleList) {
    const out = {};
    for (const style of styleList) {
      if (style) Object.assign(out, style);
    }
    return out;
  }

  function applyInlineStyle(el, style) {
    if (!style) return;
    if (style.bold) el.style.fontWeight = "700";
    if (style.italic) el.style.fontStyle = "italic";
    if (style.underline || style.strike) {
      const parts = [];
      if (style.underline) parts.push("underline");
      if (style.strike) parts.push("line-through");
      el.style.textDecoration = parts.join(" ");
    }
    if (style.color) el.style.color = style.color;
    if (style.backgroundColor) el.style.backgroundColor = style.backgroundColor;
    if (style.backgroundImage) el.style.backgroundImage = style.backgroundImage;
    if (style.fontSize) el.style.fontSize = `${style.fontSize}px`;
    if (style.display) el.style.display = style.display;
    if (style.padding) el.style.padding = style.padding;
    if (style.margin) el.style.margin = style.margin;
    if (style.border) el.style.border = style.border;
    if (style.letterSpacing) el.style.letterSpacing = style.letterSpacing;
    if (style.lineHeight) el.style.lineHeight = style.lineHeight;
    if (style.textAlign) el.style.textAlign = style.textAlign;
    if (style.textShadow) el.style.textShadow = style.textShadow;
    if (style.blur) el.style.filter = `blur(${style.blur})`;
    if (style.opacity != null) el.style.opacity = String(style.opacity);
  }

  function appendStyledFragment(container, frag) {
    if (!container || !frag) return;
    container.appendChild(createStyledFragmentNode(frag));
  }

  function createStyledFragmentNode(frag) {
    if (frag.style?.imageUrl) return createImageFragmentNode(frag);
    if (frag.style?.tooltipText) return createTooltipFragmentNode(frag);
    if (frag.style?.codeMode) return createCodeFragmentNode(frag);
    if (frag.style?.rubyText) return createRubyFragmentNode(frag);
    return createPlainTextFragmentNode(frag);
  }

  function createPlainTextFragmentNode(frag) {
    const span = document.createElement("span");
    span.className = "ccf-frag";
    span.textContent = frag.text || "";
    applyInlineStyle(span, frag.style);
    return span;
  }

  function createTooltipFragmentNode(frag) {
    const tooltipText = normalizeTooltipText(frag.style?.tooltipText);
    if (!tooltipText) {
      const fallbackStyle = frag.style ? { ...frag.style } : null;
      if (fallbackStyle) delete fallbackStyle.tooltipText;
      return createStyledFragmentNode({ ...frag, style: fallbackStyle });
    }

    const wrapper = document.createElement("span");
    wrapper.className = "ccf-frag ccf-tooltip-frag";
    wrapper.dataset.tooltip = tooltipText;
    wrapper.dataset.tooltipMultiline = tooltipText.includes("\n") ? "1" : "0";

    const innerStyle = frag.style ? { ...frag.style } : null;
    if (innerStyle) delete innerStyle.tooltipText;
    wrapper.appendChild(createStyledFragmentNode({ ...frag, style: innerStyle }));
    return wrapper;
  }

  function createCodeFragmentNode(frag) {
    const codeMode = normalizeCodeMode(frag.style?.codeMode);
    if (!codeMode) {
      const fallbackStyle = frag.style ? { ...frag.style } : null;
      if (fallbackStyle) delete fallbackStyle.codeMode;
      return createStyledFragmentNode({ ...frag, style: fallbackStyle });
    }

    const wrapper = document.createElement("span");
    wrapper.className = `ccf-frag ccf-code-frag is-${codeMode}`;

    const innerStyle = frag.style ? { ...frag.style } : null;
    if (innerStyle) delete innerStyle.codeMode;
    wrapper.appendChild(createStyledFragmentNode({ ...frag, style: innerStyle }));
    return wrapper;
  }

  function createRubyFragmentNode(frag) {
    const rubyText = normalizeRubyText(frag.style?.rubyText);
    if (!rubyText) {
      const fallback = document.createElement("span");
      fallback.className = "ccf-frag";
      fallback.textContent = frag.text || "";
      applyInlineStyle(fallback, frag.style);
      return fallback;
    }

    const wrapper = document.createElement("span");
    wrapper.className = "ccf-frag ccf-ruby-frag";
    wrapper.dataset.ruby = rubyText;
    if (frag.style?.color) wrapper.style.color = frag.style.color;
    if (frag.style?.fontSize) wrapper.style.fontSize = `${frag.style.fontSize}px`;
    if (frag.style?.bold) wrapper.style.fontWeight = "700";
    if (frag.style?.italic) wrapper.style.fontStyle = "italic";
    if (frag.style?.letterSpacing) wrapper.style.letterSpacing = frag.style.letterSpacing;
    if (frag.style?.lineHeight) wrapper.style.lineHeight = frag.style.lineHeight;
    if (frag.style?.blur) wrapper.style.filter = `blur(${frag.style.blur})`;

    const base = document.createElement("span");
    base.className = "ccf-ruby-base";
    base.textContent = frag.text || "";
    const baseStyle = frag.style ? { ...frag.style } : null;
    if (baseStyle) delete baseStyle.blur;
    applyInlineStyle(base, baseStyle);
    wrapper.appendChild(base);
    return wrapper;
  }

  function createImageFragmentNode(frag) {
    const wrapper = document.createElement("span");
    wrapper.className = "ccf-image-frag";

    const token = document.createElement("span");
    token.className = "ccf-image-token";
    token.textContent = frag.text || "";
    wrapper.appendChild(token);

    const imageUrl = resolveRenderableImageUrl(frag.style.imageUrl);
    if (!imageUrl) {
      const fallback = document.createElement("span");
      fallback.textContent = frag.style.imageAlt || frag.text || "image";
      applyInlineStyle(fallback, frag.style);
      wrapper.appendChild(fallback);
      return wrapper;
    }

    const img = document.createElement("img");
    img.className = "ccf-image";
    img.src = imageUrl;
    img.alt = frag.style.imageAlt || frag.text || "image";
    img.loading = "lazy";
    img.decoding = "async";
    applyInlineStyle(img, frag.style);
    wrapper.appendChild(img);
    return wrapper;
  }

  function findLogMessageElements(scopes = findChatLogScopes()) {
    const seen = new Set();
    const out = [];

    for (const scope of scopes) {
      if (!(scope instanceof Element)) continue;

      if (scope.matches?.(MESSAGE_TEXT_SELECTOR) && isLogMessageTextElement(scope)) {
        seen.add(scope);
        out.push(scope);
      }

      scope.querySelectorAll?.(MESSAGE_TEXT_SELECTOR).forEach((element) => {
        if (!(element instanceof HTMLElement)) return;
        if (seen.has(element)) return;
        if (!isLogMessageTextElement(element)) return;
        seen.add(element);
        out.push(element);
      });
    }

    return out;
  }

  function findPrimaryLogScope() {
    const scopes = findChatLogScopes()
      .filter((scope) => scope instanceof HTMLElement && isVisible(scope));
    if (!scopes.length) return null;

    scopes.sort((left, right) => {
      const rightCount = right.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
      const leftCount = left.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
      if (rightCount !== leftCount) return rightCount - leftCount;

      const rightRect = right.getBoundingClientRect();
      const leftRect = left.getBoundingClientRect();
      return (rightRect.height * rightRect.width) - (leftRect.height * leftRect.width);
    });

    return scopes[0] || null;
  }

  function findLogScrollContainer(scope) {
    if (!(scope instanceof HTMLElement)) return null;

    let current = scope;
    while (current && current !== document.body) {
      if (isScrollableElement(current)) {
        return current;
      }
      current = current.parentElement;
    }

    return findScrollableElementInDrawer(scope.closest(".MuiDrawer-paper")) || null;
  }

  function findScrollableElementInDrawer(drawer) {
    if (!(drawer instanceof HTMLElement)) return null;

    const candidates = [drawer, ...drawer.querySelectorAll("*")];
    const scrollables = candidates
      .filter((element) => element instanceof HTMLElement)
      .filter((element) => isScrollableElement(element))
      .sort((left, right) => right.clientHeight - left.clientHeight);

    return scrollables[0] || null;
  }

  function isScrollableElement(element) {
    if (!(element instanceof HTMLElement)) return false;
    const style = getComputedStyle(element);
    if (!/(auto|scroll|overlay)/i.test(style.overflowY || "")) return false;
    return element.scrollHeight > element.clientHeight + 24;
  }

  async function collectAllLogEntriesFromScroller(scope, scroller) {
    const originalTop = scroller.scrollTop;
    const originalBehavior = scroller.style.scrollBehavior;
    const entries = [];
    const seenFingerprints = [];

    scroller.style.scrollBehavior = "auto";

    try {
      await scrollLogToStart(scroller, scope);
      await waitForLogSettle(scope);

      for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
        appendVisibleEntries(scope, entries, seenFingerprints);

        const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
        if (scroller.scrollTop >= maxTop - 1) {
          await waitForLogSettle(scope);
          appendVisibleEntries(scope, entries, seenFingerprints);
          break;
        }

        const step = Math.max(LOG_SCAN_MIN_STEP, Math.floor(scroller.clientHeight * LOG_SCAN_STEP_RATIO));
        const nextTop = Math.min(maxTop, scroller.scrollTop + step);
        if (nextTop === scroller.scrollTop) {
          break;
        }

        scroller.scrollTop = nextTop;
        await waitForLogSettle(scope);
      }
    } finally {
      scroller.scrollTop = originalTop;
      scroller.style.scrollBehavior = originalBehavior;
    }

    return entries.map((entry, index) => ({
      ...entry,
      index: index + 1
    }));
  }

  async function scrollLogToStart(scroller, scope) {
    let stableCount = 0;
    let previousSignature = "";
    let previousHeight = -1;

    for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
      scroller.scrollTop = 0;
      await waitForLogSettle(scope);

      const firstElement = findLogMessageElements([scope])[0] || null;
      const signature = firstElement ? getElementFingerprint(firstElement) : "";
      const height = scroller.scrollHeight;
      const atTop = scroller.scrollTop <= 1;

      if (atTop && signature === previousSignature && height === previousHeight) {
        stableCount += 1;
      } else {
        stableCount = 0;
      }

      if (stableCount >= 2) {
        break;
      }

      previousSignature = signature;
      previousHeight = height;
    }
  }

  function appendVisibleEntries(scope, entries, seenFingerprints) {
    const visibleElements = findLogMessageElements([scope]);
    for (const element of visibleElements) {
      const entry = buildLogEntry(element, entries.length);
      const fingerprint = getEntryFingerprint(entry);
      if (!fingerprint) continue;
      if (hasRecentFingerprint(seenFingerprints, fingerprint)) continue;

      seenFingerprints.push(fingerprint);
      if (seenFingerprints.length > 200) {
        seenFingerprints.splice(0, seenFingerprints.length - 200);
      }

      entries.push(entry);
    }
  }

  function hasRecentFingerprint(fingerprints, value) {
    for (let i = fingerprints.length - 1; i >= 0; i -= 1) {
      if (fingerprints[i] === value) return true;
    }
    return false;
  }

  function getEntryFingerprint(entry) {
    if (!entry) return "";
    if (entry.id && !/^message-\d+$/.test(entry.id)) {
      return `id:${entry.id}`;
    }

    return [
      entry.sender || "",
      entry.timestamp || "",
      entry.rawText || "",
      entry.text || "",
      entry.visibleText || ""
    ].join("\n@@\n");
  }

  function getElementFingerprint(element) {
    return getEntryFingerprint(buildLogEntry(element, 0));
  }

  async function waitForLogSettle(scope) {
    await new Promise((resolve) => {
      let done = false;
      let quietTimer = 0;
      let timeoutTimer = 0;
      const observer = new MutationObserver(() => {
        restartQuietTimer();
      });

      const finish = () => {
        if (done) return;
        done = true;
        clearTimeout(quietTimer);
        clearTimeout(timeoutTimer);
        observer.disconnect();
        resolve();
      };

      const restartQuietTimer = () => {
        clearTimeout(quietTimer);
        quietTimer = setTimeout(finish, LOG_SETTLE_QUIET_MS);
      };

      if (scope instanceof Element) {
        observer.observe(scope, {
          childList: true,
          subtree: true,
          characterData: true
        });
      }

      timeoutTimer = setTimeout(finish, LOG_SETTLE_TIMEOUT_MS);
      restartQuietTimer();
    });

    await waitForAnimationFrame();
    await waitForAnimationFrame();
  }

  function waitForAnimationFrame() {
    return new Promise((resolve) => requestAnimationFrame(() => resolve()));
  }

  function findChatLogScopes() {
    const scopes = new Set();
    const drawers = new Set();

    for (const composer of findComposerBars()) {
      const drawer = composer.closest(".MuiDrawer-paper");
      if (drawer instanceof HTMLElement) {
        drawers.add(drawer);
      }
    }

    if (!drawers.size) {
      document.querySelectorAll(".MuiDrawer-paper").forEach((drawer) => {
        if (!(drawer instanceof HTMLElement)) return;
        if (!drawer.querySelector('button[type="submit"]')) return;
        drawers.add(drawer);
      });
    }

    if (drawers.size) {
      for (const drawer of drawers) {
        if (drawer.matches?.(MESSAGE_SCOPE_SELECTOR)) {
          scopes.add(drawer);
        }
        drawer.querySelectorAll?.(MESSAGE_SCOPE_SELECTOR).forEach((scope) => {
          scopes.add(scope);
        });
      }
      return [...scopes];
    }

    document.querySelectorAll(MESSAGE_SCOPE_SELECTOR).forEach((scope) => {
      scopes.add(scope);
    });
    return [...scopes];
  }

  function isLogMessageTextElement(element) {
    if (!(element instanceof HTMLElement)) return false;
    if (!element.matches?.(MESSAGE_TEXT_SELECTOR)) return false;
    if (!element.closest(MESSAGE_SCOPE_SELECTOR)) return false;
    if (element.closest(`[${SAFE_UI_ATTR}="1"]`)) return false;
    if (element.closest('button, form, [role="dialog"]')) return false;
    if (element.closest('textarea, input, [contenteditable="true"], [role="textbox"]')) return false;
    if (element.querySelector(MESSAGE_TEXT_SELECTOR)) return false;

    const rawText = normalizeText(element.getAttribute(RAW_ATTR) || "");
    const visibleText = normalizeText(
      typeof element.innerText === "string" ? element.innerText : (element.textContent || "")
    );
    return !!(rawText.trim() || visibleText.trim());
  }

  function buildLogEntry(element, index) {
    const rawText = normalizeText(element.getAttribute(RAW_ATTR) || element.textContent || "");
    const visibleText = normalizeText(
      typeof element.innerText === "string" ? element.innerText : stripInvisibleEnvelope(element.textContent || "")
    );
    const extracted = extractEnvelope(rawText);
    const text = extracted?.envelope?.text != null
      ? normalizeText(String(extracted.envelope.text))
      : normalizeText(stripInvisibleEnvelope(rawText || visibleText));
    const itemRoot = findMessageItemRoot(element);
    const meta = extractMessageMeta(itemRoot, element, visibleText);
    const bodyHtml = captureBodyHtml(element, !!extracted, text);
    const assetSources = collectAssetSourcesFromHtml(bodyHtml);
    const avatarSource = extractMessageAvatarSource(itemRoot, element);
    if (avatarSource && !assetSources.includes(avatarSource)) {
      assetSources.unshift(avatarSource);
    }
    const baseColor = normalizeCssColor(element.style?.color || "");

    return {
      index: index + 1,
      id: getMessageId(itemRoot, index),
      sender: meta.sender,
      avatarSource,
      timestamp: meta.timestamp,
      metaTexts: meta.metaTexts,
      channel: "",
      text,
      visibleText,
      rawText,
      baseColor,
      formatEnvelopeVersion: extracted?.envelope?.v ?? null,
      formatRuns: cloneJson(extracted?.envelope?.formatRuns || []),
      alignRuns: cloneJson(extracted?.envelope?.alignRuns || []),
      blockStyle: cloneJson(extracted?.envelope?.blockStyle || {}),
      assetSources,
      bodyHtml,
      packageHtml: ""
    };
  }

  function captureBodyHtml(element, hasEnvelope, text) {
    const wrapper = document.createElement("div");
    wrapper.className = "ccf-render-root";

    const alreadyRendered =
      element.classList.contains("ccf-render-root") ||
      element.hasAttribute(RAW_ATTR) ||
      !!element.querySelector(".ccf-line, .ccf-frag, .ccf-image, .ccf-code-frag, .ccf-ruby-frag, .ccf-tooltip-frag");

    if (alreadyRendered || !hasEnvelope) {
      wrapper.innerHTML = element.innerHTML;
    } else {
      wrapper.textContent = text;
    }

    if (!wrapper.textContent && text) {
      wrapper.textContent = text;
    }

    return wrapper.innerHTML;
  }

  function findComposerBars() {
    const submits = findVisibleSubmitButtons();
    const result = new Set();

    submits.forEach((submit) => {
      const bar = findClosestComposerBar(submit);
      if (bar) {
        result.add(bar);
      }
    });

    return [...result];
  }

  function findClosestComposerBar(node) {
    let current = node instanceof Element ? node : null;
    while (current && current !== document.body) {
      if (looksLikeComposerBar(current)) return current;
      current = current.parentElement;
    }
    return null;
  }

  function looksLikeComposerBar(element) {
    if (!(element instanceof HTMLElement)) return false;
    const submit = element.querySelector('button[type="submit"]');
    if (!submit) return false;

    const editors = [...element.querySelectorAll(EDITOR_SELECTOR)].filter((editor) => isVisible(editor));
    return editors.length > 0;
  }

  function findVisibleSubmitButtons() {
    return [...document.querySelectorAll('button[type="submit"]')].filter((button) => isVisible(button));
  }

  function findTargetMenus() {
    return [...document.querySelectorAll('[role="menu"]')]
      .filter((menu) => menu instanceof HTMLElement && isVisible(menu))
      .filter((menu) => {
        const anchors = findMenuAnchors(menu);
        return !!(anchors.exportLogsItem || anchors.tabEditItem);
      });
  }

  function findMenuAnchors(menu) {
    const items = [...menu.querySelectorAll('[role="menuitem"]')]
      .filter((item) => item instanceof HTMLElement)
      .filter((item) => item.closest('[role="menu"]') === menu)
      .filter((item) => !item.hasAttribute(EXPORT_BTN_ATTR));

    return {
      exportLogsItem: items.find((item) => isExportLogsMenuItem(item)) || null,
      tabEditItem: items.find((item) => isTabEditMenuItem(item)) || null
    };
  }

  function isExportLogsMenuItem(item) {
    const text = normalizeSpace(item.textContent || "").toLowerCase();
    if (!text) return false;

    return /export\s*logs?/.test(text)
      || /logs?\s*export/.test(text)
      || /로그\s*(출력|내보내기|익스포트)/.test(text)
      || /ログ/.test(text);
  }

  function isTabEditMenuItem(item) {
    const text = normalizeSpace(item.textContent || "").toLowerCase();
    if (!text) return false;

    return /탭\s*편집/.test(text)
      || /edit\s*tab/.test(text)
      || /tab\s*edit/.test(text)
      || /タブ\s*編集/.test(text);
  }

  function cleanupMenuItemClassName(value) {
    return String(value || "")
      .split(/\s+/)
      .filter(Boolean)
      .filter((className) => className !== "Mui-disabled")
      .join(" ");
  }

  function findMessageItemRoot(element) {
    if (!(element instanceof Element)) return null;

    return element.closest('li, [role="listitem"], .MuiListItem-root')
      || findIndexedMessageRoot(element)
      || element.parentElement
      || element;
  }

  function findIndexedMessageRoot(element) {
    if (!(element instanceof Element)) return null;

    let current = element;
    while (current && current !== document.body) {
      if (
        current.hasAttribute("data-index") &&
        current.querySelector(MESSAGE_TEXT_SELECTOR)
      ) {
        return current;
      }
      current = current.parentElement;
    }

    return null;
  }

  function getMessageId(itemRoot, index) {
    if (itemRoot instanceof Element) {
      return itemRoot.getAttribute("data-index")
        || itemRoot.getAttribute("data-id")
        || itemRoot.id
        || `message-${index + 1}`;
    }
    return `message-${index + 1}`;
  }

  function extractMessageMeta(itemRoot, textElement, visibleText) {
    if (!(itemRoot instanceof Element)) {
      return { sender: "", timestamp: "", metaTexts: [] };
    }

    const seen = new Set();
    const metaCandidates = [];
    const nodes = itemRoot.querySelectorAll('time, h1, h2, h3, h4, h5, h6, strong, b, small, span, div, p');

    nodes.forEach((node) => {
      if (!(node instanceof HTMLElement)) return;
      if (node === textElement) return;
      if (node.contains(textElement) || textElement.contains(node)) return;
      if (node.querySelector(MESSAGE_TEXT_SELECTOR)) return;

      const text = normalizeSpace(node.textContent || "");
      if (!text || text === normalizeSpace(visibleText) || text.length > 120 || seen.has(text)) return;
      seen.add(text);
      metaCandidates.push({
        text,
        score: scoreSenderCandidate(node, text)
      });
    });

    const metaTexts = metaCandidates.map((item) => item.text);
    const timestamp = metaTexts.find((text) => looksLikeTimestamp(text)) || "";
    const sender = metaCandidates
      .filter((item) => item.text !== timestamp && !looksLikeTimestamp(item.text))
      .sort((left, right) => right.score - left.score || left.text.length - right.text.length)[0]?.text || "";

    return { sender, timestamp, metaTexts };
  }

  async function enrichEntriesWithLiveAvatars(entries) {
    if (!Array.isArray(entries) || !entries.length) return;

    const liveAvatarEntries = await collectLiveAvatarEntries();
    if (!liveAvatarEntries.length) return;

    mergeEntriesWithLiveAvatars(entries, liveAvatarEntries);
  }

  async function collectLiveAvatarEntries() {
    const scope = findPrimaryLogScope();
    if (!(scope instanceof HTMLElement)) return [];

    const scroller = findLogScrollContainer(scope);
    if (!(scroller instanceof HTMLElement)) {
      return findLogMessageElements([scope]).map((element, index) => buildLiveAvatarEntry(element, index));
    }

    return collectLiveAvatarEntriesFromScroller(scope, scroller);
  }

  async function collectLiveAvatarEntriesFromScroller(scope, scroller) {
    const originalTop = scroller.scrollTop;
    const originalBehavior = scroller.style.scrollBehavior;
    const entries = [];
    const seenFingerprints = [];

    scroller.style.scrollBehavior = "auto";

    try {
      await scrollLogToStart(scroller, scope);
      await waitForLogSettle(scope);

      for (let i = 0; i < LOG_SCAN_MAX_ITERATIONS; i += 1) {
        appendVisibleAvatarEntries(scope, entries, seenFingerprints);

        const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
        if (scroller.scrollTop >= maxTop - 1) {
          await waitForLogSettle(scope);
          appendVisibleAvatarEntries(scope, entries, seenFingerprints);
          break;
        }

        const step = Math.max(LOG_SCAN_MIN_STEP, Math.floor(scroller.clientHeight * LOG_SCAN_STEP_RATIO));
        const nextTop = Math.min(maxTop, scroller.scrollTop + step);
        if (nextTop === scroller.scrollTop) {
          break;
        }

        scroller.scrollTop = nextTop;
        await waitForLogSettle(scope);
      }
    } finally {
      scroller.scrollTop = originalTop;
      scroller.style.scrollBehavior = originalBehavior;
    }

    return entries;
  }

  function appendVisibleAvatarEntries(scope, entries, seenFingerprints) {
    const visibleEntries = findLogMessageElements([scope])
      .map((element, index) => buildLiveAvatarEntry(element, entries.length + index))
      .filter((entry) => !!getLiveAvatarEntryFingerprint(entry));

    if (!visibleEntries.length) return;

    const visibleFingerprints = visibleEntries.map((entry) => getLiveAvatarEntryFingerprint(entry));
    const overlap = getFingerprintOverlapLength(seenFingerprints, visibleFingerprints);

    for (let i = overlap; i < visibleEntries.length; i += 1) {
      const entry = visibleEntries[i];
      const fingerprint = visibleFingerprints[i];
      if (!fingerprint) continue;

      seenFingerprints.push(fingerprint);
      if (seenFingerprints.length > 400) {
        seenFingerprints.splice(0, seenFingerprints.length - 400);
      }

      entries.push(entry);
    }
  }

  function getFingerprintOverlapLength(previousFingerprints, nextFingerprints) {
    if (!Array.isArray(previousFingerprints) || !previousFingerprints.length) return 0;
    if (!Array.isArray(nextFingerprints) || !nextFingerprints.length) return 0;

    const maxOverlap = Math.min(previousFingerprints.length, nextFingerprints.length);
    for (let size = maxOverlap; size > 0; size -= 1) {
      let matched = true;
      for (let i = 0; i < size; i += 1) {
        if (previousFingerprints[previousFingerprints.length - size + i] !== nextFingerprints[i]) {
          matched = false;
          break;
        }
      }
      if (matched) return size;
    }

    return 0;
  }

  function buildLiveAvatarEntry(element, index) {
    if (!(element instanceof HTMLElement)) {
      return {
        id: `message-${index + 1}`,
        sender: "",
        timestamp: "",
        mergeKey: "",
        avatarSource: ""
      };
    }

    const rawText = normalizeText(element.getAttribute(RAW_ATTR) || element.textContent || "");
    const visibleText = normalizeText(
      typeof element.innerText === "string" ? element.innerText : stripInvisibleEnvelope(element.textContent || "")
    );
    const extracted = extractEnvelope(rawText);
    const text = extracted?.envelope?.text != null
      ? normalizeText(String(extracted.envelope.text))
      : normalizeText(stripInvisibleEnvelope(rawText || visibleText));
    const itemRoot = findMessageItemRoot(element);
    const meta = extractMessageMeta(itemRoot, element, visibleText);

    return {
      id: getMessageId(itemRoot, index),
      sender: meta.sender,
      timestamp: meta.timestamp,
      mergeKey: buildAvatarMergeKey({ sender: meta.sender, text, visibleText, rawText }),
      avatarSource: extractMessageAvatarSource(itemRoot, element)
    };
  }

  function getLiveAvatarEntryFingerprint(entry) {
    if (!entry || typeof entry !== "object") return "";
    if (entry.id && !/^message-\d+$/.test(entry.id)) {
      return `id:${entry.id}`;
    }
    const senderKey = normalizeSenderKey(entry.sender || "");
    const timestampKey = normalizeSpace(entry.timestamp || "");
    return [senderKey, timestampKey, entry.mergeKey || ""].filter(Boolean).join("\n@@\n");
  }

  function buildAvatarMergeKey(entry) {
    const senderKey = normalizeSenderKey(entry?.sender || "");
    const text = normalizeText(
      entry?.text || entry?.visibleText || stripInvisibleEnvelope(entry?.rawText || "")
    );
    const textKey = normalizeSpace(text).slice(0, 500);
    return [senderKey, textKey].filter(Boolean).join("\n@@\n");
  }

  function mergeEntriesWithLiveAvatars(entries, liveEntries) {
    if (!Array.isArray(entries) || !entries.length || !Array.isArray(liveEntries) || !liveEntries.length) return;

    if (entries.length === liveEntries.length) {
      for (let i = 0; i < entries.length; i += 1) {
        if (liveEntries[i]?.avatarSource) {
          addAvatarSourceToEntry(entries[i], liveEntries[i].avatarSource);
        }
      }
    }

    const usedLiveIndexes = new Set();
    let cursor = 0;

    for (const entry of entries) {
      let matchedIndex = -1;
      for (let i = cursor; i < liveEntries.length; i += 1) {
        if (usedLiveIndexes.has(i)) continue;
        if (!isAvatarEntryMatch(entry, liveEntries[i])) continue;
        matchedIndex = i;
        break;
      }

      if (matchedIndex < 0) continue;

      usedLiveIndexes.add(matchedIndex);
      cursor = matchedIndex + 1;
      if (liveEntries[matchedIndex]?.avatarSource) {
        addAvatarSourceToEntry(entry, liveEntries[matchedIndex].avatarSource);
      }
    }

    applySenderOrderedAvatarFallback(entries, liveEntries, usedLiveIndexes);
  }

  function applySenderOrderedAvatarFallback(entries, liveEntries, usedLiveIndexes = new Set()) {
    if (!Array.isArray(entries) || !Array.isArray(liveEntries)) return;

    let cursor = 0;
    for (const entry of entries) {
      if (normalizeAssetSource(entry?.avatarSource || "")) continue;
      const targetSender = normalizeSenderKey(entry?.sender || "");
      if (!targetSender) continue;

      for (let i = cursor; i < liveEntries.length; i += 1) {
        if (usedLiveIndexes.has(i)) continue;
        const liveEntry = liveEntries[i];
        if (!liveEntry?.avatarSource) continue;
        if (normalizeSenderKey(liveEntry.sender || "") !== targetSender) continue;

        addAvatarSourceToEntry(entry, liveEntry.avatarSource);
        usedLiveIndexes.add(i);
        cursor = i + 1;
        break;
      }
    }
  }

  function isAvatarEntryMatch(entry, liveEntry) {
    if (!entry || !liveEntry) return false;

    const targetKey = buildAvatarMergeKey(entry);
    const liveKey = buildAvatarMergeKey(liveEntry);
    const targetSender = normalizeSenderKey(entry.sender || "");
    const liveSender = normalizeSenderKey(liveEntry.sender || "");
    const senderMatches = !!targetSender && !!liveSender && targetSender === liveSender;

    if (targetKey && liveKey && targetKey === liveKey) {
      return !targetSender || !liveSender || senderMatches;
    }

    if (senderMatches && targetKey && liveKey) {
      return targetKey.includes(liveKey) || liveKey.includes(targetKey);
    }

    if (!targetKey && senderMatches) return true;
    return false;
  }

  function extractMessageAvatarSource(itemRoot, textElement) {
    if (!(itemRoot instanceof Element)) return "";

    const searchRoots = getAvatarSearchRoots(itemRoot);
    const directAvatarNode = findDirectAvatarNode(searchRoots, textElement);
    const directAvatarSource = extractElementImageSource(directAvatarNode);
    if (directAvatarSource) return directAvatarSource;

    const nearbyAvatarNode = findNearbyAvatarNode(textElement);
    const nearbyAvatarSource = extractElementImageSource(nearbyAvatarNode);
    if (nearbyAvatarSource) return nearbyAvatarSource;

    const candidates = [];
    const nodes = searchRoots.flatMap((root) => [...root.querySelectorAll("*")]);
    for (const node of nodes) {
      if (!(node instanceof HTMLElement)) continue;
      if (node === textElement || textElement?.contains?.(node)) continue;
      if (!isVisible(node)) continue;

      const source = extractElementImageSource(node);
      if (!source) continue;

      const score = scoreAvatarCandidate(node);
      if (score <= 0) continue;
      candidates.push({ source, score });
    }

    candidates.sort((left, right) => right.score - left.score);
    return candidates[0]?.source || "";
  }

  function findNearbyAvatarNode(textElement) {
    if (!(textElement instanceof HTMLElement)) return null;

    const searchRoot = findNearbyAvatarSearchRoot(textElement);
    if (!(searchRoot instanceof HTMLElement)) return null;

    const textRect = textElement.getBoundingClientRect();
    if (!textRect || textRect.width <= 0 || textRect.height <= 0) return null;

    const avatarSelectors = [
      'img[alt="avatar"]',
      'img[alt*="avatar" i]',
      '.MuiAvatar-root img',
      '[class*="Avatar"] img',
      '[class*="avatar"] img',
      '[data-testid*="avatar" i] img',
      '[aria-label*="avatar" i] img'
    ];

    const candidates = [];
    for (const selector of avatarSelectors) {
      searchRoot.querySelectorAll(selector).forEach((node) => {
        if (!(node instanceof HTMLElement)) return;

        const rect = node.getBoundingClientRect();
        if (!rect || rect.width <= 0 || rect.height <= 0) return;

        const score = scoreNearbyAvatarCandidate(textRect, rect, node);
        if (!Number.isFinite(score)) return;
        candidates.push({ node, score });
      });
    }

    candidates.sort((left, right) => left.score - right.score);
    return candidates[0]?.node || null;
  }

  function findNearbyAvatarSearchRoot(textElement) {
    if (!(textElement instanceof HTMLElement)) return null;

    return textElement.closest(".MuiDrawer-paper")
      || textElement.closest('[role="presentation"]')
      || textElement.closest(MESSAGE_SCOPE_SELECTOR)
      || document.body;
  }

  function scoreNearbyAvatarCandidate(textRect, avatarRect, node) {
    const textCenterY = textRect.top + (textRect.height / 2);
    const avatarCenterY = avatarRect.top + (avatarRect.height / 2);
    const verticalDelta = Math.abs(textCenterY - avatarCenterY);
    if (verticalDelta > Math.max(72, textRect.height * 2.4)) return Number.POSITIVE_INFINITY;

    const leftGap = textRect.left - avatarRect.right;
    if (leftGap < -12) return Number.POSITIVE_INFINITY;
    if (leftGap > 220) return Number.POSITIVE_INFINITY;

    let score = verticalDelta + Math.max(0, leftGap) * 0.18;
    const width = avatarRect.width;
    const height = avatarRect.height;
    const sizeDelta = Math.abs(width - 40) + Math.abs(height - 40);
    score += sizeDelta * 0.1;

    const tokens = [
      node.getAttribute("alt") || "",
      node.className || "",
      node.getAttribute("aria-label") || "",
      node.getAttribute("data-testid") || ""
    ].join(" ").toLowerCase();

    if (/avatar/.test(tokens)) score -= 12;
    if (/muiavatar/.test(tokens)) score -= 8;
    return score;
  }

  function getAvatarSearchRoots(itemRoot) {
    if (!(itemRoot instanceof HTMLElement)) return [];

    const roots = [];
    const seen = new Set();
    let current = itemRoot;

    for (let depth = 0; depth < 4 && current instanceof HTMLElement; depth += 1) {
      if (!seen.has(current)) {
        seen.add(current);
        roots.push(current);
      }

      const parent = current.parentElement;
      if (!(parent instanceof HTMLElement)) break;
      if (parent.matches?.(MESSAGE_SCOPE_SELECTOR)) break;

      const textCount = parent.querySelectorAll(MESSAGE_TEXT_SELECTOR).length;
      if (textCount > 2) break;
      current = parent;
    }

    return roots;
  }

  function findDirectAvatarNode(searchRoots, textElement) {
    if (!Array.isArray(searchRoots) || !searchRoots.length) return null;

    const selectors = [
      'img[alt="avatar"]',
      'img[alt*="avatar" i]',
      '.MuiAvatar-root img',
      '[class*="Avatar"] img',
      '[class*="avatar"] img',
      '[data-testid*="avatar" i] img',
      '[aria-label*="avatar" i] img'
    ];

    for (const root of searchRoots) {
      for (const selector of selectors) {
        const candidates = [...root.querySelectorAll(selector)];
        const matched = candidates.find((node) =>
          node instanceof HTMLElement &&
          node !== textElement &&
          !textElement?.contains?.(node)
        );
        if (matched instanceof HTMLElement) {
          return matched;
        }
      }
    }

    return null;
  }

  function extractElementImageSource(node) {
    if (!(node instanceof HTMLElement)) return "";

    if (node instanceof HTMLImageElement) {
      const currentSource = normalizeAssetSource(node.currentSrc || "");
      if (currentSource) return currentSource;

      const attrSource = normalizeAssetSource(node.getAttribute("src") || node.src || "");
      if (attrSource) return attrSource;
    }

    for (const attrName of ["data-src", "data-original", "data-image", "src"]) {
      const attrSource = normalizeAssetSource(node.getAttribute(attrName) || "");
      if (attrSource) return attrSource;
    }

    const inlineBackground = extractCssUrls(node.style?.backgroundImage || "")[0] || "";
    if (inlineBackground) return inlineBackground;

    const computedBackground = extractCssUrls(getComputedStyle(node).backgroundImage || "")[0] || "";
    if (computedBackground) return computedBackground;

    return "";
  }

  function scoreAvatarCandidate(node) {
    if (!(node instanceof HTMLElement)) return 0;

    const tokens = [
      node.className || "",
      node.getAttribute("alt") || "",
      node.getAttribute("aria-label") || "",
      node.getAttribute("data-testid") || "",
      node.getAttribute("role") || ""
    ].join(" ").toLowerCase();

    let score = 0;
    if (node instanceof HTMLImageElement) score += 7;
    if (/avatar|icon|portrait|character|profile|user|face/.test(tokens)) score += 8;
    if (/muiavatar/.test(tokens)) score += 6;

    const rect = node.getBoundingClientRect?.();
    const width = Number(rect?.width) || Number(node.getAttribute("width")) || 0;
    const height = Number(rect?.height) || Number(node.getAttribute("height")) || 0;
    if (width > 0 && height > 0) {
      const maxSize = Math.max(width, height);
      const minSize = Math.min(width, height);
      if (maxSize <= 96) score += 4;
      if (maxSize >= 24 && minSize >= 24) score += 2;
      if (Math.abs(width - height) <= 18) score += 2;
      if (maxSize >= 180) score -= 8;
    }

    if (node.childElementCount > 6) score -= 2;
    return score;
  }

  function normalizeSenderKey(value) {
    return normalizeSpace(value).toLowerCase();
  }

  function addAvatarSourceToEntry(entry, source) {
    if (!entry || typeof entry !== "object") return;
    const normalized = normalizeAssetSource(source);
    if (!normalized) return;

    entry.avatarSource = normalized;
    entry.assetSources = mergeUniqueStrings(entry.assetSources || [], [normalized]);
  }

  function mergeUniqueStrings(existing, extras) {
    const out = [];
    const seen = new Set();

    for (const value of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(extras) ? extras : [])]) {
      const normalized = typeof value === "string" ? value : "";
      if (!normalized || seen.has(normalized)) continue;
      seen.add(normalized);
      out.push(normalized);
    }

    return out;
  }

  function scoreSenderCandidate(node, text) {
    const className = `${node.className || ""} ${node.getAttribute("aria-label") || ""}`.toLowerCase();
    let score = 0;

    if (/subtitle|primary|author|sender|name|user|character/.test(className)) score += 4;
    if (/caption|time|date|meta/.test(className)) score -= 3;
    if (/^H[1-6]$/.test(node.tagName)) score += 3;
    if (node.tagName === "STRONG" || node.tagName === "B") score += 2;
    if (text.length >= 1 && text.length <= 40) score += 1;
    if (!/\d{1,2}:\d{2}/.test(text)) score += 1;
    return score;
  }

  function looksLikeTimestamp(value) {
    const text = normalizeSpace(value);
    if (!text) return false;
    return /^(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM|am|pm|오전|오후)?|\d{4}[./-]\d{1,2}[./-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)$/.test(text);
  }

  function collectAssetSourcesFromHtml(html) {
    if (!html) return [];

    const container = document.createElement("div");
    container.innerHTML = html;

    const seen = new Set();
    const out = [];
    const addSource = (value) => {
      const normalized = normalizeAssetSource(value);
      if (!normalized || seen.has(normalized)) return;
      seen.add(normalized);
      out.push(normalized);
    };

    const nodes = [container, ...container.querySelectorAll("*")];
    for (const node of nodes) {
      if (!(node instanceof HTMLElement)) continue;

      if (node instanceof HTMLImageElement) {
        addSource(node.getAttribute("src") || node.src || "");
      }

      const backgroundImage = node.style?.backgroundImage || "";
      extractCssUrls(backgroundImage).forEach(addSource);
    }

    return out;
  }

  async function buildAssetBundle(entries) {
    const sources = [];
    const seen = new Set();

    for (const entry of entries) {
      for (const source of entry.assetSources) {
        if (seen.has(source)) continue;
        seen.add(source);
        sources.push(source);
      }
    }

    const assets = await Promise.all(sources.map((source, index) => resolveAsset(source, index)));
    return assets;
  }

  async function resolveAsset(source, index) {
    let bytes = null;
    let mimeType = "";
    let error = "";
    let included = false;

    if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(source)) {
      const parsed = parseDataUrl(source);
      if (parsed) {
        bytes = parsed.bytes;
        mimeType = parsed.mimeType;
        included = true;
      } else {
        error = "invalid-data-url";
      }
    } else {
      try {
        const response = await fetch(source);
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const blob = await response.blob();
        bytes = new Uint8Array(await blob.arrayBuffer());
        mimeType = blob.type || guessMimeTypeFromUrl(source);
        included = true;
      } catch (fetchError) {
        error = fetchError?.message || String(fetchError);
      }
    }

    const fileName = included
      ? `images/asset-${String(index + 1).padStart(3, "0")}.${guessFileExtension(mimeType, source)}`
      : "";

    return {
      index: index + 1,
      source,
      fileName,
      included,
      renderUrl: fileName || source,
      mimeType: mimeType || guessMimeTypeFromUrl(source),
      size: bytes?.length || 0,
      error,
      bytes
    };
  }

  function rewriteEntryHtmlForPackage(html, assetMap) {
    const container = document.createElement("div");
    container.className = "ccf-render-root";
    container.innerHTML = html || "";
    rewriteAssetSourcesInTree(container, assetMap);
    trimBoundaryBlankLinesInTree(container);
    return container.innerHTML;
  }

  function trimBoundaryBlankLinesInTree(root) {
    if (!(root instanceof HTMLElement)) return;

    const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
    const textNodes = [];
    let current = walker.nextNode();
    while (current) {
      textNodes.push(current);
      current = walker.nextNode();
    }

    if (!textNodes.length) return;

    const first = textNodes.find((node) => typeof node.textContent === "string" && node.textContent.length);
    const last = [...textNodes].reverse().find((node) => typeof node.textContent === "string" && node.textContent.length);

    if (first) {
      first.textContent = trimLeadingBlankLines(first.textContent);
    }

    if (last) {
      last.textContent = trimTrailingBlankLines(last.textContent);
    }
  }

  function trimLeadingBlankLines(value) {
    return String(value || "").replace(/^(?:[ \t\f\v\u00a0]*\n)+[ \t\f\v\u00a0]*/, "");
  }

  function trimTrailingBlankLines(value) {
    return String(value || "").replace(/[ \t\f\v\u00a0]*(?:\n[ \t\f\v\u00a0]*)+$/, "");
  }

  function rewriteAssetSourcesInTree(root, assetMap) {
    const nodes = [root, ...root.querySelectorAll("*")];
    for (const node of nodes) {
      if (!(node instanceof HTMLElement)) continue;

      if (node instanceof HTMLImageElement) {
        const source = normalizeAssetSource(node.getAttribute("src") || node.src || "");
        const mapped = source ? assetMap.get(source) : null;
        if (mapped?.renderUrl) {
          node.setAttribute("src", mapped.renderUrl);
        }
      }

      const backgroundImage = node.style?.backgroundImage || "";
      if (backgroundImage) {
        node.style.backgroundImage = rewriteCssUrls(backgroundImage, assetMap);
      }
    }
  }

  function buildLogJson({ roomTitle, exportedAt, entries, assets }) {
    const payload = {
      version: PACKAGE_VERSION,
      exportedAt: exportedAt.toISOString(),
      room: {
        title: roomTitle,
        url: location.href,
        path: location.pathname
      },
      assets: assets.map((asset) => ({
        index: asset.index,
        source: asset.source,
        fileName: asset.fileName,
        included: asset.included,
        renderUrl: asset.renderUrl,
        mimeType: asset.mimeType,
        size: asset.size,
        error: asset.error || ""
      })),
      messages: entries.map((entry) => ({
        index: entry.index,
        id: entry.id,
        sender: entry.sender,
        avatarSource: entry.avatarSource || "",
        timestamp: entry.timestamp,
        metaTexts: entry.metaTexts,
        channel: entry.channel || "",
        text: entry.text,
        visibleText: entry.visibleText,
        rawText: entry.rawText,
        baseColor: entry.baseColor || "",
        formatEnvelopeVersion: entry.formatEnvelopeVersion,
        formatRuns: entry.formatRuns,
        alignRuns: entry.alignRuns,
        blockStyle: entry.blockStyle,
        assetSources: entry.assetSources,
        html: entry.packageHtml
      }))
    };

    return JSON.stringify(payload, null, 2);
  }

  const PACKAGE_THEME_STORAGE_KEY = "ccf-theme-switcher-settings-v1";
  const PACKAGE_THEME_MODE_DEFAULT = "default";
  const PACKAGE_THEME_MODE_LIGHT = "light";
  const PACKAGE_THEME_MODE_CUSTOM = "custom";
  const PACKAGE_THEME_SAVED_MODE_PREFIX = "saved:";
  const PACKAGE_THEME_DEFAULT_FALLBACK = Object.freeze({
    bg: "#202020",
    appbar: "#212121",
    paper: "#2a2a2a",
    border: "#444444",
    text: "#ffffff",
    inputBg: "#202020"
  });
  const PACKAGE_THEME_LIGHT_PRESET = Object.freeze({
    bg: "#f1f1f1",
    appbar: "#dddddd",
    paper: "#fbfbfb",
    border: "#b9b9b9",
    text: "#2f2f2f",
    inputBg: "#ffffff"
  });
  const PACKAGE_THEME_CUSTOM_FALLBACK = Object.freeze({
    bg: "#151414",
    appbar: "#22201f",
    paper: "#1d1c1e",
    border: "#413d3a",
    text: "#f4f0eb",
    inputBg: "#1a191b"
  });
  const PACKAGE_THEME_FIELD_DEFS = Object.freeze([
    { key: "bg", label: "\uBC30\uACBD" },
    { key: "appbar", label: "\uC0C1\uB2E8 \uBC14" },
    { key: "paper", label: "\uD328\uB110" },
    { key: "border", label: "\uD14C\uB450\uB9AC" },
    { key: "text", label: "\uD14D\uC2A4\uD2B8" }
  ]);

  function getPackageThemeDefinition() {
    const context = readActivePackageThemeContext() || readStoredPackageThemeContext();
    return buildPackageThemeDefinition(context);
  }

  function readActivePackageThemeContext() {
    const root = document.documentElement;
    if (!(root instanceof HTMLElement)) return null;

    const styles = getComputedStyle(root);
    const rawTheme = {
      bg: styles.getPropertyValue("--ccf-theme-bg"),
      appbar: styles.getPropertyValue("--ccf-theme-appbar"),
      paper: styles.getPropertyValue("--ccf-theme-paper"),
      border: styles.getPropertyValue("--ccf-theme-border"),
      text: styles.getPropertyValue("--ccf-theme-text"),
      inputBg: styles.getPropertyValue("--ccf-theme-input-bg")
    };
    const hasLiveTheme = Object.values(rawTheme)
      .map((value) => normalizeCssColor(value))
      .filter(Boolean)
      .length >= 4;
    if (!hasLiveTheme) return null;

    return {
      mode: normalizePackageThemeMode(root.getAttribute("data-ccf-theme-mode") || ""),
      theme: normalizePackageThemePalette(rawTheme, PACKAGE_THEME_DEFAULT_FALLBACK)
    };
  }

  function readStoredPackageThemeContext() {
    const state = readStoredPackageThemeState();

    return {
      mode: state.mode,
      theme: resolvePackageThemePaletteForMode(state.mode, state),
      savedThemes: state.savedThemes
    };
  }

  function readStoredPackageThemeState() {
    let raw = null;
    try {
      raw = JSON.parse(window.localStorage.getItem(PACKAGE_THEME_STORAGE_KEY) || "null");
    } catch (error) {
      raw = null;
    }

    const savedThemes = normalizePackageSavedThemes(raw?.savedThemes);
    return {
      hasThemeSwitcher: !!(raw && typeof raw === "object"),
      mode: normalizePackageThemeMode(raw?.mode, savedThemes),
      defaultTheme: normalizeOptionalPackageThemePalette(raw?.defaultTheme),
      customTheme: normalizePackageThemePalette(
        raw?.customTheme || raw?.theme || null,
        PACKAGE_THEME_CUSTOM_FALLBACK
      ),
      savedThemes
    };
  }

  function normalizePackageSavedThemes(value) {
    if (!Array.isArray(value)) return [];

    return value
      .map((item) => {
        const id = normalizeSpace(item?.id || "");
        if (!id) return null;
        return {
          id,
          name: normalizeSpace(item?.name || ""),
          theme: normalizePackageThemePalette(item?.theme || null, PACKAGE_THEME_CUSTOM_FALLBACK)
        };
      })
      .filter(Boolean);
  }

  function normalizePackageThemeMode(value, savedThemes = []) {
    if (isPackageSavedThemeMode(value) && savedThemes.some((item) => makePackageSavedThemeMode(item.id) === value)) {
      return value;
    }

    return [
      PACKAGE_THEME_MODE_DEFAULT,
      PACKAGE_THEME_MODE_LIGHT,
      PACKAGE_THEME_MODE_CUSTOM
    ].includes(value) ? value : PACKAGE_THEME_MODE_DEFAULT;
  }

  function isPackageSavedThemeMode(value) {
    return typeof value === "string" && value.startsWith(PACKAGE_THEME_SAVED_MODE_PREFIX);
  }

  function makePackageSavedThemeMode(id) {
    return `${PACKAGE_THEME_SAVED_MODE_PREFIX}${id}`;
  }

  function normalizeOptionalPackageThemePalette(value) {
    if (!value || typeof value !== "object") return null;
    const normalized = normalizePackageThemePalette(value, PACKAGE_THEME_DEFAULT_FALLBACK);
    return Object.values(normalized).some(Boolean) ? normalized : null;
  }

  function normalizePackageThemePalette(value, fallback = PACKAGE_THEME_DEFAULT_FALLBACK) {
    const base = fallback || PACKAGE_THEME_DEFAULT_FALLBACK;
    return {
      bg: normalizeCssColor(value?.bg) || base.bg,
      appbar: normalizeCssColor(value?.appbar) || base.appbar,
      paper: normalizeCssColor(value?.paper) || base.paper,
      border: normalizeCssColor(value?.border) || base.border,
      text: normalizeCssColor(value?.text) || base.text,
      inputBg: normalizeCssColor(value?.inputBg) || base.inputBg
    };
  }

  function resolvePackageThemePaletteForMode(mode, state = readStoredPackageThemeState()) {
    const savedTheme = state.savedThemes.find((item) => makePackageSavedThemeMode(item.id) === mode) || null;
    if (savedTheme?.theme) {
      return normalizePackageThemePalette(savedTheme.theme, PACKAGE_THEME_CUSTOM_FALLBACK);
    }
    if (mode === PACKAGE_THEME_MODE_LIGHT) {
      return normalizePackageThemePalette(PACKAGE_THEME_LIGHT_PRESET, PACKAGE_THEME_LIGHT_PRESET);
    }
    if (mode === PACKAGE_THEME_MODE_CUSTOM) {
      return normalizePackageThemePalette(state.customTheme, PACKAGE_THEME_CUSTOM_FALLBACK);
    }
    return normalizePackageThemePalette(state.defaultTheme, PACKAGE_THEME_DEFAULT_FALLBACK);
  }

  function getPackageThemeOptionModel(currentMode = "") {
    const state = readStoredPackageThemeState();
    const hasThemeSwitcher = state.hasThemeSwitcher;
    const options = [
      {
        value: PACKAGE_THEME_MODE_DEFAULT,
        label: hasThemeSwitcher ? "\uAE30\uBCF8" : "\uB2E4\uD06C \uBAA8\uB4DC"
      },
      {
        value: PACKAGE_THEME_MODE_LIGHT,
        label: hasThemeSwitcher ? "\uB77C\uC774\uD2B8" : "\uB77C\uC774\uD2B8 \uBAA8\uB4DC"
      },
      {
        value: PACKAGE_THEME_MODE_CUSTOM,
        label: hasThemeSwitcher ? "\uCEE4\uC2A4\uD140" : "\uCEE4\uC2A4\uD140 \uBAA8\uB4DC"
      },
      ...(
        hasThemeSwitcher
          ? state.savedThemes.map((item) => ({
            value: makePackageSavedThemeMode(item.id),
            label: item.name || "\uC800\uC7A5 \uD14C\uB9C8"
          }))
          : []
      )
    ];

    const selectedMode = options.some((option) => option.value === currentMode)
      ? currentMode
      : (options.some((option) => option.value === state.mode) ? state.mode : options[0]?.value || PACKAGE_THEME_MODE_DEFAULT);

    const definitions = options.reduce((out, option) => {
      out[option.value] = buildPackageThemeDefinition({
        mode: option.value,
        theme: resolvePackageThemePaletteForMode(option.value, state),
        savedThemes: state.savedThemes
      });
      return out;
    }, {});

    return {
      selectedMode,
      options,
      definitions
    };
  }

  function buildPackageThemeDefinition(context = {}) {
    const theme = normalizePackageThemePalette(context.theme || null, PACKAGE_THEME_DEFAULT_FALLBACK);
    const isLight = getPackageColorLuminance(theme.bg) >= 0.62;
    const accent = mixPackageColors(theme.text, theme.appbar, isLight ? 0.38 : 0.22);
    const accentContrast = pickPackageReadableText(accent);
    const accentBorder = mixPackageColors(accent, theme.border, 0.34);
    const chipBg = mixPackageColors(theme.appbar, theme.paper, 0.58);
    const chipBorder = mixPackageColors(theme.border, theme.paper, 0.72);
    const codeBg = mixPackageColors(theme.appbar, theme.inputBg, 0.62);
    const codeBorder = mixPackageColors(theme.border, theme.paper, 0.8);
    const shadowColor = withPackageColorAlpha("#000000", isLight ? 0.12 : 0.26);
    const buttonShadow = withPackageColorAlpha("#000000", isLight ? 0.12 : 0.22);

    return {
      mode: isPackageSavedThemeMode(context.mode)
        ? String(context.mode)
        : normalizePackageThemeMode(context.mode || "", normalizePackageSavedThemes(context.savedThemes)),
      palette: theme,
      colorScheme: isLight ? "light" : "dark",
      vars: {
        "--page-bg": theme.bg,
        "--panel-bg": theme.paper,
        "--panel-border": theme.border,
        "--panel-shadow": `0 18px 48px ${shadowColor}`,
        "--text-main": theme.text,
        "--text-subtle": mixPackageColors(theme.text, theme.bg, 0.58),
        "--accent": accent,
        "--accent-contrast": accentContrast,
        "--accent-border": accentBorder,
        "--accent-shadow": `0 4px 12px ${buttonShadow}`,
        "--chip-bg": chipBg,
        "--chip-border": chipBorder,
        "--chip-text": theme.text,
        "--code-bg": codeBg,
        "--code-border": codeBorder,
        "--code-text": pickPackageReadableText(codeBg),
        "--helper-bg": mixPackageColors(theme.paper, theme.bg, 0.22)
      }
    };
  }

  function buildPackageThemeCssText(themeDefinition) {
    if (!themeDefinition?.vars) return "";
    return Object.entries(themeDefinition.vars)
      .map(([key, value]) => `${key}: ${value};`)
      .join("\n      ");
  }

  function applyPackageThemeVariables(target, themeDefinition) {
    if (!(target instanceof HTMLElement) || !themeDefinition?.vars) return;
    Object.entries(themeDefinition.vars).forEach(([key, value]) => {
      target.style.setProperty(key, value);
    });
  }

  function parsePackageColorChannels(value) {
    const normalized = normalizeCssColor(value);
    if (!normalized) return null;

    const match = normalized.match(/^rgba?\(([^)]+)\)$/i);
    if (!match) return null;

    const parts = match[1].split(",").map((part) => part.trim());
    if (parts.length < 3) return null;
    return {
      r: clamp(Number(parts[0]), 0, 255),
      g: clamp(Number(parts[1]), 0, 255),
      b: clamp(Number(parts[2]), 0, 255),
      a: parts.length >= 4 ? clamp(Number(parts[3]), 0, 1) : 1
    };
  }

  function mixPackageColors(primary, secondary, amount = 0.5) {
    const left = parsePackageColorChannels(primary);
    const right = parsePackageColorChannels(secondary);
    if (!left && !right) return normalizeCssColor(primary) || normalizeCssColor(secondary) || String(primary || secondary || "");
    if (!left) return normalizeCssColor(secondary) || String(secondary || "");
    if (!right) return normalizeCssColor(primary) || String(primary || "");

    const ratio = clamp(Number(amount), 0, 1);
    const inverse = 1 - ratio;
    const red = Math.round((left.r * ratio) + (right.r * inverse));
    const green = Math.round((left.g * ratio) + (right.g * inverse));
    const blue = Math.round((left.b * ratio) + (right.b * inverse));
    const alpha = (left.a * ratio) + (right.a * inverse);
    return alpha >= 0.999
      ? `rgb(${red}, ${green}, ${blue})`
      : `rgba(${red}, ${green}, ${blue}, ${alpha.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")})`;
  }

  function withPackageColorAlpha(value, alpha = 1) {
    const channels = parsePackageColorChannels(value);
    if (!channels) return String(value || "");
    const nextAlpha = clamp(Number(alpha), 0, 1);
    if (nextAlpha >= 0.999) {
      return `rgb(${channels.r}, ${channels.g}, ${channels.b})`;
    }
    return `rgba(${channels.r}, ${channels.g}, ${channels.b}, ${nextAlpha.toFixed(3).replace(/0+$/g, "").replace(/\.$/, "")})`;
  }

  function getPackageColorLuminance(value) {
    const channels = parsePackageColorChannels(value);
    if (!channels) return 0;
    return ((0.2126 * channels.r) + (0.7152 * channels.g) + (0.0722 * channels.b)) / 255;
  }

  function pickPackageReadableText(background) {
    return getPackageColorLuminance(background) >= 0.56 ? "#202020" : "#ffffff";
  }

  function buildIndexHtml({
    roomTitle,
    exportedAt,
    entries,
    assets,
    tistoryContentHtml = "",
    themeDefinition = null,
    themeOptionModel = null,
    tistoryContentHtmlByMode = null
  }) {
    const fallbackTheme = themeDefinition || getPackageThemeDefinition();
    const themeModel = themeOptionModel || getPackageThemeOptionModel(fallbackTheme.mode);
    const theme = themeModel.definitions[themeModel.selectedMode] || fallbackTheme;
    const themeCssText = buildPackageThemeCssText(theme);
    const packageAssetMap = new Map((Array.isArray(assets) ? assets : []).map((asset) => [asset.source, asset]));
    const tistoryHtmlMap = tistoryContentHtmlByMode && typeof tistoryContentHtmlByMode === "object"
      ? tistoryContentHtmlByMode
      : { [themeModel.selectedMode]: tistoryContentHtml || "" };
    const serializedThemeDefinitions = JSON.stringify(
      Object.entries(themeModel.definitions).reduce((out, [mode, definition]) => {
        out[mode] = {
          colorScheme: definition.colorScheme,
          vars: definition.vars,
          palette: definition.palette
        };
        return out;
      }, {})
    ).replace(/</g, "\\u003c");
    const serializedTistoryHtmlMap = JSON.stringify(tistoryHtmlMap).replace(/</g, "\\u003c");
    const serializedThemeFieldDefs = JSON.stringify(PACKAGE_THEME_FIELD_DEFS).replace(/</g, "\\u003c");
    const serializedCustomFallbackPalette = JSON.stringify(PACKAGE_THEME_CUSTOM_FALLBACK).replace(/</g, "\\u003c");
    const main = document.createElement("main");
    main.className = "ccf-log-package-page";

    const header = document.createElement("header");
    header.className = "ccf-log-package-header";

    const title = document.createElement("h1");
    title.textContent = roomTitle;
    header.appendChild(title);

    const summary = document.createElement("p");
    summary.className = "ccf-log-package-summary";
    summary.textContent = `메시지 ${entries.length}개 · 이미지 자산 ${assets.filter((asset) => asset.included).length}개 · 내보낸 시각 ${formatDisplayDate(exportedAt)}`;
    const actions = document.createElement("div");
    actions.className = "ccf-log-package-actions";

    const themeField = document.createElement("label");
    themeField.className = "ccf-log-package-theme-field";
    themeField.setAttribute("for", "ccf-theme-mode");

    const themeLabel = document.createElement("span");
    themeLabel.className = "ccf-log-package-theme-label";
    themeLabel.textContent = "\uD14C\uB9C8";
    themeField.appendChild(themeLabel);

    const themeSelect = document.createElement("select");
    themeSelect.className = "ccf-log-package-theme-select";
    themeSelect.id = "ccf-theme-mode";
    themeModel.options.forEach((optionDef) => {
      const option = document.createElement("option");
      option.value = optionDef.value;
      option.textContent = optionDef.label;
      if (optionDef.value === themeModel.selectedMode) {
        option.selected = true;
      }
      themeSelect.appendChild(option);
    });
    themeField.appendChild(themeSelect);
    actions.appendChild(themeField);

    const copyButton = document.createElement("button");
    copyButton.className = "ccf-log-package-copy-btn";
    copyButton.type = "button";
    copyButton.id = "ccf-tistory-copy-btn";
    copyButton.textContent = "티스토리 HTML 복사";
    copyButton.textContent = "티스토리 HTML 복사";
    copyButton.textContent = "\uD2F0\uC2A4\uD1A0\uB9AC HTML \uBCF5\uC0AC";
    actions.appendChild(copyButton);

    const copyHint = document.createElement("p");
    copyHint.className = "ccf-log-package-copy-hint";
    copyHint.id = "ccf-tistory-copy-status";
    copyHint.textContent = "선택한 테마 기준으로 티스토리 HTML 복사용 본문이 갱신됩니다.";
    copyHint.textContent = "버튼을 누르면 티스토리 HTML 모드에 붙여넣을 본문 HTML이 복사됩니다.";
    copyHint.textContent = "\uC120\uD0DD\uD55C \uD14C\uB9C8 \uAE30\uC900\uC73C\uB85C \uD2F0\uC2A4\uD1A0\uB9AC HTML \uBCF5\uC0AC\uC6A9 \uBCF8\uBB38\uC774 \uAC31\uC2E0\uB429\uB2C8\uB2E4.";
    actions.appendChild(copyHint);

    const customPanel = document.createElement("section");
    customPanel.className = "ccf-log-package-custom-theme";
    customPanel.id = "ccf-theme-custom-panel";
    customPanel.hidden = themeModel.selectedMode !== PACKAGE_THEME_MODE_CUSTOM;

    const customTitle = document.createElement("h2");
    customTitle.className = "ccf-log-package-custom-theme-title";
    customTitle.textContent = "\uCEE4\uC2A4\uD140 \uD14C\uB9C8 \uC0C9\uC0C1";
    customPanel.appendChild(customTitle);

    const customHint = document.createElement("p");
    customHint.className = "ccf-log-package-custom-theme-hint";
    customHint.textContent = "\uAC01 \uD56D\uBAA9 \uC0C9\uC0C1\uC744 \uBC14\uAFB8\uBA74 \uBBF8\uB9AC\uBCF4\uAE30\uC640 \uD2F0\uC2A4\uD1A0\uB9AC \uBCF5\uC0AC\uC6A9 HTML\uC5D0 \uBC14\uB85C \uBC18\uC601\uB429\uB2C8\uB2E4.";
    customPanel.appendChild(customHint);

    const customGrid = document.createElement("div");
    customGrid.className = "ccf-log-package-custom-theme-grid";

    PACKAGE_THEME_FIELD_DEFS.forEach((fieldDef) => {
      const item = document.createElement("label");
      item.className = "ccf-log-package-custom-theme-item";

      const itemLabel = document.createElement("span");
      itemLabel.className = "ccf-log-package-custom-theme-item-label";
      itemLabel.textContent = fieldDef.label;
      item.appendChild(itemLabel);

      const controls = document.createElement("div");
      controls.className = "ccf-log-package-custom-theme-controls";

      const colorInput = document.createElement("input");
      colorInput.className = "ccf-log-package-custom-theme-color";
      colorInput.type = "color";
      colorInput.id = `ccf-theme-custom-${fieldDef.key}`;
      colorInput.setAttribute("data-theme-key", fieldDef.key);
      controls.appendChild(colorInput);

      const codeInput = document.createElement("input");
      codeInput.className = "ccf-log-package-custom-theme-code";
      codeInput.type = "text";
      codeInput.inputMode = "text";
      codeInput.autocomplete = "off";
      codeInput.spellcheck = false;
      codeInput.id = `ccf-theme-custom-${fieldDef.key}-text`;
      codeInput.setAttribute("data-theme-key-text", fieldDef.key);
      controls.appendChild(codeInput);

      item.appendChild(controls);
      customGrid.appendChild(item);
    });

    customPanel.appendChild(customGrid);

    header.appendChild(actions);
    header.appendChild(customPanel);
    main.appendChild(header);

    const list = document.createElement("section");
    list.className = "ccf-log-entry-list";

    for (const entry of entries) {
      const article = document.createElement("article");
      article.className = "ccf-log-entry";

      const avatarUrl = resolvePackageRenderableImageUrl(entry.avatarSource, packageAssetMap);
      const mainRow = document.createElement("div");
      mainRow.className = "ccf-log-entry-main";

      if (avatarUrl) {
        const avatar = document.createElement("img");
        avatar.className = "ccf-log-entry-avatar";
        avatar.src = avatarUrl;
        avatar.alt = entry.sender ? `${entry.sender} avatar` : "avatar";
        avatar.loading = "eager";
        avatar.decoding = "sync";
        mainRow.appendChild(avatar);
      }

      const content = document.createElement("div");
      content.className = "ccf-log-entry-content";

      const meta = document.createElement("div");
      meta.className = "ccf-log-entry-header";

      const metaMain = document.createElement("div");
      metaMain.className = "ccf-log-entry-meta-main";

      const metaAux = document.createElement("div");
      metaAux.className = "ccf-log-entry-meta-aux";

      if (entry.sender) {
        const sender = document.createElement("span");
        sender.className = "ccf-log-entry-sender";
        sender.textContent = entry.sender;
        if (entry.baseColor) {
          sender.style.color = entry.baseColor;
        }
        metaMain.appendChild(sender);
      }

      if (entry.timestamp) {
        const timestamp = document.createElement("span");
        timestamp.className = "ccf-log-entry-timestamp";
        timestamp.textContent = entry.timestamp;
        metaMain.appendChild(timestamp);
      }

      if (Number.isFinite(entry.index)) {
        const indexTag = document.createElement("span");
        indexTag.className = "ccf-log-entry-index";
        indexTag.textContent = `#${String(entry.index).padStart(3, "0")}`;
        metaAux.appendChild(indexTag);
      }

      if (entry.channel) {
        const channel = document.createElement("span");
        channel.className = "ccf-log-entry-channel";
        channel.textContent = entry.channel;
        metaAux.appendChild(channel);
      }

      if (metaMain.childNodes.length) {
        meta.appendChild(metaMain);
      }
      if (metaAux.childNodes.length) {
        meta.appendChild(metaAux);
      }
      if (meta.childNodes.length) {
        content.appendChild(meta);
      }

      const body = document.createElement("div");
      body.className = "ccf-log-entry-body ccf-render-root";
      if (entry.packageHtml) {
        body.innerHTML = entry.packageHtml;
      } else {
        body.textContent = trimTrailingBlankLines(trimLeadingBlankLines(entry.text || entry.visibleText || ""));
      }

      content.appendChild(body);
      mainRow.appendChild(content);
      article.appendChild(mainRow);
      list.appendChild(article);
    }

    main.appendChild(list);

    return `<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(roomTitle)} - CCF Log Package</title>
  <style>
    :root {
      color-scheme: ${theme.colorScheme};
      ${themeCssText}
    }

    * {
      box-sizing: border-box;
    }

    html, body {
      margin: 0;
      padding: 0;
      background: var(--page-bg);
      color: var(--text-main);
      font-family: "Segoe UI", "Noto Sans KR", sans-serif;
      line-height: 1.6;
    }

    body {
      padding: 28px 18px 56px;
    }

    .ccf-log-package-page {
      max-width: 1080px;
      margin: 0 auto;
    }

    .ccf-log-package-header {
      margin-bottom: 18px;
    }

    .ccf-log-package-header h1 {
      margin: 0;
      font-size: clamp(24px, 3vw, 36px);
      line-height: 1.15;
    }

    .ccf-log-package-actions {
      margin-top: 14px;
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 12px;
    }

    .ccf-log-package-theme-field {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      min-width: 0;
    }

    .ccf-log-package-theme-label {
      color: var(--text-subtle);
      font-size: 13px;
      font-weight: 700;
      white-space: nowrap;
    }

    .ccf-log-package-theme-select {
      min-width: 160px;
      border: 1px solid var(--panel-border);
      border-radius: 0;
      background: var(--panel-bg);
      color: var(--text-main);
      padding: 8px 14px;
      font: inherit;
      font-size: 13px;
      line-height: 1.3;
      outline: none;
      cursor: pointer;
    }

    .ccf-log-package-theme-select:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px ${withPackageColorAlpha(theme.vars["--accent"] || "#000000", 0.16)};
    }

    .ccf-log-package-custom-theme {
      margin-top: 14px;
      padding: 14px 16px;
      border: 1px solid var(--panel-border);
      border-radius: 0;
      background: var(--helper-bg);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
    }

    .ccf-log-package-custom-theme[hidden] {
      display: none;
    }

    .ccf-log-package-custom-theme-title {
      margin: 0;
      font-size: 15px;
      line-height: 1.3;
      color: var(--text-main);
    }

    .ccf-log-package-custom-theme-hint {
      margin: 6px 0 0;
      font-size: 12px;
      line-height: 1.5;
      color: var(--text-subtle);
    }

    .ccf-log-package-custom-theme-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 12px;
      margin-top: 14px;
    }

    .ccf-log-package-custom-theme-item {
      display: grid;
      gap: 8px;
      min-width: 0;
    }

    .ccf-log-package-custom-theme-item-label {
      font-size: 12px;
      font-weight: 700;
      color: var(--text-subtle);
    }

    .ccf-log-package-custom-theme-controls {
      display: flex;
      align-items: center;
      gap: 8px;
      min-width: 0;
    }

    .ccf-log-package-custom-theme-color {
      flex: 0 0 44px;
      width: 44px;
      height: 36px;
      padding: 0;
      border: 1px solid var(--panel-border);
      border-radius: 0;
      background: var(--panel-bg);
      cursor: pointer;
    }

    .ccf-log-package-custom-theme-code {
      flex: 1 1 auto;
      min-width: 0;
      border: 1px solid var(--panel-border);
      border-radius: 0;
      background: var(--panel-bg);
      color: var(--text-main);
      padding: 9px 12px;
      font: inherit;
      font-size: 13px;
      line-height: 1.2;
      outline: none;
    }

    .ccf-log-package-custom-theme-code:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px ${withPackageColorAlpha(theme.vars["--accent"] || "#000000", 0.16)};
    }

    .ccf-log-package-copy-btn {
      appearance: none;
      border: 1px solid var(--accent-border);
      border-radius: 0;
      padding: 10px 16px;
      background: var(--accent);
      color: #151414;
      font: inherit;
      font-size: 14px;
      font-weight: 700;
      line-height: 1.2;
      text-shadow: none;
      cursor: pointer;
      box-shadow: var(--accent-shadow);
    }

    .ccf-log-package-copy-btn:hover {
      filter: brightness(1.04);
    }

    .ccf-log-package-copy-hint {
      margin: 0;
      color: var(--text-subtle);
      font-size: 13px;
    }

    .ccf-log-entry-list {
      display: grid;
      gap: 14px;
    }

    .ccf-log-entry {
      background: var(--panel-bg);
      border: 1px solid var(--panel-border);
      border-radius: 0;
      box-shadow: var(--panel-shadow);
      padding: 16px 18px;
    }

    .ccf-log-entry-main {
      display: flex;
      align-items: flex-start;
      gap: 16px;
    }

    .ccf-log-entry-content {
      min-width: 0;
      flex: 1 1 auto;
    }

    .ccf-log-entry-header {
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 12px;
      margin: 0 0 2px;
      font-size: 12px;
      line-height: 1.4;
      color: var(--text-subtle);
    }

    .ccf-log-entry-meta-main {
      min-width: 0;
      display: flex;
      flex-wrap: wrap;
      align-items: baseline;
      gap: 6px;
      flex: 1 1 auto;
    }

    .ccf-log-entry-avatar {
      width: 40px;
      min-width: 40px;
      max-width: 40px;
      height: 40px;
      min-height: 40px;
      max-height: 40px;
      flex: 0 0 40px;
      flex-shrink: 0;
      align-self: flex-start;
      display: block;
      object-fit: cover;
      border-radius: 0;
      background: var(--helper-bg);
      border: 1px solid var(--panel-border);
    }

    .ccf-log-entry-meta-aux {
      display: inline-flex;
      align-items: baseline;
      justify-content: flex-end;
      gap: 6px;
      margin-left: auto;
      flex: 0 0 auto;
    }

    .ccf-log-entry-index {
      color: var(--accent);
      font-weight: 700;
    }

    .ccf-log-entry-channel {
      display: inline-flex;
      align-items: center;
      padding: 2px 8px;
      border: 1px solid var(--chip-border);
      border-radius: 0;
      background: var(--chip-bg);
      color: var(--chip-text);
      font-weight: 700;
    }

    .ccf-log-entry-sender {
      font-size: 14px;
      font-weight: 700;
      line-height: 1.4;
      color: var(--text-main);
    }

    .ccf-log-entry-timestamp {
      line-height: 1.4;
    }

    .ccf-log-entry-body {
      margin: 0;
      padding: 0;
      font-size: 14px;
      line-height: 1.6;
    }

    .ccf-log-entry-body > * {
      margin-top: 0;
      margin-bottom: 0;
    }

    .ccf-log-entry-body p {
      margin: 0;
    }

    .ccf-render-root {
      white-space: pre-wrap;
      word-break: break-word;
    }

    .ccf-render-root .ccf-frag {
      white-space: pre-wrap;
    }

    .ccf-render-root .ccf-line {
      display: block;
      white-space: pre-wrap;
      word-break: break-word;
    }

    .ccf-render-root .ccf-ruby-frag {
      position: relative;
      display: inline-block;
      vertical-align: baseline;
      white-space: pre-wrap;
      overflow: visible;
    }

    .ccf-render-root .ccf-ruby-frag::before {
      content: attr(data-ruby);
      position: absolute;
      bottom: calc(100% - 0.08em);
      left: 50%;
      transform: translateX(-50%);
      font-size: 0.62em;
      line-height: 1;
      white-space: nowrap;
      color: currentColor;
      pointer-events: none;
    }

    .ccf-render-root .ccf-tooltip-frag {
      position: relative;
      display: inline-block;
      vertical-align: baseline;
      white-space: pre-wrap;
      overflow: visible;
      cursor: help;
      border-bottom: 1px dashed currentColor;
      padding-bottom: 0.02em;
    }

    .ccf-render-root .ccf-tooltip-frag::before,
    .ccf-render-root .ccf-tooltip-frag::after {
      position: absolute;
      left: calc(100% + 6px);
      opacity: 0;
      visibility: hidden;
      transition: opacity 120ms ease;
      pointer-events: none;
      z-index: 2;
    }

    .ccf-render-root .ccf-tooltip-frag::before {
      content: "";
      left: calc(100% + 12px);
      bottom: calc(100% + 2px);
      border-left: 6px solid transparent;
      border-right: 6px solid transparent;
      border-top: 6px solid var(--code-bg);
    }

    .ccf-render-root .ccf-tooltip-frag::after {
      content: attr(data-tooltip);
      bottom: calc(100% + 8px);
      min-width: 40px;
      max-width: min(320px, calc(100vw - 32px));
      padding: 7px 10px;
      border-radius: 0;
      background: var(--code-bg);
      color: var(--code-text);
      box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
      font-size: 12px;
      line-height: 1.35;
      text-align: left;
      white-space: pre-wrap;
      overflow-wrap: anywhere;
    }

    .ccf-render-root .ccf-tooltip-frag:hover::before,
    .ccf-render-root .ccf-tooltip-frag:hover::after {
      opacity: 1;
      visibility: visible;
    }

    .ccf-render-root .ccf-code-frag {
      font-family: Consolas, "Courier New", monospace;
      font-size: 0.92em;
      line-height: 1.5;
      color: var(--code-text);
      background: var(--code-bg);
      border: 1px solid var(--code-border);
      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
      white-space: pre-wrap;
      overflow-wrap: anywhere;
      box-sizing: border-box;
    }

    .ccf-render-root .ccf-code-frag.is-inline {
      display: inline-block;
      padding: 0.08em 0.45em 0.12em;
      vertical-align: baseline;
    }

    .ccf-render-root .ccf-code-frag.is-block {
      display: block;
      width: 100%;
      margin: 6px 0;
      padding: 10px 12px;
    }

    .ccf-render-root .ccf-image-frag {
      position: relative;
      display: inline-block;
      width: 100%;
      margin: 4px 0;
      vertical-align: top;
    }

    .ccf-render-root .ccf-image {
      display: block;
      width: auto;
      max-width: min(100%, 420px);
      height: auto;
      border: 0;
      border-radius: 0;
      box-sizing: border-box;
      margin: 0 auto;
    }

    .ccf-render-root .ccf-image-token {
      display: inline-block;
      width: 0;
      height: 0;
      overflow: hidden;
      opacity: 0;
      font-size: 0;
      line-height: 0;
      white-space: pre;
      pointer-events: none;
      user-select: none;
    }
  </style>
  <script>
    (function () {
      var themeDefinitions = ${serializedThemeDefinitions};
      var tistoryHtmlByMode = ${serializedTistoryHtmlMap};
      var themeFieldDefs = ${serializedThemeFieldDefs};
      var customFallbackPalette = ${serializedCustomFallbackPalette};
      var initialThemeMode = ${JSON.stringify(themeModel.selectedMode)};
      var currentThemeMode = initialThemeMode;
      var pendingSourceUpdateTimer = 0;
      var customPalette = normalizeThemePalette(
        (themeDefinitions.custom && themeDefinitions.custom.palette) || customFallbackPalette,
        customFallbackPalette
      );

      function clampNumber(value, min, max) {
        var numeric = Number(value);
        if (!Number.isFinite(numeric)) return min;
        if (numeric < min) return min;
        if (numeric > max) return max;
        return numeric;
      }

      function normalizeCssColorValue(value) {
        if (value == null) return '';
        var probe = document.createElement('span');
        probe.style.color = '';
        probe.style.color = String(value).trim();
        return probe.style.color || '';
      }

      function normalizeHexColor(value) {
        var match = String(value || '').trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i);
        if (!match) return '';
        var hex = match[1];
        if (hex.length === 3) {
          hex = hex.split('').map(function (char) { return char + char; }).join('');
        }
        return ('#' + hex).toUpperCase();
      }

      function colorToHex(value) {
        var direct = normalizeHexColor(value);
        if (direct) return direct;

        var normalized = normalizeCssColorValue(value);
        var match = normalized.match(/^rgba?\(([^)]+)\)$/i);
        if (!match) return '';

        var parts = match[1].split(',').map(function (part) { return part.trim(); });
        if (parts.length < 3) return '';
        return '#' + [0, 1, 2]
          .map(function (index) {
            return clampNumber(Number(parts[index]), 0, 255)
              .toString(16)
              .padStart(2, '0');
          })
          .join('')
          .toUpperCase();
      }

      function parseColorChannels(value) {
        var normalized = normalizeCssColorValue(value);
        if (!normalized) return null;

        var match = normalized.match(/^rgba?\(([^)]+)\)$/i);
        if (!match) return null;

        var parts = match[1].split(',').map(function (part) { return part.trim(); });
        if (parts.length < 3) return null;
        return {
          r: clampNumber(Number(parts[0]), 0, 255),
          g: clampNumber(Number(parts[1]), 0, 255),
          b: clampNumber(Number(parts[2]), 0, 255),
          a: parts.length >= 4 ? clampNumber(Number(parts[3]), 0, 1) : 1
        };
      }

      function mixColors(primary, secondary, amount) {
        var left = parseColorChannels(primary);
        var right = parseColorChannels(secondary);
        if (!left && !right) return normalizeCssColorValue(primary) || normalizeCssColorValue(secondary) || String(primary || secondary || '');
        if (!left) return normalizeCssColorValue(secondary) || String(secondary || '');
        if (!right) return normalizeCssColorValue(primary) || String(primary || '');

        var ratio = clampNumber(amount, 0, 1);
        var inverse = 1 - ratio;
        var red = Math.round((left.r * ratio) + (right.r * inverse));
        var green = Math.round((left.g * ratio) + (right.g * inverse));
        var blue = Math.round((left.b * ratio) + (right.b * inverse));
        var alpha = (left.a * ratio) + (right.a * inverse);
        return alpha >= 0.999
          ? 'rgb(' + red + ', ' + green + ', ' + blue + ')'
          : 'rgba(' + red + ', ' + green + ', ' + blue + ', ' + alpha.toFixed(3).replace(/0+$/g, '').replace(/\.$/, '') + ')';
      }

      function withColorAlpha(value, alpha) {
        var channels = parseColorChannels(value);
        if (!channels) return String(value || '');
        var nextAlpha = clampNumber(alpha, 0, 1);
        if (nextAlpha >= 0.999) {
          return 'rgb(' + channels.r + ', ' + channels.g + ', ' + channels.b + ')';
        }
        return 'rgba(' + channels.r + ', ' + channels.g + ', ' + channels.b + ', ' + nextAlpha.toFixed(3).replace(/0+$/g, '').replace(/\.$/, '') + ')';
      }

      function getColorLuminance(value) {
        var channels = parseColorChannels(value);
        if (!channels) return 0;
        return ((0.2126 * channels.r) + (0.7152 * channels.g) + (0.0722 * channels.b)) / 255;
      }

      function pickReadableText(value) {
        return getColorLuminance(value) >= 0.56 ? '#202020' : '#FFFFFF';
      }

      function normalizeThemePalette(palette, fallback) {
        var base = fallback || customFallbackPalette;
        return {
          bg: colorToHex(palette && palette.bg) || colorToHex(base.bg) || '#151414',
          appbar: colorToHex(palette && palette.appbar) || colorToHex(base.appbar) || '#22201F',
          paper: colorToHex(palette && palette.paper) || colorToHex(base.paper) || '#1D1C1E',
          border: colorToHex(palette && palette.border) || colorToHex(base.border) || '#413D3A',
          text: colorToHex(palette && palette.text) || colorToHex(base.text) || '#F4F0EB',
          inputBg: colorToHex(palette && palette.inputBg) || colorToHex(base.inputBg) || '#1A191B'
        };
      }

      function buildThemeDefinitionFromPalette(mode, palette) {
        var normalizedPalette = normalizeThemePalette(palette, customFallbackPalette);
        var isLight = getColorLuminance(normalizedPalette.bg) >= 0.62;
        var accent = mixColors(normalizedPalette.text, normalizedPalette.appbar, isLight ? 0.38 : 0.22);
        var accentContrast = pickReadableText(accent);
        var accentBorder = mixColors(accent, normalizedPalette.border, 0.34);
        var chipBg = mixColors(normalizedPalette.appbar, normalizedPalette.paper, 0.58);
        var chipBorder = mixColors(normalizedPalette.border, normalizedPalette.paper, 0.72);
        var codeBg = mixColors(normalizedPalette.appbar, normalizedPalette.inputBg, 0.62);
        var codeBorder = mixColors(normalizedPalette.border, normalizedPalette.paper, 0.8);
        var shadowColor = withColorAlpha('#000000', isLight ? 0.12 : 0.26);
        var buttonShadow = withColorAlpha('#000000', isLight ? 0.12 : 0.22);

        return {
          mode: mode,
          palette: normalizedPalette,
          colorScheme: isLight ? 'light' : 'dark',
          vars: {
            '--page-bg': normalizedPalette.bg,
            '--panel-bg': normalizedPalette.paper,
            '--panel-border': normalizedPalette.border,
            '--panel-shadow': '0 18px 48px ' + shadowColor,
            '--text-main': normalizedPalette.text,
            '--text-subtle': mixColors(normalizedPalette.text, normalizedPalette.bg, 0.58),
            '--accent': accent,
            '--accent-contrast': accentContrast,
            '--accent-border': accentBorder,
            '--accent-shadow': '0 4px 12px ' + buttonShadow,
            '--chip-bg': chipBg,
            '--chip-border': chipBorder,
            '--chip-text': normalizedPalette.text,
            '--code-bg': codeBg,
            '--code-border': codeBorder,
            '--code-text': pickReadableText(codeBg),
            '--helper-bg': mixColors(normalizedPalette.paper, normalizedPalette.bg, 0.22)
          }
        };
      }

      function getSourceValue() {
        var source = document.getElementById('ccf-tistory-source');
        return source ? source.value : '';
      }

      function setSourceValue(value) {
        var source = document.getElementById('ccf-tistory-source');
        if (!source) return;
        source.value = String(value || '');
      }

      function setCustomPanelVisible(visible) {
        var panel = document.getElementById('ccf-theme-custom-panel');
        if (!panel) return;
        panel.hidden = !visible;
      }

      function syncCustomThemeInputs(palette) {
        var normalizedPalette = normalizeThemePalette(palette, customFallbackPalette);
        themeFieldDefs.forEach(function (fieldDef) {
          var colorInput = document.getElementById('ccf-theme-custom-' + fieldDef.key);
          var codeInput = document.getElementById('ccf-theme-custom-' + fieldDef.key + '-text');
          var value = normalizedPalette[fieldDef.key] || '#000000';
          if (colorInput && colorInput.value !== value) {
            colorInput.value = value;
          }
          if (codeInput && codeInput.value !== value) {
            codeInput.value = value;
          }
        });
      }

      function buildThemedTistoryHtml(mode, themeDefinition) {
        var baseHtml = tistoryHtmlByMode[mode] || tistoryHtmlByMode.custom || '';
        if (!baseHtml || !themeDefinition || !themeDefinition.vars) return '';

        try {
          var doc = new DOMParser().parseFromString(baseHtml, 'text/html');
          var content = doc.querySelector('.content');
          if (!content) return baseHtml;

          content.setAttribute('data-ccf-theme-mode', mode);
          Object.keys(themeDefinition.vars).forEach(function (key) {
            content.style.setProperty(key, themeDefinition.vars[key]);
          });
          return content.outerHTML;
        } catch (error) {
          return baseHtml;
        }
      }

      function getThemeDefinitionForMode(mode) {
        if (mode === 'custom') {
          var customDefinition = buildThemeDefinitionFromPalette('custom', customPalette);
          themeDefinitions.custom = customDefinition;
          return customDefinition;
        }
        return themeDefinitions && themeDefinitions[mode];
      }

      function commitThemeSource(mode, theme) {
        setSourceValue(buildThemedTistoryHtml(mode, theme));
      }

      function scheduleThemeSourceUpdate() {
        if (pendingSourceUpdateTimer) {
          window.clearTimeout(pendingSourceUpdateTimer);
        }
        pendingSourceUpdateTimer = window.setTimeout(function () {
          pendingSourceUpdateTimer = 0;
          var theme = getThemeDefinitionForMode(currentThemeMode);
          if (!theme) return;
          commitThemeSource(currentThemeMode, theme);
        }, 120);
      }

      function applyThemeMode(mode, options) {
        var opts = options || {};
        var theme = getThemeDefinitionForMode(mode);
        if (!theme || !theme.vars) return;

        var root = document.documentElement;
        if (!root) return;

        currentThemeMode = mode;
        root.style.colorScheme = theme.colorScheme || '';
        root.setAttribute('data-ccf-theme-mode', mode);
        Object.keys(theme.vars).forEach(function (key) {
          root.style.setProperty(key, theme.vars[key]);
        });

        if (opts.deferSourceUpdate) {
          scheduleThemeSourceUpdate();
        } else {
          if (pendingSourceUpdateTimer) {
            window.clearTimeout(pendingSourceUpdateTimer);
            pendingSourceUpdateTimer = 0;
          }
          commitThemeSource(mode, theme);
        }
        setCustomPanelVisible(mode === 'custom');
        if (mode === 'custom' && opts.syncInputs !== false) {
          syncCustomThemeInputs(customPalette);
        }
      }

      function applyCustomThemeField(key, value, options) {
        var normalized = colorToHex(value);
        if (!normalized) return false;
        customPalette[key] = normalized;
        if (!options || options.syncInputs !== false) {
          syncCustomThemeInputs(customPalette);
        }
        if (currentThemeMode === 'custom') {
          applyThemeMode('custom', {
            deferSourceUpdate: !!(options && options.deferSourceUpdate),
            syncInputs: false
          });
        }
        return true;
      }

      function setCopyStatus(message, isError) {
        var status = document.getElementById('ccf-tistory-copy-status');
        if (!status) return;
        status.textContent = message;
        status.style.color = isError ? '#a61b1b' : 'var(--text-subtle)';
      }

      function fallbackCopy(text) {
        var textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.setAttribute('readonly', '');
        textarea.style.position = 'fixed';
        textarea.style.top = '-1000px';
        textarea.style.left = '-1000px';
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();
        var copied = false;
        try {
          copied = document.execCommand('copy');
        } catch (error) {
          copied = false;
        }
        document.body.removeChild(textarea);
        return copied;
      }

      async function copyTistoryHtml() {
        if (pendingSourceUpdateTimer) {
          window.clearTimeout(pendingSourceUpdateTimer);
          pendingSourceUpdateTimer = 0;
          var latestTheme = getThemeDefinitionForMode(currentThemeMode);
          if (latestTheme) {
            commitThemeSource(currentThemeMode, latestTheme);
          }
        }
        var html = getSourceValue();
        if (!html) {
          setCopyStatus('복사할 티스토리 HTML이 비어 있습니다.', true);
          return;
        }

        try {
          if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
            await navigator.clipboard.writeText(html);
          } else if (!fallbackCopy(html)) {
            throw new Error('clipboard-unavailable');
          }
          setCopyStatus('티스토리용 HTML이 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
        } catch (error) {
          if (fallbackCopy(html)) {
            setCopyStatus('티스토리용 HTML이 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
            return;
          }
          setCopyStatus('복사에 실패했습니다. 브라우저 클립보드 권한을 확인해 주세요.', true);
        }
      }

      document.addEventListener('DOMContentLoaded', function () {
        var button = document.getElementById('ccf-tistory-copy-btn');
        if (button) {
          button.addEventListener('click', function () {
            void copyTistoryHtml();
          });
        }

        var themeSelect = document.getElementById('ccf-theme-mode');
        if (themeSelect) {
          themeSelect.addEventListener('change', function () {
            applyThemeMode(themeSelect.value);
          });
          themeSelect.value = initialThemeMode;
        }

        themeFieldDefs.forEach(function (fieldDef) {
          var colorInput = document.getElementById('ccf-theme-custom-' + fieldDef.key);
          var codeInput = document.getElementById('ccf-theme-custom-' + fieldDef.key + '-text');

          if (colorInput) {
            colorInput.addEventListener('input', function () {
              applyCustomThemeField(fieldDef.key, colorInput.value, {
                syncInputs: true,
                deferSourceUpdate: true
              });
            });
            colorInput.addEventListener('change', function () {
              applyCustomThemeField(fieldDef.key, colorInput.value, {
                syncInputs: true,
                deferSourceUpdate: false
              });
            });
          }

          if (codeInput) {
            codeInput.addEventListener('change', function () {
              if (!applyCustomThemeField(fieldDef.key, codeInput.value)) {
                syncCustomThemeInputs(customPalette);
              }
            });
            codeInput.addEventListener('blur', function () {
              syncCustomThemeInputs(customPalette);
            });
            codeInput.addEventListener('keydown', function (event) {
              if (event.key !== 'Enter') return;
              event.preventDefault();
              if (!applyCustomThemeField(fieldDef.key, codeInput.value)) {
                syncCustomThemeInputs(customPalette);
              }
            });
          }
        });

        applyThemeMode(initialThemeMode);
      });
    })();
  </script>
</head>
<body>
<textarea id="ccf-tistory-source" hidden>${escapeHtml(tistoryContentHtml)}</textarea>
${main.outerHTML}
</body>
</html>`;
  }

  function buildTistoryContentHtml({ roomTitle, exportedAt, entries, assets, themeDefinition = null }) {
    const theme = themeDefinition || getPackageThemeDefinition();
    const assetMap = buildTistoryAssetMap(assets);
    const root = document.createElement("div");
    root.className = "content";
    root.setAttribute("data-ccf-export", "tistory-body");
    root.setAttribute("data-ccf-room-title", roomTitle);
    root.setAttribute("data-ccf-exported-at", exportedAt.toISOString());
    root.setAttribute("data-ccf-theme-mode", theme.mode || PACKAGE_THEME_MODE_DEFAULT);
    root.style.boxSizing = "border-box";
    root.style.width = "100%";
    root.style.maxWidth = "100%";
    root.style.margin = "0";
    root.style.padding = "0";
    root.style.color = "var(--text-main)";
    root.style.fontFamily = "\"Segoe UI\", \"Noto Sans KR\", sans-serif";
    root.style.fontSize = "14px";
    root.style.lineHeight = "1.6";
    root.style.wordBreak = "break-word";
    root.style.overflowWrap = "anywhere";
    applyPackageThemeVariables(root, theme);

    entries.forEach((entry, index) => {
      const article = createTistoryEntryNode(entry, assetMap, theme);
      if (index < entries.length - 1) {
        article.style.marginBottom = "14px";
      }
      root.appendChild(article);
    });

    return root.outerHTML;

    return `<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${escapeHtml(roomTitle)} - Tistory Body</title>
  <script>
    (function () {
      function getContentHtml() {
        var content = document.querySelector('.content');
        return content ? content.outerHTML : '';
      }

      function setStatus(message, isError) {
        var status = document.getElementById('copy-status');
        if (!status) return;
        status.textContent = message;
        status.style.color = isError ? '#a61b1b' : '#2d241c';
      }

      function fallbackCopyText(text) {
        var textarea = document.createElement('textarea');
        textarea.value = text;
        textarea.setAttribute('readonly', '');
        textarea.style.position = 'fixed';
        textarea.style.top = '-1000px';
        textarea.style.left = '-1000px';
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();
        var copied = false;
        try {
          copied = document.execCommand('copy');
        } catch (error) {
          copied = false;
        }
        document.body.removeChild(textarea);
        return copied;
      }

      async function copyContentHtml() {
        var html = getContentHtml();
        if (!html) {
          setStatus('복사할 HTML을 찾지 못했습니다.', true);
          return;
        }

        try {
          if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
            await navigator.clipboard.writeText(html);
          } else if (!fallbackCopyText(html)) {
            throw new Error('clipboard-unavailable');
          }
          setStatus('HTML이 클립보드에 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
        } catch (error) {
          if (fallbackCopyText(html)) {
            setStatus('HTML이 클립보드에 복사되었습니다. 티스토리 HTML 모드에 붙여넣어 주세요.', false);
            return;
          }
          setStatus('복사에 실패했습니다. 브라우저의 클립보드 권한을 확인해 주세요.', true);
        }
      }

      document.addEventListener('DOMContentLoaded', function () {
        var copyButton = document.getElementById('copy-html-button');
        if (copyButton) {
          copyButton.addEventListener('click', function () {
            void copyContentHtml();
          });
        }
      });

      document.addEventListener('keydown', function (event) {
        if (event.altKey && !event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyC') {
          event.preventDefault();
          void copyContentHtml();
        }
      });
    })();
  </script>
</head>
<body style="margin:0;padding:18px;background:#ffffff;color:#2d241c;font-family:&quot;Segoe UI&quot;,&quot;Noto Sans KR&quot;,sans-serif;">
<div style="box-sizing:border-box;max-width:960px;margin:0 auto 18px;padding:14px 16px;border:1px solid #e5dbcf;border-radius:0;background:#fff8ef;">
  <div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center;justify-content:space-between;">
    <div>
      <div style="font-size:16px;font-weight:700;line-height:1.4;">티스토리 HTML 복사용 파일</div>
      <div style="margin-top:4px;font-size:13px;line-height:1.5;color:#6b5d51;">이 페이지를 전체 선택해서 복사하지 말고, 아래 버튼이나 <strong>Alt+C</strong>로 HTML을 복사한 뒤 티스토리 HTML 모드에 붙여넣어 주세요.</div>
    </div>
    <button id="copy-html-button" type="button" style="cursor:pointer;border:0;border-radius:0;background:#8b5e34;color:#ffffff;padding:10px 16px;font-size:14px;font-weight:700;">HTML 복사</button>
  </div>
  <div id="copy-status" style="margin-top:10px;font-size:12px;line-height:1.5;color:#2d241c;">복사 대기 중</div>
</div>
${root.outerHTML}
</body>
</html>`;
  }

  function buildTistoryAssetMap(assets) {
    return new Map(
      (Array.isArray(assets) ? assets : []).map((asset) => [
        asset.source,
        {
          ...asset,
          renderUrl: buildTistoryAssetRenderUrl(asset)
        }
      ])
    );
  }

  function buildTistoryAssetRenderUrl(asset) {
    if (!asset || typeof asset !== "object") return "";
    if (asset.included && asset.bytes instanceof Uint8Array) {
      const mimeType = asset.mimeType || guessMimeTypeFromUrl(asset.source) || "image/png";
      return `data:${mimeType};base64,${uint8ArrayToBase64(asset.bytes)}`;
    }
    return normalizeAssetSource(asset.source) || String(asset.source || "");
  }

  function resolvePackageRenderableImageUrl(value, assetMap) {
    const renderable = resolveRenderableImageUrl(value);
    if (!renderable) return "";
    const source = normalizeAssetSource(renderable);
    const mapped = source ? assetMap.get(source) : null;
    return mapped?.renderUrl || renderable;
  }

  function createTistoryEntryNode(entry, assetMap, themeDefinition = null) {
    const article = document.createElement("section");
    article.style.boxSizing = "border-box";
    article.style.width = "100%";
    article.style.margin = "0";
    article.style.padding = "16px 18px";
    article.style.background = "var(--panel-bg)";
    article.style.border = "1px solid var(--panel-border)";
    article.style.borderRadius = "0";
    article.style.boxShadow = "var(--panel-shadow)";

    const avatarUrl = resolvePackageRenderableImageUrl(entry.avatarSource, assetMap);
    const mainRow = document.createElement("div");
    mainRow.style.display = "flex";
    mainRow.style.alignItems = "flex-start";
    mainRow.style.gap = "16px";

    if (avatarUrl) {
      const avatar = document.createElement("img");
      avatar.src = avatarUrl;
      avatar.alt = entry.sender ? `${entry.sender} avatar` : "avatar";
      avatar.loading = "eager";
      avatar.decoding = "sync";
      avatar.style.display = "block";
      avatar.style.width = "40px";
      avatar.style.minWidth = "40px";
      avatar.style.maxWidth = "40px";
      avatar.style.height = "40px";
      avatar.style.minHeight = "40px";
      avatar.style.maxHeight = "40px";
      avatar.style.flex = "0 0 40px";
      avatar.style.flexShrink = "0";
      avatar.style.alignSelf = "flex-start";
      avatar.style.objectFit = "cover";
      avatar.style.borderRadius = "0";
      avatar.style.background = "var(--helper-bg)";
      avatar.style.border = "1px solid var(--panel-border)";
      mainRow.appendChild(avatar);
    }

    const content = document.createElement("div");
    content.style.minWidth = "0";
    content.style.flex = "1 1 auto";

    if (entry.sender || entry.timestamp) {
      const header = document.createElement("div");
      header.style.display = "flex";
      header.style.alignItems = "flex-start";
      header.style.justifyContent = "space-between";
      header.style.gap = "12px";
      header.style.margin = "0 0 2px";
      header.style.fontSize = "12px";
      header.style.lineHeight = "1.4";
      header.style.color = "var(--text-subtle)";

      const headerMain = document.createElement("div");
      headerMain.style.minWidth = "0";
      headerMain.style.display = "flex";
      headerMain.style.flexWrap = "wrap";
      headerMain.style.alignItems = "baseline";
      headerMain.style.gap = "6px";
      headerMain.style.flex = "1 1 auto";

      if (entry.sender) {
        const sender = document.createElement("span");
        sender.textContent = entry.sender;
        sender.style.color = entry.baseColor || "var(--text-main)";
        sender.style.fontSize = "14px";
        sender.style.fontWeight = "700";
        sender.style.lineHeight = "1.4";
        headerMain.appendChild(sender);
      }

      if (entry.timestamp) {
        const timestamp = document.createElement("span");
        timestamp.textContent = entry.timestamp;
        timestamp.style.lineHeight = "1.4";
        headerMain.appendChild(timestamp);
      }

      header.appendChild(headerMain);
      content.appendChild(header);
    }

    const body = buildTistoryRenderedMessageNode({
      text: entry.text || entry.visibleText || "",
      formatRuns: entry.formatRuns || [],
      alignRuns: entry.alignRuns || [],
      blockStyle: entry.blockStyle || {},
      baseColor: entry.baseColor || "",
      assetMap
    });
    body.style.margin = "0";
    body.style.padding = "0";
    content.appendChild(body);
    mainRow.appendChild(content);
    article.appendChild(mainRow);

    return article;
  }

  function buildTistoryRenderedMessageNode({ text, formatRuns, alignRuns, blockStyle, baseColor, assetMap }) {
    const wrapper = document.createElement("div");
    wrapper.style.boxSizing = "border-box";
    wrapper.style.width = "100%";
    wrapper.style.margin = "0";
    wrapper.style.padding = "0";
    wrapper.style.wordBreak = "break-word";
    wrapper.style.overflowWrap = "anywhere";

    const normalizedText = typeof text === "string" ? text : "";
    if (!normalizedText) {
      wrapper.appendChild(document.createElement("br"));
      return wrapper;
    }

    const normalizedRuns = normalizeRuns(formatRuns, normalizedText.length);
    const normalizedAlignRuns = getEffectiveAlignRuns(normalizedText, alignRuns, blockStyle || {});
    if (!normalizedRuns.length && !normalizedAlignRuns.length) {
      wrapper.style.whiteSpace = "pre-wrap";
      wrapper.textContent = normalizedText;
      return wrapper;
    }

    const lines = getTextLines(normalizedText);
    let activeCodeGroup = null;
    let activeCodeGroupKey = "";

    for (const line of lines) {
      const lineEl = document.createElement("div");
      lineEl.style.margin = "0";
      lineEl.style.padding = "0";
      lineEl.style.whiteSpace = "pre-wrap";
      lineEl.style.wordBreak = "break-word";
      lineEl.style.overflowWrap = "anywhere";

      const lineAlign = getLineAlign(normalizedAlignRuns, line.index);
      if (lineAlign) {
        lineEl.style.textAlign = lineAlign;
      }

      const lineRuns = normalizedRuns
        .filter((run) => run.start < line.end && run.end > line.start)
        .map((run) => ({
          start: clamp(run.start - line.start, 0, line.text.length),
          end: clamp(run.end - line.start, 0, line.text.length),
          style: { ...run.style }
        }))
        .filter((run) => run.end > run.start);

      if (!line.text.length) {
        const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns);
        lineEl.appendChild(document.createElement("br"));
        if (blockCodeGroupKey) {
          if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
            activeCodeGroup = createTistoryCodeBlockContainer();
            activeCodeGroupKey = blockCodeGroupKey;
            wrapper.appendChild(activeCodeGroup);
          }
          activeCodeGroup.appendChild(lineEl);
          continue;
        }
        activeCodeGroup = null;
        activeCodeGroupKey = "";
        wrapper.appendChild(lineEl);
        continue;
      }

      if (!lineRuns.length) {
        lineEl.textContent = line.text;
        activeCodeGroup = null;
        activeCodeGroupKey = "";
        wrapper.appendChild(lineEl);
        continue;
      }

      const fragments = buildFragments(line.text, lineRuns);
      const blockCodeGroupKey = getBlockCodeGroupKeyForLine(line, normalizedRuns, fragments);
      if (blockCodeGroupKey) {
        if (!activeCodeGroup || activeCodeGroupKey !== blockCodeGroupKey) {
          activeCodeGroup = createTistoryCodeBlockContainer();
          activeCodeGroupKey = blockCodeGroupKey;
          wrapper.appendChild(activeCodeGroup);
        }

        for (const frag of fragments) {
          lineEl.appendChild(
            createTistoryStyledFragmentNode(
              { ...frag, style: stripCodeModeFromStyle(frag.style) },
              assetMap
            )
          );
        }
        activeCodeGroup.appendChild(lineEl);
        continue;
      }

      activeCodeGroup = null;
      activeCodeGroupKey = "";
      for (const frag of fragments) {
        lineEl.appendChild(createTistoryStyledFragmentNode(frag, assetMap));
      }
      wrapper.appendChild(lineEl);
    }

    return wrapper;
  }

  function createTistoryCodeBlockContainer() {
    const block = document.createElement("div");
    block.style.display = "block";
    block.style.width = "100%";
    block.style.margin = "6px 0";
    block.style.padding = "10px 12px";
    block.style.background = "var(--code-bg)";
    block.style.border = "1px solid var(--code-border)";
    block.style.borderRadius = "0";
    block.style.boxShadow = "inset 0 1px 0 rgba(255, 255, 255, 0.03)";
    block.style.boxSizing = "border-box";
    block.style.color = "var(--code-text)";
    block.style.fontFamily = "Consolas, \"Courier New\", monospace";
    block.style.fontSize = "0.92em";
    block.style.lineHeight = "1.5";
    return block;
  }

  function createTistoryStyledFragmentNode(frag, assetMap) {
    if (frag.style?.imageUrl) return createTistoryImageFragmentNode(frag, assetMap);
    if (frag.style?.tooltipText) return createTistoryTooltipFragmentNode(frag, assetMap);
    if (frag.style?.codeMode) return createTistoryCodeFragmentNode(frag, assetMap);
    if (frag.style?.rubyText) return createTistoryRubyFragmentNode(frag, assetMap);
    return createTistoryPlainTextFragmentNode(frag, assetMap);
  }

  function createTistoryPlainTextFragmentNode(frag, assetMap) {
    const span = document.createElement("span");
    span.textContent = frag.text || "";
    applyTistoryInlineStyle(span, frag.style, assetMap);
    return span;
  }

  function createTistoryTooltipFragmentNode(frag, assetMap) {
    const tooltipText = normalizeTooltipText(frag.style?.tooltipText);
    if (!tooltipText) {
      return createTistoryStyledFragmentNode(
        { ...frag, style: cloneStyleWithoutKeys(frag.style, ["tooltipText"]) },
        assetMap
      );
    }

    const wrapper = document.createElement("span");
    wrapper.title = tooltipText;
    wrapper.style.borderBottom = "1px dashed currentColor";
    wrapper.style.paddingBottom = "0.02em";
    wrapper.style.cursor = "help";
    wrapper.appendChild(
      createTistoryStyledFragmentNode(
        { ...frag, style: cloneStyleWithoutKeys(frag.style, ["tooltipText"]) },
        assetMap
      )
    );
    return wrapper;
  }

  function createTistoryCodeFragmentNode(frag, assetMap) {
    const codeMode = normalizeCodeMode(frag.style?.codeMode);
    if (!codeMode) {
      return createTistoryStyledFragmentNode(
        { ...frag, style: cloneStyleWithoutKeys(frag.style, ["codeMode"]) },
        assetMap
      );
    }

    const wrapper = document.createElement(codeMode === "block" ? "div" : "code");
    wrapper.style.fontFamily = "Consolas, \"Courier New\", monospace";
    wrapper.style.fontSize = "0.92em";
    wrapper.style.lineHeight = "1.5";
    wrapper.style.color = "var(--code-text)";
    wrapper.style.background = "var(--code-bg)";
    wrapper.style.border = "1px solid var(--code-border)";
    wrapper.style.boxShadow = "inset 0 1px 0 rgba(255, 255, 255, 0.03)";
    wrapper.style.boxSizing = "border-box";

    if (codeMode === "block") {
      wrapper.style.display = "block";
      wrapper.style.width = "100%";
      wrapper.style.margin = "6px 0";
      wrapper.style.padding = "10px 12px";
      wrapper.style.borderRadius = "0";
      wrapper.style.whiteSpace = "pre-wrap";
      wrapper.style.wordBreak = "break-word";
      wrapper.style.overflowWrap = "anywhere";
    } else {
      wrapper.style.display = "inline-block";
      wrapper.style.padding = "0.08em 0.45em 0.12em";
      wrapper.style.borderRadius = "0";
      wrapper.style.verticalAlign = "baseline";
      wrapper.style.whiteSpace = "pre-wrap";
      wrapper.style.overflowWrap = "anywhere";
    }

    wrapper.appendChild(
      createTistoryStyledFragmentNode(
        { ...frag, style: cloneStyleWithoutKeys(frag.style, ["codeMode"]) },
        assetMap
      )
    );
    return wrapper;
  }

  function createTistoryRubyFragmentNode(frag, assetMap) {
    const rubyText = normalizeRubyText(frag.style?.rubyText);
    if (!rubyText) {
      return createTistoryStyledFragmentNode(
        { ...frag, style: cloneStyleWithoutKeys(frag.style, ["rubyText"]) },
        assetMap
      );
    }

    const ruby = document.createElement("ruby");
    ruby.style.whiteSpace = "pre-wrap";
    applyTistoryInlineStyle(ruby, cloneStyleWithoutKeys(frag.style, ["rubyText"]), assetMap);
    ruby.appendChild(document.createTextNode(frag.text || ""));

    const rt = document.createElement("rt");
    rt.textContent = rubyText;
    rt.style.fontSize = "0.62em";
    rt.style.lineHeight = "1";
    ruby.appendChild(rt);
    return ruby;
  }

  function createTistoryImageFragmentNode(frag, assetMap) {
    const wrapper = document.createElement("span");
    wrapper.style.display = "block";
    wrapper.style.width = "100%";
    wrapper.style.margin = "4px 0";
    wrapper.style.textAlign = "center";

    const imageUrl = resolveTistoryRenderableImageUrl(frag.style?.imageUrl, assetMap);
    if (!imageUrl) {
      const fallback = document.createElement("span");
      fallback.textContent = frag.style?.imageAlt || frag.text || "image";
      applyTistoryInlineStyle(fallback, cloneStyleWithoutKeys(frag.style, ["imageUrl", "imageAlt"]), assetMap);
      wrapper.appendChild(fallback);
      return wrapper;
    }

    const img = document.createElement("img");
    img.src = imageUrl;
    img.alt = frag.style?.imageAlt || frag.text || "image";
    img.loading = "lazy";
    img.decoding = "async";
    img.style.display = "inline-block";
    img.style.maxWidth = "100%";
    img.style.height = "auto";
    img.style.border = "0";
    img.style.borderRadius = "0";
    img.style.boxSizing = "border-box";
    applyTistoryInlineStyle(img, cloneStyleWithoutKeys(frag.style, ["imageUrl", "imageAlt"]), assetMap);
    wrapper.appendChild(img);
    return wrapper;
  }

  function resolveTistoryRenderableImageUrl(value, assetMap) {
    const renderable = resolveRenderableImageUrl(value);
    if (!renderable) return "";
    const source = normalizeAssetSource(renderable);
    const mapped = source ? assetMap.get(source) : null;
    return mapped?.renderUrl || renderable;
  }

  function applyTistoryInlineStyle(el, style, assetMap) {
    if (!el || !style) return;
    if (style.bold) el.style.fontWeight = "700";
    if (style.italic) el.style.fontStyle = "italic";
    if (style.underline || style.strike) {
      const parts = [];
      if (style.underline) parts.push("underline");
      if (style.strike) parts.push("line-through");
      el.style.textDecoration = parts.join(" ");
    }
    if (style.color) el.style.color = style.color;
    if (style.backgroundColor) el.style.backgroundColor = style.backgroundColor;
    if (style.backgroundImage) {
      const rewritten = rewriteCssUrls(style.backgroundImage, assetMap);
      if (rewritten) el.style.backgroundImage = rewritten;
    }
    if (style.fontSize) el.style.fontSize = `${style.fontSize}px`;
    if (style.display) el.style.display = style.display;
    if (style.padding) el.style.padding = style.padding;
    if (style.margin) el.style.margin = style.margin;
    if (style.border) el.style.border = style.border;
    if (style.letterSpacing) el.style.letterSpacing = style.letterSpacing;
    if (style.lineHeight) el.style.lineHeight = style.lineHeight;
    if (style.textAlign) el.style.textAlign = style.textAlign;
    if (style.textShadow) el.style.textShadow = style.textShadow;
    if (style.blur) el.style.filter = `blur(${style.blur})`;
    if (style.opacity != null) el.style.opacity = String(style.opacity);
  }

  function cloneStyleWithoutKeys(style, keys) {
    if (!style || typeof style !== "object") return style ? { ...style } : {};
    const nextStyle = { ...style };
    for (const key of keys || []) {
      delete nextStyle[key];
    }
    return nextStyle;
  }

  function extractEnvelope(fullText) {
    if (typeof fullText !== "string" || !fullText) return null;

    const startIndex = fullText.indexOf(INVIS_START);
    const endIndex = fullText.indexOf(INVIS_END, startIndex + INVIS_START.length);
    if (startIndex < 0 || endIndex < 0) return null;

    const visibleText = fullText.slice(0, startIndex);
    const encodedPart = fullText.slice(startIndex + INVIS_START.length, endIndex);

    try {
      const json = decodeInvisibleToJson(encodedPart);
      const envelope = JSON.parse(json);
      return { visibleText, envelope };
    } catch (error) {
      console.warn("[CCF LOG PACKAGE] failed to decode payload", error);
      return null;
    }
  }

  function decodeInvisibleToJson(encodedPart) {
    let bits = "";
    for (const char of encodedPart) {
      const index = INVIS_REVERSE.get(char);
      if (index == null) continue;
      bits += index.toString(2).padStart(2, "0");
    }

    const bytes = [];
    for (let i = 0; i + 8 <= bits.length; i += 8) {
      bytes.push(parseInt(bits.slice(i, i + 8), 2));
    }

    const base64 = String.fromCharCode(...bytes).replace(/\0+$/g, "");
    return base64ToUtf8(base64);
  }

  function base64ToUtf8(base64) {
    return decodeURIComponent(escape(atob(base64)));
  }

  function stripInvisibleEnvelope(text) {
    if (typeof text !== "string" || !text) return "";

    const startIndex = text.indexOf(INVIS_START);
    if (startIndex < 0) return text;

    const endIndex = text.indexOf(INVIS_END, startIndex + INVIS_START.length);
    if (endIndex < 0) return text;

    return text.slice(0, startIndex) + text.slice(endIndex + INVIS_END.length);
  }

  function normalizeAssetSource(value) {
    if (typeof value !== "string") return "";
    let trimmed = value.trim();
    if (!trimmed) return "";

    if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(trimmed)) {
      return trimmed.replace(/\s+/g, "");
    }

    if (/^\/\//.test(trimmed)) {
      trimmed = `https:${trimmed}`;
    }

    try {
      const parsed = new URL(trimmed, location.href);
      if (!/^(https?|blob):$/i.test(parsed.protocol)) return "";
      return parsed.toString();
    } catch (error) {
      return "";
    }
  }

  function extractCssUrls(value) {
    if (typeof value !== "string" || !value) return [];

    const out = [];
    const re = /url\((.*?)\)/gi;
    let match = re.exec(value);
    while (match) {
      const raw = String(match[1] || "").trim().replace(/^['"]|['"]$/g, "");
      const normalized = normalizeAssetSource(raw);
      if (normalized) out.push(normalized);
      match = re.exec(value);
    }

    return out;
  }

  function rewriteCssUrls(value, assetMap) {
    if (typeof value !== "string" || !value) return "";

    return value.replace(/url\((.*?)\)/gi, (match, raw) => {
      const source = normalizeAssetSource(String(raw || "").trim().replace(/^['"]|['"]$/g, ""));
      const mapped = source ? assetMap.get(source) : null;
      if (!mapped?.renderUrl) return match;
      return `url("${escapeCssUrl(mapped.renderUrl)}")`;
    });
  }

  function escapeCssUrl(value) {
    return String(value || "").replace(/["\\\r\n]/g, "\\$&");
  }

  function parseDataUrl(value) {
    const match = String(value || "").match(/^data:([^;,]+);base64,(.+)$/i);
    if (!match) return null;

    try {
      const mimeType = match[1].toLowerCase();
      const binary = atob(match[2]);
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i += 1) {
        bytes[i] = binary.charCodeAt(i);
      }
      return { mimeType, bytes };
    } catch (error) {
      return null;
    }
  }

  function uint8ArrayToBase64(bytes) {
    if (!(bytes instanceof Uint8Array) || !bytes.length) return "";

    let binary = "";
    const chunkSize = 0x8000;
    for (let i = 0; i < bytes.length; i += chunkSize) {
      const chunk = bytes.subarray(i, i + chunkSize);
      binary += String.fromCharCode(...chunk);
    }
    return btoa(binary);
  }

  function guessFileExtension(mimeType, source) {
    const byMime = {
      "image/png": "png",
      "image/jpeg": "jpg",
      "image/gif": "gif",
      "image/webp": "webp",
      "image/bmp": "bmp",
      "image/svg+xml": "svg",
      "image/avif": "avif"
    };

    if (byMime[mimeType]) return byMime[mimeType];

    const cleaned = String(source || "").split(/[?#]/, 1)[0];
    const extMatch = cleaned.match(/\.([a-z0-9]{2,6})$/i);
    if (extMatch) {
      return extMatch[1].toLowerCase();
    }

    return "bin";
  }

  function guessMimeTypeFromUrl(source) {
    const ext = guessFileExtension("", source);
    const byExt = {
      png: "image/png",
      jpg: "image/jpeg",
      jpeg: "image/jpeg",
      gif: "image/gif",
      webp: "image/webp",
      bmp: "image/bmp",
      svg: "image/svg+xml",
      avif: "image/avif"
    };
    return byExt[ext] || "";
  }

  function buildPackageFileName(roomTitle, roomAddress, exportedAt) {
    const safeTitle = sanitizeFilePart(roomTitle || "ccfolia-room");
    const safeAddress = sanitizeFilePart(roomAddress || "room");
    const stamp = formatFileDate(exportedAt);
    return `${safeTitle}(${safeAddress})-${stamp}.zip`;
  }

  function sanitizeFilePart(value) {
    const normalized = normalizeSpace(String(value || "")).replace(/[<>:"/\\|?*\u0000-\u001F]/g, "");
    return normalized.slice(0, 80) || "ccfolia-room";
  }

  function getRoomTitle() {
    const subtitle = [...document.querySelectorAll('h6.MuiTypography-subtitle2, h6[class*="MuiTypography-subtitle2"]')]
      .find((element) => element instanceof HTMLElement && isVisible(element));
    if (subtitle instanceof HTMLElement) {
      const ownText = getOwnTextContent(subtitle);
      if (isUsableRoomTitle(ownText)) {
        return ownText;
      }
    }

    const heading = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')]
      .find((element) => element instanceof HTMLElement && isVisible(element) && isUsableRoomTitle(getOwnTextContent(element)));
    if (heading instanceof HTMLElement) {
      return getOwnTextContent(heading);
    }

    const cleanedTitle = normalizeSpace(String(document.title || "").replace(/\s*[|-]\s*CCFOLIA.*$/i, ""));
    if (isUsableRoomTitle(cleanedTitle)) {
      return cleanedTitle;
    }

    const slug = location.pathname.split("/").filter(Boolean).pop();
    return slug || "CCFOLIA Room";
  }

  function getOwnTextContent(element) {
    if (!(element instanceof HTMLElement)) return "";

    const directText = [...element.childNodes]
      .filter((node) => node.nodeType === Node.TEXT_NODE)
      .map((node) => node.textContent || "")
      .join(" ");
    const normalizedDirect = normalizeSpace(directText);
    if (normalizedDirect) return normalizedDirect;

    return normalizeSpace(element.textContent || "");
  }

  function isUsableRoomTitle(value) {
    const text = normalizeSpace(value);
    if (!text) return false;
    if (/^ccfolia\b/i.test(text)) return false;
    if (/trpgオンラインセッションツール/i.test(text)) return false;
    return true;
  }

  function getRoomAddressLabel() {
    const parts = location.pathname.split("/").filter(Boolean);
    const lastPart = parts[parts.length - 1] || "";
    if (lastPart) return lastPart;

    const fallback = `${location.hostname}${location.pathname}`.replace(/[/:]+/g, "-");
    return fallback || "room";
  }

  function downloadBlob(fileName, blob) {
    const url = URL.createObjectURL(blob);
    const anchor = document.createElement("a");
    anchor.href = url;
    anchor.download = fileName;
    document.body.appendChild(anchor);
    anchor.click();
    anchor.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  function makeZipEntry(name, data, date) {
    return {
      name,
      data,
      date: date instanceof Date ? date : new Date()
    };
  }

  function buildStoredZip(entries) {
    const locals = [];
    const centrals = [];
    let offset = 0;

    for (const entry of entries) {
      const nameBytes = encodeUtf8(entry.name);
      const dataBytes = entry.data instanceof Uint8Array ? entry.data : encodeUtf8(String(entry.data || ""));
      const crc = crc32(dataBytes);
      const dos = getDosDateTime(entry.date);

      const localHeader = new Uint8Array(30);
      const localView = new DataView(localHeader.buffer);
      localView.setUint32(0, 0x04034b50, true);
      localView.setUint16(4, 20, true);
      localView.setUint16(6, 0x0800, true);
      localView.setUint16(8, 0, true);
      localView.setUint16(10, dos.time, true);
      localView.setUint16(12, dos.date, true);
      localView.setUint32(14, crc, true);
      localView.setUint32(18, dataBytes.length, true);
      localView.setUint32(22, dataBytes.length, true);
      localView.setUint16(26, nameBytes.length, true);
      localView.setUint16(28, 0, true);
      locals.push(localHeader, nameBytes, dataBytes);

      const centralHeader = new Uint8Array(46);
      const centralView = new DataView(centralHeader.buffer);
      centralView.setUint32(0, 0x02014b50, true);
      centralView.setUint16(4, 20, true);
      centralView.setUint16(6, 20, true);
      centralView.setUint16(8, 0x0800, true);
      centralView.setUint16(10, 0, true);
      centralView.setUint16(12, dos.time, true);
      centralView.setUint16(14, dos.date, true);
      centralView.setUint32(16, crc, true);
      centralView.setUint32(20, dataBytes.length, true);
      centralView.setUint32(24, dataBytes.length, true);
      centralView.setUint16(28, nameBytes.length, true);
      centralView.setUint16(30, 0, true);
      centralView.setUint16(32, 0, true);
      centralView.setUint16(34, 0, true);
      centralView.setUint16(36, 0, true);
      centralView.setUint32(38, 0, true);
      centralView.setUint32(42, offset, true);
      centrals.push(centralHeader, nameBytes);

      offset += localHeader.length + nameBytes.length + dataBytes.length;
    }

    const centralOffset = offset;
    const centralSize = sumLengths(centrals);
    const centralRecordCount = entries.length;

    const endHeader = new Uint8Array(22);
    const endView = new DataView(endHeader.buffer);
    endView.setUint32(0, 0x06054b50, true);
    endView.setUint16(4, 0, true);
    endView.setUint16(6, 0, true);
    endView.setUint16(8, centralRecordCount, true);
    endView.setUint16(10, centralRecordCount, true);
    endView.setUint32(12, centralSize, true);
    endView.setUint32(16, centralOffset, true);
    endView.setUint16(20, 0, true);

    return concatUint8Arrays([...locals, ...centrals, endHeader]);
  }

  function getDosDateTime(value) {
    const date = value instanceof Date ? value : new Date();
    const year = Math.max(1980, date.getFullYear());
    return {
      date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
      time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2)
    };
  }

  function crc32(bytes) {
    const table = getCrc32Table();
    let crc = 0 ^ -1;
    for (let i = 0; i < bytes.length; i += 1) {
      crc = (crc >>> 8) ^ table[(crc ^ bytes[i]) & 0xFF];
    }
    return (crc ^ -1) >>> 0;
  }

  let CRC32_TABLE = null;
  function getCrc32Table() {
    if (CRC32_TABLE) return CRC32_TABLE;

    const table = new Uint32Array(256);
    for (let i = 0; i < 256; i += 1) {
      let current = i;
      for (let j = 0; j < 8; j += 1) {
        current = (current & 1) ? (0xEDB88320 ^ (current >>> 1)) : (current >>> 1);
      }
      table[i] = current >>> 0;
    }

    CRC32_TABLE = table;
    return table;
  }

  function concatUint8Arrays(parts) {
    const total = sumLengths(parts);
    const out = new Uint8Array(total);
    let offset = 0;
    for (const part of parts) {
      out.set(part, offset);
      offset += part.length;
    }
    return out;
  }

  function sumLengths(parts) {
    return parts.reduce((sum, part) => sum + part.length, 0);
  }

  function encodeUtf8(value) {
    return new TextEncoder().encode(String(value || ""));
  }

  function cloneJson(value) {
    try {
      return JSON.parse(JSON.stringify(value));
    } catch (error) {
      return Array.isArray(value) ? [] : {};
    }
  }

  function formatDisplayDate(date) {
    return new Intl.DateTimeFormat("ko-KR", {
      dateStyle: "medium",
      timeStyle: "short"
    }).format(date);
  }

  function formatFileDate(date) {
    const pad = (value) => String(value).padStart(2, "0");
    return [
      date.getFullYear(),
      pad(date.getMonth() + 1),
      pad(date.getDate())
    ].join("") + "-" + [
      pad(date.getHours()),
      pad(date.getMinutes()),
      pad(date.getSeconds())
    ].join("");
  }

  function normalizeText(value) {
    return typeof value === "string" ? value.replace(/\r\n?/g, "\n") : "";
  }

  function normalizeSpace(value) {
    return normalizeText(value).replace(/\s+/g, " ").trim();
  }

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

  function isVisible(element) {
    if (!(element instanceof HTMLElement)) return false;
    const style = getComputedStyle(element);
    if (style.display === "none" || style.visibility === "hidden") return false;
    const rect = element.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  }
})();