PDF Translator

PDF.js viewer + inline black overlay translator. Region-aware columns, adaptive translation, formula toggle, smooth zoom.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         PDF Translator
// @namespace    @reinaldyalaratte
// @author       @reinaldyalaratte
// @version      7.2.2
// @license      CC-BY-NC
// @description  PDF.js viewer + inline black overlay translator. Region-aware columns, adaptive translation, formula toggle, smooth zoom.
// @icon         https://uxwing.com/wp-content/themes/uxwing/download/communication-chat-call/language-translate-bubbles-icon.png
// @match        file:///*
// @match        http://*/*
// @match        https://*/*
// @run-at       document-start
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      *
// @require      https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js
// ==/UserScript==

(function () {
  'use strict';

  const DEFAULT_TARGET_LANG = 'id';
  let targetLang = DEFAULT_TARGET_LANG;
  let translateFormulaEnabled = false;

  const LANGUAGE_OPTIONS = [
    ['af', 'Afrikaans'], ['sq', 'Albanian'], ['am', 'Amharic'], ['ar', 'Arabic'],
    ['hy', 'Armenian'], ['as', 'Assamese'], ['ay', 'Aymara'], ['az', 'Azerbaijani'],
    ['bm', 'Bambara'], ['eu', 'Basque'], ['be', 'Belarusian'], ['bn', 'Bengali'],
    ['bho', 'Bhojpuri'], ['bs', 'Bosnian'], ['bg', 'Bulgarian'], ['ca', 'Catalan'],
    ['ceb', 'Cebuano'], ['zh-CN', 'Chinese Simplified'], ['zh-TW', 'Chinese Traditional'],
    ['co', 'Corsican'], ['hr', 'Croatian'], ['cs', 'Czech'], ['da', 'Danish'],
    ['dv', 'Dhivehi'], ['doi', 'Dogri'], ['nl', 'Dutch'], ['en', 'English'],
    ['eo', 'Esperanto'], ['et', 'Estonian'], ['ee', 'Ewe'], ['fil', 'Filipino'],
    ['fi', 'Finnish'], ['fr', 'French'], ['fy', 'Frisian'], ['gl', 'Galician'],
    ['ka', 'Georgian'], ['de', 'German'], ['el', 'Greek'], ['gn', 'Guarani'],
    ['gu', 'Gujarati'], ['ht', 'Haitian Creole'], ['ha', 'Hausa'], ['haw', 'Hawaiian'],
    ['iw', 'Hebrew'], ['hi', 'Hindi'], ['hmn', 'Hmong'], ['hu', 'Hungarian'],
    ['is', 'Icelandic'], ['ig', 'Igbo'], ['ilo', 'Ilocano'], ['id', 'Indonesian'],
    ['ga', 'Irish'], ['it', 'Italian'], ['ja', 'Japanese'], ['jw', 'Javanese'],
    ['kn', 'Kannada'], ['kk', 'Kazakh'], ['km', 'Khmer'], ['rw', 'Kinyarwanda'],
    ['gom', 'Konkani'], ['ko', 'Korean'], ['kri', 'Krio'], ['ku', 'Kurdish Kurmanji'],
    ['ckb', 'Kurdish Sorani'], ['ky', 'Kyrgyz'], ['lo', 'Lao'], ['la', 'Latin'],
    ['lv', 'Latvian'], ['ln', 'Lingala'], ['lt', 'Lithuanian'], ['lg', 'Luganda'],
    ['lb', 'Luxembourgish'], ['mk', 'Macedonian'], ['mai', 'Maithili'],
    ['mg', 'Malagasy'], ['ms', 'Malay'], ['ml', 'Malayalam'], ['mt', 'Maltese'],
    ['mi', 'Maori'], ['mr', 'Marathi'], ['mni-Mtei', 'Meiteilon'], ['lus', 'Mizo'],
    ['mn', 'Mongolian'], ['my', 'Myanmar'], ['ne', 'Nepali'], ['no', 'Norwegian'],
    ['ny', 'Nyanja'], ['or', 'Odia'], ['om', 'Oromo'], ['ps', 'Pashto'],
    ['fa', 'Persian'], ['pl', 'Polish'], ['pt', 'Portuguese'], ['pa', 'Punjabi'],
    ['qu', 'Quechua'], ['ro', 'Romanian'], ['ru', 'Russian'], ['sm', 'Samoan'],
    ['sa', 'Sanskrit'], ['gd', 'Scots Gaelic'], ['nso', 'Sepedi'], ['sr', 'Serbian'],
    ['st', 'Sesotho'], ['sn', 'Shona'], ['sd', 'Sindhi'], ['si', 'Sinhala'],
    ['sk', 'Slovak'], ['sl', 'Slovenian'], ['so', 'Somali'], ['es', 'Spanish'],
    ['su', 'Sundanese'], ['sw', 'Swahili'], ['sv', 'Swedish'], ['tl', 'Tagalog'],
    ['tg', 'Tajik'], ['ta', 'Tamil'], ['tt', 'Tatar'], ['te', 'Telugu'],
    ['th', 'Thai'], ['ti', 'Tigrinya'], ['ts', 'Tsonga'], ['tr', 'Turkish'],
    ['tk', 'Turkmen'], ['ak', 'Twi'], ['uk', 'Ukrainian'], ['ur', 'Urdu'],
    ['ug', 'Uyghur'], ['uz', 'Uzbek'], ['vi', 'Vietnamese'], ['cy', 'Welsh'],
    ['xh', 'Xhosa'], ['yi', 'Yiddish'], ['yo', 'Yoruba'], ['zu', 'Zulu']
  ];

  const DEFAULT_SCALE = 1.35;
  const PDF_WORKER = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';

  const BASE_BATCH_CONCURRENCY = 9;
  const SAFE_BATCH_CONCURRENCY = 5;
  const FALLBACK_CONCURRENCY = 4;

  const BATCH_MAX_BLOCKS = 10;
  const BATCH_MAX_CHARS = 3300;
  const MAX_SINGLE_BLOCK_CHARS = 1400;

  const FIT_MIN_FONT = 1.75;
  const FIT_MAX_GROW = 1.08;
  const FIT_LINE_HEIGHT = 1.02;

  const OVERLAY_BG = 'rgba(0, 0, 0, 0.965)';
  const MAX_RENDER_DPR = 1.0;

  const MIN_VISUAL_ZOOM = 0.35;
  const MAX_VISUAL_ZOOM = 4.0;
  const ZOOM_STEP = 1.12;

  const SPLIT_MARKER = '⟦⟦PDTO_SPLIT_720_REINALDY⟧⟧';
  const RETRY_LIMIT = 2;
  const SLOW_REQUEST_MS = 9000;

  const href = location.href;
  const path = decodeURIComponent(location.pathname || '');
  const looksLikePdf = /\.pdf(?:$|[?#])/i.test(href) || /\.pdf$/i.test(path);

  if (!looksLikePdf) return;
  if (window.top !== window.self) return;
  if (window.__PDF_TRANSLATOR_REINALDY_V720__) return;
  window.__PDF_TRANSLATOR_REINALDY_V720__ = true;

  let pdfDoc = null;
  let scale = DEFAULT_SCALE;
  let visualZoom = 1.0;
  let baseViewerWidth = 0;
  let baseViewerHeight = 0;

  let pageData = new Map();
  let translateCache = new Map();

  let translating = false;
  let activePageNumber = 1;
  let started = false;
  let translationsHidden = false;

  let toolbarHidden = false;
  let lastScrollY = 0;

  let wheelZoomScheduled = false;
  let pendingWheelZoom = null;
  let pendingWheelAnchor = null;

  let activePageTimer = null;
  let hScrollSyncing = false;

  let adaptiveStats = {
    slow: 0,
    fallback: 0,
    requests: 0
  };

  function safeStart() {
    if (started) return;
    started = true;
    start();
  }

  function start() {
    buildApp();
    bindEvents();
    setupLanguageSelect();
    updateFormulaButton();

    waitForPdfjs()
      .then(loadCurrentPdf)
      .catch(showNeedManualFile);
  }

  function waitForPdfjs() {
    return new Promise((resolve, reject) => {
      let tries = 0;

      const tick = () => {
        tries++;

        if (window.pdfjsLib) {
          window.pdfjsLib.GlobalWorkerOptions.workerSrc = PDF_WORKER;
          resolve();
          return;
        }

        if (tries > 90) {
          reject(new Error('PDF.js failed to load.'));
          return;
        }

        setTimeout(tick, 100);
      };

      tick();
    });
  }

  function buildApp() {
    try { window.stop(); } catch (e) {}

    document.open();
    document.write(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>PDF Translator</title>
<style>
  html, body {
    margin: 0;
    min-height: 100%;
    background: #292929;
    color: white;
    font-family: Arial, Helvetica, sans-serif;
    overflow-x: auto;
  }

  #ito-toolbar {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 999999;
    height: 54px;
    min-height: 54px;
    box-sizing: border-box;
    background: #1d1d1d;
    box-shadow: 0 2px 12px rgba(0,0,0,.35);
    transform: translateY(0);
    transition: transform .18s ease, opacity .18s ease;
    opacity: 1;
  }

  #ito-toolbar.ito-hidden {
    transform: translateY(-115%);
    opacity: 0;
    pointer-events: none;
  }

  #ito-left-tools {
    position: absolute;
    left: 14px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    min-width: 0;
  }

  #ito-pre-zoom-tools {
    position: absolute;
    right: calc(50% + 124px);
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 5px;
  }

  #ito-zoom-tools {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    display: flex;
    align-items: center;
    gap: 5px;
  }

  #ito-action-tools {
    position: absolute;
    left: calc(50% + 124px);
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 5px;
  }

  .ito-btn {
    border: 1px solid rgba(255,255,255,.23);
    background: #111;
    color: #fff;
    padding: 7px 10px;
    border-radius: 9px;
    cursor: pointer;
    font-weight: 800;
    font-size: 13px;
    white-space: nowrap;
  }

  .ito-btn:hover {
    background: #303030;
  }

  .ito-btn:disabled {
    opacity: .55;
    cursor: not-allowed;
  }

  #ito-title {
    min-width: 40px;
    max-width: 180px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    opacity: .92;
    font-size: 14px;
    font-weight: 700;
  }

  #ito-ig-link {
    color: #fff;
    text-decoration: none;
    font-size: 13px;
    font-weight: 900;
    opacity: .92;
    padding: 7px 8px;
    border: 1px solid rgba(255,255,255,.18);
    border-radius: 9px;
    background: #111;
    white-space: nowrap;
  }

  #ito-ig-link:hover {
    background: #303030;
    opacity: 1;
  }

  #ito-status {
    position: absolute;
    right: 14px;
    top: 50%;
    transform: translateY(-50%);
    max-width: 360px;
    min-width: 120px;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    font-size: 13px;
    opacity: .86;
    text-align: right;
    pointer-events: none;
  }

  #ito-lang-select {
    width: 124px;
    max-width: 124px;
    border: 1px solid rgba(255,255,255,.23);
    background: #111;
    color: #fff;
    padding: 7px 8px;
    border-radius: 9px;
    cursor: pointer;
    font-weight: 800;
    font-size: 13px;
    outline: none;
  }

  #ito-lang-select:hover {
    background: #303030;
  }

  #ito-lang-select:disabled {
    opacity: .55;
    cursor: not-allowed;
  }

  #ito-zoom-label {
    min-width: 54px;
    text-align: center;
    font-size: 13px;
    opacity: .9;
    padding: 7px 6px;
    border: 1px solid rgba(255,255,255,.16);
    border-radius: 7px;
    background: #101010;
    box-sizing: border-box;
  }

  #ito-formula-toggle {
    min-width: 36px;
    padding-left: 0;
    padding-right: 0;
    font-size: 16px;
    line-height: 1;
  }

  #ito-formula-toggle.ito-active {
    background: #2f6f46;
    border-color: rgba(160,255,190,.6);
  }

  #ito-zoom-shell {
    position: relative;
    padding: 78px 10px 118px;
    box-sizing: border-box;
    min-height: 100vh;
    overflow: visible;
  }

  #ito-render-mask {
    display: none;
    min-height: calc(100vh - 78px);
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
  }

  #ito-render-card {
    min-width: min(520px, calc(100vw - 42px));
    background: #101010;
    color: white;
    border-radius: 14px;
    padding: 28px;
    text-align: center;
    box-shadow: 0 8px 34px rgba(0,0,0,.42);
    font-weight: 800;
    font-size: 21px;
  }

  #ito-render-sub {
    margin-top: 10px;
    font-size: 14px;
    font-weight: 600;
    opacity: .72;
  }

  #ito-zoom-shell.ito-rendering #ito-render-mask {
    display: flex;
  }

  #ito-zoom-shell.ito-rendering #ito-zoom-stage {
    opacity: 0;
    pointer-events: none;
  }

  #ito-zoom-stage {
    position: relative;
    margin: 0 auto;
    transform: translateZ(0);
    transition: opacity .12s ease;
  }

  #ito-viewer {
    position: absolute;
    top: 0;
    left: 0;
    transform-origin: top left;
    will-change: transform;
  }

  .ito-page {
    position: relative;
    margin: 0 auto 24px;
    background: white;
    box-shadow: 0 6px 26px rgba(0,0,0,.45);
  }

  .ito-page canvas {
    display: block;
  }

  .textLayer {
    position: absolute;
    inset: 0;
    overflow: hidden;
    line-height: 1;
    opacity: 1;
    transform-origin: 0 0;
    z-index: 2;
  }

  .textLayer span,
  .textLayer br {
    color: transparent;
    position: absolute;
    white-space: pre;
    cursor: text;
    transform-origin: 0% 0%;
  }

  .textLayer ::selection {
    background: rgba(0, 115, 255, .34);
  }

  .ito-translation-layer {
    position: absolute;
    inset: 0;
    z-index: 5;
    pointer-events: none;
  }

  .ito-block {
    position: absolute;
    box-sizing: border-box;
    background: ${OVERLAY_BG};
    color: white;
    border-radius: 3px;
    border: 1px solid rgba(255,255,255,.12);
    padding: 1px 2px;
    font-family: Arial, Helvetica, sans-serif;
    font-weight: 700;
    letter-spacing: -0.22px;
    line-height: ${FIT_LINE_HEIGHT};
    white-space: normal;
    overflow: hidden;
    text-align: left;
    box-shadow: 0 1px 4px rgba(0,0,0,.28);
    pointer-events: auto;
    user-select: text;
    word-break: normal;
    overflow-wrap: break-word;
  }

  .ito-block.ito-loading {
    opacity: .62;
    font-weight: 800;
  }

  .ito-block.ito-error {
    background: rgba(120,0,0,.92);
  }

  .ito-block:hover {
    outline: 2px solid rgba(255,255,255,.35);
  }

  .ito-block.ito-expanded {
    height: auto !important;
    min-height: var(--origin-height);
    max-height: none !important;
    z-index: 99;
    overflow: visible;
    padding: 4px 5px;
    font-size: 12px !important;
    line-height: 1.18 !important;
    background: rgba(0,0,0,.98);
  }

  #ito-toast {
    position: fixed;
    left: 50%;
    bottom: 40px;
    transform: translateX(-50%);
    z-index: 2147483647;
    display: none;
    max-width: min(780px, calc(100vw - 30px));
    background: rgba(0,0,0,.92);
    color: white;
    padding: 10px 15px;
    border-radius: 999px;
    box-shadow: 0 8px 26px rgba(0,0,0,.4);
    font-size: 14px;
  }

  #ito-manual {
    max-width: 620px;
    margin: 96px auto;
    background: #101010;
    border-radius: 14px;
    padding: 28px;
    line-height: 1.55;
    text-align: center;
    box-shadow: 0 8px 34px rgba(0,0,0,.4);
  }

  #ito-manual h2 {
    margin: 0 0 18px;
    font-size: 24px;
  }

  #ito-file {
    display: none;
  }

  .ito-big {
    margin-top: 14px;
    font-size: 16px;
    padding: 12px 18px;
  }

  #ito-hscroll {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    height: 17px;
    z-index: 999998;
    display: none;
    overflow-x: auto;
    overflow-y: hidden;
    background: rgba(25,25,25,.92);
    border-top: 1px solid rgba(255,255,255,.12);
  }

  #ito-hscroll-inner {
    height: 1px;
  }

  #ito-hscroll::-webkit-scrollbar {
    height: 14px;
  }

  #ito-hscroll::-webkit-scrollbar-track {
    background: #1a1a1a;
  }

  #ito-hscroll::-webkit-scrollbar-thumb {
    background: #9b9b9b;
    border-radius: 999px;
    border: 3px solid #1a1a1a;
  }

  #ito-hscroll::-webkit-scrollbar-thumb:hover {
    background: #c5c5c5;
  }

  @media (max-width: 1500px) {
    #ito-status {
      max-width: 260px;
    }

    #ito-title {
      max-width: 110px;
    }
  }

  @media (max-width: 1320px) {
    #ito-status {
      display: none;
    }

    #ito-title {
      max-width: 90px;
    }
  }

  @media (max-width: 1160px) {
    #ito-ig-link {
      display: none;
    }
  }

  @media (max-width: 980px) {
    #ito-toolbar {
      height: 104px;
      min-height: 104px;
    }

    #ito-left-tools {
      left: 50%;
      top: 18px;
      transform: translateX(-50%);
    }

    #ito-pre-zoom-tools {
      left: 50%;
      right: auto;
      top: 70px;
      transform: translateX(-180%);
    }

    #ito-zoom-tools {
      top: 70px;
    }

    #ito-action-tools {
      left: 50%;
      top: 70px;
      transform: translateX(48%);
    }

    #ito-title {
      max-width: calc(100vw - 30px);
      text-align: center;
    }

    #ito-zoom-shell {
      padding-top: 128px;
    }
  }

  @media (max-width: 760px) {
    #ito-toolbar {
      height: 154px;
      min-height: 154px;
    }

    #ito-left-tools {
      left: 50%;
      top: 20px;
      transform: translateX(-50%);
    }

    #ito-pre-zoom-tools {
      left: 50%;
      top: 58px;
      transform: translateX(-50%);
    }

    #ito-zoom-tools {
      top: 104px;
    }

    #ito-action-tools {
      left: 50%;
      top: 140px;
      transform: translateX(-50%);
    }

    #ito-zoom-shell {
      padding-top: 178px;
    }
  }
