From 38305530bf7568cab4acef8665b6480c6db9fd88 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:02:52 +0200 Subject: [PATCH] feat: make v0.0.8 PDF viewer read-only --- CHANGELOG.md | 22 + CONTRIBUTING.md | 4 +- README.md | 142 +- SECURITY.md | 8 +- THIRD_PARTY_NOTICES.md | 10 +- media/webview/main.js | 1845 ++------------------------ media/webview/pdf-engine.js | 2452 +---------------------------------- media/webview/styles.css | 337 +---- media/webview/utils.js | 172 --- package-lock.json | 397 +----- package.json | 35 +- scripts/copy-vendor.js | 11 - src/extension.js | 4 +- src/pdf-document.js | 38 +- src/pdf-editor-provider.js | 243 +--- src/webview-html.js | 102 +- test/pdf-engine.test.mjs | 567 +------- test/ui-contract.test.mjs | 107 +- test/utils.test.mjs | 45 +- 19 files changed, 411 insertions(+), 6130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d762f..2bdc219 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ Todos los cambios relevantes de este proyecto se documentan aquí. +## 0.0.8 - 2026-09-09 + +### Cambiado + +- La extensión pasa a ser un visor de PDF estrictamente de solo lectura. +- El proveedor personalizado deja de registrar el ciclo editable de VS Code: no hay cambios, guardado, Guardar como, copias de seguridad, deshacer ni rehacer. +- La interfaz conserva miniaturas, navegación, zoom, búsqueda y apertura mediante contraseña. +- Se muestra de forma permanente el indicador `Solo lectura` junto a `v0.0.8`. + +### Eliminado + +- Edición, inserción y eliminación de texto, imágenes, tablas, anotaciones y páginas. +- Botones Guardar, Deshacer y Rehacer, barra de herramientas de edición y atajos modificadores. +- Exportación, serialización PDF, diario de operaciones de MuPDF y mensajes de actualización del webview. +- Fabric.js y la configuración `pdfViewerEditor.defaultSaveMode`. + +### Seguridad y pruebas + +- El protocolo entre webview y extensión usa una lista explícita de mensajes permitidos y rechaza cualquier orden desconocida. +- Las pruebas verifican que el proveedor no puede escribir archivos y que el motor público no expone operaciones mutables. +- El pipeline continúa validando lint, tipos, pruebas y el paquete `pdf-viewer-editor-0.0.8.vsix`. + ## 0.0.7 - 2026-08-24 ### Corregido diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ef39d7..c4174ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,10 @@ # Contribuir -Gracias por ayudar a mejorar PDF Viewer & Editor. +Gracias por ayudar a mejorar PDF Viewer. 1. Crea una rama desde `main`. 2. Instala dependencias con `npm install`. -3. Implementa un cambio acotado en JavaScript. +3. Implementa un cambio acotado en JavaScript sin añadir operaciones que modifiquen o escriban PDFs. 4. Añade o actualiza pruebas. 5. Ejecuta `npm run check` y `npm run package`. 6. Abre un pull request explicando el comportamiento y cómo se verificó. diff --git a/README.md b/README.md index 814d172..d731ce9 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,53 @@ -# PDF Viewer & Editor - -Editor visual de PDF gratuito para Visual Studio Code, construido en JavaScript con MuPDF.js y Fabric.js. - -La versión `0.0.7` se concentra en una tarea: **hacer que el marco azul sea el área real del objeto editable, con geometría alineada al PDF**. Abre el PDF como una pestaña editable de VS Code y participa en el ciclo normal de Guardar, Guardar como, Deshacer, Rehacer, recuperación y copias de seguridad. - -> Conserva una copia del documento original cuando trabajes con archivos importantes. Un PDF describe objetos colocados en coordenadas y no siempre contiene párrafos equivalentes a los de Word; esta extensión reconstruye bloques editables a partir de la estructura visual detectada por MuPDF. - -## Edición de texto con reflujo - -- Hacer clic sobre una línea selecciona solo esa línea; arrastrar selecciona exactamente palabras o frases, sin rectángulos gigantes. -- La selección visual usa los rectángulos de caracteres extraídos del PDF para ajustarse al contenido real. -- Arrastrar sobre una palabra o frase y editar, borrar, copiar o cambiar su formato. -- Editar un párrafo completo con `Editar contenido`; el marco azul coincide con el área real y se puede redimensionar. -- Mover o redimensionar un bloque de texto con `Mover / redimensionar bloque`. -- Al cambiar la anchura de un bloque, el texto se recompone dentro del nuevo ancho. -- El editor azul conserva inicialmente la geometría exacta de la selección y permite redimensionar anchura y altura; la anchura controla el reflow y la altura final se calcula según el contenido. -- Añadir texto en cualquier punto de la página. -- Cambiar o borrar el contenido real de un bloque. -- Cambiar Helvetica/Times/Courier, tamaño, color, negrita, cursiva y alineación. -- Ajustar automáticamente las líneas al ancho del bloque. -- Desplazar hacia abajo o arriba los bloques posteriores cuando cambia la altura. -- Mantener separado el flujo de columnas mediante solapamiento geométrico. -- Ampliar la página verticalmente si el nuevo contenido necesita más espacio. -- Guardar el reemplazo como texto PDF estático, seleccionable y buscable; no como una caja blanca que oculta el original. -- Usar los puntos `+` entre párrafos para insertar texto, una imagen o una tabla en el flujo. - -Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula las nuevas líneas, escribe el bloque y vuelve a colocar los bloques inferiores en sus nuevas coordenadas. Si se borra el bloque, el contenido posterior sube para conservar el espacio lógico. - -## Imágenes - -- Insertar PNG, JPEG, WebP, BMP, GIF o TIFF en cualquier posición. -- Insertar una imagen entre párrafos y desplazar automáticamente el texto inferior. -- Mover y redimensionar imágenes insertadas. -- El marco de una imagen insertada usa la misma transformación exacta que el píxel renderizado del PDF. -- Eliminar imágenes insertadas. -- Seleccionar y eliminar permanentemente una imagen original del PDF. - -## Tablas simples - -- Elegir el número de filas y columnas al crear la tabla. -- Insertar la tabla entre párrafos sin cubrir el contenido existente. -- Dibujar su zona directamente sobre la página. -- Seleccionar una tabla creada, moverla y redimensionarla con un marco que coincide exactamente con la cuadrícula. -- La geometría exacta de cada tabla se conserva en sus metadatos, evitando que el marco de selección se desplace respecto a la cuadrícula. -- Las tablas creadas por versiones anteriores recuperan esa geometría directamente desde sus trazos PDF. -- El marco de edición de la tabla comparte el mismo sistema de coordenadas que el PDF renderizado. -- Al cambiar la altura de una tabla, el texto posterior del mismo flujo se desplaza para conservar el espacio. -- Cambiar posteriormente filas, columnas, color y grosor de línea. -- Eliminar la tabla completa con `Eliminar` o la tecla `Delete`. -- Añadir texto editable independiente dentro de cada celda con `Añadir texto`. - -## Visor e integración con VS Code +# PDF Viewer (Read Only) + +Visor local de PDF para Visual Studio Code, construido en JavaScript con MuPDF.js. + +La versión `0.0.8` convierte la extensión en un visor estricto de **solo lectura**. El PDF se puede abrir, consultar y recorrer, pero la extensión no ofrece ninguna operación para editarlo, guardarlo, exportarlo o sobrescribirlo. + +## Funciones - Renderizado local de alta calidad con MuPDF.js/WASM. -- Miniaturas, navegación, zoom de 25 % a 500 %, ajustar página y ajustar ancho. -- Zoom inicial al 100 %. -- Búsqueda en todo el documento con resaltado de resultados. -- Copiar el contenido del bloque seleccionado. -- Apertura de PDFs protegidos mediante contraseña; la contraseña solo vive en la memoria del webview. -- JavaScript incrustado en el PDF desactivado por seguridad. -- Guardado incremental o limpio, según la configuración. -- Guardar, Guardar como, Deshacer, Rehacer y recuperación nativos de VS Code. -- Todo el procesamiento ocurre localmente; el documento no se envía a ningún servidor. -- La barra de edición muestra `v0.0.7` para poder comprobar visualmente que está instalada la versión correcta del VSIX. - -## Uso rápido +- Miniaturas y navegación por páginas. +- Zoom del 25 % al 500 %, ajuste a página y ajuste al ancho. +- Búsqueda en todo el documento con resaltado del resultado activo. +- Apertura de PDFs protegidos mediante contraseña; la contraseña solo permanece en memoria. +- JavaScript incrustado en el PDF desactivado. +- Indicador visible `Solo lectura` y versión `v0.0.8`. +- Procesamiento completamente local: el documento no se envía a ningún servidor. + +## Garantía de solo lectura + +La restricción no depende únicamente de ocultar botones: + +1. El proveedor de VS Code implementa la API de editor personalizado de solo lectura y no registra eventos de cambio, guardado, Guardar como, copia de seguridad, deshacer ni rehacer. +2. El protocolo del webview solo acepta cargar el documento y mostrar mensajes; rechaza órdenes no incluidas en esa lista. +3. El motor incluido solo expone carga, autenticación, renderizado, miniaturas, límites de página y búsqueda. No contiene métodos de edición ni serialización. + +También se retiraron Fabric.js, la barra de edición, los controles de texto, imagen y tabla, y la configuración del modo de guardado. + +## Uso 1. Abre un archivo `.pdf` en VS Code. -2. Si se abre otro visor, usa el menú contextual y elige **Open with PDF Viewer & Editor**. -3. Haz clic en una línea para seleccionar esa línea, o arrastra sobre cualquier palabra o frase. -4. Pulsa `Editar selección` o `Editar contenido`; el editor azul se abre sobre la zona real correspondiente. -5. Redimensiona el marco azul si quieres modificar la anchura o altura disponible para el texto. -6. Usa `Mover / redimensionar bloque` para cambiar la posición o anchura del bloque de texto completo. -7. Cambia el tamaño de fuente: la selección se recompone y el contenido inferior se desplaza. -8. Usa el punto `+` entre párrafos para insertar texto, imagen o tabla sin solapar nada. -9. También puedes usar `Añadir texto`, `Imagen` o `Tabla` para colocar contenido libremente. -10. Guarda con `Ctrl/Cmd+S`. +2. Si se abre otro visor, usa el menú contextual y elige **Open with PDF Viewer (Read Only)**. +3. Navega con las miniaturas, los botones de página o `PageUp` y `PageDown`. +4. Ajusta el zoom o busca texto con el cuadro de búsqueda. | Acción | Atajo | |---|---| -| Guardar | `Ctrl/Cmd+S` | -| Guardar como | `Ctrl/Cmd+Shift+S` | -| Deshacer | `Ctrl/Cmd+Z` | -| Rehacer | `Ctrl/Cmd+Y` o `Ctrl/Cmd+Shift+Z` | | Buscar | `Ctrl/Cmd+F` | -| Aplicar edición de texto | `Ctrl/Cmd+Enter` | -| Eliminar selección | `Delete` | -| Página anterior/siguiente | `PageUp` / `PageDown` | -| Cancelar herramienta | `Escape` | +| Página anterior | `PageUp` | +| Página siguiente | `PageDown` | + +`Ctrl/Cmd+S`, Deshacer y Rehacer no modifican el PDF y muestran el estado de solo lectura cuando el foco está dentro del visor. ## Arquitectura | Componente | Responsabilidad | Licencia | |---|---|---| -| VS Code Custom Editor API | Pestaña editable, guardar, Guardar como, deshacer/rehacer y recuperación | Microsoft API | -| MuPDF.js 1.28 | Renderizado, extracción estructurada, eliminación real, escritura estática, imágenes, contraseñas y serialización | AGPL-3.0 | -| Fabric.js 7.4 | Selección visual, movimiento, escala y zonas de colocación | MIT | +| VS Code Custom Readonly Editor API | Abrir una pestaña de PDF sin ciclo de edición o guardado | Microsoft API | +| MuPDF.js 1.28 | Carga, contraseñas, renderizado, miniaturas y búsqueda | AGPL-3.0 | + +El identificador interno `pdfViewerEditor.editor` se conserva para que una actualización desde versiones anteriores mantenga la asociación existente de archivos PDF. ## Desarrollo @@ -105,7 +57,7 @@ Requisitos: - Visual Studio Code 1.96 o posterior. ```bash -npm install +npm ci npm run check npm run package ``` @@ -114,27 +66,23 @@ npm run package |---|---| | `npm run lint` | Valida JavaScript con ESLint | | `npm run typecheck` | Comprueba el host de la extensión con TypeScript `checkJs` | -| `npm test` | Ejecuta pruebas unitarias y de integración contra MuPDF/WASM | +| `npm test` | Ejecuta las pruebas del visor y del contrato de solo lectura | | `npm run check` | Ejecuta lint, typecheck y pruebas | -| `npm run package` | Genera `pdf-viewer-editor-0.0.7.vsix` | +| `npm run package` | Genera `pdf-viewer-editor-0.0.8.vsix` | -El script `prepare` copia MuPDF y Fabric a `media/vendor`; esa carpeta se genera y no se versiona. +El script `prepare` copia MuPDF a `media/vendor`; esa carpeta se genera y no se versiona. ## Límites conocidos -- La detección de párrafos y columnas es geométrica. Maquetaciones muy complejas pueden requerir editar fragmentos más pequeños. -- La extensión recompone texto horizontal. Texto curvo, vertical o transformado puede aproximarse. -- Para una edición estable se ofrecen las tres familias PDF estándar; una fuente incrustada arbitraria se aproxima a la familia equivalente. -- Las tablas editables son las creadas por esta extensión. Una tabla original suele ser un conjunto de trazos sin semántica de filas o columnas. -- Los textos de las celdas son bloques independientes; al borrar la cuadrícula no se borran automáticamente esos textos. -- Cualquier modificación invalida una firma digital existente. -- La edición puede estar limitada por los permisos del propio PDF. +- El contenido se presenta como páginas renderizadas; no se pueden rellenar formularios ni modificar anotaciones. +- La búsqueda depende del texto que el PDF contenga. Un documento compuesto únicamente por imágenes necesita OCR externo. +- Los límites máximos de renderizado configurables pueden reducir el zoom efectivo en páginas extraordinariamente grandes. ## Licencia y coste El proyecto es gratuito y de código abierto bajo **GNU AGPL v3 o posterior**, porque MuPDF se distribuye bajo AGPL. Para incorporar MuPDF en software cerrado, Artifex ofrece una licencia comercial. -Consulta [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) para las dependencias incluidas. +Consulta [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) para la dependencia incluida. ## Contribuir diff --git a/SECURITY.md b/SECURITY.md index f549d30..16a893c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Versiones compatibles -Mientras `0.0.1` sea la versión publicada, los problemas de seguridad se corregirán en la rama de desarrollo activa y en la siguiente versión disponible. +La versión `0.0.8` es la versión compatible. Los problemas de seguridad se corrigen en la rama de desarrollo activa y en la siguiente versión disponible. ## Informar de una vulnerabilidad @@ -20,6 +20,8 @@ No publiques PDFs sensibles ni contraseñas en una incidencia. Usa la función p - El JavaScript incluido en los PDFs se desactiva con MuPDF. - El webview aplica una Content Security Policy restrictiva. - Las contraseñas no se guardan en disco ni se envían al host de la extensión. -- Los datos OCR de castellano e inglés están incluidos. Otros idiomas pueden descargarse desde la URL configurada por el usuario. +- El proveedor usa la API de editor personalizado de solo lectura y no implementa operaciones de guardado, copia de seguridad, deshacer o rehacer. +- El motor del webview no expone edición ni serialización de documentos. +- Los mensajes procedentes del webview se validan mediante una lista explícita de operaciones permitidas. -Modificar un PDF firmado invalida su firma digital. La redacción solo es permanente después de ejecutar **Apply redactions permanently** y guardar el resultado. +La extensión `0.0.8` no modifica el archivo PDF, por lo que abrir un documento firmado no altera su firma digital. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f0b643a..440eb6a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -PDF Viewer & Editor incluye las siguientes dependencias de ejecución. Las copias de sus licencias se empaquetan dentro de `media/vendor` durante `npm install`/`npm run prepare`. +PDF Viewer incluye la siguiente dependencia de ejecución. Una copia de su licencia se empaqueta dentro de `media/vendor` durante `npm install`/`npm run prepare`. ## MuPDF.js @@ -9,10 +9,4 @@ PDF Viewer & Editor incluye las siguientes dependencias de ejecución. Las copia - Copyright: Artifex Software, Inc. - Licencia: GNU Affero General Public License v3.0 o posterior. -## Fabric.js - -- Proyecto: https://fabricjs.com/ -- Paquete: `fabric` 7.4.0 -- Licencia: MIT. - -Los nombres de productos y proyectos pertenecen a sus respectivos titulares. Esta extensión no está afiliada ni respaldada oficialmente por Artifex ni Fabric.js. +Los nombres de productos y proyectos pertenecen a sus respectivos titulares. Esta extensión no está afiliada ni respaldada oficialmente por Artifex. diff --git a/media/webview/main.js b/media/webview/main.js index 0f97568..2e6e7d5 100644 --- a/media/webview/main.js +++ b/media/webview/main.js @@ -1,93 +1,47 @@ import { PdfEngine } from './pdf-engine.js'; -import { - base64ToBytes, - bytesToBase64, - clamp, - fileToBytes, - hexToPdfColor, - invertMatrix, - normalizeRect, - pdfColorToHex, - transformPoint -} from './utils.js'; +import { base64ToBytes, clamp, transformPoint } from './utils.js'; const vscode = acquireVsCodeApi(); const engine = new PdfEngine(); const state = { - revision: 0, fileName: 'document.pdf', currentPage: 0, pageCount: 0, zoom: 1, zoomMode: 'numeric', - tool: 'edit', settings: { defaultZoom: 1, - maxRenderPixels: 24_000_000, - defaultSaveMode: 'incremental' + maxRenderPixels: 24_000_000 }, render: null, - pageModel: null, - fabricCanvas: null, - overlayObjects: [], - selected: null, - textRange: null, - textDrag: null, - textResizeObject: null, - editing: null, - insertionAnchor: null, - pendingImage: null, - pendingTable: null, - drawStart: null, - drawPreview: null, + pageBounds: null, searchQuery: '', searchResults: [], searchIndex: -1, - password: '', loadToken: 0, thumbnailToken: 0, thumbnailUrls: new Map(), busy: false, - formatBusy: false, dialogRequest: null }; const elements = Object.fromEntries([ 'app', 'toggle-pages', 'previous-page', 'next-page', 'page-number', 'page-count', 'zoom-out', 'zoom-select', 'zoom-in', 'search-input', 'search-button', - 'undo-button', 'redo-button', 'save-button', 'edit-text-tool', 'add-text-tool', - 'image-tool', 'table-tool', 'delete-tool', 'tool-hint', 'selection-context', - 'text-context', 'edit-content', 'resize-text-block', 'copy-text', 'text-font-family', 'text-font-size', - 'text-bold', 'text-italic', 'text-color', 'text-alignment', 'table-context', - 'table-rows', 'table-columns', 'table-color', 'table-border-width', 'apply-table', - 'image-context', 'image-kind', 'image-help', 'pages-sidebar', 'thumbnail-list', - 'page-viewport', 'page-stage', 'pdf-canvas', 'text-layer', 'editor-canvas', - 'insertion-layer', 'insert-menu', 'insert-text-here', 'insert-image-here', - 'insert-table-here', 'edit-range-button', - 'block-editor', 'block-editor-text', 'cancel-block-edit', 'apply-block-edit', - 'status-message', 'document-info', 'loading-overlay', 'loading-message', - 'dialog-backdrop', 'editor-dialog', 'dialog-form', 'dialog-title', - 'dialog-content', 'dialog-cancel', 'dialog-confirm', 'image-file-input' + 'pages-sidebar', 'thumbnail-list', 'page-viewport', 'page-stage', 'pdf-canvas', + 'search-layer', 'status-message', 'document-info', 'loading-overlay', + 'loading-message', 'dialog-backdrop', 'viewer-dialog', 'dialog-form', + 'dialog-title', 'dialog-content', 'dialog-cancel', 'dialog-confirm' ].map((id) => [id, document.getElementById(id)])); -const blockEditorResizeObserver = new window.ResizeObserver(() => { - if (state.editing && !elements['block-editor'].classList.contains('hidden')) { - syncEditingRectFromEditor(); - } -}); -blockEditorResizeObserver.observe(elements['block-editor-text']); - window.addEventListener('message', async (event) => { const message = event.data; if (message.type === 'load-document') { await loadDocument(message); } else if (message.type === 'operation-error') { setBusy(false); - setStatus(message.message, 'error'); - } else if (message.type === 'document-saved') { - setBusy(false); - setStatus('PDF guardado correctamente.'); + setStatus(String(message.message || 'No se pudo abrir el PDF.'), 'error'); } }); @@ -97,22 +51,21 @@ vscode.postMessage({ type: 'ready' }); async function loadDocument(message) { const token = ++state.loadToken; setBusy(true, 'Abriendo PDF…'); - cancelBlockEditor(); - clearSelection(); revokeThumbnailUrls(); + try { - state.revision = Number(message.revision || 0); + if (typeof message.data !== 'string') { + throw new Error('No se recibieron datos PDF válidos.'); + } + state.fileName = String(message.fileName || 'document.pdf'); state.settings = { ...state.settings, ...(message.settings || {}) }; + state.currentPage = 0; state.zoom = Number(state.settings.defaultZoom || 1); state.zoomMode = 'numeric'; - state.password = ''; state.searchQuery = ''; state.searchResults = []; state.searchIndex = -1; - state.pendingImage = null; - state.pendingTable = null; - setTool('edit'); elements['search-input'].value = ''; syncZoomControl(); @@ -123,7 +76,6 @@ async function loadDocument(message) { if (password === null) { throw new Error('Se necesita una contraseña para abrir este PDF.'); } - state.password = password; if (engine.unlock(password)) { info = engine.getDocumentInfo(); } else { @@ -134,12 +86,12 @@ async function loadDocument(message) { if (token !== state.loadToken) { return; } + state.pageCount = info.pageCount; - state.currentPage = clamp(state.currentPage, 0, Math.max(0, state.pageCount - 1)); await renderCurrentPage(); void renderThumbnails(); elements.app.setAttribute('aria-busy', 'false'); - setStatus('Haz clic en un párrafo, selecciona una frase o usa + para insertar.'); + setStatus('Solo lectura: el PDF original no se puede modificar.'); } catch (error) { reportError(error); } finally { @@ -149,31 +101,24 @@ async function loadDocument(message) { } } -async function renderCurrentPage(options = {}) { +async function renderCurrentPage() { if (state.pageCount === 0) { return; } - hideInsertMenu(); + setBusy(true, `Renderizando página ${state.currentPage + 1}…`); try { - state.pageModel = engine.getPageModel(state.currentPage); - const scale = calculateRenderScale(state.pageModel.bounds); + state.pageBounds = engine.getPageBounds(state.currentPage); + const scale = calculateRenderScale(state.pageBounds); state.render = engine.renderPage( state.currentPage, scale, - Number(state.settings.maxRenderPixels), - true + Number(state.settings.maxRenderPixels) ); drawRenderedPage(); - initializeOrResizeFabricCanvas(); - buildTextLayer(); - buildEditingOverlay(); - buildInsertionLayer(); + buildSearchLayer(); updatePageControls(); updateDocumentInfo(); - if (!restoreSelection(options)) { - clearSelection(); - } } finally { setBusy(false); } @@ -184,6 +129,7 @@ function calculateRenderScale(bounds) { const height = Math.max(1, bounds[3] - bounds[1]); const viewportWidth = Math.max(200, elements['page-viewport'].clientWidth - 48); const viewportHeight = Math.max(200, elements['page-viewport'].clientHeight - 48); + if (state.zoomMode === 'fit') { return Math.min(viewportWidth / width, viewportHeight / height); } @@ -209,1440 +155,73 @@ function drawRenderedPage() { elements['page-stage'].style.height = `${state.render.height}px`; } -function initializeOrResizeFabricCanvas() { - if (!state.fabricCanvas) { - state.fabricCanvas = new fabric.Canvas('editor-canvas', { - width: state.render.width, - height: state.render.height, - preserveObjectStacking: true, - selection: false, - enableRetinaScaling: false, - stopContextMenu: true - }); - state.fabricCanvas.on('selection:created', selectionChanged); - state.fabricCanvas.on('selection:updated', selectionChanged); - state.fabricCanvas.on('selection:cleared', () => { - if (!state.editing && state.selected?.kind !== 'text') { - clearSelection(); - } - }); - state.fabricCanvas.on('mouse:over', overlayMouseOver); - state.fabricCanvas.on('mouse:out', overlayMouseOut); - state.fabricCanvas.on('mouse:down', overlayMouseDown); - state.fabricCanvas.on('mouse:move', overlayMouseMove); - state.fabricCanvas.on('mouse:up', overlayMouseUp); - state.fabricCanvas.on('mouse:dblclick', overlayDoubleClick); - state.fabricCanvas.on('object:modified', overlayObjectModified); - } else { - state.fabricCanvas.setDimensions({ - width: state.render.width, - height: state.render.height - }); - } - syncFabricCanvasGeometry(); - state.fabricCanvas.clear(); - state.overlayObjects = []; - state.textResizeObject = null; -} - -function syncFabricCanvasGeometry() { - if (!state.fabricCanvas || !state.render) { - return; - } - const width = `${state.render.width}px`; - const height = `${state.render.height}px`; - const wrapper = state.fabricCanvas.wrapperEl; - if (wrapper) { - Object.assign(wrapper.style, { - position: 'absolute', - left: '0px', - top: '0px', - width, - height, - margin: '0px' - }); - } - for (const canvas of [state.fabricCanvas.lowerCanvasEl, state.fabricCanvas.upperCanvasEl]) { - if (!canvas) { - continue; - } - Object.assign(canvas.style, { - left: '0px', - top: '0px', - width, - height, - margin: '0px' - }); - } -} - -function buildTextLayer() { - const layer = elements['text-layer']; +function buildSearchLayer() { + const layer = elements['search-layer']; layer.replaceChildren(); layer.style.width = `${state.render.width}px`; layer.style.height = `${state.render.height}px`; - layer.classList.toggle('disabled', state.tool !== 'edit'); - layer.classList.toggle('placement-mode', state.tool !== 'edit'); - for (const line of state.pageModel.textLines) { - const screen = pdfRectToScreen(line.rect); - const item = document.createElement('span'); - item.className = 'text-layer-line'; - item.textContent = line.text; - item.dataset.blockIndex = String(line.blockIndex); - item.dataset.lineIndex = String(line.lineIndex); - item.dataset.textStart = String(line.textStart); - item.dataset.textEnd = String(line.textEnd); - item.title = 'Haz clic para seleccionar la línea; arrastra para seleccionar texto exacto'; - item.style.left = `${screen[0]}px`; - item.style.top = `${screen[1]}px`; - item.style.width = `${Math.max(1, screen[2] - screen[0])}px`; - item.style.height = `${Math.max(1, screen[3] - screen[1])}px`; - item.addEventListener('pointerdown', (event) => beginTextSelection(event, line)); - item.addEventListener('pointermove', updateTextSelectionDrag); - item.addEventListener('pointerup', finishTextSelection); - item.addEventListener('pointercancel', cancelTextSelectionDrag); - item.addEventListener('dblclick', (event) => selectWordAtPointer(event, line)); - layer.appendChild(item); - } -} -function beginTextSelection(event, line) { - if (state.tool !== 'edit' || state.busy || state.editing || event.button !== 0) { - return; - } - event.preventDefault(); - hideInsertMenu(); - clearNativeTextSelection(); - setTextResizeMode(false); - const position = textOffsetAtScreenPoint(line.blockIndex, event.clientX, event.clientY); - if (!position) { - return; - } - state.textDrag = { - pointerId: event.pointerId, - blockIndex: line.blockIndex, - lineStart: line.textStart, - lineEnd: line.textEnd, - anchorOffset: position.offset, - lastOffset: position.offset, - startX: event.clientX, - startY: event.clientY, - moved: false - }; - const target = /** @type {HTMLElement | null} */ (event.currentTarget); - target?.setPointerCapture(event.pointerId); -} - -function updateTextSelectionDrag(event) { - const drag = state.textDrag; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY); - if (!drag.moved && distance < 3) { - return; - } - drag.moved = true; - const position = textOffsetAtScreenPoint( - drag.blockIndex, - event.clientX, - event.clientY - ); - if (!position) { - return; - } - drag.lastOffset = position.offset; - setTextRange(drag.blockIndex, drag.anchorOffset, drag.lastOffset, { quiet: true }); -} - -function finishTextSelection(event) { - const drag = state.textDrag; - if (!drag || drag.pointerId !== event.pointerId) { - return; - } - state.textDrag = null; - const target = /** @type {HTMLElement | null} */ (event.currentTarget); - if (target?.hasPointerCapture(event.pointerId)) { - target.releasePointerCapture(event.pointerId); - } - if (drag.moved) { - setTextRange(drag.blockIndex, drag.anchorOffset, drag.lastOffset); - } else { - setTextRange(drag.blockIndex, drag.lineStart, drag.lineEnd); - } -} - -function cancelTextSelectionDrag(event) { - if (state.textDrag?.pointerId === event.pointerId) { - state.textDrag = null; - } -} - -function selectWordAtPointer(event, line) { - if (state.tool !== 'edit' || state.busy || state.editing) { - return; - } - event.preventDefault(); - const position = textOffsetAtScreenPoint(line.blockIndex, event.clientX, event.clientY); - const block = state.pageModel?.textBlocks.find((candidate) => - candidate.index === line.blockIndex - ); - if (!position || !block?.text) { - return; - } - let index = clamp(position.offset, 0, Math.max(0, block.text.length - 1)); - if (!isWordCharacter(block.text[index]) && index > 0 && isWordCharacter(block.text[index - 1])) { - index -= 1; - } - if (!isWordCharacter(block.text[index])) { - setTextRange(block.index, index, Math.min(block.text.length, index + 1)); - return; - } - let start = index; - let end = index + 1; - while (start > 0 && isWordCharacter(block.text[start - 1])) { - start -= 1; - } - while (end < block.text.length && isWordCharacter(block.text[end])) { - end += 1; - } - setTextRange(block.index, start, end); -} - -function isWordCharacter(character) { - return Boolean(character && /[\p{L}\p{N}\p{M}_]/u.test(character)); -} - -function textOffsetAtScreenPoint(blockIndex, clientX, clientY) { - const block = state.pageModel?.textBlocks.find((candidate) => - candidate.index === blockIndex - ); - if (!block) { - return null; - } - const stageRect = elements['page-stage'].getBoundingClientRect(); - const point = [clientX - stageRect.left, clientY - stageRect.top]; - const lines = block.visualLines || []; - const line = lines.reduce((closest, candidate) => { - const rect = pdfRectToScreen(candidate.rect); - const distance = distanceToRect(point, rect); - return !closest || distance < closest.distance - ? { line: candidate, distance } - : closest; - }, null)?.line; - if (!line) { - return null; - } - - const characters = (line.characters || []) - .filter((character) => - Array.isArray(character.rect) && character.rect.length === 4 && - Number.isFinite(character.start) && Number.isFinite(character.end) - ) - .sort((left, right) => left.start - right.start); - if (characters.length === 0) { - const screen = pdfRectToScreen(line.rect); - const ratio = clamp((point[0] - screen[0]) / Math.max(1, screen[2] - screen[0]), 0, 1); - return { - blockIndex, - offset: Math.round(line.textStart + ratio * line.text.length) - }; - } - - const closest = characters.reduce((result, character) => { - const rect = pdfRectToScreen(character.rect); - const distance = distanceToRect(point, rect); - return !result || distance < result.distance - ? { character, rect, distance } - : result; - }, null); - const offset = point[0] < (closest.rect[0] + closest.rect[2]) / 2 - ? closest.character.start - : closest.character.end; - return { blockIndex, offset }; -} - -function distanceToRect(point, rect) { - const dx = Math.max(rect[0] - point[0], 0, point[0] - rect[2]); - const dy = Math.max(rect[1] - point[1], 0, point[1] - rect[3]); - return Math.hypot(dx, dy); -} - -function selectTextBlock(blockIndex, options = {}) { - const block = state.pageModel?.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - return false; - } - if (!options.preserveRange) { - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - } - const meta = { - kind: 'text', - rect: block.rect, - blockIndex: block.index, - block - }; - state.selected = meta; - if (state.fabricCanvas?.getActiveObject()) { - state.fabricCanvas.discardActiveObject(); - state.fabricCanvas.requestRenderAll(); - } - selectMeta(meta); - return true; -} - -function setTextRange(blockIndex, start, end, options = {}) { - const block = state.pageModel?.textBlocks.find((candidate) => - candidate.index === blockIndex - ); - if (!block) { - return false; - } - const rangeStart = clamp(Math.min(start, end), 0, block.text.length); - const rangeEnd = clamp(Math.max(start, end), 0, block.text.length); - if (rangeEnd <= rangeStart) { - state.textRange = null; - clearTextRangeHighlights(); - hideEditRangeButton(); - return false; - } - state.textRange = { - blockIndex: block.index, - start: rangeStart, - end: rangeEnd, - text: block.text.slice(rangeStart, rangeEnd) - }; - renderTextRangeHighlights(); - selectTextBlock(block.index, { preserveRange: true }); - showEditRangeButton(getTextRangeRects(state.textRange)); - if (!options.quiet) { - setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); - } - return true; -} - -function showEditRangeButton(pdfRects) { - if (!Array.isArray(pdfRects) || pdfRects.length === 0) { - hideEditRangeButton(); - return; - } - const button = elements['edit-range-button']; - const selectionRect = pdfRects.map(pdfRectToScreen).reduce((result, rect) => [ - Math.min(result[0], rect[0]), - Math.min(result[1], rect[1]), - Math.max(result[2], rect[2]), - Math.max(result[3], rect[3]) - ]); - const width = 126; - button.style.left = `${clamp( - selectionRect[0] + (selectionRect[2] - selectionRect[0]) / 2 - width / 2, - 4, - Math.max(4, state.render.width - width - 4) - )}px`; - button.style.top = `${clamp( - selectionRect[3] + 5, - 4, - Math.max(4, state.render.height - 34) - )}px`; - button.classList.remove('hidden'); -} - -function hideEditRangeButton() { - elements['edit-range-button'].classList.add('hidden'); -} - -function clearTextRangeHighlights() { - for (const highlight of elements['text-layer'].querySelectorAll('.text-range-highlight')) { - highlight.remove(); - } -} - -function getTextRangeRects(selectedRange) { - if (!selectedRange || !state.pageModel) { - return []; - } - - const rects = []; - const lines = state.pageModel.textLines.filter((line) => - line.blockIndex === selectedRange.blockIndex && - selectedRange.end > line.textStart && - selectedRange.start < line.textEnd - ); - - for (const line of lines) { - const localStart = clamp(selectedRange.start - line.textStart, 0, line.text.length); - const localEnd = clamp(selectedRange.end - line.textStart, 0, line.text.length); - if (localEnd <= localStart) { - continue; - } - - const characters = Array.isArray(line.characters) ? line.characters : []; - const selectedCharacters = characters - .filter((character) => - Number.isFinite(character.start) && Number.isFinite(character.end) && - character.end > selectedRange.start && character.start < selectedRange.end - ) - .map((character) => character.rect) - .filter((candidate) => Array.isArray(candidate) && candidate.length === 4); - - if (selectedCharacters.length > 0) { - rects.push(selectedCharacters.reduce((result, candidate) => result - ? [ - Math.min(result[0], candidate[0]), - Math.min(result[1], candidate[1]), - Math.max(result[2], candidate[2]), - Math.max(result[3], candidate[3]) - ] - : [...candidate], null)); - continue; + state.searchResults.forEach((result, index) => { + if (result.pageIndex !== state.currentPage) { + return; } - const lineWidth = Math.max(1, line.rect[2] - line.rect[0]); - const startRatio = localStart / Math.max(1, line.text.length); - const endRatio = localEnd / Math.max(1, line.text.length); - rects.push([ - line.rect[0] + lineWidth * startRatio, - line.rect[1], - line.rect[0] + lineWidth * endRatio, - line.rect[3] - ]); - } - - return rects; -} - -function getTextRangeRect(selectedRange) { - const rects = getTextRangeRects(selectedRange); - if (rects.length === 0) { - return null; - } - return rects.reduce((result, rect) => [ - Math.min(result[0], rect[0]), - Math.min(result[1], rect[1]), - Math.max(result[2], rect[2]), - Math.max(result[3], rect[3]) - ], [...rects[0]]); -} - -function renderTextRangeHighlights() { - clearTextRangeHighlights(); - for (const rect of getTextRangeRects(state.textRange)) { - const screen = pdfRectToScreen(rect); + const screenRect = pdfRectToScreen(result.rect); const highlight = document.createElement('div'); - highlight.className = 'text-range-highlight'; - highlight.style.left = `${screen[0]}px`; - highlight.style.top = `${screen[1]}px`; - highlight.style.width = `${Math.max(1, screen[2] - screen[0])}px`; - highlight.style.height = `${Math.max(1, screen[3] - screen[1])}px`; - elements['text-layer'].appendChild(highlight); - } -} - -function setTextResizeMode(active) { - if (!active && state.textResizeObject && state.fabricCanvas) { - state.fabricCanvas.remove(state.textResizeObject); - state.overlayObjects = state.overlayObjects.filter(({ object }) => - object !== state.textResizeObject - ); - state.textResizeObject = null; - state.fabricCanvas.requestRenderAll(); - } - const wrapper = state.fabricCanvas?.wrapperEl; - if (wrapper) { - wrapper.style.zIndex = active ? '6' : '3'; - } - elements['text-layer'].classList.toggle('resize-mode', active); - elements['insertion-layer'].classList.toggle('resize-mode', active); -} - -function activateTextResizeMode() { - const selected = state.selected; - if (selected?.kind !== 'text' || state.busy) { - return; - } - - setTextResizeMode(false); - setTextResizeMode(true); - const resizeMeta = { ...selected, resizeMode: true }; - const object = addOverlayObject(resizeMeta, { - stroke: '#1473e6', - fill: 'rgba(20, 115, 230, 0.025)', - movable: true, - resizable: true + highlight.className = 'search-highlight'; + if (index === state.searchIndex) { + highlight.classList.add('current'); + } + highlight.style.left = `${screenRect[0]}px`; + highlight.style.top = `${screenRect[1]}px`; + highlight.style.width = `${Math.max(1, screenRect[2] - screenRect[0])}px`; + highlight.style.height = `${Math.max(1, screenRect[3] - screenRect[1])}px`; + layer.appendChild(highlight); }); - state.textResizeObject = object; - state.fabricCanvas.setActiveObject(object); - object.set('stroke', object.editorStroke); - state.fabricCanvas.requestRenderAll(); - setStatus('Arrastra el marco para mover el texto o sus esquinas para cambiar la anchura; al soltar se recompone el texto.'); -} - -function buildInsertionLayer() { - const layer = elements['insertion-layer']; - layer.replaceChildren(); - layer.style.width = `${state.render.width}px`; - layer.style.height = `${state.render.height}px`; - const blocks = [...state.pageModel.textBlocks].sort((left, right) => - left.rect[1] - right.rect[1] || left.rect[0] - right.rect[0] - ); - const bounds = state.pageModel.bounds; - const anchors = []; - - if (blocks.length === 0) { - anchors.push({ - x: bounds[0] + 24, - y: bounds[1] + 36, - width: Math.max(100, bounds[2] - bounds[0] - 48), - displayY: bounds[1] + 36, - label: 'Insertar contenido en la página' - }); - } else { - const first = blocks[0]; - anchors.push({ - x: first.rect[0], - y: Math.max(bounds[1] + 6, first.rect[1] - 10), - width: Math.max(80, first.rect[2] - first.rect[0]), - displayY: Math.max(bounds[1] + 6, first.rect[1] - 10), - label: 'Insertar antes del primer párrafo' - }); - for (let index = 1; index < blocks.length; index += 1) { - const previous = blocks[index - 1]; - const current = blocks[index]; - if (!rectanglesShareScreenFlow(previous.rect, current.rect)) { - continue; - } - const displayY = Math.max( - previous.rect[3] + 2, - (previous.rect[3] + current.rect[1]) / 2 - ); - anchors.push({ - x: current.rect[0], - y: displayY, - width: Math.max(80, current.rect[2] - current.rect[0]), - displayY, - label: `Insertar antes de “${current.text.slice(0, 36)}”` - }); - } - const last = blocks.at(-1); - const afterY = Math.min(bounds[3] - 8, last.rect[3] + 10); - anchors.push({ - x: last.rect[0], - y: afterY, - width: Math.max(80, last.rect[2] - last.rect[0]), - displayY: afterY, - label: 'Insertar después del último párrafo' - }); - } - - const usedPositions = []; - for (const anchor of anchors) { - const screenPoint = pdfPointToScreen([anchor.x, anchor.displayY]); - if (usedPositions.some((position) => Math.abs(position - screenPoint[1]) < 8)) { - continue; - } - usedPositions.push(screenPoint[1]); - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'insertion-marker'; - button.setAttribute('aria-label', anchor.label); - button.title = `${anchor.label}: texto, imagen o tabla`; - button.style.left = `${Math.max(2, screenPoint[0] - 24)}px`; - button.style.top = `${clamp(screenPoint[1] - 9, 1, state.render.height - 19)}px`; - button.style.width = `${Math.min( - state.render.width - Math.max(2, screenPoint[0] - 24) - 2, - Math.max(54, anchor.width * state.render.scale + 24) - )}px`; - button.innerHTML = '+'; - button.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - openInsertMenu(anchor, button); - }); - layer.appendChild(button); - } -} - -function rectanglesShareScreenFlow(left, right) { - const overlap = Math.max(0, Math.min(left[2], right[2]) - Math.max(left[0], right[0])); - const minimumWidth = Math.max(1, Math.min(left[2] - left[0], right[2] - right[0])); - return overlap / minimumWidth >= 0.25; -} - -function pdfPointToScreen(point) { - return transformPoint(state.render.pageToScreen, point); -} - -function openInsertMenu(anchor, marker) { - state.insertionAnchor = anchor; - const menu = elements['insert-menu']; - const markerRect = marker.getBoundingClientRect(); - const stageRect = elements['page-stage'].getBoundingClientRect(); - menu.classList.remove('hidden'); - const menuWidth = Math.max(220, menu.offsetWidth); - menu.style.left = `${clamp( - markerRect.left - stageRect.left, - 4, - Math.max(4, state.render.width - menuWidth - 4) - )}px`; - menu.style.top = `${clamp( - markerRect.bottom - stageRect.top + 3, - 4, - Math.max(4, state.render.height - menu.offsetHeight - 4) - )}px`; -} - -function hideInsertMenu(options = {}) { - elements['insert-menu'].classList.add('hidden'); - if (!options.preserveAnchor) { - state.insertionAnchor = null; - } -} - -function buildEditingOverlay() { - const currentHits = state.searchResults.filter((result) => - result.pageIndex === state.currentPage - ); - for (const hit of currentHits) { - addOverlayObject({ kind: 'search-hit', rect: hit.rect }, { - fill: 'rgba(255, 215, 0, 0.28)', - stroke: 'rgba(160, 120, 0, 0.85)', - selectable: false, - evented: false - }); - } - - for (const image of state.pageModel.images) { - addOverlayObject({ kind: 'native-image', rect: image.rect, image }, { - stroke: '#7c3aed', - movable: false, - resizable: false - }); - } - - for (const annotation of state.pageModel.annotations) { - if (annotation.table) { - addOverlayObject({ - kind: 'table', - rect: annotation.rect, - annotationIndex: annotation.index, - table: annotation.table - }, { - stroke: '#0e7490', - movable: true, - resizable: true - }); - } else if (annotation.type === 'Stamp') { - addOverlayObject({ - kind: 'inserted-image', - rect: annotation.rect, - annotationIndex: annotation.index, - annotation - }, { - stroke: '#7c3aed', - movable: true, - resizable: true - }); - } - } - - state.fabricCanvas.requestRenderAll(); -} - -function addOverlayObject(meta, options = {}) { - const screen = pdfRectToScreen(meta.rect); - const color = options.stroke || '#1473e6'; - const object = new fabric.Rect({ - left: screen[0], - top: screen[1], - width: Math.max(2, screen[2] - screen[0]), - height: Math.max(2, screen[3] - screen[1]), - originX: 'left', - originY: 'top', - strokeUniform: true, - centeredScaling: false, - padding: 0, - fill: options.fill || 'rgba(0, 0, 0, 0.001)', - stroke: options.selectable === false ? color : 'rgba(0, 0, 0, 0)', - strokeWidth: 1.25, - strokeDashArray: meta.kind === 'text' ? [4, 3] : null, - transparentCorners: false, - cornerColor: color, - cornerStrokeColor: '#ffffff', - cornerSize: 9, - borderColor: color, - hasRotatingPoint: false, - lockRotation: true, - selectable: options.selectable !== false, - evented: options.evented !== false, - hasControls: Boolean(options.resizable), - lockMovementX: !options.movable, - lockMovementY: !options.movable, - lockScalingX: !options.resizable, - lockScalingY: !options.resizable, - lockScalingFlip: true, - minScaleLimit: 0.02, - hoverCursor: options.movable ? 'move' : 'pointer' - }); - object.editorMeta = meta; - object.editorStroke = color; - object.setControlsVisibility({ mtr: false }); - state.fabricCanvas.add(object); - state.overlayObjects.push({ meta, object }); - return object; -} - -function overlayMouseOver(event) { - const object = event.target; - if (!object?.editorMeta || object === state.fabricCanvas.getActiveObject()) { - return; - } - object.set('stroke', object.editorStroke); - state.fabricCanvas.requestRenderAll(); -} - -function overlayMouseOut(event) { - const object = event.target; - if (!object?.editorMeta || object === state.fabricCanvas.getActiveObject()) { - return; - } - object.set('stroke', 'rgba(0, 0, 0, 0)'); - state.fabricCanvas.requestRenderAll(); -} - -function selectionChanged(event) { - const object = event.selected?.[0]; - if (!object?.editorMeta) { - clearSelection(); - return; - } - selectMeta(object.editorMeta); -} - -function selectMeta(meta) { - state.selected = meta; - if (meta.kind !== 'text') { - setTextResizeMode(false); - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - clearNativeTextSelection(); - } - elements['delete-tool'].disabled = false; - elements['selection-context'].classList.remove('hidden'); - elements['text-context'].classList.toggle('hidden', meta.kind !== 'text'); - elements['table-context'].classList.toggle('hidden', meta.kind !== 'table'); - elements['image-context'].classList.toggle( - 'hidden', - !['native-image', 'inserted-image'].includes(meta.kind) - ); - - if (meta.kind === 'text') { - setTextControlsFromBlock(meta.block); - setStatus(state.textRange - ? 'Texto seleccionado: edítalo, cambia su formato o elimínalo.' - : 'Párrafo seleccionado: pulsa “Editar contenido” o selecciona una frase.'); - } else if (meta.kind === 'table') { - elements['table-rows'].value = String(meta.table.rows); - elements['table-columns'].value = String(meta.table.columns); - elements['table-color'].value = pdfColorToHex(meta.table.color); - elements['table-border-width'].value = String(meta.table.borderWidth); - setStatus('Tabla seleccionada: cambia filas/columnas, muévela o elimínala.'); - } else if (meta.kind === 'inserted-image') { - elements['image-kind'].textContent = 'Imagen insertada'; - elements['image-help'].textContent = 'Arrastra para mover, usa las esquinas para redimensionar o pulsa Imagen para reemplazarla.'; - setStatus('Imagen seleccionada: puedes moverla, redimensionarla, reemplazarla o eliminarla.'); - } else { - elements['image-kind'].textContent = 'Imagen original'; - elements['image-help'].textContent = 'Pulsa Imagen para reemplazarla manteniendo su zona, o Eliminar para quitarla.'; - setStatus('Imagen original seleccionada: puedes reemplazarla o eliminarla.'); - } -} - -function clearSelection() { - state.selected = null; - state.textRange = null; - state.textDrag = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - setTextResizeMode(false); - elements['delete-tool'].disabled = true; - elements['selection-context'].classList.add('hidden'); - elements['text-context'].classList.add('hidden'); - elements['table-context'].classList.add('hidden'); - elements['image-context'].classList.add('hidden'); - if (state.fabricCanvas?.getActiveObject()) { - state.fabricCanvas.discardActiveObject(); - state.fabricCanvas.requestRenderAll(); - } -} - -function clearNativeTextSelection() { - window.getSelection()?.removeAllRanges(); -} - -function overlayMouseDown(event) { - if (state.busy) { - return; - } - const pointer = state.fabricCanvas.getScenePoint(event.e); - if (state.tool === 'add-text') { - event.e.preventDefault(); - openNewTextEditor(screenPointToPdf(pointer)); - return; - } - if (state.tool === 'place-image' && state.pendingImage) { - event.e.preventDefault(); - void placePendingImage(screenPointToPdf(pointer)); - return; - } - if (state.tool === 'table' && state.pendingTable) { - event.e.preventDefault(); - state.drawStart = pointer; - state.drawPreview = new fabric.Rect({ - left: pointer.x, - top: pointer.y, - width: 1, - height: 1, - fill: 'rgba(14, 116, 144, 0.08)', - stroke: '#0e7490', - strokeDashArray: [6, 4], - selectable: false, - evented: false - }); - state.fabricCanvas.add(state.drawPreview); - } -} - -function overlayMouseMove(event) { - if (!state.drawStart || !state.drawPreview) { - return; - } - const pointer = state.fabricCanvas.getScenePoint(event.e); - state.drawPreview.set({ - left: Math.min(state.drawStart.x, pointer.x), - top: Math.min(state.drawStart.y, pointer.y), - width: Math.abs(pointer.x - state.drawStart.x), - height: Math.abs(pointer.y - state.drawStart.y) - }); - state.drawPreview.setCoords(); - state.fabricCanvas.requestRenderAll(); -} - -function overlayMouseUp(event) { - if (!state.drawStart || !state.pendingTable) { - return; - } - const pointer = state.fabricCanvas.getScenePoint(event.e); - const start = state.drawStart; - let screenRect = normalizeRect([start.x, start.y, pointer.x, pointer.y]); - if (screenRect[2] - screenRect[0] < 40 || screenRect[3] - screenRect[1] < 30) { - screenRect = [ - start.x, - start.y, - Math.min(state.render.width - 8, start.x + 260), - Math.min(state.render.height - 8, start.y + 140) - ]; - } - state.fabricCanvas.remove(state.drawPreview); - state.drawPreview = null; - state.drawStart = null; - const table = state.pendingTable; - state.pendingTable = null; - setTool('edit'); - engine.insertTableBlock( - state.currentPage, - screenRectToPdf(screenRect), - table.rows, - table.columns, - { color: table.color, borderWidth: table.borderWidth } - ); - void commitAndRefresh('Añadir tabla', { restoreKind: 'table-last' }); -} - -function overlayDoubleClick(event) { - const meta = event.target?.editorMeta; - if (meta?.kind === 'table') { - void applyTableProperties(); - } -} - -async function overlayObjectModified(event) { - const object = event.target; - const meta = object?.editorMeta; - if (!meta || state.busy) { - return; - } - const targetRect = objectScreenRectToPdf(object); - try { - if (meta.kind === 'inserted-image') { - engine.updateAnnotationRect(state.currentPage, meta.annotationIndex, targetRect); - await commitAndRefresh('Mover o redimensionar imagen', { - restoreKind: 'image-last' - }); - } else if (meta.kind === 'text' && meta.resizeMode) { - setTextResizeMode(false); - engine.resizeTextBlock(state.currentPage, meta.blockIndex, targetRect); - await commitAndRefresh('Mover o redimensionar texto', { - restoreText: meta.block.text - }); - } else if (meta.kind === 'table') { - engine.updateTable( - state.currentPage, - meta.annotationIndex, - targetRect, - meta.table.rows, - meta.table.columns, - { color: meta.table.color, borderWidth: meta.table.borderWidth } - ); - await commitAndRefresh('Mover o redimensionar tabla', { - restoreKind: 'table-last' - }); - } - } catch (error) { - reportError(error); - await renderCurrentPage(); - } } function pdfRectToScreen(rect) { - const [x0, y0, x1, y1] = normalizeRect(rect); - const topLeft = pdfPointToScreen([x0, y0]); - const bottomRight = pdfPointToScreen([x1, y1]); - return normalizeRect([...topLeft, ...bottomRight]); + const first = transformPoint(state.render.pageToScreen, [rect[0], rect[1]]); + const second = transformPoint(state.render.pageToScreen, [rect[2], rect[3]]); + return [ + Math.min(first[0], second[0]), + Math.min(first[1], second[1]), + Math.max(first[0], second[0]), + Math.max(first[1], second[1]) + ]; } -function screenRectToPdf(rect) { - const topLeft = screenPointToPdf({ x: rect[0], y: rect[1] }); - const bottomRight = screenPointToPdf({ x: rect[2], y: rect[3] }); - return normalizeRect([...topLeft, ...bottomRight]); -} - -function screenPointToPdf(point) { - return transformPoint( - invertMatrix(state.render.pageToScreen), - [Number(point.x), Number(point.y)] - ); -} - -function objectScreenRectToPdf(object) { - // All editable Fabric objects are axis-aligned with a left/top origin. - // Using the object's content box (not getBoundingRect, which includes the - // visual stroke) keeps the blue frame and the PDF target rectangle identical. - const left = Number(object.left || 0); - const top = Number(object.top || 0); - const width = Math.max(2, Number(object.width || 0) * Math.abs(Number(object.scaleX || 1))); - const height = Math.max(2, Number(object.height || 0) * Math.abs(Number(object.scaleY || 1))); - return screenRectToPdf([left, top, left + width, top + height]); -} - -function setTextControlsFromBlock(block) { - elements['text-font-family'].value = block.font.editableFamily || 'Helvetica'; - elements['text-font-size'].value = String(Number(block.font.size || 12).toFixed(1)); - setToggle(elements['text-bold'], Boolean(block.font.bold || block.font.weight === 'bold')); - setToggle(elements['text-italic'], Boolean(block.font.italic || block.font.style === 'italic')); - elements['text-color'].value = pdfColorToHex(block.color, '#000000'); - elements['text-alignment'].value = String(block.alignment || 0); -} - -function setDefaultTextControls() { - elements['text-font-family'].value = 'Helvetica'; - elements['text-font-size'].value = '12'; - setToggle(elements['text-bold'], false); - setToggle(elements['text-italic'], false); - elements['text-color'].value = '#000000'; - elements['text-alignment'].value = '0'; -} - -function getTextFormat() { - return { - fontFamily: elements['text-font-family'].value, - fontSize: clamp(Number(elements['text-font-size'].value || 12), 4, 144), - bold: elements['text-bold'].getAttribute('aria-pressed') === 'true', - italic: elements['text-italic'].getAttribute('aria-pressed') === 'true', - color: hexToPdfColor(elements['text-color'].value), - alignment: Number(elements['text-alignment'].value || 0) - }; -} - -function setToggle(button, active) { - button.setAttribute('aria-pressed', String(active)); -} - -function toggleFormatButton(button) { - setToggle(button, button.getAttribute('aria-pressed') !== 'true'); - void textFormatChanged(); -} - -async function textFormatChanged() { - syncBlockEditorPreview(); - if (state.editing || state.formatBusy || state.selected?.kind !== 'text') { - return; - } - state.formatBusy = true; - const block = state.selected.block; - const selectedRange = state.textRange; - try { - if (selectedRange?.blockIndex === block.index) { - engine.editTextRange( - state.currentPage, - block.index, - selectedRange.start, - selectedRange.end, - selectedRange.text, - getTextFormat() - ); - await commitAndRefresh('Cambiar formato de la selección', { - restoreText: selectedRange.text - }); - } else { - engine.editTextBlock(state.currentPage, block.index, { - text: block.text, - ...getTextFormat() - }); - await commitAndRefresh('Cambiar formato de texto', { restoreText: block.text }); - } - } catch (error) { - reportError(error); - } finally { - state.formatBusy = false; - } -} - -function openExistingTextEditor(block) { - state.textRange = null; - hideEditRangeButton(); - clearNativeTextSelection(); - state.selected = { - kind: 'text', - rect: block.rect, - blockIndex: block.index, - block - }; - setTextControlsFromBlock(block); - showTextContext(); - openBlockEditor({ - mode: 'edit', - blockIndex: block.index, - rect: block.rect, - text: block.text - }); -} - -function openSelectedRangeEditor() { - const selectedRange = state.textRange; - if (!selectedRange || state.busy) { - return; - } - const block = state.pageModel.textBlocks.find((candidate) => - candidate.index === selectedRange.blockIndex - ); - if (!block) { - return; - } - setTextControlsFromBlock(block); - showTextContext(); - openBlockEditor({ - mode: 'range', - blockIndex: block.index, - start: selectedRange.start, - end: selectedRange.end, - sourceRect: block.rect, - rect: getTextRangeRect(selectedRange) || block.rect, - text: selectedRange.text - }); -} - -function openNewTextEditor(pointOrAnchor) { - const bounds = state.pageModel.bounds; - const point = Array.isArray(pointOrAnchor) - ? pointOrAnchor - : [pointOrAnchor.x, pointOrAnchor.y]; - const x = clamp(point[0], bounds[0] + 8, bounds[2] - 70); - const y = clamp(point[1], bounds[1] + 8, bounds[3] - 24); - const requestedWidth = Array.isArray(pointOrAnchor) ? null : pointOrAnchor.width; - const width = Math.min( - requestedWidth || 300, - Math.max(60, bounds[2] - x - 24) - ); - setDefaultTextControls(); - showTextContext(); - openBlockEditor({ - mode: 'add', - blockIndex: null, - rect: [x, y, x + width, Math.min(bounds[3] - 8, y + 72)], - text: '' - }); - setTool('edit'); -} - -function showTextContext() { - elements['selection-context'].classList.remove('hidden'); - elements['text-context'].classList.remove('hidden'); - elements['table-context'].classList.add('hidden'); - elements['image-context'].classList.add('hidden'); -} - -function openBlockEditor(editing) { - state.editing = { - ...editing, - rect: [...editing.rect], - sourceRect: editing.sourceRect ? [...editing.sourceRect] : null, - initialRect: [...editing.rect] - }; - hideEditRangeButton(); - clearNativeTextSelection(); - const screen = pdfRectToScreen(editing.rect); - const editor = elements['block-editor']; - const textarea = elements['block-editor-text']; - const left = clamp(screen[0], 0, Math.max(0, state.render.width - 24)); - const top = clamp(screen[1], 0, Math.max(0, state.render.height - 18)); - const requestedWidth = Math.max(24, screen[2] - screen[0]); - const requestedHeight = Math.max(18, screen[3] - screen[1]); - const width = clamp(requestedWidth, 24, Math.max(24, state.render.width - left)); - const height = clamp(requestedHeight, 18, Math.max(18, state.render.height - top)); - - editor.style.left = `${left}px`; - editor.style.top = `${top}px`; - textarea.style.width = `${width}px`; - textarea.style.height = `${height}px`; - editor.classList.remove('hidden'); - textarea.value = editing.text; - syncBlockEditorPreview(); - syncEditingRectFromEditor(); - state.editing.initialRect = [...state.editing.rect]; - textarea.focus(); - textarea.select(); - setStatus(editing.mode === 'add' - ? 'Escribe dentro del marco azul. Puedes arrastrar su esquina para cambiar anchura y altura.' - : (editing.mode === 'range' - ? 'El marco azul coincide con la selección. Edita el texto y redimensiona el área si lo necesitas.' - : 'El marco azul es el área real del bloque. Redimensiónalo para cambiar el reflow.')); -} - -function syncEditingRectFromEditor() { - if (!state.editing || elements['block-editor'].classList.contains('hidden')) { - return; - } - const textareaRect = elements['block-editor-text'].getBoundingClientRect(); - const stageRect = elements['page-stage'].getBoundingClientRect(); - const screenRect = normalizeRect([ - clamp(textareaRect.left - stageRect.left, 0, state.render.width), - clamp(textareaRect.top - stageRect.top, 0, state.render.height), - clamp(textareaRect.right - stageRect.left, 0, state.render.width), - clamp(textareaRect.bottom - stageRect.top, 0, state.render.height) - ]); - state.editing.rect = screenRectToPdf(screenRect); -} - -function syncBlockEditorPreview() { - if (!state.editing) { - return; - } - const format = getTextFormat(); - const textarea = elements['block-editor-text']; - textarea.style.fontFamily = cssFontFamily(format.fontFamily); - textarea.style.fontSize = `${Math.max(8, format.fontSize * state.render.scale)}px`; - textarea.style.fontWeight = format.bold ? 'bold' : 'normal'; - textarea.style.fontStyle = format.italic ? 'italic' : 'normal'; - textarea.style.color = elements['text-color'].value; - textarea.style.textAlign = ['left', 'center', 'right'][format.alignment] || 'left'; - syncEditingRectFromEditor(); -} - -function cssFontFamily(fontFamily) { - if (fontFamily === 'Times-Roman') { - return 'Times New Roman, serif'; - } - if (fontFamily === 'Courier') { - return 'Courier New, monospace'; - } - return 'Arial, Helvetica, sans-serif'; -} - -function cancelBlockEditor() { - const rangeWasOpen = state.editing?.mode === 'range'; - state.editing = null; - elements['block-editor'].classList.add('hidden'); - elements['block-editor-text'].value = ''; - if (rangeWasOpen) { - state.textRange = null; - clearTextRangeHighlights(); - } - if (!state.selected) { - clearSelection(); - } -} - -async function applyBlockEditor() { - if (!state.editing || state.busy) { - return; - } - const editing = state.editing; - const text = elements['block-editor-text'].value; - const values = { text, ...getTextFormat() }; - cancelBlockEditor(); - if (editing.mode === 'add') { - if (!text.trim()) { - setStatus('No se añadió texto vacío.'); - return; - } - engine.insertTextBlock(state.currentPage, editing.rect, values); - await commitAndRefresh('Añadir texto', { restoreText: text }); - } else if (editing.mode === 'range') { - const sourceRect = editing.sourceRect || editing.rect; - const initialWidth = Math.max(1, editing.initialRect[2] - editing.initialRect[0]); - const editedWidth = Math.max(1, editing.rect[2] - editing.rect[0]); - const targetWidth = Math.max( - 24, - sourceRect[2] - sourceRect[0] + editedWidth - initialWidth - ); - engine.editTextRange( - state.currentPage, - editing.blockIndex, - editing.start, - editing.end, - text, - { - ...values, - targetRect: [ - sourceRect[0], - sourceRect[1], - sourceRect[0] + targetWidth, - sourceRect[3] - ] - } - ); - await commitAndRefresh(text ? 'Editar selección' : 'Eliminar selección', { - restoreText: text || null - }); - } else { - engine.editTextBlock(state.currentPage, editing.blockIndex, { - ...values, - targetRect: editing.rect - }); - await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { - restoreText: text || null - }); - } -} - -async function deleteSelection() { - if (!state.selected || state.busy) { +async function performSearch() { + const query = elements['search-input'].value.trim(); + if (!query) { + state.searchQuery = ''; + state.searchResults = []; + state.searchIndex = -1; + await renderCurrentPage(); + setStatus('Escribe texto para buscar.'); return; } - const selected = state.selected; - const selectedRange = state.textRange; - clearSelection(); - if (selected.kind === 'text') { - if (selectedRange?.blockIndex === selected.blockIndex) { - engine.editTextRange( - state.currentPage, - selected.blockIndex, - selectedRange.start, - selectedRange.end, - '' - ); - await commitAndRefresh('Eliminar selección'); - } else { - engine.editTextBlock(state.currentPage, selected.blockIndex, { text: '' }); - await commitAndRefresh('Eliminar texto'); - } - } else if (selected.kind === 'native-image') { - engine.deleteExistingContent(state.currentPage, selected.rect, { - images: true, - lineArt: false, - text: false - }); - await commitAndRefresh('Eliminar imagen'); - } else if (selected.kind === 'inserted-image') { - engine.deleteAnnotation(state.currentPage, selected.annotationIndex); - await commitAndRefresh('Eliminar imagen'); - } else if (selected.kind === 'table') { - engine.deleteAnnotation(state.currentPage, selected.annotationIndex); - await commitAndRefresh('Eliminar tabla'); - } -} -async function copySelectedText() { - const text = state.selected?.kind === 'text' - ? (state.textRange?.text || state.selected.block.text) - : ''; - if (!text) { - return; - } + setBusy(true, 'Buscando en el documento…'); try { - await navigator.clipboard.writeText(text); - } catch { - const helper = document.createElement('textarea'); - helper.value = text; - document.body.appendChild(helper); - helper.select(); - document.execCommand('copy'); - helper.remove(); - } - setStatus('Texto copiado.'); -} - -async function applyTableProperties() { - if (state.selected?.kind !== 'table' || state.busy) { - return; - } - const selected = state.selected; - engine.updateTable( - state.currentPage, - selected.annotationIndex, - selected.rect, - Number(elements['table-rows'].value), - Number(elements['table-columns'].value), - { - color: hexToPdfColor(elements['table-color'].value), - borderWidth: Number(elements['table-border-width'].value) + if (query !== state.searchQuery) { + state.searchQuery = query; + state.searchResults = engine.search(query); + state.searchIndex = -1; } - ); - await commitAndRefresh('Actualizar tabla', { restoreKind: 'table-last' }); -} -async function chooseTableTool(anchor = null) { - const values = await showFieldsDialog({ - title: 'Crear tabla', - confirmText: anchor ? 'Insertar' : 'Elegir zona', - fields: [ - { name: 'rows', label: 'Filas', type: 'number', value: '3', min: '1', max: '30' }, - { name: 'columns', label: 'Columnas', type: 'number', value: '3', min: '1', max: '20' }, - { name: 'color', label: 'Color de línea', type: 'color', value: '#000000' }, - { name: 'borderWidth', label: 'Grosor', type: 'number', value: '1', min: '0.25', max: '8', step: '0.25' } - ], - grid: true - }); - if (!values) { - hideInsertMenu(); - return; - } - if (anchor) { - hideInsertMenu(); - const rowCount = Number(values.rows); - const tableHeight = clamp(rowCount * 24, 54, 360); - engine.insertTableBlock( - state.currentPage, - [anchor.x, anchor.y, anchor.x + anchor.width, anchor.y + tableHeight], - rowCount, - Number(values.columns), - { - color: hexToPdfColor(values.color), - borderWidth: Number(values.borderWidth) - } - ); - await commitAndRefresh('Insertar tabla', { restoreKind: 'table-last' }); - return; - } - state.pendingTable = { - rows: Number(values.rows), - columns: Number(values.columns), - color: hexToPdfColor(values.color), - borderWidth: Number(values.borderWidth) - }; - setTool('table'); - setStatus('Arrastra sobre la página para colocar la tabla.'); -} - -async function handleImageFile(event) { - const file = event.target.files?.[0]; - event.target.value = ''; - if (!file) { - return; - } - try { - const bytes = await fileToBytes(file); - const selectedImage = ['native-image', 'inserted-image'].includes(state.selected?.kind) - ? state.selected - : null; - - if (selectedImage?.kind === 'native-image') { - const targetRect = [...selectedImage.rect]; - engine.replaceExistingImage( - state.currentPage, - selectedImage.rect, - targetRect, - bytes - ); - clearSelection(); - await commitAndRefresh('Reemplazar imagen original', { restoreKind: 'image-last' }); - return; - } - - if (selectedImage?.kind === 'inserted-image') { - const targetRect = [...selectedImage.rect]; - engine.replaceAnnotationImage( - state.currentPage, - selectedImage.annotationIndex, - targetRect, - bytes - ); - clearSelection(); - await commitAndRefresh('Reemplazar imagen', { restoreKind: 'image-last' }); - return; - } - - const dimensions = engine.getImageDimensions(bytes); - state.pendingImage = { bytes, dimensions }; - const anchor = state.insertionAnchor; - if (anchor) { - hideInsertMenu({ preserveAnchor: true }); - await placePendingImage([anchor.x, anchor.y], anchor); - state.insertionAnchor = null; + if (state.searchResults.length === 0) { + await renderCurrentPage(); + setStatus(`No se encontró “${query}”.`); return; } - setTool('place-image'); - setStatus('Haz clic en la página para colocar la imagen.'); - } catch (error) { - reportError(error); - } -} -async function placePendingImage(point, anchor = null) { - const pending = state.pendingImage; - if (!pending) { - return; - } - state.pendingImage = null; - setTool('edit'); - const bounds = state.pageModel.bounds; - const aspectRatio = pending.dimensions.height / Math.max(1, pending.dimensions.width); - let width = anchor - ? Math.min(Math.max(80, anchor.width), 240) - : Math.min(180, Math.max(40, bounds[2] - point[0] - 18)); - let height = width * aspectRatio; - if (!anchor && point[1] + height > bounds[3] - 18) { - height = Math.max(30, bounds[3] - point[1] - 18); - width = height / Math.max(0.01, aspectRatio); - } - engine.insertImageBlock(state.currentPage, [ - point[0], - point[1], - point[0] + width, - point[1] + height - ], pending.bytes); - await commitAndRefresh('Añadir imagen', { restoreKind: 'image-last' }); -} - -async function commitAndRefresh(label, options = {}) { - setBusy(true, `${label}…`); - try { - const bytes = engine.serialize(state.settings.defaultSaveMode); - vscode.postMessage({ - type: 'document-edited', - label, - baseRevision: state.revision, - data: bytesToBase64(bytes) - }); - state.revision += 1; - state.pageCount = engine.countPages(); - state.currentPage = clamp(state.currentPage, 0, Math.max(0, state.pageCount - 1)); - await renderCurrentPage(options); - void renderSingleThumbnail(state.currentPage); - setStatus(`${label}. Guarda el archivo para conservar el cambio.`); + state.searchIndex = (state.searchIndex + 1) % state.searchResults.length; + const result = state.searchResults[state.searchIndex]; + state.currentPage = result.pageIndex; + await renderCurrentPage(); + elements['page-viewport'].scrollTo({ top: 0, left: 0 }); + setStatus(`Resultado ${state.searchIndex + 1} de ${state.searchResults.length}.`); } catch (error) { reportError(error); } finally { @@ -1650,68 +229,20 @@ async function commitAndRefresh(label, options = {}) { } } -function restoreSelection(options) { - let entry = null; - if (options.restoreText) { - const prefix = String(options.restoreText).trim().slice(0, 48); - const block = state.pageModel.textBlocks.find((candidate) => - candidate.text.includes(prefix) - ); - return block ? selectTextBlock(block.index) : false; - } else if (options.restoreKind === 'table-last') { - entry = state.overlayObjects.findLast(({ meta }) => meta.kind === 'table'); - } else if (options.restoreKind === 'image-last') { - entry = state.overlayObjects.findLast(({ meta }) => meta.kind === 'inserted-image'); - } - if (!entry) { - return false; - } - state.fabricCanvas.setActiveObject(entry.object); - entry.object.set('stroke', entry.object.editorStroke); - state.fabricCanvas.requestRenderAll(); - selectMeta(entry.meta); - return true; -} - -async function performSearch() { - const query = elements['search-input'].value.trim(); - if (!query) { - state.searchQuery = ''; - state.searchResults = []; - state.searchIndex = -1; - await renderCurrentPage(); - setStatus('Escribe texto para buscar.'); - return; - } - if (query !== state.searchQuery) { - state.searchQuery = query; - state.searchResults = engine.search(query); - state.searchIndex = -1; - } - if (state.searchResults.length === 0) { - await renderCurrentPage(); - setStatus(`No se encontró “${query}”.`); - return; - } - state.searchIndex = (state.searchIndex + 1) % state.searchResults.length; - const result = state.searchResults[state.searchIndex]; - if (state.currentPage !== result.pageIndex) { - state.currentPage = result.pageIndex; - } - await renderCurrentPage(); - setStatus(`Resultado ${state.searchIndex + 1} de ${state.searchResults.length}.`); -} - async function renderThumbnails() { const token = ++state.thumbnailToken; elements['thumbnail-list'].replaceChildren(); + for (let pageIndex = 0; pageIndex < state.pageCount; pageIndex += 1) { if (token !== state.thumbnailToken) { return; } + const button = document.createElement('button'); button.className = 'thumbnail-button'; button.dataset.pageIndex = String(pageIndex); + button.title = `Ir a la página ${pageIndex + 1}`; + const image = document.createElement('img'); image.alt = `Miniatura de la página ${pageIndex + 1}`; const label = document.createElement('span'); @@ -1719,39 +250,26 @@ async function renderThumbnails() { button.append(image, label); button.addEventListener('click', () => goToPage(pageIndex)); elements['thumbnail-list'].appendChild(button); - await updateThumbnailImage(button, image, pageIndex); - } - updateThumbnailSelection(); -} -async function renderSingleThumbnail(pageIndex) { - const button = elements['thumbnail-list'].querySelector( - `[data-page-index="${pageIndex}"]` - ); - const image = button?.querySelector('img'); - if (button && image) { - await updateThumbnailImage(button, image, pageIndex); - updateThumbnailSelection(); - } -} - -async function updateThumbnailImage(button, image, pageIndex) { - try { - const bytes = engine.renderThumbnail(pageIndex, 142); - const previousUrl = state.thumbnailUrls.get(pageIndex); - if (previousUrl) { - URL.revokeObjectURL(previousUrl); + try { + const bytes = engine.renderThumbnail(pageIndex, 142); + const url = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' })); + state.thumbnailUrls.set(pageIndex, url); + image.src = url; + } catch (error) { + button.title = error instanceof Error ? error.message : String(error); } - const url = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' })); - state.thumbnailUrls.set(pageIndex, url); - image.src = url; - button.title = `Ir a la página ${pageIndex + 1}`; - } catch (error) { - button.title = error instanceof Error ? error.message : String(error); + + // Yield between pages so large documents keep the viewer responsive and a + // replacement document can cancel the remaining thumbnail work. + await new Promise((resolve) => setTimeout(resolve, 0)); } + + updateThumbnailSelection(); } function revokeThumbnailUrls() { + state.thumbnailToken += 1; for (const url of state.thumbnailUrls.values()) { URL.revokeObjectURL(url); } @@ -1769,10 +287,10 @@ function updateThumbnailSelection() { async function goToPage(pageIndex) { const target = clamp(Math.round(pageIndex), 0, Math.max(0, state.pageCount - 1)); - if (target === state.currentPage && state.pageModel) { + if (target === state.currentPage && state.render) { return; } - cancelBlockEditor(); + state.currentPage = target; await renderCurrentPage(); updateThumbnailSelection(); @@ -1789,38 +307,11 @@ function updatePageControls() { } function updateDocumentInfo() { - const dimensions = state.pageModel.bounds; - const width = Math.round(dimensions[2] - dimensions[0]); - const height = Math.round(dimensions[3] - dimensions[1]); + const width = Math.round(state.pageBounds[2] - state.pageBounds[0]); + const height = Math.round(state.pageBounds[3] - state.pageBounds[1]); elements['document-info'].textContent = `${state.fileName} · ${width} × ${height} pt`; } -function setTool(tool) { - setTextResizeMode(false); - state.tool = tool; - elements['text-layer']?.classList.toggle('placement-mode', tool !== 'edit'); - elements['insertion-layer']?.classList.toggle('disabled', tool !== 'edit'); - if (state.fabricCanvas) { - state.fabricCanvas.skipTargetFind = tool !== 'edit'; - state.fabricCanvas.selection = false; - if (tool !== 'edit') { - state.fabricCanvas.discardActiveObject(); - state.fabricCanvas.requestRenderAll(); - } - } - const visibleTool = tool === 'place-image' ? 'image' : tool; - for (const button of document.querySelectorAll('[data-tool]')) { - button.classList.toggle('active', button.dataset.tool === visibleTool); - } - const hints = { - edit: 'Haz clic en un párrafo, selecciona cualquier texto o usa + para insertar.', - 'add-text': 'Haz clic donde quieras escribir.', - 'place-image': 'Haz clic donde quieras colocar la imagen.', - table: 'Arrastra para definir el tamaño de la tabla.' - }; - elements['tool-hint'].textContent = hints[tool] || ''; -} - async function changeZoom(delta) { state.zoomMode = 'numeric'; state.zoom = clamp((state.render?.scale || state.zoom) + delta, 0.25, 5); @@ -1829,12 +320,18 @@ async function changeZoom(delta) { } function syncZoomControl() { + if (state.zoomMode === 'fit' || state.zoomMode === 'width') { + elements['zoom-select'].value = state.zoomMode; + return; + } + const exactOption = [...elements['zoom-select'].options] .find((option) => Number(option.value) === state.zoom); if (exactOption) { elements['zoom-select'].value = exactOption.value; return; } + let custom = elements['zoom-select'].querySelector('[data-custom="true"]'); if (!custom) { custom = document.createElement('option'); @@ -1873,99 +370,17 @@ function initializeEventHandlers() { void performSearch(); } }); - elements['undo-button'].addEventListener('click', () => postCommand('undo')); - elements['redo-button'].addEventListener('click', () => postCommand('redo')); - elements['save-button'].addEventListener('click', () => { - if (state.busy) { - return; - } - setBusy(true, 'Guardando PDF…'); - postCommand('save'); - }); - - elements['edit-text-tool'].addEventListener('click', () => { - hideInsertMenu(); - setTool('edit'); - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['add-text-tool'].addEventListener('click', () => { - hideInsertMenu(); - cancelBlockEditor(); - clearSelection(); - setTool('add-text'); - setStatus('Haz clic en cualquier punto de la página para escribir.'); - }); - elements['image-tool'].addEventListener('click', () => { - hideInsertMenu(); - elements['image-file-input'].click(); - }); - elements['table-tool'].addEventListener('click', () => { - hideInsertMenu(); - void chooseTableTool(); - }); - elements['delete-tool'].addEventListener('click', deleteSelection); - elements['edit-content'].addEventListener('click', () => { - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['resize-text-block'].addEventListener('click', activateTextResizeMode); - elements['copy-text'].addEventListener('click', copySelectedText); - elements['text-font-family'].addEventListener('change', textFormatChanged); - elements['text-font-size'].addEventListener('change', textFormatChanged); - elements['text-color'].addEventListener('change', textFormatChanged); - elements['text-alignment'].addEventListener('change', textFormatChanged); - elements['text-bold'].addEventListener('click', () => toggleFormatButton(elements['text-bold'])); - elements['text-italic'].addEventListener('click', () => toggleFormatButton(elements['text-italic'])); - elements['apply-table'].addEventListener('click', applyTableProperties); - elements['edit-range-button'].addEventListener('click', openSelectedRangeEditor); - elements['insert-text-here'].addEventListener('click', () => { - const anchor = state.insertionAnchor; - hideInsertMenu(); - if (anchor) { - cancelBlockEditor(); - clearSelection(); - openNewTextEditor(anchor); - } - }); - elements['insert-image-here'].addEventListener('click', () => { - elements['image-file-input'].click(); - }); - elements['insert-table-here'].addEventListener('click', () => { - const anchor = state.insertionAnchor; - if (anchor) { - void chooseTableTool(anchor); - } - }); - elements['image-file-input'].addEventListener('change', handleImageFile); - elements['block-editor-text'].addEventListener('input', syncEditingRectFromEditor); - elements['block-editor-text'].addEventListener('keydown', (event) => { - if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { - event.preventDefault(); - void applyBlockEditor(); - } else if (event.key === 'Escape') { - event.preventDefault(); - cancelBlockEditor(); + elements['search-input'].addEventListener('input', () => { + if (!elements['search-input'].value.trim() && state.searchResults.length > 0) { + state.searchQuery = ''; + state.searchResults = []; + state.searchIndex = -1; + buildSearchLayer(); + setStatus('Solo lectura: el PDF original no se puede modificar.'); } }); - elements['cancel-block-edit'].addEventListener('click', cancelBlockEditor); - elements['apply-block-edit'].addEventListener('click', applyBlockEditor); - elements['dialog-form'].addEventListener('submit', submitDialog); elements['dialog-cancel'].addEventListener('click', () => closeDialog(null)); - elements['page-stage'].addEventListener('pointerdown', (event) => { - if (!elements['insert-menu'].contains(event.target) && - !event.target.closest?.('.insertion-marker')) { - hideInsertMenu(); - } - if (state.tool === 'edit' && state.selected?.kind === 'text' && - !event.target.closest?.('.text-layer-line, .edit-range-button, .block-editor')) { - clearNativeTextSelection(); - clearSelection(); - } - }); document.addEventListener('keydown', keyboardHandler); window.addEventListener('resize', debounce(async () => { if (state.zoomMode !== 'numeric' && state.pageCount > 0) { @@ -1984,30 +399,19 @@ function keyboardHandler(event) { closeDialog(null); return; } - if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { - event.preventDefault(); - postCommand(event.shiftKey ? 'saveAs' : 'save'); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') { - event.preventDefault(); - postCommand(event.shiftKey ? 'redo' : 'undo'); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'y') { - event.preventDefault(); - postCommand('redo'); - } else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'f') { + + const modifier = event.ctrlKey || event.metaKey; + const key = event.key.toLowerCase(); + if (modifier && key === 'f') { event.preventDefault(); elements['search-input'].focus(); elements['search-input'].select(); - } else if (event.key === 'Delete' && !isFormControl(event.target)) { - void deleteSelection(); - } else if (event.key === 'Escape' && !state.editing) { - state.pendingImage = null; - state.pendingTable = null; - if (state.drawPreview) { - state.fabricCanvas.remove(state.drawPreview); - state.drawPreview = null; - state.drawStart = null; - } - setTool('edit'); + } else if (modifier && key === 's') { + event.preventDefault(); + setStatus('Solo lectura: guardar y guardar como están deshabilitados.'); + } else if (modifier && ['z', 'y'].includes(key) && !isFormControl(event.target)) { + event.preventDefault(); + setStatus('Solo lectura: deshacer y rehacer no son aplicables.'); } else if (event.key === 'PageUp' && !isFormControl(event.target)) { event.preventDefault(); void goToPage(state.currentPage - 1); @@ -2018,13 +422,7 @@ function keyboardHandler(event) { } function isFormControl(target) { - return target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target instanceof HTMLSelectElement; -} - -function postCommand(command) { - vscode.postMessage({ type: 'command', command }); + return target instanceof HTMLInputElement || target instanceof HTMLSelectElement; } async function requestPassword(invalid) { @@ -2051,9 +449,6 @@ function showFieldsDialog(options) { elements['dialog-confirm'].textContent = options.confirmText || 'Aceptar'; elements['dialog-content'].replaceChildren(); const fieldContainer = document.createElement('div'); - if (options.grid) { - fieldContainer.className = 'dialog-grid'; - } for (const field of options.fields) { const label = document.createElement('label'); label.textContent = field.label; @@ -2061,18 +456,16 @@ function showFieldsDialog(options) { input.name = field.name; input.type = field.type || 'text'; input.value = field.value || ''; - for (const attribute of ['min', 'max', 'step', 'autocomplete']) { - if (field[attribute] !== undefined) { - input.setAttribute(attribute, String(field[attribute])); - } - } input.required = Boolean(field.required); + if (field.autocomplete) { + input.autocomplete = field.autocomplete; + } label.appendChild(input); fieldContainer.appendChild(label); } elements['dialog-content'].appendChild(fieldContainer); elements['dialog-backdrop'].classList.remove('hidden'); - elements['editor-dialog'].setAttribute('open', ''); + elements['viewer-dialog'].setAttribute('open', ''); fieldContainer.querySelector('input')?.focus(); return new Promise((resolve) => { state.dialogRequest = { resolve }; @@ -2088,7 +481,7 @@ function submitDialog(event) { function closeDialog(value) { const request = state.dialogRequest; state.dialogRequest = null; - elements['editor-dialog'].removeAttribute('open'); + elements['viewer-dialog'].removeAttribute('open'); elements['dialog-backdrop'].classList.add('hidden'); request?.resolve(value); } diff --git a/media/webview/pdf-engine.js b/media/webview/pdf-engine.js index b07513e..27f895c 100644 --- a/media/webview/pdf-engine.js +++ b/media/webview/pdf-engine.js @@ -1,24 +1,12 @@ import mupdf from '../vendor/mupdf/mupdf.js'; -import { - normalizeRect, - quadToRect, - rectFromBbox, - rectToQuad, - transformPoint -} from './utils.js'; - -const DEFAULT_TEXT_PROPERTIES = Object.freeze({ - font: 'Helv', - fontFamily: 'Helvetica', - fontSize: 12, - color: [0, 0, 0], - alignment: 0, - bold: false, - italic: false, - opacity: 1 -}); -const TABLE_SUBJECT = 'PDF Viewer Editor Table'; - +import { quadToRect } from './utils.js'; + +/** + * Read-only MuPDF facade used by the webview. + * + * Deliberately exposes only load, authentication, rendering, bounds, and + * search operations. No document journal or serialization API is enabled. + */ export class PdfEngine { constructor() { this.document = null; @@ -30,12 +18,12 @@ export class PdfEngine { try { this.pdfDocument?.destroy(); } catch { - // The underlying document may already have released the PDF wrapper. + // The PDF wrapper may already have been released. } try { this.document?.destroy(); } catch { - // Ignore repeated destruction while reloading a document. + // Ignore repeated destruction while replacing the loaded document. } this.pdfDocument = null; this.document = null; @@ -65,7 +53,6 @@ export class PdfEngine { this.passwordAccepted = true; } - this.pdfDocument.enableJournal(); return this.getDocumentInfo(); } @@ -76,7 +63,6 @@ export class PdfEngine { return false; } this.passwordAccepted = true; - this.pdfDocument.enableJournal(); return true; } @@ -84,16 +70,7 @@ export class PdfEngine { this.assertUnlocked(); return { passwordRequired: false, - pageCount: this.document.countPages(), - permissions: { - edit: this.document.hasPermission('edit'), - annotate: this.document.hasPermission('annotate'), - copy: this.document.hasPermission('copy'), - assemble: this.document.hasPermission('assemble'), - form: this.document.hasPermission('form') - }, - metadata: this.getMetadata(), - outline: this.getOutline() + pageCount: this.document.countPages() }; } @@ -102,22 +79,34 @@ export class PdfEngine { return this.document.countPages(); } - renderPage(pageIndex, requestedScale, maxRenderPixels, showExtras = true) { + getPageBounds(pageIndex) { + this.assertPageIndex(pageIndex); + const page = this.document.loadPage(pageIndex); + try { + return [...page.getBounds()]; + } finally { + page.destroy(); + } + } + + renderPage(pageIndex, requestedScale, maxRenderPixels) { this.assertPageIndex(pageIndex); - const page = this.pdfDocument.loadPage(pageIndex); + const page = this.document.loadPage(pageIndex); try { const bounds = page.getBounds(); const widthInPoints = Math.max(1, bounds[2] - bounds[0]); const heightInPoints = Math.max(1, bounds[3] - bounds[1]); - const requestedPixels = widthInPoints * heightInPoints * requestedScale * requestedScale; - const scale = requestedPixels > maxRenderPixels - ? Math.sqrt(maxRenderPixels / (widthInPoints * heightInPoints)) - : requestedScale; + const safeScale = Math.max(0.01, Number(requestedScale) || 1); + const pixelLimit = Math.max(1, Number(maxRenderPixels) || 24_000_000); + const requestedPixels = widthInPoints * heightInPoints * safeScale * safeScale; + const scale = requestedPixels > pixelLimit + ? Math.sqrt(pixelLimit / (widthInPoints * heightInPoints)) + : safeScale; const pixmap = page.toPixmap( mupdf.Matrix.scale(scale, scale), mupdf.ColorSpace.DeviceRGB, true, - showExtras + true ); try { const pixelBounds = [...pixmap.getBounds()]; @@ -140,11 +129,11 @@ export class PdfEngine { renderThumbnail(pageIndex, maximumWidth = 160) { this.assertPageIndex(pageIndex); - const page = this.pdfDocument.loadPage(pageIndex); + const page = this.document.loadPage(pageIndex); try { const bounds = page.getBounds(); const width = Math.max(1, bounds[2] - bounds[0]); - const scale = Math.min(0.35, maximumWidth / width); + const scale = Math.min(0.35, Math.max(1, maximumWidth) / width); const pixmap = page.toPixmap( mupdf.Matrix.scale(scale, scale), mupdf.ColorSpace.DeviceRGB, @@ -161,127 +150,6 @@ export class PdfEngine { } } - getPageModel(pageIndex) { - this.assertPageIndex(pageIndex); - const page = this.pdfDocument.loadPage(pageIndex); - try { - const structuredText = page.toStructuredText('preserve-spans,preserve-images'); - let parsed; - let textBlocks; - try { - parsed = JSON.parse(structuredText.asJSON()); - textBlocks = extractTextBlocks(structuredText); - } finally { - structuredText.destroy(); - } - - const images = []; - for (const block of parsed.blocks || []) { - if (block.type === 'image') { - images.push({ - rect: rectFromBbox(block.bbox), - width: Number(block.width || 0), - height: Number(block.height || 0) - }); - } - } - - return { - bounds: [...page.getBounds()], - textBlocks, - textLines: textBlocks.flatMap((block) => block.visualLines), - images, - annotations: this.readAnnotations(page), - widgets: this.readWidgets(page) - }; - } finally { - page.destroy(); - } - } - - readAnnotations(page) { - const annotations = page.getAnnotations(); - return annotations.map((annotation, index) => { - try { - const type = annotation.getType(); - const contents = safeCall(() => annotation.getContents(), ''); - const subject = safeCall(() => annotation.getSubject(), ''); - const table = parseTableMetadata(type, subject, contents); - const annotationRect = annotation.hasRect() - ? [...annotation.getRect()] - : [...annotation.getBounds()]; - let rect = [...annotationRect]; - if (table) { - rect = table.rect - ? [...table.rect] - : rectFromInkStrokes( - safeCall(() => annotation.getInkList(), []), - annotationRect - ); - table.rect = [...rect]; - } - - let defaultAppearance = null; - if (type === 'FreeText') { - try { - defaultAppearance = annotation.getDefaultAppearance(); - } catch { - defaultAppearance = null; - } - } - return { - index, - type, - rect, - contents, - author: safeCall(() => annotation.getAuthor(), ''), - subject, - table, - color: safeCall(() => [...annotation.getColor()], []), - interiorColor: annotation.hasInteriorColor() - ? safeCall(() => [...annotation.getInteriorColor()], []) - : [], - opacity: safeCall(() => annotation.getOpacity(), 1), - borderWidth: annotation.hasBorder() - ? safeCall(() => annotation.getBorderWidth(), 1) - : 0, - alignment: type === 'FreeText' - ? safeCall(() => annotation.getQuadding(), 0) - : 0, - font: defaultAppearance?.font || 'Helv', - fontSize: defaultAppearance?.size || 12, - fontColor: defaultAppearance?.color || [] - }; - } finally { - annotation.destroy(); - } - }); - } - - readWidgets(page) { - const widgets = page.getWidgets(); - return widgets.map((widget, index) => { - try { - return { - index, - type: widget.getFieldType(), - rect: [...widget.getRect()], - name: safeCall(() => widget.getName(), ''), - label: safeCall(() => widget.getLabel(), ''), - value: safeCall(() => widget.getValue(), ''), - options: widget.isChoice() ? safeCall(() => widget.getOptions(false), []) : [], - readOnly: safeCall(() => widget.isReadOnly(), false), - checkbox: safeCall(() => widget.isCheckbox(), false), - radio: safeCall(() => widget.isRadioButton(), false), - text: safeCall(() => widget.isText(), false), - choice: safeCall(() => widget.isChoice(), false) - }; - } finally { - widget.destroy(); - } - }); - } - search(query) { this.assertUnlocked(); const results = []; @@ -291,14 +159,13 @@ export class PdfEngine { } for (let pageIndex = 0; pageIndex < this.countPages(); pageIndex += 1) { - const page = this.pdfDocument.loadPage(pageIndex); + const page = this.document.loadPage(pageIndex); try { const hits = page.search(normalizedQuery, 10000); for (const quads of hits) { const rects = quads.map((quad) => quadToRect(quad)); results.push({ pageIndex, - quads: quads.map((quad) => [...quad]), rect: unionRects(rects) }); } @@ -309,2197 +176,30 @@ export class PdfEngine { return results; } - addText(pageIndex, rect, properties = {}) { - const values = { ...DEFAULT_TEXT_PROPERTIES, ...properties }; - this.withOperation('Add text', () => { - this.withPage(pageIndex, (page) => { - this.createFreeText(page, rect, values); - }); - }); - } - - replaceExistingText(pageIndex, rect, properties = {}) { - const values = { ...DEFAULT_TEXT_PROPERTIES, ...properties }; - this.withOperation('Edit existing text', () => { - this.withPage(pageIndex, (page) => { - this.removeContentInRect(page, rect, { - images: false, - lineArt: false, - text: true - }); - if (String(values.text || '').length > 0) { - this.createFreeText(page, rect, values); - } - }); - }); - } - - /** - * Replaces one extracted PDF text block with static, searchable page content. - * The text is wrapped to the original block width and every text block below it - * in the same visual flow is moved by the resulting height difference. - */ - editTextBlock(pageIndex, blockIndex, properties = {}) { - const model = this.getPageModel(pageIndex); - const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - throw new Error('The selected text block no longer exists. Select it again.'); - } - - const values = normalizeBlockProperties(block, properties); - const requested = values.text && Array.isArray(properties.targetRect) - ? normalizeRect(properties.targetRect) - : [...block.rect]; - const minimumWidth = Math.max(24, Number(values.fontSize || block.font?.size || 12) * 2); - const targetRect = [ - requested[0], - requested[1], - Math.max(requested[0] + minimumWidth, requested[2]), - requested[3] - ]; - const layoutRect = [targetRect[0], targetRect[1], targetRect[2], targetRect[1]]; - const layout = layoutTextBlock(layoutRect, values, block.metrics, block); - const newBottom = values.text ? targetRect[1] + layout.height : block.rect[1]; - const delta = newBottom - block.rect[3]; - const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation(values.text ? 'Edit text block' : 'Delete text block', () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of [block, ...followingBlocks]) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - - const entries = values.text - ? [...layout.entries, ...shiftedEntries] - : shiftedEntries; - const maximumBottom = Math.max( - values.text ? newBottom : block.rect[1], - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - ); - this.extendPageToFit(page, maximumBottom); - this.appendStaticText(page, entries); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: values.text - ? [targetRect[0], targetRect[1], targetRect[2], newBottom] - : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] - }; - } - - /** - * Replaces an arbitrary character range inside an extracted text block. The - * complete paragraph is reflowed afterwards, so a longer replacement or a - * larger font creates real room and moves the following content down. - */ - editTextRange(pageIndex, blockIndex, start, end, replacement, properties = {}) { - const model = this.getPageModel(pageIndex); - const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - throw new Error('The selected text block no longer exists. Select it again.'); - } - - const rangeStart = Math.max(0, Math.min(block.text.length, Math.round(Number(start) || 0))); - const rangeEnd = Math.max( - rangeStart, - Math.min(block.text.length, Math.round(Number(end) || 0)) - ); - const replacementText = String(replacement || '').replaceAll('\0', ''); - const originalValues = normalizeBlockProperties(block, { text: block.text }); - const replacementValues = normalizeBlockProperties(block, { - ...properties, - text: replacementText - }); - const runs = [ - { text: block.text.slice(0, rangeStart), values: originalValues }, - { text: replacementText, values: replacementValues }, - { text: block.text.slice(rangeEnd), values: originalValues } - ].filter((run) => run.text.length > 0); - const resultingText = runs.map((run) => run.text).join(''); - const requested = resultingText && Array.isArray(properties.targetRect) - ? normalizeRect(properties.targetRect) - : [...block.rect]; - const minimumWidth = Math.max( - 24, - Number(replacementValues.fontSize || block.font?.size || 12) * 2 - ); - const layoutRect = [ - requested[0], - requested[1], - Math.max(requested[0] + minimumWidth, requested[2]), - requested[1] - ]; - const layout = layoutRichTextBlock( - layoutRect, - runs, - block.metrics, - replacementValues.alignment - ); - const newBottom = resultingText ? layoutRect[1] + layout.height : block.rect[1]; - const delta = newBottom - block.rect[3]; - const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation(resultingText ? 'Edit text selection' : 'Delete text selection', () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of [block, ...followingBlocks]) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - const entries = resultingText - ? [...layout.entries, ...shiftedEntries] - : shiftedEntries; - const maximumBottom = Math.max( - resultingText ? newBottom : block.rect[1], - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - ); - this.extendPageToFit(page, maximumBottom); - this.appendStaticText(page, entries); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - text: resultingText, - rect: resultingText - ? [layoutRect[0], layoutRect[1], layoutRect[2], newBottom] - : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] - }; - } - - /** - * Inserts a new static text block and makes room by moving intersecting text - * blocks and all lower blocks in the same visual column. - */ - insertTextBlock(pageIndex, rect, properties = {}) { - const model = this.getPageModel(pageIndex); - const normalizedRect = normalizeRect(rect); - const seedBlock = { - font: { - editableFamily: 'Helvetica', - bold: false, - italic: false, - size: 12 - }, - color: [0, 0, 0], - alignment: 0, - text: '', - visualLines: [], - metrics: defaultTextMetrics(12) - }; - const values = normalizeBlockProperties(seedBlock, properties); - if (!values.text.trim()) { - return { delta: 0, shiftedBlocks: 0, rect: normalizedRect }; - } - - const width = Math.max(48, normalizedRect[2] - normalizedRect[0]); - const anchorRect = [ - normalizedRect[0], - normalizedRect[1], - normalizedRect[0] + width, - normalizedRect[1] - ]; - const layout = layoutTextBlock(anchorRect, values, defaultTextMetrics(values.fontSize)); - const spacing = Math.max(4, values.fontSize * 0.4); - const delta = layout.height + spacing; - const followingBlocks = findBlocksAtOrBelow(model.textBlocks, anchorRect); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation('Add text block', () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of followingBlocks) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - - const maximumBottom = Math.max( - anchorRect[1] + layout.height, - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - ); - this.extendPageToFit(page, maximumBottom); - this.appendStaticText(page, [...layout.entries, ...shiftedEntries]); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: [anchorRect[0], anchorRect[1], anchorRect[2], anchorRect[1] + layout.height] - }; - } - - /** - * Inserts an image as a flow object. Text intersecting the insertion point - * and all text below it in the same column is rewritten lower on the page. - */ - insertImageBlock(pageIndex, rect, imageBytes) { - return this.insertFlowObject(pageIndex, rect, 'Add image block', (page, targetRect) => { - this.createImageStamp(page, targetRect, imageBytes); - }); - } - - /** - * Inserts an editable table as a flow object and makes room for it by moving - * lower text blocks instead of covering them. - */ - insertTableBlock(pageIndex, rect, rows = 2, columns = 2, properties = {}) { - const rowCount = Math.max(1, Math.min(30, Math.round(Number(rows) || 2))); - const columnCount = Math.max(1, Math.min(20, Math.round(Number(columns) || 2))); - return this.insertFlowObject(pageIndex, rect, 'Add table block', (page, targetRect) => { - this.createTableAnnotation(page, targetRect, rowCount, columnCount, properties); - }); - } - - insertFlowObject(pageIndex, rect, label, createObject) { - const model = this.getPageModel(pageIndex); - const normalizedRect = normalizeRect(rect); - const objectHeight = Math.max(8, normalizedRect[3] - normalizedRect[1]); - const spacing = 6; - const delta = objectHeight + spacing; - const anchorRect = [ - normalizedRect[0], - normalizedRect[1], - normalizedRect[2], - normalizedRect[1] - ]; - const followingBlocks = findBlocksAtOrBelow(model.textBlocks, anchorRect); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation(label, () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of followingBlocks) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - const maximumBottom = Math.max( - normalizedRect[3], - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - ); - this.extendPageToFit(page, maximumBottom); - this.appendStaticText(page, shiftedEntries); - createObject(page, normalizedRect); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: normalizedRect - }; - } - - resizeTextBlock(pageIndex, blockIndex, targetRect) { - const model = this.getPageModel(pageIndex); - const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - throw new Error('The selected text block no longer exists. Select it again.'); - } - - const target = normalizeRect(targetRect); - const minimumWidth = Math.max(36, Number(block.font?.size || 12) * 3); - const width = Math.max(minimumWidth, target[2] - target[0]); - const layoutRect = [target[0], target[1], target[0] + width, target[1]]; - const values = normalizeBlockProperties(block, { text: block.text }); - const layout = layoutTextBlock(layoutRect, values, block.metrics, block); - const newBottom = layoutRect[1] + layout.height; - const delta = newBottom - block.rect[3]; - const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation('Resize text block', () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of [block, ...followingBlocks]) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - this.extendPageToFit(page, Math.max( - newBottom, - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - )); - this.appendStaticText(page, [...layout.entries, ...shiftedEntries]); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: [layoutRect[0], layoutRect[1], layoutRect[2], newBottom] - }; - } - - moveTextBlock(pageIndex, blockIndex, targetRect) { - const model = this.getPageModel(pageIndex); - const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - throw new Error('The selected text block no longer exists. Select it again.'); - } - - const normalizedTarget = normalizeRect(targetRect); - const deltaX = normalizedTarget[0] - block.rect[0]; - const deltaY = normalizedTarget[1] - block.rect[1]; - const entries = textEntriesForExistingBlock(block, deltaY, deltaX); - this.withOperation('Move text block', () => { - this.withPage(pageIndex, (page) => { - this.removeContentInRect(page, expandRect(block.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - this.extendPageToFit(page, block.rect[3] + deltaY); - this.appendStaticText(page, entries); - }); - }); - } - - addTable(pageIndex, rect, rows = 2, columns = 2, properties = {}) { - const normalizedRect = normalizeRect(rect); - const rowCount = Math.max(1, Math.min(30, Math.round(Number(rows) || 2))); - const columnCount = Math.max(1, Math.min(20, Math.round(Number(columns) || 2))); - - this.withOperation('Add table', () => { - this.withPage(pageIndex, (page) => { - this.createTableAnnotation( - page, - normalizedRect, - rowCount, - columnCount, - properties - ); - }); - }); - } - - updateTable(pageIndex, annotationIndex, rect, rows, columns, properties = {}) { - const model = this.getPageModel(pageIndex); - const sourceTable = model.annotations.find((annotation) => - annotation.index === annotationIndex && annotation.table - ); - if (!sourceTable) { - throw new Error('The selected table no longer exists.'); - } - const targetRect = normalizeRect(rect); - const horizontalFlowRect = unionRects([sourceTable.rect, targetRect]); - const flowRect = [ - horizontalFlowRect[0], - sourceTable.rect[1], - horizontalFlowRect[2], - sourceTable.rect[3] - ]; - const delta = targetRect[3] - sourceTable.rect[3]; - const followingBlocks = Math.abs(delta) > 0.01 - ? findFollowingBlocks(model.textBlocks, flowRect, -1) - : []; - const shiftedEntries = followingBlocks.flatMap((block) => - textEntriesForExistingBlock(block, delta) - ); - - this.withOperation('Edit table', () => { - this.withPage(pageIndex, (page) => { - const annotations = page.getAnnotations(); - try { - const annotation = annotations[annotationIndex]; - if (!annotation) { - throw new Error('The selected table no longer exists.'); - } - const metadata = parseTableMetadata( - annotation.getType(), - safeCall(() => annotation.getSubject(), ''), - safeCall(() => annotation.getContents(), '') - ); - if (!metadata) { - throw new Error('The selected object is not an editable table.'); - } - for (const block of followingBlocks) { - this.removeContentInRect(page, expandRect(block.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - page.deleteAnnotation(annotation); - this.createTableAnnotation(page, targetRect, rows, columns, { - color: properties.color || metadata.color, - borderWidth: properties.borderWidth || metadata.borderWidth - }); - if (shiftedEntries.length > 0) { - this.extendPageToFit(page, Math.max( - targetRect[3], - ...followingBlocks.map((block) => block.rect[3] + delta) - )); - this.appendStaticText(page, shiftedEntries); - } - page.update(); - } finally { - destroyAll(annotations); - } - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: targetRect - }; - } - - createTableAnnotation(page, rect, rows, columns, properties = {}) { - const normalizedRect = normalizeRect(rect); - const rowCount = Math.max(1, Math.min(30, Math.round(Number(rows) || 2))); - const columnCount = Math.max(1, Math.min(20, Math.round(Number(columns) || 2))); - const color = normalizeColor(properties.color, [0, 0, 0]); - const borderWidth = Math.max(0.25, Math.min(8, Number(properties.borderWidth || 1))); - const strokes = []; - for (let row = 0; row <= rowCount; row += 1) { - const y = normalizedRect[1] + ( - (normalizedRect[3] - normalizedRect[1]) * row / rowCount - ); - strokes.push([[normalizedRect[0], y], [normalizedRect[2], y]]); - } - for (let column = 0; column <= columnCount; column += 1) { - const x = normalizedRect[0] + ( - (normalizedRect[2] - normalizedRect[0]) * column / columnCount - ); - strokes.push([[x, normalizedRect[1]], [x, normalizedRect[3]]]); - } - - const annotation = page.createAnnotation('Ink'); - try { - annotation.setInkList(strokes); - annotation.setColor(color); - if (annotation.hasBorder()) { - annotation.setBorderWidth(borderWidth); - } - annotation.setAuthor('PDF Viewer & Editor'); - annotation.setSubject(TABLE_SUBJECT); - annotation.setContents(JSON.stringify({ - type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth, - rect: normalizedRect - })); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - } - } - - appendRawContent(page, commands) { - const pageObject = page.getObject(); - try { - const stream = this.pdfDocument.addStream(String(commands || '')); - let contents = pageObject.get('Contents'); - if (contents.isArray()) { - contents.push(stream); - } else { - const contentsArray = this.pdfDocument.newArray(); - if (!contents.isNull()) { - contentsArray.push(contents); - } - contentsArray.push(stream); - pageObject.put('Contents', contentsArray); - contentsArray.destroy(); - } - stream.destroy(); - contents.destroy(); - page.update(); - } finally { - pageObject.destroy(); - } - } - - extendPageToFit(page, maximumBottom) { - const bounds = page.getBounds(); - if (maximumBottom <= bounds[3] - 18) { - return false; - } - - const extendedBounds = [ - bounds[0], - bounds[1], - bounds[2], - Math.ceil(maximumBottom + 24) - ]; - page.setPageBox('MediaBox', extendedBounds); - page.setPageBox('CropBox', extendedBounds); - page.update(); - return true; - } - - appendStaticText(page, entries) { - if (!Array.isArray(entries) || entries.length === 0) { - return; - } - - const pageObject = page.getObject(); - try { - let resources = pageObject.getInheritable('Resources'); - if (resources.isNull()) { - resources.destroy(); - resources = this.pdfDocument.newDictionary(); - pageObject.put('Resources', resources); - } - - let fonts = resources.get('Font'); - if (fonts.isNull()) { - fonts.destroy(); - fonts = this.pdfDocument.newDictionary(); - resources.put('Font', fonts); - } - - const fontEntries = new Map(); - const fontToken = Date.now().toString(36); - const getFontEntry = (fontName) => { - if (!fontEntries.has(fontName)) { - const font = new mupdf.Font(fontName); - const reference = this.pdfDocument.addFont(font); - const resourceName = `FPVE${fontToken}${fontEntries.size}`; - fonts.put(resourceName, reference); - fontEntries.set(fontName, { font, reference, resourceName }); - } - return fontEntries.get(fontName); - }; - - const transform = page.getTransform(); - const commands = ['q']; - try { - for (const entry of entries) { - const text = String(entry.text || ''); - if (!text) { - continue; - } - const fontName = selectWritingFont(text, entry.fontName); - const fontEntry = getFontEntry(fontName); - const encoded = encodePdfGlyphHex(fontEntry.font, text); - const color = normalizeColor(entry.color, [0, 0, 0]); - const matrix = textMatrixForPage(transform, entry.baseline); - commands.push( - `${formatNumber(color[0])} ${formatNumber(color[1])} ${formatNumber(color[2])} rg`, - 'BT', - `/${fontEntry.resourceName} ${formatNumber(entry.fontSize)} Tf`, - `${matrix.map(formatNumber).join(' ')} Tm`, - `<${encoded.hex}> Tj`, - 'ET' - ); - } - commands.push('Q'); - } finally { - for (const entry of fontEntries.values()) { - entry.reference.destroy(); - entry.font.destroy(); - } - } - - const stream = this.pdfDocument.addStream(commands.join('\n')); - let contents = pageObject.get('Contents'); - if (contents.isArray()) { - contents.push(stream); - } else { - const contentsArray = this.pdfDocument.newArray(); - if (!contents.isNull()) { - contentsArray.push(contents); - } - contentsArray.push(stream); - pageObject.put('Contents', contentsArray); - contentsArray.destroy(); - } - - stream.destroy(); - contents.destroy(); - fonts.destroy(); - resources.destroy(); - page.update(); - } finally { - pageObject.destroy(); - } - } - - createFreeText(page, rect, properties) { - const annotation = page.createAnnotation('FreeText'); - try { - annotation.setRect(normalizeRect(rect)); - annotation.setContents(String(properties.text || '')); - annotation.setDefaultAppearance( - normalizePdfFont(properties.font), - Number(properties.fontSize || 12), - normalizeColor(properties.color, [0, 0, 0]) - ); - annotation.setQuadding(Number(properties.alignment || 0)); - annotation.setOpacity(Number(properties.opacity ?? 1)); - annotation.setBorderWidth(Number(properties.borderWidth || 0)); - annotation.setAuthor(String(properties.author || 'PDF Viewer & Editor')); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - } - } - - addImage(pageIndex, rect, imageBytes) { - this.withOperation('Add image', () => { - this.withPage(pageIndex, (page) => this.createImageStamp(page, rect, imageBytes)); - }); - } - - replaceExistingImage(pageIndex, sourceRect, targetRect, imageBytes) { - this.withOperation('Replace image', () => { - this.withPage(pageIndex, (page) => { - this.removeContentInRect(page, sourceRect, { - images: true, - lineArt: false, - text: false - }); - this.createImageStamp(page, targetRect, imageBytes); - }); - }); - } - - replaceAnnotationImage(pageIndex, annotationIndex, rect, imageBytes) { - this.withOperation('Replace image', () => { - this.withPage(pageIndex, (page) => { - const annotations = page.getAnnotations(); - try { - const annotation = annotations[annotationIndex]; - if (!annotation) { - throw new Error('The selected image annotation no longer exists.'); - } - page.deleteAnnotation(annotation); - this.createImageStamp(page, rect, imageBytes); - } finally { - destroyAll(annotations); - } - }); - }); - } - - createImageStamp(page, rect, imageBytes) { - const image = new mupdf.Image(imageBytes); - const annotation = page.createAnnotation('Stamp'); - try { - annotation.setRect(normalizeRect(rect)); - annotation.setIntent('StampImage'); - annotation.setStampImage(image); - annotation.setAuthor('PDF Viewer & Editor'); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - image.destroy(); - } - } - - getImageDimensions(imageBytes) { - const image = new mupdf.Image(imageBytes); - try { - return { - width: image.getWidth(), - height: image.getHeight() - }; - } finally { - image.destroy(); - } - } - - deleteExistingContent(pageIndex, rect, options) { - this.withOperation('Delete PDF content', () => { - this.withPage(pageIndex, (page) => this.removeContentInRect(page, rect, options)); - }); - } - - removeContentInRect(page, rect, options) { - const annotation = page.createAnnotation('Redact'); - try { - annotation.setRect(normalizeRect(rect)); - annotation.update(); - annotation.applyRedaction( - 0, - options.images ? mupdf.PDFPage.REDACT_IMAGE_PIXELS : mupdf.PDFPage.REDACT_IMAGE_NONE, - options.lineArt - ? mupdf.PDFPage.REDACT_LINE_ART_REMOVE_IF_TOUCHED - : mupdf.PDFPage.REDACT_LINE_ART_NONE, - options.text ? mupdf.PDFPage.REDACT_TEXT_REMOVE : mupdf.PDFPage.REDACT_TEXT_NONE - ); - page.update(); - } finally { - annotation.destroy(); - } - } - - addShape(pageIndex, shape) { - this.withOperation(`Add ${shape.type}`, () => { - this.withPage(pageIndex, (page) => { - const annotation = page.createAnnotation(shape.type); - try { - if (shape.type === 'Line') { - annotation.setLine(shape.points[0], shape.points[1]); - annotation.setLineEndingStyles( - shape.arrow ? 'None' : 'None', - shape.arrow ? 'ClosedArrow' : 'None' - ); - } else if (shape.type === 'Ink') { - annotation.setInkList(shape.strokes || []); - } else if (shape.quads) { - annotation.setQuadPoints(shape.quads); - } else { - annotation.setRect(normalizeRect(shape.rect)); - } - - annotation.setColor(normalizeColor(shape.color, [1, 0, 0])); - if (annotation.hasInteriorColor() && shape.fillColor) { - annotation.setInteriorColor(normalizeColor(shape.fillColor, [])); - } - if (annotation.hasBorder()) { - annotation.setBorderWidth(Number(shape.borderWidth || 2)); - } - annotation.setOpacity(Number(shape.opacity ?? 1)); - annotation.setAuthor('PDF Viewer & Editor'); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - } - }); - }); - } - - addTextMarkup(pageIndex, type, rect) { - const allowed = new Set(['Highlight', 'Underline', 'StrikeOut', 'Squiggly']); - if (!allowed.has(type)) { - throw new Error(`Unsupported text markup: ${type}.`); + assertLoaded() { + if (!this.document || !this.pdfDocument) { + throw new Error('No PDF document is loaded.'); } - this.addShape(pageIndex, { - type, - quads: [rectToQuad(rect)], - color: type === 'Highlight' ? [1, 0.85, 0] : [0.9, 0.1, 0.1], - opacity: type === 'Highlight' ? 0.45 : 1 - }); - } - - addComment(pageIndex, point, text) { - this.withOperation('Add comment', () => { - this.withPage(pageIndex, (page) => { - const annotation = page.createAnnotation('Text'); - try { - annotation.setRect([point[0], point[1], point[0] + 24, point[1] + 24]); - annotation.setContents(String(text || '')); - annotation.setAuthor('PDF Viewer & Editor'); - annotation.setIcon('Comment'); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - } - }); - }); - } - - addRedaction(pageIndex, rect) { - this.withOperation('Mark redaction', () => { - this.withPage(pageIndex, (page) => { - const annotation = page.createAnnotation('Redact'); - try { - annotation.setRect(normalizeRect(rect)); - annotation.setColor([1, 0, 0]); - if (annotation.hasInteriorColor()) { - annotation.setInteriorColor([0, 0, 0]); - } - annotation.setContents('Marked for permanent redaction'); - annotation.update(); - page.update(); - } finally { - annotation.destroy(); - } - }); - }); - } - - applyRedactions(pageIndex = null) { - this.withOperation('Apply redactions permanently', () => { - const pageIndexes = pageIndex === null - ? Array.from({ length: this.countPages() }, (_, index) => index) - : [pageIndex]; - for (const index of pageIndexes) { - this.withPage(index, (page) => { - page.applyRedactions( - false, - mupdf.PDFPage.REDACT_IMAGE_PIXELS, - mupdf.PDFPage.REDACT_LINE_ART_REMOVE_IF_TOUCHED, - mupdf.PDFPage.REDACT_TEXT_REMOVE - ); - page.update(); - }); - } - }); - } - - updateAnnotation(pageIndex, annotationIndex, properties) { - this.withOperation('Edit annotation', () => { - this.withPage(pageIndex, (page) => { - const annotations = page.getAnnotations(); - try { - const annotation = annotations[annotationIndex]; - if (!annotation) { - throw new Error('The selected annotation no longer exists.'); - } - - if (properties.rect && annotation.hasRect()) { - annotation.setRect(normalizeRect(properties.rect)); - } - if (properties.text !== undefined) { - annotation.setContents(String(properties.text)); - } - if (properties.color) { - annotation.setColor(normalizeColor(properties.color, [0, 0, 0])); - } - if (properties.fillColor && annotation.hasInteriorColor()) { - annotation.setInteriorColor(normalizeColor(properties.fillColor, [])); - } - if (properties.opacity !== undefined) { - annotation.setOpacity(Number(properties.opacity)); - } - if (properties.borderWidth !== undefined && annotation.hasBorder()) { - annotation.setBorderWidth(Number(properties.borderWidth)); - } - if (annotation.getType() === 'FreeText') { - annotation.setDefaultAppearance( - normalizePdfFont(properties.font), - Number(properties.fontSize || 12), - normalizeColor(properties.color, [0, 0, 0]) - ); - annotation.setQuadding(Number(properties.alignment || 0)); - } - annotation.setModificationDate(new Date()); - annotation.update(); - page.update(); - } finally { - destroyAll(annotations); - } - }); - }); - } - - updateAnnotationRect(pageIndex, annotationIndex, rect) { - this.updateAnnotation(pageIndex, annotationIndex, { rect }); - } - - deleteAnnotation(pageIndex, annotationIndex) { - this.withOperation('Delete annotation', () => { - this.withPage(pageIndex, (page) => { - const annotations = page.getAnnotations(); - try { - const annotation = annotations[annotationIndex]; - if (!annotation) { - throw new Error('The selected annotation no longer exists.'); - } - page.deleteAnnotation(annotation); - page.update(); - } finally { - destroyAll(annotations); - } - }); - }); - } - - updateWidget(pageIndex, widgetIndex, value) { - this.withOperation('Fill PDF form', () => { - this.withPage(pageIndex, (page) => { - const widgets = page.getWidgets(); - try { - const widget = widgets[widgetIndex]; - if (!widget) { - throw new Error('The selected form field no longer exists.'); - } - if (widget.isReadOnly()) { - throw new Error('This PDF form field is read-only.'); - } - if (widget.isCheckbox() || widget.isRadioButton()) { - widget.toggle(); - } else if (widget.isChoice()) { - widget.setChoiceValue(String(value)); - } else if (widget.isText()) { - widget.setTextValue(String(value)); - } - widget.update(); - page.update(); - } finally { - destroyAll(widgets); - } - }); - }); - } - - rotatePage(pageIndex, degrees = 90) { - this.withOperation('Rotate page', () => { - const pageObject = this.pdfDocument.findPage(pageIndex); - try { - const inheritedRotation = pageObject.getInheritable('Rotate'); - const currentRotation = inheritedRotation.isNumber() - ? inheritedRotation.asNumber() - : 0; - pageObject.put('Rotate', ((currentRotation + degrees) % 360 + 360) % 360); - inheritedRotation.destroy(); - } finally { - pageObject.destroy(); - } - }); - } - - cropPage(pageIndex, rect) { - this.withOperation('Crop page', () => { - this.withPage(pageIndex, (page) => page.setPageBox('CropBox', normalizeRect(rect))); - }); - } - - addBlankPage(afterPageIndex) { - this.withOperation('Add blank page', () => { - let bounds = [0, 0, 595, 842]; - if (this.countPages() > 0) { - this.withPage(Math.max(0, Math.min(afterPageIndex, this.countPages() - 1)), (page) => { - bounds = [...page.getBounds('MediaBox')]; - }); - } - const resources = this.pdfDocument.addObject(this.pdfDocument.newDictionary()); - const pageObject = this.pdfDocument.addPage(bounds, 0, resources, ''); - this.pdfDocument.insertPage(afterPageIndex + 1, pageObject); - pageObject.destroy(); - resources.destroy(); - }); } - deletePage(pageIndex) { - if (this.countPages() <= 1) { - throw new Error('A PDF must contain at least one page.'); + assertUnlocked() { + this.assertLoaded(); + if (this.document.needsPassword() && !this.passwordAccepted) { + throw new Error('The PDF is password protected.'); } - this.withOperation('Delete page', () => this.pdfDocument.deletePage(pageIndex)); - } - - duplicatePage(pageIndex) { - const snapshot = this.serialize('incremental'); - this.withOperation('Duplicate page', () => { - const sourceDocument = mupdf.Document.openDocument(snapshot, 'application/pdf'); - const sourcePdf = sourceDocument.asPDF(); - try { - this.pdfDocument.graftPage(pageIndex + 1, sourcePdf, pageIndex); - } finally { - sourcePdf.destroy(); - sourceDocument.destroy(); - } - }); } - reorderPage(fromIndex, toIndex) { - const pageCount = this.countPages(); - if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || - fromIndex >= pageCount || toIndex >= pageCount) { - return; + assertPageIndex(pageIndex) { + this.assertUnlocked(); + if (!Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= this.countPages()) { + throw new Error(`Invalid PDF page index: ${pageIndex}.`); } - this.withOperation('Reorder pages', () => { - const order = Array.from({ length: pageCount }, (_, index) => index); - const [moved] = order.splice(fromIndex, 1); - order.splice(toIndex, 0, moved); - this.pdfDocument.rearrangePages(order); - }); } +} - mergePdf(bytes, insertAt) { - this.withOperation('Merge PDF documents', () => { - const sourceDocument = mupdf.Document.openDocument(bytes, 'application/pdf'); - if (!sourceDocument.isPDF()) { - sourceDocument.destroy(); - throw new Error('The selected file is not a PDF.'); - } - const sourcePdf = sourceDocument.asPDF(); - try { - if (sourceDocument.needsPassword()) { - throw new Error('Open the protected PDF separately and save an unlocked copy before merging it.'); - } - for (let index = 0; index < sourceDocument.countPages(); index += 1) { - this.pdfDocument.graftPage(insertAt + index, sourcePdf, index); - } - } finally { - sourcePdf.destroy(); - sourceDocument.destroy(); - } - }); - } - - exportPages(pageIndexes) { - this.assertUnlocked(); - const destination = new mupdf.PDFDocument(); - try { - for (const pageIndex of pageIndexes) { - this.assertPageIndex(pageIndex); - destination.graftPage(-1, this.pdfDocument, pageIndex); - } - const buffer = destination.saveToBuffer('garbage=4,compress=yes'); - try { - return new Uint8Array(buffer.asUint8Array()).slice(); - } finally { - buffer.destroy(); - } - } finally { - destination.destroy(); - } - } - - exportPageImage(pageIndex, format, dpi = 150) { - this.assertPageIndex(pageIndex); - const page = this.pdfDocument.loadPage(pageIndex); - try { - const scale = Math.max(0.1, Number(dpi) / 72); - const pixmap = page.toPixmap( - mupdf.Matrix.scale(scale, scale), - mupdf.ColorSpace.DeviceRGB, - false, - true - ); - try { - if (format === 'jpeg') { - return new Uint8Array(pixmap.asJPEG(90)).slice(); - } - return new Uint8Array(pixmap.asPNG()).slice(); - } finally { - pixmap.destroy(); - } - } finally { - page.destroy(); - } - } - - flattenAnnotations() { - this.withOperation('Flatten annotations', () => this.pdfDocument.bake(true, false)); - } - - getMetadata() { - this.assertLoaded(); - const keys = ['Title', 'Author', 'Subject', 'Keywords', 'Creator', 'Producer']; - return Object.fromEntries(keys.map((key) => [ - key.toLowerCase(), - safeCall(() => this.document.getMetaData(`info:${key}`), '') || '' - ])); - } - - setMetadata(metadata) { - this.withOperation('Edit document properties', () => { - for (const [key, value] of Object.entries(metadata)) { - const normalizedKey = key.charAt(0).toUpperCase() + key.slice(1); - this.document.setMetaData(`info:${normalizedKey}`, String(value || '')); - } - }); - } - - getOutline() { - this.assertLoaded(); - return safeCall(() => this.document.loadOutline(), []) || []; - } - - listAttachments() { - this.assertUnlocked(); - const files = this.pdfDocument.getEmbeddedFiles(); - return Object.entries(files).map(([name, reference]) => { - try { - const params = this.pdfDocument.getFilespecParams(reference); - return { - name, - mimeType: params.mimetype || 'application/octet-stream' - }; - } finally { - reference.destroy(); - } - }); - } - - addAttachment(name, mimeType, bytes) { - this.withOperation('Add embedded file', () => { - const now = new Date(); - const fileSpecification = this.pdfDocument.addEmbeddedFile( - String(name), - String(mimeType || 'application/octet-stream'), - bytes, - now, - now, - true - ); - try { - this.pdfDocument.insertEmbeddedFile(String(name), fileSpecification); - } finally { - fileSpecification.destroy(); - } - }); - } - - extractAttachment(name) { - this.assertUnlocked(); - const files = this.pdfDocument.getEmbeddedFiles(); - try { - const reference = files[name]; - if (!reference) { - throw new Error(`Embedded file not found: ${name}.`); - } - const buffer = this.pdfDocument.getEmbeddedFileContents(reference); - if (!buffer) { - throw new Error(`Could not read embedded file: ${name}.`); - } - try { - return new Uint8Array(buffer.asUint8Array()).slice(); - } finally { - buffer.destroy(); - } - } finally { - destroyAll(Object.values(files)); - } - } - - deleteAttachment(name) { - this.withOperation('Delete embedded file', () => this.pdfDocument.deleteEmbeddedFile(name)); - } - - addOcrTextLayer(pageIndex, words, language = '') { - if (!Array.isArray(words) || words.length === 0) { - return; - } - this.withOperation('Add OCR text layer', () => { - const page = this.pdfDocument.loadPage(pageIndex); - try { - const pageObject = page.getObject(); - try { - let resources = pageObject.getInheritable('Resources'); - if (resources.isNull()) { - resources.destroy(); - resources = this.pdfDocument.newDictionary(); - pageObject.put('Resources', resources); - } - - let fonts = resources.get('Font'); - if (fonts.isNull()) { - fonts.destroy(); - fonts = this.pdfDocument.newDictionary(); - resources.put('Font', fonts); - } - - const fontEntries = new Map(); - const fontToken = Date.now().toString(36); - const getFontEntry = (fontName) => { - if (!fontEntries.has(fontName)) { - const font = new mupdf.Font(fontName); - const reference = this.pdfDocument.addFont(font); - const resourceName = `FOCR${fontToken}${fontEntries.size}`; - fonts.put(resourceName, reference); - fontEntries.set(fontName, { font, reference, resourceName }); - } - return fontEntries.get(fontName); - }; - - const transform = page.getTransform(); - const commands = ['q', 'BT', '3 Tr']; - try { - for (const word of words) { - const text = String(word.text || '').trim(); - if (!text) { - continue; - } - const fontEntry = getFontEntry(selectOcrFont(text, language)); - const encoded = encodePdfGlyphHex(fontEntry.font, text); - const rect = normalizeRect(word.rect); - const baseline = transformPoint(transform, [rect[0], rect[3]]); - const fontSize = Math.max(4, rect[3] - rect[1]); - const estimatedWidth = Math.max(fontSize * 0.25, encoded.advance * fontSize); - const targetWidth = Math.max(1, rect[2] - rect[0]); - const horizontalScale = Math.max(10, Math.min(400, (targetWidth / estimatedWidth) * 100)); - commands.push( - `/${fontEntry.resourceName} ${formatNumber(fontSize)} Tf`, - `${formatNumber(horizontalScale)} Tz`, - `1 0 0 1 ${formatNumber(baseline[0])} ${formatNumber(baseline[1])} Tm`, - `<${encoded.hex}> Tj` - ); - } - commands.push('ET', 'Q'); - } finally { - for (const entry of fontEntries.values()) { - entry.reference.destroy(); - entry.font.destroy(); - } - } - - const stream = this.pdfDocument.addStream(commands.join('\n')); - let contents = pageObject.get('Contents'); - if (contents.isArray()) { - contents.push(stream); - } else { - const contentsArray = this.pdfDocument.newArray(); - if (!contents.isNull()) { - contentsArray.push(contents); - } - contentsArray.push(stream); - pageObject.put('Contents', contentsArray); - contentsArray.destroy(); - } - - stream.destroy(); - contents.destroy(); - fonts.destroy(); - resources.destroy(); - } finally { - pageObject.destroy(); - } - } finally { - page.destroy(); - } - }); - } - - serialize(mode = 'incremental') { - this.assertUnlocked(); - const options = mode === 'clean' - ? 'garbage=4,compress=yes,compress-images=yes,compress-fonts=yes' - : (this.pdfDocument.canBeSavedIncrementally() ? 'incremental' : 'compress=yes'); - const buffer = this.pdfDocument.saveToBuffer(options); - try { - return new Uint8Array(buffer.asUint8Array()).slice(); - } finally { - buffer.destroy(); - } - } - - withOperation(label, callback) { - this.assertUnlocked(); - this.pdfDocument.beginOperation(label); - try { - callback(); - this.pdfDocument.endOperation(); - } catch (error) { - this.pdfDocument.abandonOperation(); - throw error; - } - } - - withPage(pageIndex, callback) { - this.assertPageIndex(pageIndex); - const page = this.pdfDocument.loadPage(pageIndex); - try { - return callback(page); - } finally { - page.destroy(); - } - } - - assertLoaded() { - if (!this.document || !this.pdfDocument) { - throw new Error('No PDF document is loaded.'); - } - } - - assertUnlocked() { - this.assertLoaded(); - if (this.document.needsPassword() && !this.passwordAccepted) { - throw new Error('The PDF is password protected.'); - } - } - - assertPageIndex(pageIndex) { - this.assertUnlocked(); - if (!Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= this.countPages()) { - throw new Error(`Invalid PDF page index: ${pageIndex}.`); - } - } -} - -function extractTextBlocks(structuredText) { - const blocks = []; - let currentBlock = null; - let currentLine = null; - - structuredText.walk({ - beginTextBlock(bbox) { - currentBlock = { - sourceRect: normalizeRect([...bbox]), - lines: [] - }; - }, - beginLine(bbox) { - currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map(), - characters: [] - }; - }, - onChar(character, origin, font, size, quad, color) { - if (!currentLine) { - font.destroy(); - return; - } - try { - const fontInfo = { - name: safeCall(() => font.getName(), 'Helvetica'), - family: safeCall(() => font.isMono(), false) - ? 'monospace' - : (safeCall(() => font.isSerif(), false) ? 'serif' : 'sans-serif'), - weight: safeCall(() => font.isBold(), false) ? 'bold' : 'normal', - style: safeCall(() => font.isItalic(), false) ? 'italic' : 'normal', - size: Number(size || 12), - editableFamily: safeCall(() => font.isMono(), false) - ? 'Courier' - : (safeCall(() => font.isSerif(), false) ? 'Times-Roman' : 'Helvetica'), - bold: safeCall(() => font.isBold(), false), - italic: safeCall(() => font.isItalic(), false) - }; - const normalizedColor = normalizeColor([...color], [0, 0, 0]); - const styleKey = JSON.stringify([fontInfo, normalizedColor]); - const weightedStyle = currentLine.styles.get(styleKey) || { - font: fontInfo, - color: normalizedColor, - weight: 0 - }; - weightedStyle.weight += Math.max(1, String(character).trim().length); - currentLine.styles.set(styleKey, weightedStyle); - const characterText = String(character); - const characterRect = quadToRect(quad); - const characterStart = currentLine.text.length; - currentLine.text += characterText; - currentLine.characters.push({ - text: characterText, - rect: characterRect, - start: characterStart, - end: currentLine.text.length - }); - currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; - currentLine.rect = unionRects([ - currentLine.rect, - characterRect - ]); - } finally { - font.destroy(); - } - }, - endLine() { - if (!currentBlock || !currentLine) { - currentLine = null; - return; - } - const style = dominantWeightedStyle(currentLine.styles); - currentBlock.lines.push({ - text: currentLine.text, - rect: currentLine.rect, - baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color, - characters: currentLine.characters - }); - currentLine = null; - }, - endTextBlock() { - if (!currentBlock) { - return; - } - const lines = currentBlock.lines.filter((line) => line.text.length > 0); - if (lines.length > 0) { - const visualLines = buildVisualLines(lines); - for (const paragraphLines of splitVisualLinesIntoParagraphs(visualLines)) { - const paragraphFragments = paragraphLines.flatMap((line) => line.fragments); - const rect = unionRects(paragraphLines.map((line) => line.rect)); - const blockStyle = dominantLineStyle(paragraphFragments); - const index = blocks.length; - const decoratedLines = decorateParagraphLines(paragraphLines, index); - blocks.push({ - index, - rect, - text: decoratedLines.text, - lines: paragraphFragments.map((line, lineIndex) => ({ - ...line, - blockIndex: index, - lineIndex - })), - visualLines: decoratedLines.lines, - font: blockStyle.font, - color: blockStyle.color, - alignment: inferTextAlignment(rect, paragraphLines), - metrics: metricsForBlock(rect, paragraphLines, blockStyle.font.size) - }); - } - } - currentBlock = null; - } - }); - - return blocks; -} - -function dominantWeightedStyle(styles) { - const fallback = { - font: { - name: 'Helvetica', - family: 'sans-serif', - weight: 'normal', - style: 'normal', - size: 12, - editableFamily: 'Helvetica', - bold: false, - italic: false - }, - color: [0, 0, 0], - weight: 1 - }; - return [...styles.values()].reduce( - (best, candidate) => candidate.weight > best.weight ? candidate : best, - fallback - ); -} - -function dominantLineStyle(lines) { - const styles = new Map(); - for (const line of lines) { - const key = JSON.stringify([line.font, line.color]); - const value = styles.get(key) || { - font: line.font, - color: line.color, - weight: 0 - }; - value.weight += Math.max(1, line.text.trim().length); - styles.set(key, value); - } - return dominantWeightedStyle(styles); -} - -function buildVisualLines(lines) { - const sortedLines = [...lines].sort((left, right) => { - const yDifference = left.baseline[1] - right.baseline[1]; - return Math.abs(yDifference) > 0.75 - ? yDifference - : left.rect[0] - right.rect[0]; - }); - const rows = []; - for (const line of sortedLines) { - const tolerance = Math.max(1, line.font.size * 0.2); - const row = rows.findLast((candidate) => - Math.abs(candidate.baseline[1] - line.baseline[1]) <= tolerance - ); - if (row) { - row.fragments.push(line); - row.rect = unionRects([row.rect, line.rect]); - } else { - rows.push({ - fragments: [line], - rect: [...line.rect], - baseline: [...line.baseline] - }); - } - } - - return rows.map((row) => { - row.fragments.sort((left, right) => left.rect[0] - right.rect[0]); - let text = ''; - const characters = []; - let previous = null; - for (const fragment of row.fragments) { - if (previous && needsVisualSpace(previous, fragment)) { - const start = text.length; - text += ' '; - characters.push({ - text: ' ', - rect: [ - previous.rect[2], - Math.min(previous.rect[1], fragment.rect[1]), - fragment.rect[0], - Math.max(previous.rect[3], fragment.rect[3]) - ], - start, - end: text.length - }); - } - const fragmentStart = text.length; - text += fragment.text; - characters.push(...(fragment.characters || []).map((character) => ({ - ...character, - start: fragmentStart + Number(character.start || 0), - end: fragmentStart + Number(character.end ?? character.text?.length ?? 0) - }))); - previous = fragment; - } - const style = dominantLineStyle(row.fragments); - return { - text, - characters, - fragments: row.fragments, - rect: row.rect, - baseline: [row.fragments[0].baseline[0], row.baseline[1]], - font: style.font, - color: style.color - }; - }); -} - -function needsVisualSpace(left, right) { - if (/\s$/u.test(left.text) || /^\s/u.test(right.text)) { - return false; - } - const gap = right.rect[0] - left.rect[2]; - return gap > Math.max(0.75, Math.min(left.font.size, right.font.size) * 0.12); -} - -function inferTextAlignment(rect, visualLines) { - if (visualLines.length < 2) { - return 0; - } - const leftSpread = Math.max(...visualLines.map((line) => Math.abs(line.rect[0] - rect[0]))); - const rightSpread = Math.max(...visualLines.map((line) => Math.abs(line.rect[2] - rect[2]))); - const center = (rect[0] + rect[2]) / 2; - const centerSpread = Math.max(...visualLines.map((line) => - Math.abs((line.rect[0] + line.rect[2]) / 2 - center) - )); - if (centerSpread < leftSpread * 0.65 && centerSpread < rightSpread * 0.65) { - return 1; - } - if (rightSpread < leftSpread * 0.65) { - return 2; - } - return 0; -} - -function metricsForBlock(rect, visualLines, fontSize) { - if (visualLines.length === 0) { - return defaultTextMetrics(fontSize); - } - const firstBaseline = visualLines[0].baseline[1]; - const lastBaseline = visualLines.at(-1).baseline[1]; - const baselineOffset = Math.max(1, firstBaseline - rect[1]); - const lineHeight = visualLines.length > 1 - ? Math.max(1, (lastBaseline - firstBaseline) / (visualLines.length - 1)) - : Math.max(fontSize * 1.2, visualLines[0].rect[3] - visualLines[0].rect[1]); - const descent = Math.max(1, rect[3] - lastBaseline); - const size = Math.max(1, Number(fontSize || 12)); - return { - baselineOffset, - lineHeight, - descent, - baselineOffsetRatio: baselineOffset / size, - lineHeightRatio: lineHeight / size, - descentRatio: descent / size - }; -} - -function defaultTextMetrics(fontSize) { - const size = Math.max(1, Number(fontSize || 12)); - return { - baselineOffset: size * 1.05, - lineHeight: size * 1.25, - descent: size * 0.25, - baselineOffsetRatio: 1.05, - lineHeightRatio: 1.25, - descentRatio: 0.25 - }; -} - -function normalizeBlockProperties(block, properties) { - const originalFont = block.font || {}; - const requestedFamily = properties.fontFamily || properties.font || originalFont.editableFamily; - const fontFamily = normalizeEditableFontFamily(requestedFamily); - const bold = properties.bold === undefined - ? Boolean(originalFont.bold) - : Boolean(properties.bold); - const italic = properties.italic === undefined - ? Boolean(originalFont.italic) - : Boolean(properties.italic); - const fontSize = Math.max(4, Math.min(144, Number( - properties.fontSize ?? originalFont.size ?? 12 - ))); - return { - text: String(properties.text ?? block.text ?? '').replaceAll('\0', ''), - fontFamily, - fontName: resolveBase14Font(fontFamily, bold, italic), - fontSize, - bold, - italic, - color: normalizeColor(properties.color || block.color, [0, 0, 0]), - alignment: Math.max(0, Math.min(2, Number( - properties.alignment ?? block.alignment ?? 0 - ))) - }; -} - -function normalizeEditableFontFamily(value) { - const normalized = String(value || '').toLowerCase(); - if (normalized.includes('times') || normalized.includes('serif') || normalized === 'tiro') { - return 'Times-Roman'; - } - if (normalized.includes('courier') || normalized.includes('mono') || normalized === 'cour') { - return 'Courier'; - } - return 'Helvetica'; -} - -function resolveBase14Font(family, bold, italic) { - if (family === 'Times-Roman') { - if (bold && italic) { - return 'Times-BoldItalic'; - } - if (bold) { - return 'Times-Bold'; - } - if (italic) { - return 'Times-Italic'; - } - return 'Times-Roman'; - } - if (family === 'Courier') { - if (bold && italic) { - return 'Courier-BoldOblique'; - } - if (bold) { - return 'Courier-Bold'; - } - if (italic) { - return 'Courier-Oblique'; - } - return 'Courier'; - } - if (bold && italic) { - return 'Helvetica-BoldOblique'; - } - if (bold) { - return 'Helvetica-Bold'; - } - if (italic) { - return 'Helvetica-Oblique'; - } - return 'Helvetica'; -} - - -function splitVisualLinesIntoParagraphs(visualLines) { - if (!Array.isArray(visualLines) || visualLines.length === 0) { - return []; - } - if (visualLines.length === 1) { - return [[visualLines[0]]]; - } - - const lines = [...visualLines].sort((left, right) => { - const yDifference = left.baseline[1] - right.baseline[1]; - return Math.abs(yDifference) > 0.75 - ? yDifference - : left.rect[0] - right.rect[0]; - }); - const paragraphs = []; - let paragraph = [lines[0]]; - - for (const line of lines.slice(1)) { - const previous = paragraph.at(-1); - const previousHeight = Math.max(1, previous.rect[3] - previous.rect[1]); - const currentHeight = Math.max(1, line.rect[3] - line.rect[1]); - const fontSize = Math.max( - 1, - Number(previous.font?.size || 0), - Number(line.font?.size || 0) - ); - const verticalGap = line.rect[1] - previous.rect[3]; - const baselineGap = line.baseline[1] - previous.baseline[1]; - const expectedLineHeight = Math.max(previousHeight, currentHeight, fontSize * 1.05); - const paragraphGap = Math.max(3, fontSize * 0.55); - const startsNewParagraph = - verticalGap > paragraphGap || - baselineGap > expectedLineHeight * 1.75; - - if (startsNewParagraph) { - paragraphs.push(paragraph); - paragraph = [line]; - } else { - paragraph.push(line); - } - } - - paragraphs.push(paragraph); - return paragraphs; -} - -function decorateParagraphLines(paragraphLines, blockIndex) { - let text = ''; - const lines = []; - - for (const sourceLine of paragraphLines) { - const rawText = String(sourceLine.text || ''); - const lineText = rawText.trim(); - if (!lineText) { - continue; - } - const leadingTrim = rawText.length - rawText.trimStart().length; - const rawEnd = leadingTrim + lineText.length; - const separator = text ? ' ' : ''; - const start = text.length + separator.length; - text += separator + lineText; - const end = text.length; - const lineIndex = lines.length; - const characters = (sourceLine.characters || []) - .filter((character) => character.end > leadingTrim && character.start < rawEnd) - .map((character) => ({ - ...character, - start: start + Math.max(0, character.start - leadingTrim), - end: start + Math.min(lineText.length, character.end - leadingTrim) - })); - lines.push({ - ...sourceLine, - text: lineText, - characters, - blockIndex, - lineIndex, - start, - end, - startOffset: start, - endOffset: end, - textStart: start, - textEnd: end - }); - } - - return { text, lines }; -} - -function layoutRichTextBlock(rect, runs, metrics, alignment = 0) { - const normalizedRuns = (Array.isArray(runs) ? runs : []) - .map((run) => ({ - text: String(run?.text || '').replaceAll('\r\n', '\n').replaceAll('\r', '\n'), - values: run?.values || {} - })) - .filter((run) => run.text.length > 0); - - if (normalizedRuns.length === 0) { - return { height: 0, entries: [], lines: [] }; - } - - const width = Math.max(24, rect[2] - rect[0]); - const fonts = new Map(); - const getFont = (fontName) => { - const name = String(fontName || 'Helvetica'); - if (!fonts.has(name)) { - fonts.set(name, new mupdf.Font(name)); - } - return fonts.get(name); - }; - const itemWidth = (item) => measureText( - getFont(item.values.fontName), - item.character, - Math.max(4, Number(item.values.fontSize || 12)) - ); - const lineWidth = (items) => items.reduce((sum, item) => sum + itemWidth(item), 0); - const trimLine = (items) => { - let start = 0; - let end = items.length; - while (start < end && /\s/u.test(items[start].character)) { - start += 1; - } - while (end > start && /\s/u.test(items[end - 1].character)) { - end -= 1; - } - return items.slice(start, end); - }; - - try { - const items = []; - for (const run of normalizedRuns) { - for (const character of run.text) { - items.push({ character, values: run.values }); - } - } - - const wrappedLines = []; - let current = []; - let currentWidth = 0; - - const commitLine = (lineItems) => { - wrappedLines.push(trimLine(lineItems)); - }; - - for (const item of items) { - if (item.character === '\n') { - commitLine(current); - current = []; - currentWidth = 0; - continue; - } - - const widthToAdd = itemWidth(item); - if (current.length > 0 && currentWidth + widthToAdd > width + 0.5) { - let breakAt = -1; - for (let index = current.length - 1; index >= 0; index -= 1) { - if (/\s/u.test(current[index].character)) { - breakAt = index; - break; - } - } - - if (breakAt >= 0) { - const carry = current.slice(breakAt + 1); - commitLine(current.slice(0, breakAt)); - current = carry; - currentWidth = lineWidth(current); - } else { - commitLine(current); - current = []; - currentWidth = 0; - } - } - - if (/\s/u.test(item.character) && current.length === 0) { - continue; - } - current.push(item); - currentWidth += widthToAdd; - } - commitLine(current); - - const maximumFontSize = Math.max( - 4, - ...normalizedRuns.map((run) => Math.max(4, Number(run.values.fontSize || 12))) - ); - const baselineOffset = clampNumber( - Number(metrics?.baselineOffsetRatio || 1.05) * maximumFontSize, - maximumFontSize * 0.75, - maximumFontSize * 1.5 - ); - const lineHeight = clampNumber( - Number(metrics?.lineHeightRatio || 1.25) * maximumFontSize, - maximumFontSize, - maximumFontSize * 2 - ); - const descent = clampNumber( - Number(metrics?.descentRatio || 0.25) * maximumFontSize, - maximumFontSize * 0.12, - maximumFontSize * 0.6 - ); - const height = baselineOffset + Math.max(0, wrappedLines.length - 1) * lineHeight + descent; - const entries = []; - - wrappedLines.forEach((lineItems, lineIndex) => { - const measuredWidth = lineWidth(lineItems); - let x = rect[0]; - if (alignment === 1) { - x = rect[0] + (width - measuredWidth) / 2; - } else if (alignment === 2) { - x = rect[2] - measuredWidth; - } - const baselineY = rect[1] + baselineOffset + lineIndex * lineHeight; - let segment = []; - let segmentKey = null; - - const flushSegment = () => { - if (segment.length === 0) { - return; - } - const values = segment[0].values; - const text = segment.map((entry) => entry.character).join(''); - if (text) { - entries.push({ - text, - baseline: [x, baselineY], - fontName: values.fontName || 'Helvetica', - fontSize: Math.max(4, Number(values.fontSize || 12)), - color: normalizeColor(values.color, [0, 0, 0]) - }); - x += measureText( - getFont(values.fontName), - text, - Math.max(4, Number(values.fontSize || 12)) - ); - } - segment = []; - segmentKey = null; - }; - - for (const entry of lineItems) { - const values = entry.values; - const key = JSON.stringify([ - values.fontName || 'Helvetica', - Math.max(4, Number(values.fontSize || 12)), - normalizeColor(values.color, [0, 0, 0]) - ]); - if (segmentKey !== null && key !== segmentKey) { - flushSegment(); - } - segmentKey = key; - segment.push(entry); - } - flushSegment(); - }); - - return { - height, - entries, - lines: wrappedLines.map((line) => line.map((item) => item.character).join('')) - }; - } finally { - destroyAll(fonts.values()); - } -} - -function layoutTextBlock(rect, values, metrics, originalBlock = null) { - if (!values.text) { - return { height: 0, entries: [], lines: [] }; - } - const width = Math.max(24, rect[2] - rect[0]); - const font = new mupdf.Font(values.fontName); - let lines; - try { - lines = wrapText(font, values.text, values.fontSize, width); - } finally { - font.destroy(); - } - - const baselineOffset = clampNumber( - Number(metrics?.baselineOffsetRatio || 1.05) * values.fontSize, - values.fontSize * 0.75, - values.fontSize * 1.5 - ); - const lineHeight = clampNumber( - Number(metrics?.lineHeightRatio || 1.25) * values.fontSize, - values.fontSize, - values.fontSize * 2 - ); - const descent = clampNumber( - Number(metrics?.descentRatio || 0.25) * values.fontSize, - values.fontSize * 0.12, - values.fontSize * 0.6 - ); - const height = baselineOffset + Math.max(0, lines.length - 1) * lineHeight + descent; - const measureFont = new mupdf.Font(values.fontName); - try { - const entries = lines.flatMap((text, index) => { - if (!text) { - return []; - } - const textWidth = measureText(measureFont, text, values.fontSize); - let x = rect[0]; - if (values.alignment === 1) { - x = rect[0] + (width - textWidth) / 2; - } else if (values.alignment === 2) { - x = rect[2] - textWidth; - } - return [{ - text, - baseline: [x, rect[1] + baselineOffset + index * lineHeight], - fontName: values.fontName, - fontSize: values.fontSize, - color: values.color - }]; - }); - return { height, entries, lines, originalBlock }; - } finally { - measureFont.destroy(); - } -} - -function wrapText(font, value, fontSize, maximumWidth) { - const lines = []; - for (const paragraph of String(value).replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')) { - if (!paragraph.trim()) { - lines.push(''); - continue; - } - const words = paragraph.trim().split(/\s+/u); - let line = ''; - for (const word of words) { - const candidate = line ? `${line} ${word}` : word; - if (measureText(font, candidate, fontSize) <= maximumWidth + 0.5) { - line = candidate; - continue; - } - if (line) { - lines.push(line); - line = ''; - } - const pieces = splitLongWord(font, word, fontSize, maximumWidth); - lines.push(...pieces.slice(0, -1)); - line = pieces.at(-1) || ''; - } - lines.push(line); - } - return lines.length > 0 ? lines : ['']; -} - -function splitLongWord(font, word, fontSize, maximumWidth) { - if (measureText(font, word, fontSize) <= maximumWidth + 0.5) { - return [word]; - } - const pieces = []; - let piece = ''; - for (const character of word) { - const candidate = piece + character; - if (piece && measureText(font, candidate, fontSize) > maximumWidth + 0.5) { - pieces.push(piece); - piece = character; - } else { - piece = candidate; - } - } - if (piece) { - pieces.push(piece); - } - return pieces.length > 0 ? pieces : [word]; -} - -function measureText(font, value, fontSize) { - return encodePdfGlyphHex(font, value).advance * fontSize; -} - -function findFollowingBlocks(blocks, anchorRect, excludedIndex) { - return blocks.filter((block) => - block.index !== excludedIndex && - block.rect[1] >= anchorRect[3] - 1 && - rectanglesShareFlow(anchorRect, block.rect) - ); -} - -function findBlocksAtOrBelow(blocks, anchorRect) { - return blocks.filter((block) => - block.rect[3] >= anchorRect[1] - 1 && - rectanglesShareFlow(anchorRect, block.rect) - ); -} - -function rectanglesShareFlow(left, right) { - const overlap = Math.max(0, Math.min(left[2], right[2]) - Math.max(left[0], right[0])); - const minimumWidth = Math.max(1, Math.min(left[2] - left[0], right[2] - right[0])); - return overlap / minimumWidth >= 0.25; -} - -function textEntriesForExistingBlock(block, deltaY, deltaX = 0) { - return block.lines.flatMap((line) => { - if (!line.text) { - return []; - } - return [{ - text: line.text, - baseline: [line.baseline[0] + deltaX, line.baseline[1] + deltaY], - fontName: resolveBase14Font( - normalizeEditableFontFamily(line.font.editableFamily || line.font.family), - Boolean(line.font.bold || line.font.weight === 'bold'), - Boolean(line.font.italic || line.font.style === 'italic') - ), - fontSize: Math.max(4, Number(line.font.size || 12)), - color: normalizeColor(line.color, [0, 0, 0]) - }]; - }); -} - -function expandRect(rect, amount) { - return [rect[0] - amount, rect[1] - amount, rect[2] + amount, rect[3] + amount]; -} - -function selectWritingFont(text, preferredFont) { - const unicodeFont = selectOcrFont(text, ''); - return unicodeFont === 'Helvetica' ? preferredFont : unicodeFont; -} - -function parseTableMetadata(type, subject, contents) { - if (type !== 'Ink' || subject !== TABLE_SUBJECT) { - return null; - } - try { - const value = JSON.parse(contents); - if (value?.type !== 'table') { - return null; - } - const metadataRect = Array.isArray(value.rect) && value.rect.length === 4 - ? value.rect.map(Number) - : null; - return { - rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), - columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), - color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))), - rect: metadataRect?.every(Number.isFinite) - ? normalizeRect(metadataRect) - : null - }; - } catch { - return null; - } -} - -function rectFromInkStrokes(strokes, fallback) { - const points = []; - for (const stroke of Array.isArray(strokes) ? strokes : []) { - for (const point of Array.isArray(stroke) ? stroke : []) { - if (Array.isArray(point) && point.length >= 2 && - Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1]))) { - points.push([Number(point[0]), Number(point[1])]); - } - } - } - if (points.length === 0) { - return normalizeRect(fallback); - } - return [ - Math.min(...points.map((point) => point[0])), - Math.min(...points.map((point) => point[1])), - Math.max(...points.map((point) => point[0])), - Math.max(...points.map((point) => point[1])) - ]; -} - -function textMatrixForPage(transform, baseline) { - const point = transformPoint(transform, baseline); - return [ - transform[0], - transform[1], - -transform[2], - -transform[3], - point[0], - point[1] - ]; -} - -function clampNumber(value, minimum, maximum) { - return Math.min(maximum, Math.max(minimum, Number(value))); -} - -function normalizePdfFont(value) { - return ['Helv', 'TiRo', 'Cour'].includes(value) ? value : 'Helv'; -} - -function normalizeColor(value, fallback) { - if (!Array.isArray(value) || ![0, 1, 3, 4].includes(value.length)) { - return fallback; - } - return value.map((component) => Math.max(0, Math.min(1, Number(component)))); -} - -function unionRects(rects) { - if (rects.length === 0) { - return [0, 0, 0, 0]; +function unionRects(rects) { + if (rects.length === 0) { + return [0, 0, 0, 0]; } return [ Math.min(...rects.map((rect) => rect[0])), @@ -2508,59 +208,3 @@ function unionRects(rects) { Math.max(...rects.map((rect) => rect[3])) ]; } - -function safeCall(callback, fallback) { - try { - return callback(); - } catch { - return fallback; - } -} - -function destroyAll(objects) { - for (const object of objects) { - try { - object?.destroy(); - } catch { - // Ignore cleanup failures for invalidated MuPDF handles. - } - } -} - -function formatNumber(value) { - return Number(value).toFixed(4).replace(/\.?0+$/, ''); -} - -function selectOcrFont(text, language) { - if (/[\uAC00-\uD7AF]/u.test(text)) { - return 'ko'; - } - if (/[\u3040-\u30FF]/u.test(text)) { - return 'ja'; - } - if (/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(text)) { - const normalizedLanguage = String(language || '').toLowerCase(); - if (normalizedLanguage.includes('jpn') || normalizedLanguage === 'ja') { - return 'ja'; - } - if (normalizedLanguage.includes('chi_tra') || normalizedLanguage.includes('zh-hant')) { - return 'zh-Hant'; - } - return 'zh-Hans'; - } - return 'Helvetica'; -} - -function encodePdfGlyphHex(font, value) { - let hex = ''; - let advance = 0; - const replacementGlyph = font.encodeCharacter('?'); - for (const character of String(value)) { - const codePoint = character.codePointAt(0) || 32; - const encodedGlyph = font.encodeCharacter(codePoint); - const glyph = encodedGlyph === 0 && codePoint !== 0 ? replacementGlyph : encodedGlyph; - hex += glyph.toString(16).padStart(4, '0').slice(-4); - advance += Math.max(0, font.advanceGlyph(glyph)); - } - return { hex: hex.toUpperCase(), advance }; -} diff --git a/media/webview/styles.css b/media/webview/styles.css index b684b0c..92b0916 100644 --- a/media/webview/styles.css +++ b/media/webview/styles.css @@ -34,8 +34,7 @@ body { button, input, -select, -textarea { +select { min-height: 28px; border: 1px solid var(--border); border-radius: 3px; @@ -56,8 +55,7 @@ button:hover:not(:disabled) { button:focus-visible, input:focus-visible, -select:focus-visible, -textarea:focus-visible { +select:focus-visible { outline: 1px solid var(--focus); outline-offset: 1px; } @@ -72,12 +70,6 @@ select { padding: 3px 7px; } -input[type="color"] { - width: 34px; - min-height: 26px; - padding: 2px; -} - .hidden { display: none !important; } @@ -96,30 +88,24 @@ input[type="color"] { .app { display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%; } -.topbar, -.editbar, -.selection-context { +.topbar { display: flex; align-items: center; gap: 6px; min-width: 0; padding: 6px 8px; + overflow-x: auto; border-bottom: 1px solid var(--border); background: var(--toolbar); -} - -.topbar { - overflow-x: auto; scrollbar-width: thin; } -.toolbar-group, -.context-row { +.toolbar-group { display: flex; align-items: center; gap: 5px; @@ -153,10 +139,6 @@ input[type="color"] { background: var(--primary-hover); } -.danger-button:not(:disabled) { - color: var(--danger); -} - .page-number-label { display: flex; align-items: center; @@ -183,70 +165,31 @@ input[type="color"] { width: 100%; } -.editbar { - min-height: 42px; - overflow-x: auto; -} - -.tool-button.active, -.toggle-button[aria-pressed="true"] { - border-color: var(--focus); - color: var(--vscode-button-foreground); - background: var(--primary); -} - -.tool-hint { - margin-left: 8px; - overflow: hidden; - color: var(--muted); - text-overflow: ellipsis; - white-space: nowrap; -} - -.selection-context { - min-height: 42px; - overflow-x: auto; - background: var(--panel); -} - -.context-row { - width: 100%; -} - -.context-row label { - display: flex; - align-items: center; - gap: 5px; - color: var(--muted); +.readonly-badge, +.version-badge { + flex: 0 0 auto; + padding: 2px 7px; + border: 1px solid var(--border); + border-radius: 999px; white-space: nowrap; } -#text-font-family { - width: 116px; -} - -#text-font-size { - width: 68px; -} - -#text-alignment { - width: 92px; -} - -#table-rows, -#table-columns, -#table-border-width { - width: 62px; +.readonly-badge { + border-color: color-mix(in srgb, var(--focus) 55%, var(--border)); + color: var(--foreground); + background: var(--selection); + font-weight: 600; } -.context-help { +.version-badge { color: var(--muted); + font-size: 11px; } .workspace { display: flex; - min-height: 0; min-width: 0; + min-height: 0; } .pages-sidebar { @@ -325,226 +268,37 @@ input[type="color"] { .page-stage { position: relative; margin: 0 auto; - overflow: visible; + overflow: hidden; background: #fff; box-shadow: 0 3px 16px rgba(0, 0, 0, 0.35); } #pdf-canvas, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container { - position: absolute !important; +.search-layer { + position: absolute; inset: 0; } -.page-stage > .canvas-container, -.page-stage > .canvas-container .lower-canvas, -.page-stage > .canvas-container .upper-canvas { - left: 0 !important; - top: 0 !important; - margin: 0 !important; -} - #pdf-canvas { z-index: 1; } -.text-layer { - z-index: 4; - overflow: hidden; - pointer-events: none; -} - -.text-layer-line { - position: absolute; - z-index: 1; +.search-layer { + z-index: 2; overflow: hidden; - color: transparent; - border-radius: 2px; - line-height: 1; - white-space: pre; - cursor: text; - pointer-events: auto; - user-select: none; - touch-action: none; - transform-origin: 0 0; - transition: background-color 80ms ease; -} - -.text-layer-line:hover { - background: color-mix(in srgb, var(--focus) 9%, transparent); -} - -.text-range-highlight { - position: absolute; - z-index: 0; - box-sizing: border-box; - border: 1px solid color-mix(in srgb, var(--focus) 82%, transparent); - border-radius: 1px; - background: color-mix(in srgb, var(--focus) 28%, transparent); - pointer-events: none; -} - -.text-layer.resize-mode, -.text-layer.resize-mode .text-layer-line, -.insertion-layer.resize-mode { - pointer-events: none; - user-select: none; -} - -.text-layer.placement-mode .text-layer-line { - pointer-events: none; - user-select: none; -} - -.page-stage > .canvas-container { - z-index: 3; -} - -.insertion-layer { - z-index: 5; - overflow: visible; - pointer-events: none; -} - -.insertion-marker { - position: absolute; - display: flex; - align-items: center; - min-height: 18px; - height: 18px; - padding: 0; - border: 0; - color: var(--focus); - background: transparent; - opacity: 0.32; - pointer-events: auto; - transition: opacity 100ms ease; -} - -.insertion-marker:hover, -.insertion-marker:focus-visible { - border: 0; - background: transparent; - opacity: 1; -} - -.insertion-layer.disabled .insertion-marker { - opacity: 0; pointer-events: none; } -.insertion-plus { - display: grid; - flex: 0 0 18px; - width: 18px; - height: 18px; - place-items: center; - border-radius: 50%; - color: #fff; - background: var(--focus); - font-size: 16px; - font-weight: 700; - line-height: 18px; -} - -.insertion-guide { - flex: 1 1 auto; - height: 1px; - margin-left: 4px; - background: var(--focus); -} - -.insert-menu { - position: absolute; - z-index: 7; - display: flex; - align-items: center; - gap: 5px; - min-width: 220px; - padding: 5px; - border: 1px solid var(--focus); - border-radius: 5px; - color: var(--foreground); - background: var(--surface); - box-shadow: 0 5px 18px rgba(0, 0, 0, 0.28); -} - -.insert-menu span { - margin-right: 3px; - color: var(--muted); - font-size: 11px; -} - -.insert-menu button { - min-height: 25px; - padding: 3px 8px; -} - -.edit-range-button { - position: absolute; - z-index: 7; - width: 126px; - min-height: 28px; - color: var(--primary-text); - background: var(--primary); - box-shadow: 0 3px 12px rgba(0, 0, 0, 0.28); -} - -.edit-range-button:hover:not(:disabled) { - background: var(--primary-hover); -} - -.block-editor { +.search-highlight { position: absolute; - z-index: 8; - min-width: 0; - min-height: 0; - padding: 0; - overflow: visible; - background: transparent; -} - -.block-editor textarea { - display: block; - box-sizing: border-box; - width: 100%; - height: 100%; - min-width: 24px; - min-height: 18px; - resize: both; - padding: 2px 3px; - overflow: auto; - border: 2px solid var(--focus); + border: 1px solid rgba(160, 120, 0, 0.85); border-radius: 2px; - outline: 0; - color: #111; - background: rgba(255, 255, 255, 0.98); - line-height: 1.18; - white-space: pre-wrap; - box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22); -} - -.block-editor textarea:focus { - outline: 0; - border-color: var(--focus); -} - -.block-editor-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - width: max-content; - min-width: 100%; - padding-top: 5px; + background: rgba(255, 215, 0, 0.28); } -.block-editor-actions span { - margin-right: auto; - color: var(--muted); - font-size: 11px; +.search-highlight.current { + border: 2px solid #ff8c00; + background: rgba(255, 165, 0, 0.38); } .statusbar { @@ -597,7 +351,7 @@ input[type="color"] { background: rgba(0, 0, 0, 0.5); } -#editor-dialog { +#viewer-dialog { display: block; width: min(430px, calc(100vw - 36px)); max-height: calc(100vh - 48px); @@ -610,11 +364,11 @@ input[type="color"] { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); } -#editor-dialog form { +#viewer-dialog form { padding: 18px; } -#editor-dialog h2 { +#viewer-dialog h2 { margin: 0 0 14px; font-size: 17px; } @@ -629,12 +383,6 @@ input[type="color"] { gap: 5px; } -.dialog-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - .dialog-actions { display: flex; justify-content: flex-end; @@ -648,24 +396,7 @@ input[type="color"] { min-width: 138px; } - .tool-hint, - .context-help { - display: none; - } - .page-viewport { padding: 16px; } } - - -.version-badge { - flex: 0 0 auto; - margin-left: auto; - padding: 2px 6px; - border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted); - font-size: 11px; - white-space: nowrap; -} diff --git a/media/webview/utils.js b/media/webview/utils.js index 6c250e4..3fe67c5 100644 --- a/media/webview/utils.js +++ b/media/webview/utils.js @@ -2,42 +2,6 @@ export function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); } -export function normalizeRect(rect) { - const [x0, y0, x1, y1] = rect; - return [ - Math.min(x0, x1), - Math.min(y0, y1), - Math.max(x0, x1), - Math.max(y0, y1) - ]; -} - -export function rectFromBbox(bbox) { - if (Array.isArray(bbox)) { - return normalizeRect(bbox); - } - - if (!bbox || typeof bbox !== 'object') { - return [0, 0, 0, 0]; - } - - const x = Number(bbox.x || 0); - const y = Number(bbox.y || 0); - const width = Number(bbox.w || bbox.width || 0); - const height = Number(bbox.h || bbox.height || 0); - return normalizeRect([x, y, x + width, y + height]); -} - -export function rectToObject(rect) { - const normalized = normalizeRect(rect); - return { - x: normalized[0], - y: normalized[1], - width: normalized[2] - normalized[0], - height: normalized[3] - normalized[1] - }; -} - export function quadToRect(quad) { const xValues = [quad[0], quad[2], quad[4], quad[6]]; const yValues = [quad[1], quad[3], quad[5], quad[7]]; @@ -49,11 +13,6 @@ export function quadToRect(quad) { ]; } -export function rectToQuad(rect) { - const [x0, y0, x1, y1] = normalizeRect(rect); - return [x0, y0, x1, y0, x0, y1, x1, y1]; -} - export function transformPoint(matrix, point) { return [ point[0] * matrix[0] + point[1] * matrix[2] + matrix[4], @@ -61,31 +20,6 @@ export function transformPoint(matrix, point) { ]; } -export function invertMatrix(matrix) { - const determinant = matrix[0] * matrix[3] - matrix[1] * matrix[2]; - if (Math.abs(determinant) < 1e-9) { - throw new Error('Cannot invert a singular transformation matrix.'); - } - return [ - matrix[3] / determinant, - -matrix[1] / determinant, - -matrix[2] / determinant, - matrix[0] / determinant, - (matrix[2] * matrix[5] - matrix[3] * matrix[4]) / determinant, - (matrix[1] * matrix[4] - matrix[0] * matrix[5]) / determinant - ]; -} - -export function bytesToBase64(bytes) { - const chunkSize = 0x8000; - let binary = ''; - for (let index = 0; index < bytes.length; index += chunkSize) { - const chunk = bytes.subarray(index, Math.min(index + chunkSize, bytes.length)); - binary += String.fromCharCode(...chunk); - } - return btoa(binary); -} - export function base64ToBytes(value) { const binary = atob(value); const bytes = new Uint8Array(binary.length); @@ -94,109 +28,3 @@ export function base64ToBytes(value) { } return bytes; } - -export function hexToPdfColor(value) { - const normalized = String(value || '#000000').replace('#', '').padEnd(6, '0'); - return [ - Number.parseInt(normalized.slice(0, 2), 16) / 255, - Number.parseInt(normalized.slice(2, 4), 16) / 255, - Number.parseInt(normalized.slice(4, 6), 16) / 255 - ]; -} - -export function pdfColorToHex(color, fallback = '#000000') { - if (!Array.isArray(color) || color.length === 0) { - return fallback; - } - - let rgb; - if (color.length === 1) { - rgb = [color[0], color[0], color[0]]; - } else if (color.length >= 3) { - rgb = color.slice(0, 3); - } else { - return fallback; - } - - return `#${rgb - .map((component) => Math.round(clamp(component, 0, 1) * 255) - .toString(16) - .padStart(2, '0')) - .join('')}`; -} - -export function parsePageRange(value, pageCount) { - const pages = new Set(); - const normalized = String(value || '').trim(); - if (!normalized) { - throw new Error('Enter at least one page number.'); - } - - for (const part of normalized.split(',')) { - const token = part.trim(); - if (!token) { - continue; - } - - const rangeMatch = token.match(/^(\d+)\s*-\s*(\d+)$/); - if (rangeMatch) { - const start = Number(rangeMatch[1]); - const end = Number(rangeMatch[2]); - if (start > end) { - throw new Error(`Invalid descending page range: ${token}.`); - } - for (let page = start; page <= end; page += 1) { - validatePageNumber(page, pageCount); - pages.add(page - 1); - } - continue; - } - - if (!/^\d+$/.test(token)) { - throw new Error(`Invalid page expression: ${token}.`); - } - const page = Number(token); - validatePageNumber(page, pageCount); - pages.add(page - 1); - } - - return [...pages]; -} - -function validatePageNumber(page, pageCount) { - if (!Number.isInteger(page) || page < 1 || page > pageCount) { - throw new Error(`Page ${page} is outside the document (1-${pageCount}).`); - } -} - -export function sanitizeFileStem(value) { - return String(value || 'document') - .replace(/\.pdf$/i, '') - .replace(/[<>:"/\\|?*]/g, '_') - .split('') - .map((character) => character.charCodeAt(0) < 32 ? '_' : character) - .join('') - .trim() || 'document'; -} - -export function fileToBytes(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error || new Error('Could not read the selected file.')); - reader.onload = () => resolve(new Uint8Array(reader.result)); - reader.readAsArrayBuffer(file); - }); -} - -export function uint8ArrayToBlobUrl(bytes, mimeType) { - return URL.createObjectURL(new Blob([bytes], { type: mimeType })); -} - -export function escapeHtml(value) { - return String(value) - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} diff --git a/package-lock.json b/package-lock.json index 77f3fc0..27b2ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,18 @@ { "name": "pdf-viewer-editor", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.7", + "version": "0.0.8", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/node": "^24.0.0", "@types/vscode": "^1.96.0", "@vscode/vsce": "^3.9.2", "eslint": "^9.39.0", - "fabric": "7.4.0", "mupdf": "1.28.0", "typescript": "^5.9.0" }, @@ -21,25 +20,6 @@ "vscode": "^1.96.0" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "dev": true, - "license": "ISC", - "optional": true - }, "node_modules/@azu/format-text": { "version": "1.0.2", "dev": true, @@ -229,116 +209,6 @@ "node": ">=6.9.0" } }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "dev": true, @@ -1361,20 +1231,6 @@ "node": ">=6" } }, - "node_modules/canvas": { - "version": "3.2.3", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": "^18.12.0 || >= 20.9.0" - } - }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -1523,32 +1379,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssstyle": { - "version": "4.6.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/data-urls": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/debug": { "version": "4.4.3", "dev": true, @@ -1565,12 +1395,6 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/decompress-response": { "version": "6.0.0", "dev": true, @@ -2033,18 +1857,6 @@ "node": ">=6" } }, - "node_modules/fabric": { - "version": "7.4.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - }, - "optionalDependencies": { - "canvas": "^3.2.0", - "jsdom": "^26.1.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -2382,18 +2194,6 @@ "node": ">=10" } }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/htmlparser2": { "version": "10.1.0", "dev": true, @@ -2598,12 +2398,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/is-wsl": { "version": "3.1.1", "dev": true, @@ -2665,45 +2459,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "26.1.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/json-buffer": { "version": "3.0.1", "dev": true, @@ -3100,12 +2855,6 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-sarif-builder": { "version": "3.4.0", "dev": true, @@ -3158,12 +2907,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.24", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/object-inspect": { "version": "1.13.4", "dev": true, @@ -3631,12 +3374,6 @@ "node": ">=0.10.0" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/run-applescript": { "version": "7.1.0", "dev": true, @@ -3702,18 +3439,6 @@ "node": ">=11.0.0" } }, - "node_modules/saxes": { - "version": "6.0.0", - "dev": true, - "license": "ISC", - "optional": true, - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/secretlint": { "version": "10.2.2", "dev": true, @@ -4032,12 +3757,6 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/table": { "version": "6.9.0", "dev": true, @@ -4134,24 +3853,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/tldts": { - "version": "6.1.86", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/tmp": { "version": "0.2.7", "dev": true, @@ -4171,30 +3872,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tslib": { "version": "2.8.1", "dev": true, @@ -4345,27 +4022,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "dev": true, @@ -4385,19 +4041,6 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -4426,27 +4069,6 @@ "license": "ISC", "optional": true }, - "node_modules/ws": { - "version": "8.21.3", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/wsl-utils": { "version": "0.1.0", "dev": true, @@ -4461,15 +4083,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=18" - } - }, "node_modules/xml2js": { "version": "0.5.0", "dev": true, @@ -4490,12 +4103,6 @@ "node": ">=4.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/yallist": { "version": "4.0.0", "dev": true, diff --git a/package.json b/package.json index d708ffb..375bc1c 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "pdf-viewer-editor", - "displayName": "PDF Viewer & Editor", - "description": "A focused visual PDF editor with direct text selection, real reflow, image editing, and editable tables in Visual Studio Code.", - "version": "0.0.7", + "displayName": "PDF Viewer (Read Only)", + "description": "A secure, local, read-only PDF viewer for Visual Studio Code with thumbnails, zoom, search, and password support.", + "version": "0.0.8", "publisher": "suzdalenko-dev", "license": "AGPL-3.0-or-later", "repository": { @@ -20,10 +20,9 @@ "keywords": [ "pdf", "viewer", - "editor", - "text", - "reflow", - "table" + "read-only", + "readonly", + "preview" ], "engines": { "vscode": "^1.96.0" @@ -48,7 +47,7 @@ "customEditors": [ { "viewType": "pdfViewerEditor.editor", - "displayName": "PDF Viewer & Editor", + "displayName": "PDF Viewer (Read Only)", "selector": [ { "filenamePattern": "*.pdf" @@ -63,8 +62,8 @@ "commands": [ { "command": "pdfViewerEditor.openWith", - "title": "Open with PDF Viewer & Editor", - "category": "PDF Viewer & Editor", + "title": "Open with PDF Viewer (Read Only)", + "category": "PDF Viewer", "icon": "$(file-pdf)" } ], @@ -78,7 +77,7 @@ ] }, "configuration": { - "title": "PDF Viewer & Editor", + "title": "PDF Viewer", "properties": { "pdfViewerEditor.defaultZoom": { "type": "number", @@ -93,19 +92,6 @@ "minimum": 1000000, "maximum": 100000000, "description": "Maximum number of pixels used to render a page." - }, - "pdfViewerEditor.defaultSaveMode": { - "type": "string", - "enum": [ - "incremental", - "clean" - ], - "default": "incremental", - "enumDescriptions": [ - "Preserve the original file and append changes.", - "Garbage-collect and compress the complete PDF." - ], - "description": "Default serialization mode used while editing." } } } @@ -124,7 +110,6 @@ "@types/vscode": "^1.96.0", "@vscode/vsce": "^3.9.2", "eslint": "^9.39.0", - "fabric": "7.4.0", "mupdf": "1.28.0", "typescript": "^5.9.0" }, diff --git a/scripts/copy-vendor.js b/scripts/copy-vendor.js index 91805b6..7718bfb 100644 --- a/scripts/copy-vendor.js +++ b/scripts/copy-vendor.js @@ -35,17 +35,6 @@ function copyVendors() { path.join(mupdfDestination, 'LICENSE') ); - const fabricRoot = path.join(projectRoot, 'node_modules', 'fabric'); - const fabricDestination = path.join(vendorRoot, 'fabric'); - copyFile( - path.join(fabricRoot, 'dist', 'index.min.js'), - path.join(fabricDestination, 'fabric.min.js') - ); - copyFile( - path.join(fabricRoot, 'LICENSE'), - path.join(fabricDestination, 'LICENSE') - ); - console.log('Vendor assets copied to media/vendor.'); } diff --git a/src/extension.js b/src/extension.js index 4078967..122ece9 100644 --- a/src/extension.js +++ b/src/extension.js @@ -1,13 +1,13 @@ 'use strict'; const vscode = require('vscode'); -const { PdfEditorProvider, VIEW_TYPE } = require('./pdf-editor-provider'); +const { PdfViewerProvider, VIEW_TYPE } = require('./pdf-editor-provider'); /** * @param {vscode.ExtensionContext} context */ function activate(context) { - const provider = new PdfEditorProvider(context); + const provider = new PdfViewerProvider(context); context.subscriptions.push( vscode.window.registerCustomEditorProvider(VIEW_TYPE, provider, { diff --git a/src/pdf-document.js b/src/pdf-document.js index e5da19c..9574634 100644 --- a/src/pdf-document.js +++ b/src/pdf-document.js @@ -1,20 +1,13 @@ 'use strict'; -const vscode = require('vscode'); - class PdfDocument { /** - * @param {vscode.Uri} uri + * @param {import('vscode').Uri} uri * @param {Uint8Array} data */ constructor(uri, data) { this.uri = uri; this._data = data.slice(); - this._revision = 0; - this._disposed = false; - /** @type {vscode.EventEmitter} */ - this._onDidDispose = new vscode.EventEmitter(); - this.onDidDispose = this._onDidDispose.event; } /** @returns {Uint8Array} */ @@ -22,35 +15,8 @@ class PdfDocument { return this._data.slice(); } - /** @returns {number} */ - get revision() { - return this._revision; - } - - /** - * Replace the current binary representation and advance the revision. - * - * @param {Uint8Array} data - * @returns {number} - */ - replaceData(data) { - if (this._disposed) { - throw new Error('Cannot update a disposed PDF document.'); - } - - this._data = data.slice(); - this._revision += 1; - return this._revision; - } - dispose() { - if (this._disposed) { - return; - } - - this._disposed = true; - this._onDidDispose.fire(); - this._onDidDispose.dispose(); + // The immutable byte snapshot is released with the document instance. } } diff --git a/src/pdf-editor-provider.js b/src/pdf-editor-provider.js index ac108eb..899e219 100644 --- a/src/pdf-editor-provider.js +++ b/src/pdf-editor-provider.js @@ -2,38 +2,33 @@ const path = require('node:path'); const vscode = require('vscode'); -const { base64ToBytes, bytesToBase64 } = require('./base64'); +const { bytesToBase64 } = require('./base64'); const { PdfDocument } = require('./pdf-document'); const { getWebviewHtml } = require('./webview-html'); +// Keep the existing view type so upgrades retain their PDF editor association. const VIEW_TYPE = 'pdfViewerEditor.editor'; -class PdfEditorProvider { +class PdfViewerProvider { /** * @param {vscode.ExtensionContext} context */ constructor(context) { this.context = context; - this._onDidChangeCustomDocument = new vscode.EventEmitter(); - this.onDidChangeCustomDocument = this._onDidChangeCustomDocument.event; - /** @type {Map>} */ - this.panels = new Map(); } /** + * This class intentionally implements CustomReadonlyEditorProvider only. It + * has no change, save, save-as, backup, revert, undo, or redo hooks. + * * @param {vscode.Uri} uri - * @param {vscode.CustomDocumentOpenContext} openContext + * @param {vscode.CustomDocumentOpenContext} _openContext * @param {vscode.CancellationToken} _token * @returns {Promise} */ - async openCustomDocument(uri, openContext, _token) { - const sourceUri = openContext.backupId - ? vscode.Uri.parse(openContext.backupId) - : uri; - const data = await vscode.workspace.fs.readFile(sourceUri); - const document = new PdfDocument(uri, data); - document.onDidDispose(() => this.panels.delete(document)); - return document; + async openCustomDocument(uri, _openContext, _token) { + const data = await vscode.workspace.fs.readFile(uri); + return new PdfDocument(uri, data); } /** @@ -53,27 +48,12 @@ class PdfEditorProvider { this.context.extensionUri ); - let documentPanels = this.panels.get(document); - if (!documentPanels) { - documentPanels = new Set(); - this.panels.set(document, documentPanels); - } - documentPanels.add(webviewPanel); - - webviewPanel.onDidDispose(() => { - const panels = this.panels.get(document); - panels?.delete(webviewPanel); - if (panels?.size === 0) { - this.panels.delete(document); - } - }); - webviewPanel.webview.onDidReceiveMessage(async (message) => { try { await this.handleMessage(document, webviewPanel, message); } catch (error) { const messageText = error instanceof Error ? error.message : String(error); - void vscode.window.showErrorMessage(`PDF Viewer & Editor: ${messageText}`); + void vscode.window.showErrorMessage(`PDF Viewer: ${messageText}`); void webviewPanel.webview.postMessage({ type: 'operation-error', message: messageText @@ -83,121 +63,25 @@ class PdfEditorProvider { } /** + * The webview protocol is deliberately allow-listed. Update, command, + * export, and serialization messages are not accepted. + * * @param {PdfDocument} document * @param {vscode.WebviewPanel} panel * @param {Record} message */ async handleMessage(document, panel, message) { - switch (message.type) { - case 'ready': - await this.sendDocument(panel, document); - return; - - case 'document-edited': { - if (typeof message.data !== 'string' || typeof message.label !== 'string') { - throw new Error('The editor returned an invalid PDF update.'); - } - - if (Number(message.baseRevision) !== document.revision) { - await this.sendDocument(panel, document); - throw new Error('The PDF changed in another editor. The latest version was reloaded.'); - } - - const nextData = base64ToBytes(message.data); - this.applyEdit(document, message.label, nextData); - await this.broadcastDocument(document, panel); - return; - } - - case 'command': - await this.executeEditorCommand(document, panel, String(message.command || '')); - return; - - case 'export': - await this.exportFile(message); - return; - - case 'show-message': - await this.showMessage(message); - return; - - default: - return; - } - } - - /** - * @param {PdfDocument} document - * @param {string} label - * @param {Uint8Array} nextData - */ - applyEdit(document, label, nextData) { - const previousData = document.data; - document.replaceData(nextData); - - this._onDidChangeCustomDocument.fire({ - document, - label, - undo: async () => { - document.replaceData(previousData); - await this.broadcastDocument(document); - }, - redo: async () => { - document.replaceData(nextData); - await this.broadcastDocument(document); - } - }); - } - - /** - * @param {PdfDocument} document - * @param {vscode.WebviewPanel} panel - * @param {string} command - */ - async executeEditorCommand(document, panel, command) { - if (command === 'save') { - await vscode.workspace.fs.writeFile(document.uri, document.data); - await vscode.commands.executeCommand('workbench.action.files.save'); - await panel.webview.postMessage({ type: 'document-saved' }); + if (message.type === 'ready') { + await this.sendDocument(panel, document); return; } - const allowedCommands = new Map([ - ['saveAs', 'workbench.action.files.saveAs'], - ['undo', 'undo'], - ['redo', 'redo'] - ]); - const vscodeCommand = allowedCommands.get(command); - if (vscodeCommand) { - await vscode.commands.executeCommand(vscodeCommand); - } - } - - /** - * @param {Record} message - */ - async exportFile(message) { - if (typeof message.data !== 'string') { - throw new Error('Export data is missing.'); + if (message.type === 'show-message') { + await this.showMessage(message); + return; } - const suggestedName = sanitizeFileName(String(message.suggestedName || 'export.pdf')); - const extension = path.extname(suggestedName).slice(1) || 'pdf'; - const destination = await vscode.window.showSaveDialog({ - saveLabel: 'Export', - defaultUri: vscode.Uri.file(path.join( - vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(), - suggestedName - )), - filters: { - [extension.toUpperCase()]: [extension] - } - }); - - if (destination) { - await vscode.workspace.fs.writeFile(destination, base64ToBytes(message.data)); - void vscode.window.showInformationMessage(`Exported ${path.basename(destination.fsPath)}.`); - } + throw new Error('This PDF viewer is read-only. Editing commands are disabled.'); } /** @@ -227,99 +111,20 @@ class PdfEditorProvider { await panel.webview.postMessage({ type: 'load-document', data: bytesToBase64(document.data), - revision: document.revision, fileName: path.basename(document.uri.fsPath || document.uri.path), settings: { defaultZoom: configuration.get('defaultZoom', 1), - maxRenderPixels: configuration.get('maxRenderPixels', 24000000), - defaultSaveMode: configuration.get('defaultSaveMode', 'incremental') + maxRenderPixels: configuration.get('maxRenderPixels', 24000000) } }); } - /** - * @param {PdfDocument} document - * @param {vscode.WebviewPanel} [excludedPanel] - */ - async broadcastDocument(document, excludedPanel) { - const panels = this.panels.get(document); - if (!panels) { - return; - } - - await Promise.all( - [...panels] - .filter((panel) => panel !== excludedPanel) - .map((panel) => this.sendDocument(panel, document)) - ); - } - - /** - * @param {PdfDocument} document - * @param {vscode.CancellationToken} _cancellation - */ - async saveCustomDocument(document, _cancellation) { - await vscode.workspace.fs.writeFile(document.uri, document.data); - } - - /** - * @param {PdfDocument} document - * @param {vscode.Uri} destination - * @param {vscode.CancellationToken} _cancellation - */ - async saveCustomDocumentAs(document, destination, _cancellation) { - await vscode.workspace.fs.writeFile(destination, document.data); - } - - /** - * @param {PdfDocument} document - * @param {vscode.CancellationToken} _cancellation - */ - async revertCustomDocument(document, _cancellation) { - const data = await vscode.workspace.fs.readFile(document.uri); - document.replaceData(data); - await this.broadcastDocument(document); - } - - /** - * @param {PdfDocument} document - * @param {vscode.CustomDocumentBackupContext} context - * @param {vscode.CancellationToken} _cancellation - * @returns {Promise} - */ - async backupCustomDocument(document, context, _cancellation) { - await vscode.workspace.fs.writeFile(context.destination, document.data); - return { - id: context.destination.toString(), - delete: async () => { - try { - await vscode.workspace.fs.delete(context.destination); - } catch { - // VS Code may already have removed an obsolete backup. - } - } - }; - } - dispose() { - this._onDidChangeCustomDocument.dispose(); + // The provider owns no mutable document state or background resources. } } -/** - * @param {string} value - * @returns {string} - */ -function sanitizeFileName(value) { - return value - .replace(/[<>:"/\\|?*]/g, '_') - .split('') - .map((character) => character.charCodeAt(0) < 32 ? '_' : character) - .join(''); -} - module.exports = { - PdfEditorProvider, - VIEW_TYPE, - sanitizeFileName + PdfViewerProvider, + VIEW_TYPE }; diff --git a/src/webview-html.js b/src/webview-html.js index 67b79bc..f948c32 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -11,7 +11,6 @@ function getWebviewHtml(webview, extensionUri) { const vscode = require('vscode'); const mediaUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media')); const styleUri = `${mediaUri}/webview/styles.css`; - const fabricUri = `${mediaUri}/vendor/fabric/fabric.min.js`; const mainUri = `${mediaUri}/webview/main.js`; const nonce = crypto.randomBytes(18).toString('base64'); @@ -21,11 +20,11 @@ function getWebviewHtml(webview, extensionUri) { - PDF Viewer & Editor + PDF Viewer @@ -59,69 +58,15 @@ function getWebviewHtml(webview, extensionUri) { - Buscar + Buscar - - ↶ - ↷ - Guardar + + Solo lectura + v0.0.8 - - Editar texto - Añadir texto - Imagen - Tabla - Eliminar - Haz clic en un párrafo, selecciona texto o usa + para insertar. - v0.0.7 - - - - - Editar contenido - Mover / redimensionar bloque - Copiar - Fuente - - Helvetica - Times - Courier - - - Tamaño - - - B - I - Color - Alinear - - Izquierda - Centro - Derecha - - - - - - Tabla - Filas - Columnas - Color - Línea - Actualizar tabla - Arrastra para mover; usa las esquinas para redimensionar. - - - - Imagen - Usa las esquinas para redimensionar y arrastra para mover. - - - @@ -170,21 +93,18 @@ function getWebviewHtml(webview, extensionUri) { - + - PDF Viewer & Editor + PDF Viewer Cancelar - Aceptar + Abrir PDF - - -