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();
  }
})();