</style>
</head>
<body>
  <div id="ito-toolbar">
    <div id="ito-left-tools">
      <div id="ito-title">PDF Translator</div>
    </div>

    <div id="ito-pre-zoom-tools">
      <a id="ito-ig-link" href="https://www.instagram.com/reinaldyalaratte/" target="_blank" rel="noopener noreferrer">@reinaldyalaratte</a>
      <select id="ito-lang-select" title="Target language"></select>
    </div>

    <div id="ito-zoom-tools">
      <button id="ito-minus" class="ito-btn" title="Zoom out">−</button>
      <button id="ito-fit" class="ito-btn" title="Fit to width">Fit</button>
      <button id="ito-plus" class="ito-btn" title="Zoom in">+</button>
      <div id="ito-zoom-label">100%</div>
    </div>

    <div id="ito-action-tools">
      <button id="ito-formula-toggle" class="ito-btn" title="Formula translation OFF">⌬</button>
      <button id="ito-translate-toggle" class="ito-btn">Translate</button>
      <button id="ito-clear" class="ito-btn">Clear</button>
    </div>

    <div id="ito-status">Loading...</div>
  </div>

  <div id="ito-zoom-shell">
    <div id="ito-render-mask">
      <div id="ito-render-card">
        Rendering PDF
        <div id="ito-render-sub">Please wait...</div>
      </div>
    </div>
    <div id="ito-zoom-stage">
      <main id="ito-viewer"></main>
    </div>
  </div>

  <div id="ito-hscroll"><div id="ito-hscroll-inner"></div></div>
  <div id="ito-toast"></div>
  <input id="ito-file" type="file" accept="application/pdf,.pdf">
