PDF.js viewer + inline black overlay translator. Region-aware columns, adaptive translation, formula toggle, smooth zoom.
// ==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();
}
})();