/
diz
/
lens
Обзор
Документация
Войти
/
diz
/
lens
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/shared.js
746 строк
31 KB
Diman
fix: архив сайта (кнопка «↓») собирается динамически из index.html вместо статического списка — в него не попадал готовый prepare-image.js; fetch с cache:'no-store', чтобы в ZIP шли свежие версии файлов с сервера, минуя кеш браузера
10 авг 2026, 19:12
10 авг 2026, 19:12
8cd51ad
Код
Авторство
О чём код?
'use strict'; pdfjsLib.GlobalWorkerOptions.workerSrc = 'lib/pdf.worker.min.js'; // ═══════════════════════════════════════════════════════════════ // Карта модуля (общий слой: утилиты, PDF, зум/пан, навигация) // • Утилиты: $, hex2rgb, showToast, clamp, downloadBlob, … // • Загрузка PDF: reloadPdfAndRender, fileInput handler // • Рендер: sharedRender, гибридный зум (renderLevel/screenScale), // fitToScreen, applyTransform, renderPage-хелперы // • Зум/пан: onWrapWheel, doPan (relevel, levelRendersInFlight) // • Навигация/поворот: prevPage, nextPage, rotatePage // • Применение правок: applyLinesEditsAndReload, applyLayerOverrides // • Слои: buildLayersPanel, layersList, downloadSiteAsZip // • Гомография (общая для red/text-режимов и подложки): // solveHomography, invertHomography, applyHomographyPoint // Глобальные: pdfDoc, srcBytes, curPage, numPages, pageW/H, pageRot, // zoom/panX/panY, renderToken, RENDER_SCALE, screenScale, showToast // ═══════════════════════════════════════════════════════════════ const $ = id => document.getElementById(id); const fileInput = $('fileInput'); const fileText = $('fileText'); const canvas = $('canvas'); const ctx = canvas.getContext('2d'); const wrap = $('canvasWrap'); const tg = $('tg'); const zoomInd = $('zoomInd'); const prevBtn = $('prevBtn'); const nextBtn = $('nextBtn'); const rotateCcwBtn = $('rotateCcwBtn'); const rotateCwBtn = $('rotateCwBtn'); const pageInd = $('pageInd'); const downloadBtn = $('downloadBtn'); const statusEl = $('status'); const copyNotFoundBtn = $('copyNotFoundBtn'); const toast = $('toast'); const startInstruction = $('startInstruction'); let pdfDoc = null; let srcBytes = null; let originalBytes = null; let numPages = 0; let curPage = 1; let pageH = 0, pageW = 0, pageRot = 0; let zoom = 1, panX = 0, panY = 0; let isPanning = false, panSX = 0, panSY = 0, panBX = 0, panBY = 0; let toastTimer; let renderToken = 0; let hasUserZoomed = false; let changesApplied = false; let currentConfig = null; let layersModified = false; const layerOverrides = {}; startInstruction.addEventListener('click', () => fileInput.click()); const RENDER_SCALE = 1.5; // Гибридный зум: canvas рендерится в screenScale = RENDER_SCALE * renderLevel, // между уровнями зум идёт мгновенным CSS-transform, при пересечении границы // уровня страница перерисовывается заново (чёткость на любом зуме). let screenScale = RENDER_SCALE; // текущий масштаб canvas-рендера let renderLevel = 1; // уровень чёткости: 1 | 1.5 | 3.5 let renderTask = null; // текущий RenderTask pdf.js (для отмены) const MAX_CANVAS_AREA = 64 * 1024 * 1024; // потолок площади canvas (px) ≈ 256MB RGBA // Шкала уровней чёткости (от большего к меньшему) и пороги zoom, // начиная с которых уровень активен. Дробные уровни допустимы. const RENDER_LEVELS = [3.5, 1.5, 1]; const RENDER_LEVEL_ZOOMS = { 3.5: 3.5, 1.5: 1.5, 1: 0 }; function hex2rgb(h) { const r = parseInt(h.slice(1,3),16)/255, g = parseInt(h.slice(3,5),16)/255, b = parseInt(h.slice(5,7),16)/255; return [r,g,b]; } // ── Utilities ── function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => toast.classList.remove('show'), 2800); } function setStatus(msg) { statusEl.textContent = msg; copyNotFoundBtn.classList.remove('visible'); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } copyNotFoundBtn.addEventListener('click', async () => { const text = copyNotFoundBtn.dataset.values; if (!text) return; try { await navigator.clipboard.writeText(text); showToast('Скопировано'); } catch { // fallback for older browsers const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast('Скопировано'); } }); function applyTransform() { tg.style.transform = `translate(${panX}px,${panY}px) scale(${zoom / renderLevel})`; zoomInd.textContent = Math.round(zoom * 100) + '%'; } // Уровень чёткости для заданного zoom с потолком по площади canvas // (на больших страницах уровень не растёт — защита от гигабайтов RAM). function computeRenderLevel(z) { // Потолок по площади страницы: большие страницы не могут рендериться // в высоком разрешении (защита от гигабайтов RAM). const areaLimit = Math.sqrt(MAX_CANVAS_AREA / Math.max(1, pageW * pageH * RENDER_SCALE * RENDER_SCALE)); for (const lvl of RENDER_LEVELS) { if (lvl <= areaLimit && z >= RENDER_LEVEL_ZOOMS[lvl]) return lvl; } return 1; } function fitToScreen(force) { const cw = canvas.width, ch = canvas.height; if (!cw || !ch) return; const w = wrap.clientWidth, h = wrap.clientHeight; if (!w || !h) return; if (!force && hasUserZoomed) { const minPanX = Math.min(0, w - cw * zoom / renderLevel); const maxPanX = Math.max(0, w - cw * zoom / renderLevel); const minPanY = Math.min(0, h - ch * zoom / renderLevel); const maxPanY = Math.max(0, h - ch * zoom / renderLevel); panX = Math.max(minPanX, Math.min(panX, maxPanX)); panY = Math.max(minPanY, Math.min(panY, maxPanY)); applyTransform(); return; } zoom = Math.max(0.1, Math.min(w / (cw / renderLevel), h / (ch / renderLevel), 2 * renderLevel)); panX = Math.max(0, (w - cw * zoom / renderLevel) / 2); panY = Math.max(0, (h - ch * zoom / renderLevel) / 2); applyTransform(); } function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(r.result); r.onerror = reject; r.readAsArrayBuffer(file); }); } // ── PDF loading ── async function loadAndRender(file) { if (!file) return; if (file.type && file.type !== 'application/pdf') { showToast('Файл не является PDF'); return; } fileText.textContent = file.name; srcBytes = await readFileAsArrayBuffer(file); originalBytes = srcBytes.slice(0); pdfDoc = await pdfjsLib.getDocument({ data: srcBytes.slice(0) }).promise; numPages = pdfDoc.numPages; if (numPages === 0) { showToast('PDF не содержит страниц'); pdfDoc = null; return; } startInstruction.style.display = 'none'; $('fileLabel').classList.remove('start-pulse'); curPage = 1; hasUserZoomed = false; renderLevel = 1; screenScale = RENDER_SCALE; renderTask = null; await reloadPdfAndRender(); } async function reloadPdfAndRender() { try { currentConfig = await pdfDoc.getOptionalContentConfig(); const order = currentConfig.getOrder(); if (!order || !order.length) currentConfig = null; layersModified = false; if (originalBytes) { for (const key in layerOverrides) delete layerOverrides[key]; } } catch (e) { currentConfig = null; } buildLayersPanel(); updateNav(); await renderPage(); } function updateNav() { prevBtn.disabled = curPage <= 1; nextBtn.disabled = curPage >= numPages; const hasDoc = pdfDoc !== null; rotateCcwBtn.disabled = !hasDoc; rotateCwBtn.disabled = !hasDoc; } async function rotatePage(delta) { if (!pdfDoc) return; $('loadingOverlay').style.display = 'flex'; $('loadingOverlay').querySelector('span').textContent = 'Поворот страницы…'; await new Promise(r => setTimeout(r, 50)); try { const doc = await PDFLib.PDFDocument.load(srcBytes); const pg = doc.getPage(curPage - 1); const currentAngle = pg.getRotation().angle || 0; let newAngle = (currentAngle + delta) % 360; if (newAngle < 0) newAngle += 360; if (newAngle === 360) newAngle = 0; pg.setRotation(PDFLib.degrees(newAngle)); const bytes = await doc.save({ objectsPerTick: 20 }); const newDoc = await pdfjsLib.getDocument({ data: bytes.slice(0) }).promise; srcBytes = bytes; pdfDoc = newDoc; numPages = pdfDoc.numPages; curPage = Math.min(curPage, numPages); changesApplied = true; downloadBtn.disabled = false; // Update page geometry BEFORE recalcCanvasCoords (used by pdfToCanvas) const rotPage = await newDoc.getPage(curPage); const rvp = rotPage.getViewport({ scale: 1 }); pageW = rvp.width; pageH = rvp.height; pageRot = rotPage.rotate; // Clear caches in modes if (window.linesMode && window.linesMode.clearStreamCaches) window.linesMode.clearStreamCaches(); if (window.textMode) window.textMode.clearPageCaches(); // Recalculate canvas coords with updated pageRot/pageW/pageH if (window.textMode && window.textMode.recalcCanvasCoords) window.textMode.recalcCanvasCoords(curPage); await renderPage(); showToast(`Страница повёрнута на ${Math.abs(delta)}°`); } catch (e) { showToast('Ошибка поворота: ' + e.message); console.error(e); } finally { $('loadingOverlay').style.display = 'none'; $('loadingOverlay').querySelector('span').textContent = 'Применение изменений…'; } } async function applyLinesEditsAndReload(stayInMode, forceSave) { if (currentMode !== 'lines' || !window.linesMode) return; $('loadingOverlay').style.display = 'flex'; await new Promise(r => setTimeout(r, 50)); try { let newBytes = window.linesMode.hasAppliedEdits() ? await window.linesMode.exportBytes() : null; if (!newBytes && forceSave) { // Force save+reload to normalize content streams for navigation const doc = await PDFLib.PDFDocument.load(srcBytes); const pg = doc.getPages()[0]; pg.drawRectangle({ x: 0, y: 0, width: 0, height: 0, color: PDFLib.rgb(1, 1, 1) }); newBytes = await doc.save({ objectsPerTick: 20 }); } if (!newBytes) { if (stayInMode) { showToast('Нет изменений для применения'); return; } doSwitchMode('text'); return; } const newDoc = await pdfjsLib.getDocument({ data: newBytes.slice(0) }).promise; srcBytes = newBytes; pdfDoc = newDoc; if (currentConfig) { try { currentConfig = await pdfDoc.getOptionalContentConfig(); applyLayerOverrides(); } catch (e) { /* keep old config */ } } window.linesMode.clearAll(); if (window.textMode && window.textMode.clearPageCaches) window.textMode.clearPageCaches(); curPage = Math.min(curPage, pdfDoc.numPages); numPages = pdfDoc.numPages; if (stayInMode) { changesApplied = true; downloadBtn.disabled = false; } await renderPage(); if (!stayInMode) doSwitchMode('text'); } catch (e) { showToast('Ошибка: ' + e.message); console.error(e); if (!stayInMode) doSwitchMode('text'); } finally { $('loadingOverlay').style.display = 'none'; } } async function prevPage() { if (curPage <= 1) return; const target = curPage - 1; if (currentMode === 'lines' && window.linesMode) { curPage = target; await applyLinesEditsAndReload(true, true); } else { curPage = target; await renderPage(); } } async function nextPage() { if (curPage >= numPages) return; const target = curPage + 1; if (currentMode === 'lines' && window.linesMode) { curPage = target; await applyLinesEditsAndReload(true, true); } else { curPage = target; await renderPage(); } } // renderPage is mode-specific, defined in mode files // Shared: sets up canvas dimensions, renders PDF page, fits to screen async function sharedRender(page) { const token = renderToken; // При relevel-зуме pan уже пересчитан в onWrapWheel (точка под курсором // сохраняется); кламп fitToScreen по границам окна в этом случае даёт // «прыжок» экрана вверх/вниз, пока onWrapWheel не вернёт pan обратно. const skipFit = suppressFitToScreen; suppressFitToScreen = false; const vp1 = page.getViewport({ scale: 1 }); pageW = vp1.width; pageH = vp1.height; pageRot = page.rotate; // Сверка уровня чёткости с текущим zoom и потолком площади canvas // (смена страницы / поворот могут изменить pageW/H — уровень обязан быть актуальным). const wantLevel = computeRenderLevel(zoom); if (wantLevel !== renderLevel) { renderLevel = wantLevel; screenScale = RENDER_SCALE * renderLevel; if (window.textMode && window.textMode.clearPageCaches) window.textMode.clearPageCaches(); // Смена уровня вне wheel-зума (перелистывание страницы / поворот при // большом zoom): показать «подождите» на время перерисовки слоёв. // Скрытие — в renderPage() (app.js), актуальным токеном. const rw = $('renderWaiting'); if (rw) rw.classList.add('show'); } const vp = page.getViewport({ scale: screenScale }); const cw = Math.ceil(vp.width), ch = Math.ceil(vp.height); // Оффскрин-рендер: пока pdf.js рисует новый кадр (сотни мс), основной // canvas и визуальные слои НЕ трогаем — на экране остаётся прежний // согласованный кадр. Раньше размер canvas менялся сразу и рисовалась // мутная stale-копия — на время рендера скрытое/закрашенное проглядывало. const off = document.createElement('canvas'); off.width = cw; off.height = ch; const offCtx = off.getContext('2d'); const renderOpts = { canvasContext: offCtx, viewport: vp, background: 'white' }; if (currentConfig) renderOpts.optionalContentConfigPromise = Promise.resolve(currentConfig); // Отменяем устаревший рендер: pdf.js запрещает два render() на один canvas if (renderTask) { try { renderTask.cancel(); } catch (e) { /* ignore */ } renderTask = null; } const task = page.render(renderOpts); renderTask = task; try { await task.promise; } catch (err) { if (err && err.name !== 'RenderingCancelledException') throw err; return; // отменён — новый рендер уже идёт } finally { if (renderTask === task) renderTask = null; } if (token !== renderToken) return; // Синхронный переключатель: размер canvas + готовый кадр + пересчёт слоёв // одним блоком — браузер не успевает отрисовать промежуточное состояние. if (canvas.width !== cw || canvas.height !== ch) { canvas.width = cw; canvas.height = ch; } ctx.drawImage(off, 0, 0); const lc = $('labelCanvas'); if (lc.width !== cw || lc.height !== ch) { lc.width = cw; lc.height = ch; } if (window.linesMode && window.linesMode.renderOverlay) window.linesMode.renderOverlay(); if (window.imageOverlay && window.imageOverlay.renderCurrentPage) window.imageOverlay.renderCurrentPage(); pageInd.textContent = curPage + ' / ' + numPages; if (skipFit) applyTransform(); else fitToScreen(); } function applyLayerOverrides() { for (const id in layerOverrides) { try { currentConfig.setVisibility(id, layerOverrides[id]); } catch (e) { /* ignore stale override */ } } } function buildLayersPanel() { const panel = $('layersPanel'); const list = $('layersList'); if (!currentConfig) { panel.style.display = 'none'; return; } applyLayerOverrides(); const groups = currentConfig.getGroups(); if (!groups) { panel.style.display = 'none'; return; } panel.style.display = 'block'; list.innerHTML = ''; for (const id of Object.keys(groups)) { const g = groups[id]; if (!g) continue; const label = document.createElement('label'); label.className = 'layer-item'; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = id in layerOverrides ? layerOverrides[id] : g.visible; cb.addEventListener('change', () => { layerOverrides[id] = cb.checked; currentConfig.setVisibility(id, cb.checked); layersModified = true; renderPage(); }); label.appendChild(cb); label.appendChild(document.createTextNode(g.name || id)); list.appendChild(label); } } async function applyOCGToExport(doc) { if (!currentConfig || !layersModified) return; try { const PDFName = PDFLib.PDFName, PDFRef = PDFLib.PDFRef; const catalog = doc.catalog; const ocProps = catalog.get(PDFName.of('OCProperties')); if (!ocProps) return; const d = ocProps.get(PDFName.of('D')); if (!d) return; const onArr = [], offArr = []; const groups = currentConfig.getGroups(); if (groups) { for (const id of Object.keys(groups)) { const ref = PDFRef.of(JSON.parse(id.replace('R', ''))); if (groups[id].visible) onArr.push(ref); else offArr.push(ref); } } if (onArr.length) d.set(PDFName.of('ON'), doc.context.obj(onArr)); else d.set(PDFName.of('ON'), doc.context.obj([])); if (offArr.length) d.set(PDFName.of('OFF'), doc.context.obj(offArr)); else d.set(PDFName.of('OFF'), doc.context.obj([])); } catch (e) { console.error('OCG export error:', e); } } // ── Coordinate transforms (rotation-aware) ── function pdfToCanvas(pdfX, pdfY) { const s = screenScale; switch (pageRot) { case 0: return { x: pdfX * s, y: (pageH - pdfY) * s }; case 90: return { x: pdfY * s, y: pdfX * s }; case 180: return { x: (pageW - pdfX) * s, y: pdfY * s }; case 270: return { x: (pageW - pdfY) * s, y: (pageH - pdfX) * s }; default: return { x: pdfX * s, y: (pageH - pdfY) * s }; } } function canvasToPdf(canvasX, canvasY) { const s = screenScale; switch (pageRot) { case 0: return { x: canvasX / s, y: pageH - canvasY / s }; case 90: return { x: canvasY / s, y: canvasX / s }; case 180: return { x: pageW - canvasX / s, y: canvasY / s }; case 270: return { x: pageH - canvasY / s, y: pageW - canvasX / s }; default: return { x: canvasX / s, y: pageH - canvasY / s }; } } function pdfRectToCanvas(pdfX, pdfY, pdfW, pdfH) { const s = screenScale; switch (pageRot) { case 0: return { left: pdfX * s, top: (pageH - (pdfY + pdfH)) * s, w: pdfW * s, h: pdfH * s }; case 90: return { left: pdfY * s, top: pdfX * s, w: pdfH * s, h: pdfW * s }; case 180: return { left: (pageW - (pdfX + pdfW)) * s, top: pdfY * s, w: pdfW * s, h: pdfH * s }; case 270: return { left: (pageW - pdfY - pdfH) * s, top: (pageH - pdfX - pdfW) * s, w: pdfH * s, h: pdfW * s }; default: return { left: pdfX * s, top: (pageH - (pdfY + pdfH)) * s, w: pdfW * s, h: pdfH * s }; } } // Pan/zoom handlers (attached in app.js) function onWrapMouseDown(e) { if (e.button !== 0) return; if (currentMode === 'lines' && window.linesMode) { window.linesMode.onMouseDown(e); } else if (currentMode === 'text' && window.textMode) { window.textMode.onMouseDown(e); } else { startPan(e); } } function onWindowMouseMove(e) { if (currentMode === 'lines' && window.linesMode) { window.linesMode.onMouseMove(e); } else if (currentMode === 'text' && window.textMode) { window.textMode.onMouseMove(e); } else { doPan(e); } } function onWindowMouseUp(e) { if (currentMode === 'lines' && window.linesMode) { window.linesMode.onMouseUp(e); } else if (currentMode === 'text' && window.textMode) { window.textMode.onMouseUp(e); } else { endPan(e); } } let wheelRafId = 0; let pendingWheel = null; let lastWheelCursor = null; let wheelToken = 0; // инвалидация устаревших .then() при смене уровня let suppressFitToScreen = false; // при relevel-зуме pan уже рассчитан в onWrapWheel — fitToScreen не должен клампить его let levelRendersInFlight = 0; // сколько relevel-рендеров сейчас в полёте (canvas ещё старый) function onWrapWheel(e) { e.preventDefault(); if (!pdfDoc) return; hasUserZoomed = true; const r = wrap.getBoundingClientRect(); lastWheelCursor = { mx: e.clientX - r.left, my: e.clientY - r.top }; const dy = e.deltaY; const sign = dy > 0 ? -1 : 1; let step; if (e.deltaMode === 0) { step = Math.abs(dy) * 0.002; } else if (e.deltaMode === 1) { step = Math.abs(dy) * 0.12; } else { step = Math.abs(dy) * 0.5; } step = Math.min(step, 3); // Накопление приращений: пока rAF не применил предыдущее значение, каждый // щелчок умножает последнее запланированное (а не текущий zoom). Иначе // быстрые серии и щелчки во время relevel-рендера считались от одного и // того же zoom и «проглатывались» (3 щелчка = 1 шаг). const mult = 1 + sign * step; const baseZoom = pendingWheel ? pendingWheel.zoom : zoom; const nz = Math.min(20, Math.max(0.1, baseZoom * mult)); pendingWheel = { zoom: nz, level: computeRenderLevel(nz) }; if (wheelRafId) return; // уже запланирован — возьмётся последний pendingWheel wheelRafId = requestAnimationFrame(async () => { wheelRafId = 0; const p = pendingWheel; pendingWheel = null; if (!p) return; const cur = lastWheelCursor; const oldLevel = renderLevel; // Точка PDF под курсором (в canvas-координатах старого масштаба) const cssScaleOld = zoom / oldLevel; const cxp = (cur.mx - panX) / cssScaleOld; const cyp = (cur.my - panY) / cssScaleOld; const levelChanged = p.level !== oldLevel; zoom = p.zoom; if (levelChanged) { // Пересекли границу уровня — перерисовываем страницу в новом разрешении. // Кэши снимков canvas (text-mode) в старом масштабе недействительны. renderLevel = p.level; screenScale = RENDER_SCALE * renderLevel; const myToken = ++wheelToken; if (window.textMode && window.textMode.clearPageCaches) window.textMode.clearPageCaches(); // Точка под курсором в новом canvas = cxp * (newLevel/oldLevel); // CSS scale = zoom/newLevel → pan' = mx - cxp * (zoom/oldLevel). // Transform НЕ применяем до рендера: canvas ещё старый (в старых // пикселях), новый transform его рассогласует. sharedRender рендерит в // оффскрин и переключает canvas+слои одним синхронным блоком, после // чего здесь применяется новый transform — мигания нет. panX = cur.mx - cxp * (zoom / oldLevel); panY = cur.my - cyp * (zoom / oldLevel); suppressFitToScreen = true; // pan уже финальный — fitToScreen не клампить const levelPanX = panX, levelPanY = panY; // pan, выставленный этим relevel const rw = $('renderWaiting'); if (rw) rw.classList.add('show'); // «Подождите…» на время перерисовки слоёв levelRendersInFlight++; try { await renderPage(); } catch (err) { console.error('renderPage (zoom):', err); } levelRendersInFlight--; if (myToken !== wheelToken) return; // свежий relevel уже идёт — pan скорректирует он // Во время рендера могли прийти wheel/pan-события и пересчитать pan по // последнему курсу/зуму — не затираем их. Иначе возвращаем точку под курсором. if (panX === levelPanX && panY === levelPanY) { panX = cur.mx - cxp * (zoom / oldLevel); panY = cur.my - cyp * (zoom / oldLevel); } applyTransform(); if (rw) rw.classList.remove('show'); } else { panX = cur.mx - cxp * (zoom / renderLevel); panY = cur.my - cyp * (zoom / renderLevel); // Пока идёт relevel-рендер, canvas ещё старый, а renderLevel уже новый: // applyTransform рассогласует экран (прыжок на мгновение). Transform // применится синхронно после завершения рендера — с актуальным pan. if (levelRendersInFlight === 0) applyTransform(); } }); } function startPan(e) { isPanning = true; panSX = e.clientX; panSY = e.clientY; panBX = panX; panBY = panY; wrap.classList.add('grabbing'); } let panRafId = 0; function doPan(e) { if (!isPanning) return; hasUserZoomed = true; panX = panBX + (e.clientX - panSX); panY = panBY + (e.clientY - panSY); if (!panRafId) { panRafId = requestAnimationFrame(() => { // Во время relevel-рендера canvas ещё старый — transform не применяем // (экран прыгнет); pan применится синхронно после завершения рендера. if (levelRendersInFlight === 0) applyTransform(); panRafId = 0; }); } } function endPan(e) { isPanning = false; wrap.classList.remove('grabbing'); } // ── Download site as ZIP (offline use) ── // Имя ZIP-архива при скачивании сайта const SITE_ZIP_NAME = 'board-lens-site.zip'; // Файлы, не упомянутые в index.html, но обязательные для работы: // pdf.worker.min.js подключается pdf.js динамически через workerSrc (см. шапку shared.js) const SITE_EXTRA_FILES = ['lib/pdf.worker.min.js']; // Собирает актуальный список файлов сайта из index.html (все script[src]/link[href]) // плюс обязательные доп. файлы. Архив всегда содержит ровно тот набор файлов, // который реально использует сайт, — новые модули попадают в него автоматически. function collectSiteFiles(htmlText) { const doc = new DOMParser().parseFromString(htmlText, 'text/html'); const files = new Set(['index.html']); for (const el of doc.querySelectorAll('script[src], link[href]')) { const url = (el.getAttribute('src') || el.getAttribute('href') || '').split('#')[0].split('?')[0]; if (!url || url.startsWith('/') || /^(https?:)?\/\//.test(url) || url.startsWith('data:')) continue; files.add(url); } for (const f of SITE_EXTRA_FILES) files.add(f); return [...files]; } async function downloadSiteAsZip() { if (location.protocol === 'file:') { showToast('Эта кнопка работает только на хостинге (GitHub Pages / GitVerse Pages). Вы уже используете локальную версию.'); return; } // cache:'no-store' — всегда свежая версия с сервера, минуя браузерный кеш const zip = new JSZip(); showToast('Сбор файлов…'); let ok = 0; try { const htmlResp = await fetch('index.html', { cache: 'no-store' }); if (!htmlResp.ok) throw new Error('index.html status ' + htmlResp.status); const htmlText = await htmlResp.text(); zip.file('index.html', htmlText); ok++; for (const file of collectSiteFiles(htmlText)) { if (file === 'index.html') continue; try { const resp = await fetch(file, { cache: 'no-store' }); if (!resp.ok) continue; const blob = await resp.blob(); zip.file(file, blob); ok++; } catch (e) { console.error('Failed to fetch ' + file, e); } } } catch (e) { console.error('Failed to fetch index.html', e); } if (!ok) { showToast('Ошибка: не удалось собрать файлы'); return; } const content = await zip.generateAsync({ type: 'blob' }); const url = URL.createObjectURL(content); const a = document.createElement('a'); a.href = url; a.download = SITE_ZIP_NAME; a.click(); URL.revokeObjectURL(url); showToast(`Сайт скачан: ${SITE_ZIP_NAME} (${ok} файлов)`); } // ═══════════════════════════════════════════════════════════════ // Homography helpers (shared between image-overlay.js and prepare-image.js) // ═══════════════════════════════════════════════════════════════ function solveHomography(src, dst) { var M = []; for (var i = 0; i < 4; i++) { var sx = src[i].x, sy = src[i].y; var dx = dst[i].x, dy = dst[i].y; M.push([sx, sy, 1, 0, 0, 0, -sx*dx, -sy*dx, dx]); M.push([0, 0, 0, sx, sy, 1, -sx*dy, -sy*dy, dy]); } for (var col = 0; col < 8; col++) { var maxVal = Math.abs(M[col][col]), maxRow = col; for (var row = col + 1; row < 8; row++) { if (Math.abs(M[row][col]) > maxVal) { maxVal = Math.abs(M[row][col]); maxRow = row; } } if (maxVal < 1e-15) return null; if (maxRow !== col) { var tmp = M[col]; M[col] = M[maxRow]; M[maxRow] = tmp; } for (var row = col + 1; row < 8; row++) { var factor = M[row][col] / M[col][col]; for (var j = col; j <= 8; j++) M[row][j] -= factor * M[col][j]; } } var h = Array(8); for (var i = 7; i >= 0; i--) { var s = M[i][8]; for (var j = i + 1; j < 8; j++) s -= M[i][j] * h[j]; h[i] = s / M[i][i]; } return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1]; } function invertHomography(H) { var a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7],i=H[8]; var det = a*(e*i-f*h)-b*(d*i-f*g)+c*(d*h-e*g); if (Math.abs(det)<1e-12) return null; var id=1/det; return [(e*i-f*h)*id,(c*h-b*i)*id,(b*f-c*e)*id,(f*g-d*i)*id,(a*i-c*g)*id,(c*d-a*f)*id,(d*h-e*g)*id,(b*g-a*h)*id,(a*e-b*d)*id]; } function applyHomographyPoint(H, x, y) { var w = H[6]*x + H[7]*y + H[8]; if (Math.abs(w) < 1e-12) return null; return { x: (H[0]*x + H[1]*y + H[2]) / w, y: (H[3]*x + H[4]*y + H[5]) / w }; } $('downloadSiteBtn').addEventListener('click', downloadSiteAsZip);