</body>
</html>`);
    document.close();
  }

  function setupLanguageSelect() {
    const select = byId('ito-lang-select');
    if (!select) return;

    select.innerHTML = '';

    for (const [code, label] of LANGUAGE_OPTIONS) {
      const option = document.createElement('option');
      option.value = code;
      option.textContent = label;
      select.appendChild(option);
    }

    select.value = targetLang;

    select.addEventListener('change', () => {
      if (translating) {
        select.value = targetLang;
        showToast('Wait until translation finishes before changing language.');
        return;
      }

      const nextLang = select.value;
      if (!nextLang || nextLang === targetLang) return;

      targetLang = nextLang;
      resetOverlaysForLanguageChange();
      resetAdaptiveStats();

      const label = getLanguageLabel(targetLang);
      setStatus(`Target: ${label}. Click Translate.`);
      showToast(`Target language changed to ${label}.`);
    });
  }

  function bindEvents() {
    byId('ito-translate-toggle').addEventListener('click', handleTranslateToggle);
    byId('ito-clear').addEventListener('click', clearAllOverlays);

    byId('ito-minus').addEventListener('click', () => zoomBy(1 / ZOOM_STEP));
    byId('ito-plus').addEventListener('click', () => zoomBy(ZOOM_STEP));
    byId('ito-fit').addEventListener('click', fitWidthVisual);

    byId('ito-formula-toggle').addEventListener('click', toggleFormulaTranslation);

    window.addEventListener('wheel', handleWheelZoom, { passive: false });
    window.addEventListener('scroll', () => {
      handleWindowScroll();
      syncHScrollFromWindow();
    }, { passive: true });

    window.addEventListener('resize', () => {
      updateStageMetrics();
      scheduleActivePageUpdate();
    }, { passive: true });

    const hScroll = byId('ito-hscroll');
    if (hScroll) {
      hScroll.addEventListener('scroll', () => {
        if (hScrollSyncing) return;

        hScrollSyncing = true;
        window.scrollTo({
          left: hScroll.scrollLeft,
          top: window.scrollY
        });

        requestAnimationFrame(() => {
          hScrollSyncing = false;
        });
      }, { passive: true });
    }

    document.addEventListener('click', handleBlankAreaClick, false);

    byId('ito-file').addEventListener('change', async e => {
      const file = e.target.files && e.target.files[0];
      if (!file) return;

      byId('ito-title').textContent = file.name;
      setStatus('Reading selected file...');

      const buffer = await file.arrayBuffer();
      await openPdfBuffer(buffer);
    });
  }

  function getLanguageLabel(code) {
    const found = LANGUAGE_OPTIONS.find(item => item[0] === code);
    return found ? found[1] : code;
  }

  function setLanguageSelectDisabled(disabled) {
    const select = byId('ito-lang-select');
    if (select) select.disabled = disabled;
  }

  async function toggleFormulaTranslation() {
    if (translating) {
      showToast('Wait until translation finishes before changing formula mode.');
      return;
    }

    translateFormulaEnabled = !translateFormulaEnabled;
    updateFormulaButton();

    if (!translateFormulaEnabled) {
      hideFormulaOverlaysOnly();
      setStatus('Formula OFF. Formula overlays hidden.');
      showToast('Formula translation OFF.');
      refreshTranslateButtonState();
      return;
    }

    showFormulaOverlaysOnly();

    const normalTotal = countNormalBlocks();
    const normalDone = countDoneNormalBlocks();
    const formulaTotal = countFormulaBlocks();
    const formulaPending = countPendingFormulaBlocks();

    if (!formulaTotal) {
      setStatus('Formula ON. No formula blocks found.');
      showToast('Formula translation ON, but no formula blocks were found.');
      refreshTranslateButtonState();
      return;
    }

    if (normalTotal > 0 && normalDone >= normalTotal) {
      if (formulaPending > 0) {
        showToast('Formula translation ON. Translating formulas only...');
        await translateFormulaOnly();
      } else {
        setStatus(`Formula ON. Formulas already translated. ${countDoneBlocks()}/${countAllBlocks()}.`);
        showToast('Formula translation ON. Existing formula translation restored.');
        refreshTranslateButtonState();
      }

      return;
    }

    setStatus('Formula ON. Click Translate to include formulas.');
    showToast('Formula translation ON. Click Translate to start.');
    refreshTranslateButtonState();
  }

  function updateFormulaButton() {
    const btn = byId('ito-formula-toggle');
    if (!btn) return;

    btn.classList.toggle('ito-active', translateFormulaEnabled);
    btn.title = translateFormulaEnabled
      ? 'Formula translation ON'
      : 'Formula translation OFF';

    btn.setAttribute('aria-pressed', translateFormulaEnabled ? 'true' : 'false');
  }

  function setFormulaButtonDisabled(disabled) {
    const btn = byId('ito-formula-toggle');
    if (btn) btn.disabled = disabled;
  }

  function hideFormulaOverlaysOnly() {
    for (const page of pageData.values()) {
      for (const block of page.blocks || []) {
        if (block.isFormula && block.overlay) {
          block.overlay.style.visibility = 'hidden';
        }
      }
    }
  }

  function showFormulaOverlaysOnly() {
    if (translationsHidden) return;

    for (const page of pageData.values()) {
      for (const block of page.blocks || []) {
        if (block.isFormula && block.overlay) {
          block.overlay.style.visibility = 'visible';
        }
      }
    }
  }

  function applyFormulaVisibility() {
    for (const page of pageData.values()) {
      for (const block of page.blocks || []) {
        if (!block.isFormula || !block.overlay) continue;
        block.overlay.style.visibility = translateFormulaEnabled && !translationsHidden
          ? 'visible'
          : 'hidden';
      }
    }
  }

  function resetOverlaysForLanguageChange() {
    translationsHidden = false;

    for (const page of pageData.values()) {
      if (page.translationLayer) {
        page.translationLayer.style.visibility = 'visible';
        page.translationLayer.innerHTML = '';
      }

      for (const block of page.blocks || []) {
        block.overlay = null;
        block.done = false;
        block.error = false;
      }
    }

    setTranslateButtonText('Translate');
  }

  function ensureViewerStructure() {
    let shell = byId('ito-zoom-shell');

    if (!shell) {
      shell = document.createElement('div');
      shell.id = 'ito-zoom-shell';
      document.body.appendChild(shell);
    }

    let mask = byId('ito-render-mask');

    if (!mask) {
      mask = document.createElement('div');
      mask.id = 'ito-render-mask';
      mask.innerHTML = `
        <div id="ito-render-card">
          Rendering PDF
          <div id="ito-render-sub">Please wait...</div>
        </div>
      `;
      shell.insertBefore(mask, shell.firstChild);
    }

    let stage = byId('ito-zoom-stage');

    if (!stage) {
      stage = document.createElement('div');
      stage.id = 'ito-zoom-stage';
      shell.appendChild(stage);
    }

    let viewer = byId('ito-viewer');

    if (!viewer) {
      viewer = document.createElement('main');
      viewer.id = 'ito-viewer';
      stage.appendChild(viewer);
    }

    return { shell, mask, stage, viewer };
  }

  function setRenderBusy(isBusy, subText) {
    const refs = ensureViewerStructure();
    const shell = refs.shell;
    const sub = byId('ito-render-sub');

    if (sub && subText) {
      sub.textContent = subText;
    }

    if (isBusy) {
      shell.classList.add('ito-rendering');
    } else {
      shell.classList.remove('ito-rendering');
    }
  }

  async function loadCurrentPdf() {
    const cleanName = decodeURIComponent((location.pathname || '').split('/').pop() || 'PDF');
    byId('ito-title').textContent = cleanName;

    setStatus('Reading PDF...');

    const buffer = await readPdfAutomatically(location.href);
    await openPdfBuffer(buffer);
  }

  async function readPdfAutomatically(url) {
    const methods = [
      () => readWithGmPromise(url),
      () => readWithGmCallback(url),
      () => readWithFetch(url)
    ];

    let lastError = null;

    for (const method of methods) {
      try {
        const buffer = await method();

        if (buffer && buffer.byteLength > 100) {
          return buffer;
        }
      } catch (err) {
        lastError = err;
      }
    }

    throw lastError || new Error('Failed to open local PDF automatically.');
  }

  function readWithGmPromise(url) {
    return new Promise((resolve, reject) => {
      if (typeof GM === 'undefined' || !GM.xmlHttpRequest) {
        reject(new Error('GM.xmlHttpRequest is not available.'));
        return;
      }

      GM.xmlHttpRequest({
        method: 'GET',
        url,
        responseType: 'arraybuffer',
        timeout: 60000
      }).then(res => {
        if (res.response && res.response.byteLength > 100) {
          resolve(res.response);
        } else {
          reject(new Error('Empty PDF response.'));
        }
      }).catch(reject);
    });
  }

  function readWithGmCallback(url) {
    return new Promise((resolve, reject) => {
      if (typeof GM_xmlhttpRequest !== 'function') {
        reject(new Error('GM_xmlhttpRequest is not available.'));
        return;
      }

      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'arraybuffer',
        timeout: 60000,
        onload: res => {
          if (res.response && res.response.byteLength > 100) {
            resolve(res.response);
            return;
          }

          reject(new Error('Empty PDF response.'));
        },
        onerror: () => reject(new Error('Network error while reading PDF.')),
        ontimeout: () => reject(new Error('Timeout while reading PDF.'))
      });
    });
  }

  async function readWithFetch(url) {
    const res = await fetch(url);

    if (!res.ok && !url.startsWith('file:')) {
      throw new Error('fetch HTTP ' + res.status);
    }

    return await res.arrayBuffer();
  }

  async function openPdfBuffer(buffer) {
    const refs = ensureViewerStructure();
    const viewer = refs.viewer;
    const stage = refs.stage;

    clearAllOverlays();
    pageData.clear();
    resetAdaptiveStats();

    visualZoom = 1.0;
    baseViewerWidth = 0;
    baseViewerHeight = 0;
    updateZoomLabel();

    viewer.style.transform = 'scale(1)';
    viewer.style.width = '0px';
    viewer.style.height = '0px';
    viewer.innerHTML = '';

    stage.style.width = '0px';
    stage.style.height = '0px';

    setRenderBusy(true, 'Opening PDF...');
    setStatus('Opening PDF...');

    try {
      pdfDoc = await window.pdfjsLib.getDocument({
        data: new Uint8Array(buffer),
        useSystemFonts: true,
        disableFontFace: false
      }).promise;

      await renderAllPages();
      updateStageMetrics();

      window.scrollTo({ left: 0, top: 0 });
      syncHScrollFromWindow();

      setStatus(`Ready. ${pdfDoc.numPages} pages. Target: ${getLanguageLabel(targetLang)}.`);
      lastScrollY = window.scrollY || 0;
      updateActivePage();
      setTranslateButtonText('Translate');
      showToolbar();
    } finally {
      setRenderBusy(false);
    }
  }

  async function renderAllPages() {
    if (!pdfDoc) return;

    pageData.clear();

    const refs = ensureViewerStructure();
    const viewer = refs.viewer;
    viewer.innerHTML = '';

    for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
      setStatus(`Rendering page ${pageNum}/${pdfDoc.numPages}...`);
      setRenderBusy(true, `Rendering page ${pageNum}/${pdfDoc.numPages}...`);

      const page = await pdfDoc.getPage(pageNum);
      await renderOnePage(page, pageNum);

      if (pageNum === 1 || pageNum % 3 === 0 || pageNum === pdfDoc.numPages) {
        updateStageMetrics();
      }

      await sleep(3);
    }
  }

  async function renderOnePage(page, pageNum) {
    const viewport = page.getViewport({ scale });

    const pageEl = document.createElement('section');
    pageEl.className = 'ito-page';
    pageEl.dataset.pageNumber = String(pageNum);
    pageEl.style.width = Math.ceil(viewport.width) + 'px';
    pageEl.style.height = Math.ceil(viewport.height) + 'px';

    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d', { alpha: false });

    const dpr = Math.min(window.devicePixelRatio || 1, MAX_RENDER_DPR);
    canvas.width = Math.floor(viewport.width * dpr);
    canvas.height = Math.floor(viewport.height * dpr);
    canvas.style.width = Math.floor(viewport.width) + 'px';
    canvas.style.height = Math.floor(viewport.height) + 'px';

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

    const textLayer = document.createElement('div');
    textLayer.className = 'textLayer';
    textLayer.style.width = Math.floor(viewport.width) + 'px';
    textLayer.style.height = Math.floor(viewport.height) + 'px';

    const translationLayer = document.createElement('div');
    translationLayer.className = 'ito-translation-layer';

    pageEl.appendChild(canvas);
    pageEl.appendChild(textLayer);
    pageEl.appendChild(translationLayer);

    const refs = ensureViewerStructure();
    refs.viewer.appendChild(pageEl);

    await page.render({
      canvasContext: ctx,
      viewport,
      annotationMode: 0
    }).promise;

    const textContent = await page.getTextContent({
      normalizeWhitespace: true,
      disableCombineTextItems: false
    });

    const task = window.pdfjsLib.renderTextLayer({
      textContent,
      container: textLayer,
      viewport,
      textDivs: [],
      enhanceTextSelection: true
    });

    if (task && task.promise) {
      await task.promise;
    }

    await sleep(0);

    const blocks = buildBlocksFromTextLayer(pageEl, textLayer, pageNum);

    pageData.set(pageNum, {
      pageNum,
      pageEl,
      textLayer,
      translationLayer,
      blocks
    });
  }

  function buildBlocksFromTextLayer(pageEl, textLayer, pageNum) {
    const pageRect = pageEl.getBoundingClientRect();
    const spans = Array.from(textLayer.querySelectorAll('span'));

    const items = spans
      .map(span => {
        const raw = span.textContent || '';
        const text = raw.replace(/\s+/g, ' ').trim();

        if (!text) return null;

        const rect = span.getBoundingClientRect();

        if (!rect || rect.width < 1 || rect.height < 1) return null;

        const css = window.getComputedStyle(span);
        const cssFontSize = parseFloat(css.fontSize);
        const fontSize = Number.isFinite(cssFontSize) && cssFontSize > 0
          ? cssFontSize
          : rect.height * 0.65;

        return {
          text,
          left: rect.left - pageRect.left,
          top: rect.top - pageRect.top,
          width: rect.width,
          height: rect.height,
          right: rect.right - pageRect.left,
          bottom: rect.bottom - pageRect.top,
          cx: rect.left - pageRect.left + rect.width / 2,
          cy: rect.top - pageRect.top + rect.height / 2,
          fontSize
        };
      })
      .filter(Boolean);

    if (!items.length) return [];

    const lineSegments = makeHybridLineSegments(items);
    const deduped = dedupeLineSegments(lineSegments);

    return deduped
      .map((line, index) => normalizeLineBlock(line, pageNum, index))
      .filter(block => {
        if (block.text.length < 2 || block.width <= 8 || block.height <= 5) return false;
        return true;
      });
  }

  function makeHybridLineSegments(items) {
    const medianHeight = median(items.map(i => i.height));
    const yTolerance = Math.max(2.2, medianHeight * 0.46);

    const sorted = items.slice().sort((a, b) => {
      if (Math.abs(a.cy - b.cy) > yTolerance) return a.cy - b.cy;
      return a.left - b.left;
    });

    const rawLines = [];

    for (const item of sorted) {
      let target = null;

      for (let i = rawLines.length - 1; i >= Math.max(0, rawLines.length - 7); i--) {
        const line = rawLines[i];

        if (Math.abs(item.cy - line.cy) <= yTolerance) {
          target = line;
          break;
        }
      }

      if (target) {
        target.items.push(item);
        target.cy = average(target.items.map(x => x.cy));
        target.top = Math.min(target.top, item.top);
        target.bottom = Math.max(target.bottom, item.bottom);
      } else {
        rawLines.push({
          cy: item.cy,
          top: item.top,
          bottom: item.bottom,
          items: [item]
        });
      }
    }

    rawLines.sort((a, b) => a.cy - b.cy);

    const pageLeft = Math.min(...items.map(i => i.left));
    const pageRight = Math.max(...items.map(i => i.right));
    const pageWidth = pageRight - pageLeft;

    const regions = detectColumnRegions(rawLines, pageLeft, pageRight, pageWidth, medianHeight);
    const lineSegments = [];

    for (const rawLine of rawLines) {
      const lineItems = rawLine.items.slice().sort((a, b) => a.left - b.left);
      const region = findColumnRegionForLine(rawLine, regions);
      const columnModel = region ? region.columnModel : null;
      const segments = splitLineHybrid(lineItems, medianHeight, pageWidth, columnModel);

      for (const seg of segments) {
        if (!seg) continue;
        if (region) seg.columnRegion = region;
        lineSegments.push(seg);
      }
    }

    return lineSegments.sort((a, b) => {
      if (Math.abs(a.top - b.top) > 3) return a.top - b.top;
      return a.left - b.left;
    });
  }

  function detectColumnRegions(rawLines, pageLeft, pageRight, pageWidth, medianHeight) {
    if (!rawLines || rawLines.length < 8 || pageWidth < 250) return [];

    const minBoundary = pageLeft + pageWidth * 0.35;
    const maxBoundary = pageLeft + pageWidth * 0.65;

    const rows = rawLines.map(line => {
      const lineItems = line.items.slice().sort((a, b) => a.left - b.left);
      const candidate = findColumnGapCandidate(lineItems, minBoundary, maxBoundary, medianHeight);

      return {
        line,
        cy: line.cy,
        top: line.top,
        bottom: line.bottom,
        hasColumn: !!candidate,
        boundary: candidate ? candidate.boundary : null,
        gap: candidate ? candidate.gap : 0
      };
    });

    const groups = [];
    let current = null;

    for (const row of rows) {
      if (row.hasColumn) {
        if (!current) {
          current = {
            top: row.top,
            bottom: row.bottom,
            boundaries: [row.boundary],
            rows: [row],
            misses: 0
          };
        } else {
          current.bottom = row.bottom;
          current.boundaries.push(row.boundary);
          current.rows.push(row);
          current.misses = 0;
        }
        continue;
      }

      if (current && current.boundaries.length >= 2 && current.misses < 2) {
        current.bottom = row.bottom;
        current.rows.push(row);
        current.misses++;
        continue;
      }

      if (current) {
        groups.push(current);
        current = null;
      }
    }

    if (current) groups.push(current);

    const regions = [];

    for (const group of groups) {
      if (group.boundaries.length < 3) continue;

      const boundary = median(group.boundaries);
      const gapPad = Math.max(5, medianHeight * 0.62);

      const top = Math.max(0, group.top - medianHeight * 1.25);
      const bottom = group.bottom + medianHeight * 1.75;

      regions.push({
        top,
        bottom,
        boundary,
        columnModel: {
          boundary,
          leftLimit: boundary - gapPad,
          rightLimit: boundary + gapPad,
          minBoundary,
          maxBoundary,
          hard: true
        }
      });
    }

    return mergeCloseColumnRegions(regions, medianHeight);
  }

  function findColumnGapCandidate(lineItems, minBoundary, maxBoundary, medianHeight) {
    if (!lineItems || lineItems.length < 4) return null;

    let best = null;

    for (let i = 1; i < lineItems.length; i++) {
      const prev = lineItems[i - 1];
      const next = lineItems[i];

      const gap = next.left - prev.right;
      const mid = (prev.right + next.left) / 2;

      if (gap <= Math.max(14, medianHeight * 1.05)) continue;
      if (mid < minBoundary || mid > maxBoundary) continue;

      const leftText = joinTextRuns(lineItems.slice(0, i).map(x => x.text));
      const rightText = joinTextRuns(lineItems.slice(i).map(x => x.text));

      if (leftText.length < 7 || rightText.length < 7) continue;

      const leftLetters = countLetters(leftText);
      const rightLetters = countLetters(rightText);

      if (leftLetters < 3 || rightLetters < 3) continue;

      if (!best || gap > best.gap) {
        best = {
          boundary: mid,
          gap,
          index: i
        };
      }
    }

    return best;
  }

  function mergeCloseColumnRegions(regions, medianHeight) {
    if (!regions.length) return [];

    const sorted = regions.slice().sort((a, b) => a.top - b.top);
    const merged = [];

    for (const region of sorted) {
      const last = merged[merged.length - 1];

      if (
        last &&
        region.top <= last.bottom + medianHeight * 2.5 &&
        Math.abs(region.boundary - last.boundary) < medianHeight * 2.8
      ) {
        last.bottom = Math.max(last.bottom, region.bottom);
        last.boundary = median([last.boundary, region.boundary]);
        last.columnModel.boundary = last.boundary;
        last.columnModel.leftLimit = last.boundary - Math.max(5, medianHeight * 0.62);
        last.columnModel.rightLimit = last.boundary + Math.max(5, medianHeight * 0.62);
      } else {
        merged.push(region);
      }
    }

    return merged;
  }

  function findColumnRegionForLine(rawLine, regions) {
    if (!regions || !regions.length) return null;

    for (const region of regions) {
      if (rawLine.cy >= region.top && rawLine.cy <= region.bottom) {
        return region;
      }
    }

    return null;
  }

  function splitLineHybrid(lineItems, medianHeight, pageWidth, columnModel) {
    if (!lineItems.length) return [];

    const gaps = [];

    for (let i = 1; i < lineItems.length; i++) {
      const prev = lineItems[i - 1];
      const next = lineItems[i];
      const gap = next.left - prev.right;

      if (gap > 0) {
        gaps.push({
          index: i,
          gap,
          left: prev.right,
          right: next.left,
          mid: (prev.right + next.left) / 2,
          prev,
          next
        });
      }
    }

    const positiveGaps = gaps.map(g => g.gap).filter(g => g < 160);
    const medGap = median(positiveGaps);
    const medHeight = median(lineItems.map(i => i.height)) || medianHeight;

    const normalGapThreshold = Math.max(
      26,
      medHeight * 2.25,
      medGap ? medGap * 5.0 : 0
    );

    const splitIndexes = new Set();

    for (const g of gaps) {
      if (g.gap > normalGapThreshold) {
        splitIndexes.add(g.index);
      }
    }

    const boundaryGap = findBoundaryGap(gaps, lineItems, columnModel, medHeight);

    if (boundaryGap) {
      splitIndexes.add(boundaryGap.index);
    }

    const segments = [];
    let current = [];

    for (let i = 0; i < lineItems.length; i++) {
      if (splitIndexes.has(i) && current.length) {
        const seg = makeLineSegment(current);
        if (seg) segments.push(applyColumnSide(seg, columnModel));
        current = [];
      }

      current.push(lineItems[i]);
    }

    const last = makeLineSegment(current);
    if (last) segments.push(applyColumnSide(last, columnModel));

    return segments;
  }

  function findBoundaryGap(gaps, lineItems, columnModel, medHeight) {
    if (!columnModel || !gaps.length) return null;

    const boundary = columnModel.boundary;

    const candidates = gaps
      .filter(g => {
        const crossesBoundary =
          g.prev.cx < boundary &&
          g.next.cx > boundary;

        const nearBoundary =
          g.mid > columnModel.minBoundary &&
          g.mid < columnModel.maxBoundary;

        if (!crossesBoundary && !nearBoundary) return false;

        const leftText = joinTextRuns(lineItems.slice(0, g.index).map(x => x.text));
        const rightText = joinTextRuns(lineItems.slice(g.index).map(x => x.text));

        if (leftText.length < 5 || rightText.length < 5) return false;

        const requiredGap = Math.max(10, medHeight * 0.86);

        return g.gap >= requiredGap;
      })
      .sort((a, b) => {
        const da = Math.abs(a.mid - boundary);
        const db = Math.abs(b.mid - boundary);

        if (Math.abs(da - db) > 3) return da - db;
        return b.gap - a.gap;
      });

    return candidates[0] || null;
  }

  function applyColumnSide(seg, columnModel) {
    if (!seg || !columnModel) return seg;

    const boundary = columnModel.boundary;

    if (seg.right <= boundary) {
      seg.columnSide = 'left';
      seg.columnBoundary = boundary;
      seg.columnLimitRight = columnModel.leftLimit;
      seg.columnLocked = true;
    } else if (seg.left >= boundary) {
      seg.columnSide = 'right';
      seg.columnBoundary = boundary;
      seg.columnLimitLeft = columnModel.rightLimit;
      seg.columnLocked = true;
    } else {
      seg.columnSide = 'full';
      seg.columnBoundary = boundary;
      seg.columnLocked = false;
    }

    return seg;
  }

  function makeLineSegment(segmentItems) {
    if (!segmentItems || !segmentItems.length) return null;

    const rect = unionRect(segmentItems);
    const text = joinTextRuns(segmentItems.map(i => i.text));
    const fontSize = median(segmentItems.map(i => i.fontSize));

    if (!text.trim()) return null;

    return {
      text,
      left: rect.left,
      top: rect.top,
      width: rect.width,
      height: rect.height,
      right: rect.right,
      bottom: rect.bottom,
      cx: rect.left + rect.width / 2,
      cy: rect.top + rect.height / 2,
      fontSize,
      items: segmentItems,
      columnSide: null,
      columnBoundary: null,
      columnLimitRight: null,
      columnLimitLeft: null,
      columnLocked: false
    };
  }

  function dedupeLineSegments(lines) {
    const accepted = [];

    for (const line of lines) {
      let duplicate = false;

      for (const old of accepted) {
        const samePlace =
          Math.abs(line.left - old.left) < 2 &&
          Math.abs(line.top - old.top) < 2 &&
          Math.abs(line.width - old.width) < 4 &&
          Math.abs(line.height - old.height) < 3;

        const sameText = cleanSourceText(line.text) === cleanSourceText(old.text);

        if (samePlace && sameText) {
          duplicate = true;
          break;
        }
      }

      if (!duplicate) accepted.push(line);
    }

    return accepted;
  }

  function normalizeLineBlock(line, pageNum, index) {
    const text = cleanSourceText(line.text);

    const baseFont = clamp(
      Math.min(line.fontSize * 0.72, line.height * 0.70),
      FIT_MIN_FONT,
      13.5
    );

    const padX = Math.max(2.1, baseFont * 0.24);
    const padY = Math.max(1.5, baseFont * 0.23);

    let left = Math.max(0, line.left - padX);
    let top = Math.max(0, line.top - padY);
    let width = line.width + padX * 2;
    const height = Math.max(line.height + padY * 2, baseFont + 5);

    const columnLocked = !!line.columnLocked;

    if (line.columnSide === 'left' && line.columnLimitRight) {
      const maxRight = line.columnLimitRight;
      width = Math.min(width, Math.max(8, maxRight - left));
    }

    if (line.columnSide === 'right' && line.columnLimitLeft) {
      const minLeft = line.columnLimitLeft;
      if (left < minLeft && line.right > minLeft) {
        const delta = minLeft - left;
        left = minLeft;
        width = Math.max(8, width - delta);
      }
    }

    return {
      id: `${pageNum}-${index}`,
      pageNum,
      index,
      text,
      left,
      top,
      width,
      height,
      baseFont,
      columnSide: line.columnSide || null,
      columnBoundary: line.columnBoundary || null,
      columnLocked,
      columnLimitRight: line.columnLimitRight || null,
      columnLimitLeft: line.columnLimitLeft || null,
      isFormula: isMostlyMathOrNumber(text),
      overlay: null,
      done: false,
      error: false
    };
  }

  function handleWheelZoom(event) {
    if (!event.ctrlKey) return;

    event.preventDefault();

    const base = pendingWheelZoom || visualZoom;
    const factor = Math.exp(-event.deltaY * 0.00145);

    pendingWheelZoom = clamp(base * factor, MIN_VISUAL_ZOOM, MAX_VISUAL_ZOOM);
    pendingWheelAnchor = {
      x: event.clientX,
      y: event.clientY
    };

    if (wheelZoomScheduled) return;

    wheelZoomScheduled = true;

    requestAnimationFrame(() => {
      wheelZoomScheduled = false;

      if (!pendingWheelZoom || !pendingWheelAnchor) return;

      const nextZoom = pendingWheelZoom;
      const anchor = pendingWheelAnchor;

      pendingWheelZoom = null;
      pendingWheelAnchor = null;

      applyVisualZoom(nextZoom, anchor.x, anchor.y);
    });
  }

  function zoomBy(factor) {
    showToolbar();
    applyVisualZoom(visualZoom * factor, window.innerWidth / 2, window.innerHeight / 2);
  }

  function fitWidthVisual() {
    if (!pdfDoc) return;

    showToolbar();

    const firstPage = document.querySelector('.ito-page');
    if (!firstPage) return;

    const available = Math.max(360, window.innerWidth - 90);
    const pageWidth = firstPage.offsetWidth;

    if (!pageWidth) return;

    const nextZoom = available / pageWidth;
    applyVisualZoom(nextZoom, window.innerWidth / 2, window.innerHeight / 2);
  }

  function applyVisualZoom(nextZoom, anchorX, anchorY) {
    const refs = ensureViewerStructure();
    const stage = refs.stage;

    nextZoom = clamp(nextZoom, MIN_VISUAL_ZOOM, MAX_VISUAL_ZOOM);

    if (!Number.isFinite(nextZoom) || Math.abs(nextZoom - visualZoom) < 0.001) {
      return;
    }

    const oldZoom = visualZoom;
    const oldRect = stage.getBoundingClientRect();

    const oldStageDocLeft = window.scrollX + oldRect.left;
    const oldStageDocTop = window.scrollY + oldRect.top;

    const anchorDocX = window.scrollX + anchorX;
    const anchorDocY = window.scrollY + anchorY;

    const contentX = (anchorDocX - oldStageDocLeft) / oldZoom;
    const contentY = (anchorDocY - oldStageDocTop) / oldZoom;

    visualZoom = nextZoom;
    updateStageMetrics();

    requestAnimationFrame(() => {
      const newRect = stage.getBoundingClientRect();
      const newStageDocLeft = window.scrollX + newRect.left;
      const newStageDocTop = window.scrollY + newRect.top;

      const nextScrollX = newStageDocLeft + contentX * visualZoom - anchorX;
      const nextScrollY = newStageDocTop + contentY * visualZoom - anchorY;

      window.scrollTo({
        left: Math.max(0, nextScrollX),
        top: Math.max(0, nextScrollY)
      });

      syncHScrollFromWindow();
      scheduleActivePageUpdate();
    });
  }

  function updateStageMetrics() {
    const refs = ensureViewerStructure();
    const viewer = refs.viewer;
    const stage = refs.stage;

    const pages = Array.from(document.querySelectorAll('.ito-page'));

    if (!pages.length) {
      baseViewerWidth = 0;
      baseViewerHeight = 0;
      stage.style.width = '0px';
      stage.style.height = '0px';
      viewer.style.transform = 'scale(1)';
      updateZoomLabel();
      updateHorizontalScrollbar();
      return;
    }

    baseViewerWidth = Math.max(...pages.map(p => p.offsetWidth));

    baseViewerHeight = pages.reduce((sum, page) => {
      const style = window.getComputedStyle(page);
      const marginBottom = parseFloat(style.marginBottom) || 0;
      return sum + page.offsetHeight + marginBottom;
    }, 0);

    viewer.style.width = baseViewerWidth + 'px';
    viewer.style.height = baseViewerHeight + 'px';
    viewer.style.transform = `scale(${visualZoom})`;

    stage.style.width = Math.ceil(baseViewerWidth * visualZoom) + 'px';
    stage.style.height = Math.ceil(baseViewerHeight * visualZoom) + 'px';

    updateZoomLabel();
    updateHorizontalScrollbar();
  }

  function updateHorizontalScrollbar() {
    const hScroll = byId('ito-hscroll');
    const inner = byId('ito-hscroll-inner');
    const stage = byId('ito-zoom-stage');

    if (!hScroll || !inner || !stage) return;

    requestAnimationFrame(() => {
      const docWidth = Math.max(
        document.documentElement.scrollWidth,
        document.body.scrollWidth,
        Math.ceil(stage.getBoundingClientRect().right + window.scrollX)
      );

      const need = pdfDoc && docWidth > window.innerWidth + 2;

      if (!need) {
        hScroll.style.display = 'none';
        inner.style.width = '0px';
        return;
      }

      hScroll.style.display = 'block';
      inner.style.width = docWidth + 'px';
      syncHScrollFromWindow();
    });
  }

  function syncHScrollFromWindow() {
    const hScroll = byId('ito-hscroll');
    if (!hScroll || hScroll.style.display === 'none') return;
    if (hScrollSyncing) return;

    hScrollSyncing = true;
    hScroll.scrollLeft = window.scrollX || document.documentElement.scrollLeft || 0;

    requestAnimationFrame(() => {
      hScrollSyncing = false;
    });
  }

  function handleWindowScroll() {
    const y = window.scrollY || document.documentElement.scrollTop || 0;
    const delta = y - lastScrollY;

    if (Math.abs(delta) >= 8) {
      if (delta > 0 && y > 80) {
        hideToolbar();
      } else if (delta < 0) {
        showToolbar();
      }

      lastScrollY = y;
    }

    scheduleActivePageUpdate();
  }

  function handleBlankAreaClick(event) {
    const target = event.target;
    if (!target || typeof target.closest !== 'function') return;

    if (
      target.closest('#ito-toolbar') ||
      target.closest('#ito-manual') ||
      target.closest('#ito-toast') ||
      target.closest('#ito-render-mask') ||
      target.closest('#ito-hscroll') ||
      target.closest('.ito-block') ||
      target.closest('.ito-page') ||
      target.closest('button') ||
      target.closest('input') ||
      target.closest('select') ||
      target.closest('a')
    ) {
      return;
    }

    toggleToolbar();
  }

  function hideToolbar() {
    const toolbar = byId('ito-toolbar');
    if (!toolbar || toolbarHidden) return;

    toolbarHidden = true;
    toolbar.classList.add('ito-hidden');
  }

  function showToolbar() {
    const toolbar = byId('ito-toolbar');
    if (!toolbar || !toolbarHidden) return;

    toolbarHidden = false;
    toolbar.classList.remove('ito-hidden');
  }

  function toggleToolbar() {
    if (toolbarHidden) {
      showToolbar();
    } else {
      hideToolbar();
    }
  }

  async function handleTranslateToggle() {
    if (!pdfDoc) {
      showToast('PDF is not open yet.');
      return;
    }

    if (translating) {
      showToast('Translation is still running.');
      return;
    }

    const totalAll = countAllBlocks();
    const totalDone = countDoneBlocks();

    if (!totalAll) {
      showToast('No readable text found. This PDF may be scanned/image-based.');
      return;
    }

    if (translationsHidden && totalDone > 0) {
      showTranslations();
      setStatus(`Translation active. ${totalDone}/${totalAll}.`);
      return;
    }

    if (!translationsHidden && totalDone > 0 && totalDone >= totalAll) {
      hideTranslations();
      return;
    }

    await translateAllPages();
  }

  async function translateAllPages() {
    if (!pdfDoc) {
      showToast('PDF is not open yet.');
      return;
    }

    showToolbar();
    showTranslations();

    if (translating) {
      showToast('Translation is still running.');
      return;
    }

    translating = true;
    setTranslateButtonDisabled(true);
    setLanguageSelectDisabled(true);
    setFormulaButtonDisabled(true);

    try {
      const order = buildPageOrder();
      const totalAll = countAllBlocks();
      let totalErrors = 0;

      if (!totalAll) {
        showToast('No readable text found. This PDF may be scanned/image-based.');
        return;
      }

      let hasPending = false;

      for (const pageNum of order) {
        const page = pageData.get(pageNum);
        if (!page) continue;

        const pending = getPendingBlocks(page);
        if (pending.length) {
          hasPending = true;
          break;
        }
      }

      if (!hasPending) {
        setStatus(`Translation ready. ${countDoneBlocks()}/${totalAll}.`);
        showToast('Translation is already available. Click Original to view the source text.');
        return;
      }

      for (const pageNum of order) {
        const page = pageData.get(pageNum);
        if (!page) continue;

        const pending = getPendingBlocks(page);
        if (!pending.length) continue;

        const workers = getAdaptiveConcurrency(pending);

        setStatus(`Page ${pageNum}/${pdfDoc.numPages}: preparing ${pending.length} blocks | workers ${workers}`);

        for (const block of pending) {
          const el = ensureOverlay(page, block);
          el.textContent = '...';
          el.classList.add('ito-loading');
        }

        const result = await translatePageBlocks(page, pending, (doneOnPage, pageTotal, extra) => {
          const suffix = extra ? ` | ${extra}` : '';
          setStatus(`Page ${pageNum}/${pdfDoc.numPages}: ${doneOnPage}/${pageTotal} | Total ${countDoneBlocks()}/${totalAll}${suffix}`);
        }, 'normal');

        totalErrors += result.errors;
        decayAdaptiveStats();
        setStatus(`Page ${pageNum}/${pdfDoc.numPages} done. Total ${countDoneBlocks()}/${totalAll}.`);
      }

      setStatus(`Done. ${countDoneBlocks()}/${totalAll}. Errors ${totalErrors}.`);
      showToast(`Done. ${countDoneBlocks()}/${totalAll} blocks translated.`);
    } finally {
      translating = false;
      setTranslateButtonDisabled(false);
      setLanguageSelectDisabled(false);
      setFormulaButtonDisabled(false);
      showTranslations();
    }
  }

  async function translateFormulaOnly() {
    if (!pdfDoc || translating) return;

    showToolbar();
    showTranslations();

    translating = true;
    setTranslateButtonDisabled(true);
    setLanguageSelectDisabled(true);
    setFormulaButtonDisabled(true);

    try {
      const order = buildPageOrder();
      const formulaTotal = countFormulaBlocks();
      let formulaErrors = 0;

      if (!formulaTotal) {
        setStatus('No formula blocks found.');
        return;
      }

      for (const pageNum of order) {
        const page = pageData.get(pageNum);
        if (!page) continue;

        const pending = getPendingFormulaBlocks(page);
        if (!pending.length) continue;

        setStatus(`Formula page ${pageNum}/${pdfDoc.numPages}: ${pending.length} blocks...`);

        for (const block of pending) {
          const el = ensureOverlay(page, block);
          el.textContent = '...';
          el.classList.add('ito-loading');
          el.style.visibility = 'visible';
        }

        const result = await translatePageBlocks(page, pending, (doneOnPage, pageTotal, extra) => {
          const suffix = extra ? ` | ${extra}` : '';
          setStatus(`Formula ${doneOnPage}/${pageTotal} | Total ${countDoneFormulaBlocks()}/${formulaTotal}${suffix}`);
        }, 'formula');

        formulaErrors += result.errors;
        decayAdaptiveStats();
      }

      setStatus(`Formula done. ${countDoneFormulaBlocks()}/${formulaTotal}. Errors ${formulaErrors}.`);
      showToast(`Formula translation done. ${countDoneFormulaBlocks()}/${formulaTotal}.`);
    } finally {
      translating = false;
      setTranslateButtonDisabled(false);
      setLanguageSelectDisabled(false);
      setFormulaButtonDisabled(false);
      showTranslations();
      refreshTranslateButtonState();
    }
  }

  function getPendingBlocks(page) {
    return (page.blocks || []).filter(block => {
      if (block.done) return false;
      if (block.isFormula && !translateFormulaEnabled) return false;
      return true;
    });
  }

  function getPendingFormulaBlocks(page) {
    return (page.blocks || []).filter(block => block.isFormula && !block.done);
  }

  function countPendingFormulaBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += getPendingFormulaBlocks(page).length;
    }

    return total;
  }

  function isEligibleBlock(block) {
    if (block.isFormula && !translateFormulaEnabled) return false;
    return true;
  }

  function buildPageOrder() {
    const current = getVisiblePageNumber();
    const order = [];

    for (let p = current; p <= pdfDoc.numPages; p++) order.push(p);
    for (let p = 1; p < current; p++) order.push(p);

    return order;
  }

  function getAdaptiveConcurrency(blocks) {
    const manyBlocks = blocks.length > 160;
    const fragileLanguage = !isFastLanguage(targetLang);

    if (adaptiveStats.slow >= 2 || adaptiveStats.fallback >= 4 || manyBlocks) {
      return SAFE_BATCH_CONCURRENCY;
    }

    if (fragileLanguage || adaptiveStats.fallback >= 2) {
      return 7;
    }

    return BASE_BATCH_CONCURRENCY;
  }

  function isFastLanguage(code) {
    return [
      'id', 'en', 'ms', 'es', 'fr', 'de', 'it', 'pt', 'nl',
      'ja', 'ko', 'zh-CN', 'zh-TW', 'ar', 'hi'
    ].includes(code);
  }

  function resetAdaptiveStats() {
    adaptiveStats = {
      slow: 0,
      fallback: 0,
      requests: 0
    };
  }

  function decayAdaptiveStats() {
    adaptiveStats.slow = Math.max(0, adaptiveStats.slow - 1);
    adaptiveStats.fallback = Math.max(0, adaptiveStats.fallback - 1);
  }

  function markFallback() {
    adaptiveStats.fallback++;
  }

  function markSlow() {
    adaptiveStats.slow++;
  }

  async function translatePageBlocks(page, blocks, onProgress, mode) {
    const batches = makeBatches(blocks);
    let batchCursor = 0;
    let done = 0;
    let errors = 0;

    const workerCount = Math.min(getAdaptiveConcurrency(blocks), batches.length);

    async function worker(workerId) {
      while (batchCursor < batches.length) {
        const batchIndex = batchCursor;
        const batch = batches[batchCursor++];

        try {
          const translations = await translateBatchWithRetry(batch, attempt => {
            if (attempt > 0) {
              onProgress(done, blocks.length, `retry ${attempt}/${RETRY_LIMIT}`);
            }
          }, info => {
            if (info) onProgress(done, blocks.length, info);
          });

          for (let i = 0; i < batch.length; i++) {
            applyTranslation(page, batch[i], translations[i] || '');
            done++;
          }
        } catch (err) {
          for (const block of batch) {
            markBlockError(page, block, err);
            done++;
            errors++;
          }
        }

        onProgress(done, blocks.length);
        await sleep(5);
      }
    }

    const workers = [];

    for (let i = 0; i < workerCount; i++) {
      workers.push(worker(i));
    }

    await Promise.all(workers);

    return { done, errors };
  }

  function makeBatches(blocks) {
    const batches = [];
    let current = [];
    let chars = 0;

    for (const block of blocks) {
      const text = prepareBlockText(block);
      const length = text.length;

      if (
        current.length &&
        (current.length >= BATCH_MAX_BLOCKS || chars + length > BATCH_MAX_CHARS)
      ) {
        batches.push(current);
        current = [];
        chars = 0;
      }

      current.push(block);
      chars += length + SPLIT_MARKER.length + 8;
    }

    if (current.length) batches.push(current);

    return batches;
  }

  function prepareBlockText(block) {
    let text = block.text || '';

    if (text.length > MAX_SINGLE_BLOCK_CHARS) {
      text = text.slice(0, MAX_SINGLE_BLOCK_CHARS);
    }

    return text.trim();
  }

  async function translateBatchWithRetry(batch, onRetry, onInfo) {
    let lastErr = null;

    for (let attempt = 0; attempt <= RETRY_LIMIT; attempt++) {
      try {
        if (attempt > 0 && typeof onRetry === 'function') onRetry(attempt);
        return await translateBatch(batch, onInfo);
      } catch (err) {
        lastErr = err;
        await sleep(180 + attempt * 280);
      }
    }

    throw lastErr || new Error('Batch translation failed.');
  }

  async function translateBatch(batch, onInfo) {
    if (batch.length === 1) {
      const text = prepareBlockText(batch[0]);
      return [await translateText(text)];
    }

    const direct = await tryTranslateJoinedBatch(batch);

    if (direct.ok) {
      return direct.parts;
    }

    markFallback();
    if (typeof onInfo === 'function') onInfo('fallback small batch');

    if (batch.length > 3) {
      const chunks = chunkArray(batch, 3);

      const chunkResults = await mapLimit(chunks, 2, async chunk => {
        const result = await tryTranslateJoinedBatch(chunk);

        if (result.ok) return result.parts;

        const texts = chunk.map(prepareBlockText);
        return await mapLimit(texts, FALLBACK_CONCURRENCY, async text => {
          await sleep(6);
          return await translateText(text);
        });
      });

      return chunkResults.flat();
    }

    const texts = batch.map(prepareBlockText);

    return await mapLimit(texts, FALLBACK_CONCURRENCY, async text => {
      await sleep(8);
      return await translateText(text);
    });
  }

  async function tryTranslateJoinedBatch(batch) {
    const texts = batch.map(prepareBlockText);
    const joined = texts.join(`\n${SPLIT_MARKER}\n`);
    const translatedJoined = await translateTextRaw(joined);

    const parts = splitTranslatedJoined(translatedJoined);

    if (parts.length !== batch.length) {
      return { ok: false, parts: [] };
    }

    return {
      ok: true,
      parts: parts.map(x => x.trim())
    };
  }

  function splitTranslatedJoined(text) {
    const escaped = escapeRegExp(SPLIT_MARKER);

    let parts = String(text || '')
      .split(new RegExp('\\s*' + escaped + '\\s*', 'g'))
      .map(x => x.trim());

    if (parts.length > 1) return parts;

    const relaxed = String(text || '')
      .split(/⟦⟦\s*PDTO[_\s-]*SPLIT[_\s-]*720[_\s-]*REINALDY\s*⟧⟧/gi)
      .map(x => x.trim());

    return relaxed;
  }

  async function mapLimit(items, limit, mapper) {
    const results = new Array(items.length);
    let cursor = 0;

    async function worker() {
      while (cursor < items.length) {
        const index = cursor++;
        results[index] = await mapper(items[index], index);
      }
    }

    const workers = [];
    const count = Math.min(limit, items.length);

    for (let i = 0; i < count; i++) {
      workers.push(worker());
    }

    await Promise.all(workers);
    return results;
  }

  function chunkArray(items, size) {
    const chunks = [];

    for (let i = 0; i < items.length; i += size) {
      chunks.push(items.slice(i, i + size));
    }

    return chunks;
  }

  async function translateText(text) {
    const cleaned = String(text || '').trim();

    if (!cleaned) return '';

    const cacheKey = targetLang + '|' + cleaned;

    if (translateCache.has(cacheKey)) return translateCache.get(cacheKey);

    const translated = await translateTextRaw(cleaned);

    translateCache.set(cacheKey, translated);

    if (translateCache.size > 3600) {
      translateCache.delete(translateCache.keys().next().value);
    }

    return translated;
  }

  function translateTextRaw(text) {
    const url =
      'https://translate.googleapis.com/translate_a/single' +
      '?client=gtx' +
      '&sl=auto' +
      '&tl=' + encodeURIComponent(targetLang) +
      '&dt=t' +
      '&q=' + encodeURIComponent(text);

    adaptiveStats.requests++;

    const startedAt = performance.now();

    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout: 30000,
        onload: res => {
          try {
            const duration = performance.now() - startedAt;

            if (duration > SLOW_REQUEST_MS) {
              markSlow();
            }

            if (res.status < 200 || res.status >= 300) {
              throw new Error('Google Translate HTTP ' + res.status);
            }

            const data = JSON.parse(res.responseText);

            const translated = (data[0] || [])
              .map(part => part && part[0] ? part[0] : '')
              .join('')
              .replace(/\s+/g, ' ')
              .trim();

            resolve(translated);
          } catch (err) {
            reject(err);
          }
        },
        onerror: () => reject(new Error('Network error while translating.')),
        ontimeout: () => {
          markSlow();
          reject(new Error('Translation timeout.'));
        }
      });
    });
  }

  function ensureOverlay(page, block) {
    if (block.overlay) return block.overlay;

    const el = document.createElement('div');
    el.className = 'ito-block';
    el.dataset.page = String(block.pageNum);
    el.dataset.block = String(block.index);

    el.style.left = block.left + 'px';
    el.style.top = block.top + 'px';
    el.style.width = Math.max(8, block.width) + 'px';
    el.style.height = Math.max(6, block.height) + 'px';
    el.style.fontSize = block.baseFont + 'px';
    el.style.lineHeight = FIT_LINE_HEIGHT;
    el.style.setProperty('--origin-height', Math.max(6, block.height) + 'px');

    if (block.isFormula && !translateFormulaEnabled) {
      el.style.visibility = 'hidden';
    }

    el.title = 'Double click to expand/collapse';
    el.addEventListener('dblclick', () => {
      el.classList.toggle('ito-expanded');
    });

    page.translationLayer.appendChild(el);
    block.overlay = el;

    return el;
  }

  function applyTranslation(page, block, translated) {
    const el = ensureOverlay(page, block);

    el.classList.remove('ito-loading');
    el.classList.remove('ito-error');
    el.textContent = translated || '';

    if (block.isFormula) {
      el.style.visibility = translateFormulaEnabled && !translationsHidden ? 'visible' : 'hidden';
    }

    fitTextToBox(el, block);

    block.done = true;
    block.error = false;
  }

  function markBlockError(page, block, err) {
    const el = ensureOverlay(page, block);

    el.classList.remove('ito-loading');
    el.classList.add('ito-error');
    el.textContent = 'Failed';

    block.done = false;
    block.error = true;

    el.title = err && err.message ? err.message : String(err);
  }

  function fitTextToBox(el, block) {
    const boxW = Math.max(8, block.width);
    const boxH = Math.max(6, block.height);

    el.style.width = boxW + 'px';
    el.style.height = boxH + 'px';
    el.style.lineHeight = FIT_LINE_HEIGHT;
    el.style.padding = '1px 2px';
    el.style.overflow = 'hidden';

    const textLength = (el.textContent || '').length;
    const densityGuess = Math.sqrt((boxW * Math.max(boxH, 4)) / Math.max(textLength, 1)) * 1.22;

    const maxFont = Math.max(
      FIT_MIN_FONT,
      Math.min(block.baseFont * FIT_MAX_GROW, 14, Math.max(block.baseFont, densityGuess))
    );

    let low = FIT_MIN_FONT;
    let high = maxFont;
    let best = low;

    for (let i = 0; i < 10; i++) {
      const mid = (low + high) / 2;
      el.style.fontSize = mid + 'px';

      if (fits(el)) {
        best = mid;
        low = mid;
      } else {
        high = mid;
      }
    }

    el.style.fontSize = best + 'px';

    if (!fits(el)) {
      let font = best;

      while (font > FIT_MIN_FONT && !fits(el)) {
        font -= 0.18;
        el.style.fontSize = font + 'px';
      }
    }

    if (!fits(el)) {
      const currentFont = parseFloat(el.style.fontSize) || FIT_MIN_FONT;
      el.style.fontSize = Math.max(FIT_MIN_FONT, currentFont - 0.2) + 'px';

      const neededH = Math.ceil(el.scrollHeight + 3);

      const maxH = block.columnLocked
        ? Math.max(boxH, Math.min(boxH * 4.5, boxH + 52))
        : Math.max(boxH, Math.min(boxH * 2.8, boxH + 34));

      if (neededH > boxH) {
        el.style.height = Math.min(neededH, maxH) + 'px';
      }
    }

    if (!fits(el) && !block.columnLocked) {
      const neededW = Math.ceil(el.scrollWidth + 4);
      const maxW = Math.min(boxW * 1.35, boxW + 120);

      if (neededW > boxW) {
        el.style.width = Math.min(neededW, maxW) + 'px';
      }
    }

    if (block.columnLocked) {
      lockOverlayToColumn(el, block);
    }

    if (!fits(el)) {
      el.title = el.textContent + '\n\nText is still too long for the original box. Double click to expand.';
    } else {
      el.title = 'Double click to expand/collapse';
    }
  }

  function lockOverlayToColumn(el, block) {
    if (block.columnSide === 'left' && block.columnLimitRight) {
      const currentLeft = parseFloat(el.style.left) || block.left;
      const maxWidth = Math.max(8, block.columnLimitRight - currentLeft);

      if ((parseFloat(el.style.width) || block.width) > maxWidth) {
        el.style.width = maxWidth + 'px';
      }
    }

    if (block.columnSide === 'right' && block.columnLimitLeft) {
      const currentLeft = parseFloat(el.style.left) || block.left;

      if (currentLeft < block.columnLimitLeft) {
        const oldWidth = parseFloat(el.style.width) || block.width;
        const delta = block.columnLimitLeft - currentLeft;
        el.style.left = block.columnLimitLeft + 'px';
        el.style.width = Math.max(8, oldWidth - delta) + 'px';
      }
    }
  }

  function fits(el) {
    return el.scrollWidth <= el.clientWidth + 2 && el.scrollHeight <= el.clientHeight + 2;
  }

  function hideTranslations() {
    translationsHidden = true;

    for (const page of pageData.values()) {
      if (page.translationLayer) {
        page.translationLayer.style.visibility = 'hidden';
      }
    }

    setTranslateButtonText('Translate');
    setStatus('Original active. Click Translate to restore.');
  }

  function showTranslations() {
    translationsHidden = false;

    for (const page of pageData.values()) {
      if (page.translationLayer) {
        page.translationLayer.style.visibility = 'visible';
      }
    }

    applyFormulaVisibility();
    refreshTranslateButtonState();
  }

  function refreshTranslateButtonState() {
    if (countDoneBlocks() > 0) {
      setTranslateButtonText('Original');
    } else {
      setTranslateButtonText('Translate');
    }
  }

  function clearAllOverlays() {
    translationsHidden = false;

    for (const page of pageData.values()) {
      if (page.translationLayer) {
        page.translationLayer.style.visibility = 'visible';
        page.translationLayer.innerHTML = '';
      }

      for (const block of page.blocks || []) {
        block.overlay = null;
        block.done = false;
        block.error = false;
      }
    }

    setTranslateButtonText('Translate');
    setStatus('Overlay cleared.');
  }

  function countAllBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(isEligibleBlock).length;
    }

    return total;
  }

  function countDoneBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(block => isEligibleBlock(block) && block.done).length;
    }

    return total;
  }

  function countNormalBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(block => !block.isFormula).length;
    }

    return total;
  }

  function countDoneNormalBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(block => !block.isFormula && block.done).length;
    }

    return total;
  }

  function countFormulaBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(block => block.isFormula).length;
    }

    return total;
  }

  function countDoneFormulaBlocks() {
    let total = 0;

    for (const page of pageData.values()) {
      total += page.blocks.filter(block => block.isFormula && block.done).length;
    }

    return total;
  }

  function getVisiblePageNumber() {
    const pages = Array.from(document.querySelectorAll('.ito-page'));

    if (!pages.length) return activePageNumber || 1;

    const centerY = window.innerHeight / 2;
    let best = pages[0];
    let bestDistance = Infinity;

    for (const page of pages) {
      const rect = page.getBoundingClientRect();
      const pageCenter = rect.top + rect.height / 2;
      const distance = Math.abs(pageCenter - centerY);

      if (rect.bottom > 80 && rect.top < window.innerHeight && distance < bestDistance) {
        best = page;
        bestDistance = distance;
      }
    }

    return Number(best.dataset.pageNumber || 1);
  }

  function updateActivePage() {
    activePageNumber = getVisiblePageNumber();

    if (pdfDoc && !translating && !translationsHidden) {
      setStatus(`Page ${activePageNumber}/${pdfDoc.numPages}. Target: ${getLanguageLabel(targetLang)}.`);
    }
  }

  function scheduleActivePageUpdate() {
    clearTimeout(activePageTimer);
    activePageTimer = setTimeout(updateActivePage, 70);
  }

  function showNeedManualFile() {
    setStatus('Choose PDF');

    const refs = ensureViewerStructure();
    const stage = refs.stage;
    const viewer = refs.viewer;

    setRenderBusy(false);

    visualZoom = 1.0;
    updateZoomLabel();
    updateHorizontalScrollbar();

    stage.style.width = '100%';
    stage.style.height = 'calc(100vh - 78px)';

    viewer.style.transform = 'scale(1)';
    viewer.style.width = '100%';
    viewer.style.height = '100%';
    viewer.innerHTML = `
      <div id="ito-manual">
        <h2>Unable to open this local PDF automatically</h2>
        <button id="ito-pick-now" class="ito-btn ito-big">Choose PDF</button>
      </div>
    `;

    byId('ito-pick-now').addEventListener('click', () => {
      byId('ito-file').click();
    });
  }

  function isMostlyMathOrNumber(text) {
    const t = String(text || '').trim();

    if (!t) return true;

    const letters = (t.match(/[A-Za-zÀ-ÿ]/g) || []).length;
    const digits = (t.match(/[0-9]/g) || []).length;
    const math = (t.match(/[=+\-−×÷*/<>≤≥±√∑∫∞≈≠^_{}()[\]|\\]/g) || []).length;
    const visible = t.replace(/\s/g, '').length;

    if (visible <= 1) return true;

    const letterRatio = letters / Math.max(1, visible);
    const mathRatio = (digits + math) / Math.max(1, visible);

    if (letters < 2 && mathRatio > 0.35) return true;
    if (letterRatio < 0.16 && mathRatio > 0.48) return true;

    return false;
  }

  function cleanSourceText(text) {
    return String(text || '')
      .replace(/-\s+/g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function joinTextRuns(parts) {
    let out = '';

    for (const raw of parts) {
      const part = String(raw || '').trim();
      if (!part) continue;

      if (!out) {
        out = part;
      } else if (/^[,.;:!?%)\]}]/.test(part)) {
        out += part;
      } else if (/[(\[{]$/.test(out)) {
        out += part;
      } else if (/[-–—]$/.test(out) || /^[-–—]/.test(part)) {
        out += part;
      } else {
        out += ' ' + part;
      }
    }

    return out.trim();
  }

  function unionRect(items) {
    const left = Math.min(...items.map(i => i.left));
    const top = Math.min(...items.map(i => i.top));
    const right = Math.max(...items.map(i => i.right || (i.left + i.width)));
    const bottom = Math.max(...items.map(i => i.bottom || (i.top + i.height)));

    return {
      left,
      top,
      right,
      bottom,
      width: right - left,
      height: bottom - top
    };
  }

  function median(values) {
    const arr = values
      .filter(v => Number.isFinite(v))
      .slice()
      .sort((a, b) => a - b);

    if (!arr.length) return 0;

    const mid = Math.floor(arr.length / 2);

    if (arr.length % 2) return arr[mid];

    return (arr[mid - 1] + arr[mid]) / 2;
  }

  function average(values) {
    if (!values.length) return 0;
    return values.reduce((a, b) => a + b, 0) / values.length;
  }

  function countLetters(text) {
    return (String(text || '').match(/[A-Za-zÀ-ÿ]/g) || []).length;
  }

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

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function byId(id) {
    return document.getElementById(id);
  }

  function setStatus(text) {
    const el = byId('ito-status');
    if (el) el.textContent = text;
  }

  function setTranslateButtonText(text) {
    const btn = byId('ito-translate-toggle');
    if (btn) btn.textContent = text;
  }

  function setTranslateButtonDisabled(disabled) {
    const btn = byId('ito-translate-toggle');
    if (btn) btn.disabled = disabled;
  }

  function updateZoomLabel() {
    const label = byId('ito-zoom-label');
    if (label) label.textContent = Math.round(visualZoom * 100) + '%';
  }

  function showToast(text, ms = 2700) {
    const toast = byId('ito-toast');
    toast.textContent = text;
    toast.style.display = 'block';

    clearTimeout(showToast._timer);
    showToast._timer = setTimeout(() => {
      toast.style.display = 'none';
    }, ms);
  }

  function escapeRegExp(str) {
    return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', safeStart, { once: true });
    setTimeout(() => {
      if (!document.getElementById('ito-zoom-shell')) safeStart();
    }, 700);
  } else {
    safeStart();
  }
})();