diff --git a/CHANGELOG.md b/CHANGELOG.md index 466a59a..45d762f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Todos los cambios relevantes de este proyecto se documentan aquí. +## 0.0.7 - 2026-08-24 + +### Corregido + +- El marco azul de edición de texto pasa a ser el área real del contenido editable. +- El editor deja de autoagrandarse al abrir: primero coincide con la selección y solo cambia de tamaño cuando el usuario lo redimensiona. +- La selección parcial se calcula por impacto contra los rectángulos y offsets reales de cada carácter, sin depender del layout de texto del navegador. +- El editor de texto se puede redimensionar en anchura y altura; su anchura pasa al motor y controla el reflow al aplicar. +- PDF, texto, imágenes, tablas y Fabric comparten la transformación exacta del pixmap renderizado, incluido su origen de píxel. +- Las tablas guardan y recuperan su rectángulo exacto; las tablas antiguas lo reconstruyen desde sus trazos Ink. +- Al aumentar o reducir la altura de una tabla, el texto posterior del mismo flujo se desplaza en la misma medida. +- El marco temporal de redimensionado se elimina al cancelar, cambiar de herramienta o seleccionar otro objeto. +- Nuevo icono `images/icon.png` de 256 × 256 en el estilo visual azul marino del Marketplace de Alexey SuzDalenko. +- La barra de edición muestra `v0.0.7` para comprobar la versión instalada. + ## 0.0.6 - 2026-08-24 ### Corregido diff --git a/README.md b/README.md index 02180c1..814d172 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Editor visual de PDF gratuito para Visual Studio Code, construido en JavaScript con MuPDF.js y Fabric.js. -La versión `0.0.6` se concentra en una tarea: **seleccionar y editar directamente el contenido visible de forma natural, 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. +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. @@ -11,9 +11,10 @@ La versión `0.0.6` se concentra en una tarea: **seleccionar y editar directamen - 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`. -- Mover o redimensionar un bloque de texto con `Mover / redimensionar`. +- 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. @@ -31,6 +32,7 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - 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. @@ -39,8 +41,11 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - 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. +- 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`. @@ -57,18 +62,20 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - 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 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`; al aplicar, el texto inferior se recoloca. -5. Usa `Mover / redimensionar` para cambiar la posición o anchura del bloque de texto. -6. Cambia el tamaño de fuente: la selección se recompone y el contenido inferior se desplaza. -7. Usa el punto `+` entre párrafos para insertar texto, imagen o tabla sin solapar nada. -8. También puedes usar `Añadir texto`, `Imagen` o `Tabla` para colocar contenido libremente. -9. Guarda con `Ctrl/Cmd+S`. +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`. | Acción | Atajo | |---|---| @@ -109,7 +116,7 @@ npm run package | `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 run check` | Ejecuta lint, typecheck y pruebas | -| `npm run package` | Genera `pdf-viewer-editor-0.0.6.vsix` | +| `npm run package` | Genera `pdf-viewer-editor-0.0.7.vsix` | El script `prepare` copia MuPDF y Fabric a `media/vendor`; esa carpeta se genera y no se versiona. diff --git a/images/icon.png b/images/icon.png index afb21c7..dc6847c 100644 Binary files a/images/icon.png and b/images/icon.png differ diff --git a/media/webview/main.js b/media/webview/main.js index ab7cb4a..0f97568 100644 --- a/media/webview/main.js +++ b/media/webview/main.js @@ -5,8 +5,10 @@ import { clamp, fileToBytes, hexToPdfColor, + invertMatrix, normalizeRect, - pdfColorToHex + pdfColorToHex, + transformPoint } from './utils.js'; const vscode = acquireVsCodeApi(); @@ -31,6 +33,8 @@ const state = { overlayObjects: [], selected: null, textRange: null, + textDrag: null, + textResizeObject: null, editing: null, insertionAnchor: null, pendingImage: null, @@ -67,6 +71,13 @@ const elements = Object.fromEntries([ 'dialog-content', 'dialog-cancel', 'dialog-confirm', 'image-file-input' ].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') { @@ -228,8 +239,41 @@ function initializeOrResizeFabricCanvas() { 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() { @@ -248,43 +292,180 @@ function buildTextLayer() { item.dataset.lineIndex = String(line.lineIndex); item.dataset.textStart = String(line.textStart); item.dataset.textEnd = String(line.textEnd); - item.title = 'Haz clic para editar este párrafo; arrastra para seleccionar texto'; + 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.style.fontSize = `${Math.max(4, line.font.size * state.render.scale)}px`; - item.style.fontFamily = line.font.family; - item.style.fontWeight = line.font.weight; - item.style.fontStyle = line.font.style; - item.addEventListener('click', textLineClicked); - item.addEventListener('dblclick', () => window.requestAnimationFrame(updateTextRangeFromSelection)); + 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 textLineClicked(event) { - if (state.tool !== 'edit' || state.busy) { +function beginTextSelection(event, line) { + if (state.tool !== 'edit' || state.busy || state.editing || event.button !== 0) { return; } + event.preventDefault(); hideInsertMenu(); - if (event.detail >= 2) { - window.requestAnimationFrame(updateTextRangeFromSelection); + clearNativeTextSelection(); + setTextResizeMode(false); + const position = textOffsetAtScreenPoint(line.blockIndex, event.clientX, event.clientY); + if (!position) { return; } - const line = event.currentTarget; - window.requestAnimationFrame(() => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) { - updateTextRangeFromSelection(); - return; - } - const range = document.createRange(); - range.selectNodeContents(line); - selection?.removeAllRanges(); - selection?.addRange(range); - updateTextRangeFromSelection(); - }); + 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 = {}) { @@ -308,90 +489,60 @@ function selectTextBlock(blockIndex, options = {}) { state.fabricCanvas.discardActiveObject(); state.fabricCanvas.requestRenderAll(); } - clearTextLineHighlights(); selectMeta(meta); return true; } -function updateTextRangeFromSelection() { - if (state.busy || state.editing) { - return; - } - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !selectionInsideTextLayer(selection)) { - hideEditRangeButton(); - return; +function setTextRange(blockIndex, start, end, options = {}) { + const block = state.pageModel?.textBlocks.find((candidate) => + candidate.index === blockIndex + ); + if (!block) { + return false; } - const range = selection.getRangeAt(0); - const start = selectionEndpointToTextOffset(range.startContainer, range.startOffset); - const end = selectionEndpointToTextOffset(range.endContainer, range.endOffset); - if (!start || !end || start.blockIndex !== end.blockIndex || end.offset <= start.offset) { + 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(); - setStatus('Para editar, selecciona texto dentro de un mismo párrafo.'); - return; - } - const block = state.pageModel.textBlocks.find((candidate) => - candidate.index === start.blockIndex - ); - if (!block) { - return; + return false; } state.textRange = { blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) + start: rangeStart, + end: rangeEnd, + text: block.text.slice(rangeStart, rangeEnd) }; renderTextRangeHighlights(); selectTextBlock(block.index, { preserveRange: true }); - showEditRangeButton(range); - setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); -} - -function selectionInsideTextLayer(selection) { - const anchor = selection.anchorNode; - const focus = selection.focusNode; - return Boolean( - anchor && focus && - elements['text-layer'].contains(anchor) && - elements['text-layer'].contains(focus) - ); -} - -function selectionEndpointToTextOffset(node, offset) { - const element = node.nodeType === window.Node.TEXT_NODE ? node.parentElement : node; - const line = element instanceof window.Element ? element.closest('.text-layer-line') : null; - if (!line || !elements['text-layer'].contains(line)) { - return null; + showEditRangeButton(getTextRangeRects(state.textRange)); + if (!options.quiet) { + setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); } - let localOffset = 0; - try { - const localRange = document.createRange(); - localRange.selectNodeContents(line); - localRange.setEnd(node, offset); - localOffset = localRange.toString().length; - } catch { - localOffset = offset > 0 ? line.textContent.length : 0; - } - return { - blockIndex: Number(line.dataset.blockIndex), - offset: Number(line.dataset.textStart) + clamp(localOffset, 0, line.textContent.length) - }; + return true; } -function showEditRangeButton(range) { +function showEditRangeButton(pdfRects) { + if (!Array.isArray(pdfRects) || pdfRects.length === 0) { + hideEditRangeButton(); + return; + } const button = elements['edit-range-button']; - const selectionRect = range.getBoundingClientRect(); - const stageRect = elements['page-stage'].getBoundingClientRect(); + 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.left - stageRect.left + selectionRect.width / 2 - width / 2, + selectionRect[0] + (selectionRect[2] - selectionRect[0]) / 2 - width / 2, 4, Math.max(4, state.render.width - width - 4) )}px`; button.style.top = `${clamp( - selectionRect.bottom - stageRect.top + 5, + selectionRect[3] + 5, 4, Math.max(4, state.render.height - 34) )}px`; @@ -408,13 +559,12 @@ function clearTextRangeHighlights() { } } -function renderTextRangeHighlights() { - clearTextRangeHighlights(); - const selectedRange = state.textRange; +function getTextRangeRects(selectedRange) { if (!selectedRange || !state.pageModel) { - return; + return []; } + const rects = []; const lines = state.pageModel.textLines.filter((line) => line.blockIndex === selectedRange.blockIndex && selectedRange.end > line.textStart && @@ -428,33 +578,57 @@ function renderTextRangeHighlights() { continue; } - let rect = null; const characters = Array.isArray(line.characters) ? line.characters : []; - const selectedCharacters = characters.slice(localStart, localEnd) + 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) { - rect = selectedCharacters.reduce((result, candidate) => result + 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); - } else { - 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); - rect = [ - line.rect[0] + lineWidth * startRatio, - line.rect[1], - line.rect[0] + lineWidth * endRatio, - line.rect[3] - ]; + : [...candidate], null)); + continue; } + 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 highlight = document.createElement('div'); highlight.className = 'text-range-highlight'; @@ -467,6 +641,14 @@ function renderTextRangeHighlights() { } 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'; @@ -481,6 +663,7 @@ function activateTextResizeMode() { return; } + setTextResizeMode(false); setTextResizeMode(true); const resizeMeta = { ...selected, resizeMode: true }; const object = addOverlayObject(resizeMeta, { @@ -489,6 +672,7 @@ function activateTextResizeMode() { movable: true, resizable: true }); + state.textResizeObject = object; state.fabricCanvas.setActiveObject(object); object.set('stroke', object.editorStroke); state.fabricCanvas.requestRenderAll(); @@ -587,11 +771,7 @@ function rectanglesShareScreenFlow(left, right) { } function pdfPointToScreen(point) { - const bounds = state.render.bounds; - return [ - (point[0] - bounds[0]) * state.render.scale, - (point[1] - bounds[1]) * state.render.scale - ]; + return transformPoint(state.render.pageToScreen, point); } function openInsertMenu(anchor, marker) { @@ -678,6 +858,11 @@ function addOverlayObject(meta, options = {}) { 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, @@ -696,6 +881,8 @@ function addOverlayObject(meta, options = {}) { lockMovementY: !options.movable, lockScalingX: !options.resizable, lockScalingY: !options.resizable, + lockScalingFlip: true, + minScaleLimit: 0.02, hoverCursor: options.movable ? 'move' : 'pointer' }); object.editorMeta = meta; @@ -736,9 +923,10 @@ function selectionChanged(event) { function selectMeta(meta) { state.selected = meta; if (meta.kind !== 'text') { + setTextResizeMode(false); state.textRange = null; hideEditRangeButton(); - clearTextLineHighlights(); + clearTextRangeHighlights(); clearNativeTextSelection(); } elements['delete-tool'].disabled = false; @@ -775,10 +963,10 @@ function selectMeta(meta) { function clearSelection() { state.selected = null; state.textRange = null; + state.textDrag = null; hideEditRangeButton(); clearTextRangeHighlights(); setTextResizeMode(false); - clearTextLineHighlights(); elements['delete-tool'].disabled = true; elements['selection-context'].classList.add('hidden'); elements['text-context'].classList.add('hidden'); @@ -790,17 +978,8 @@ function clearSelection() { } } -function clearTextLineHighlights() { - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line.selected')) { - line.classList.remove('selected'); - } -} - function clearNativeTextSelection() { - const selection = window.getSelection(); - if (selection && selectionInsideTextLayer(selection)) { - selection.removeAllRanges(); - } + window.getSelection()?.removeAllRanges(); } function overlayMouseDown(event) { @@ -929,43 +1108,33 @@ async function overlayObjectModified(event) { function pdfRectToScreen(rect) { const [x0, y0, x1, y1] = normalizeRect(rect); - const bounds = state.render.bounds; - const scale = state.render.scale; - return [ - (x0 - bounds[0]) * scale, - (y0 - bounds[1]) * scale, - (x1 - bounds[0]) * scale, - (y1 - bounds[1]) * scale - ]; + const topLeft = pdfPointToScreen([x0, y0]); + const bottomRight = pdfPointToScreen([x1, y1]); + return normalizeRect([...topLeft, ...bottomRight]); } function screenRectToPdf(rect) { - const bounds = state.render.bounds; - const scale = state.render.scale; - return normalizeRect([ - rect[0] / scale + bounds[0], - rect[1] / scale + bounds[1], - rect[2] / scale + bounds[0], - rect[3] / scale + bounds[1] - ]); + 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) { - const bounds = state.render.bounds; - return [ - point.x / state.render.scale + bounds[0], - point.y / state.render.scale + bounds[1] - ]; + return transformPoint( + invertMatrix(state.render.pageToScreen), + [Number(point.x), Number(point.y)] + ); } function objectScreenRectToPdf(object) { - const bounds = object.getBoundingRect(); - return screenRectToPdf([ - bounds.left, - bounds.top, - bounds.left + Math.max(2, bounds.width), - bounds.top + Math.max(2, bounds.height) - ]); + // 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) { @@ -1079,7 +1248,8 @@ function openSelectedRangeEditor() { blockIndex: block.index, start: selectedRange.start, end: selectedRange.end, - rect: block.rect, + sourceRect: block.rect, + rect: getTextRangeRect(selectedRange) || block.rect, text: selectedRange.text }); } @@ -1101,7 +1271,7 @@ function openNewTextEditor(pointOrAnchor) { openBlockEditor({ mode: 'add', blockIndex: null, - rect: [x, y, x + width, y + 18], + rect: [x, y, x + width, Math.min(bounds[3] - 8, y + 72)], text: '' }); setTool('edit'); @@ -1115,25 +1285,55 @@ function showTextContext() { } function openBlockEditor(editing) { - state.editing = 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']; - editor.style.left = `${clamp(screen[0], 0, Math.max(0, state.render.width - 190))}px`; - editor.style.top = `${clamp(screen[1], 0, Math.max(0, state.render.height - 90))}px`; - editor.style.width = `${Math.max(180, Math.min(state.render.width, screen[2] - screen[0]))}px`; + 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'); - elements['block-editor-text'].value = editing.text; + textarea.value = editing.text; syncBlockEditorPreview(); - autoGrowBlockEditor(); - elements['block-editor-text'].focus(); - elements['block-editor-text'].select(); + syncEditingRectFromEditor(); + state.editing.initialRect = [...state.editing.rect]; + textarea.focus(); + textarea.select(); setStatus(editing.mode === 'add' - ? 'Escribe el texto nuevo y pulsa Aplicar.' + ? 'Escribe dentro del marco azul. Puedes arrastrar su esquina para cambiar anchura y altura.' : (editing.mode === 'range' - ? 'Edita la selección; el párrafo se recompondrá automáticamente.' - : 'Edita el párrafo; el contenido inferior se desplazará automáticamente.')); + ? '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() { @@ -1148,7 +1348,7 @@ function syncBlockEditorPreview() { textarea.style.fontStyle = format.italic ? 'italic' : 'normal'; textarea.style.color = elements['text-color'].value; textarea.style.textAlign = ['left', 'center', 'right'][format.alignment] || 'left'; - autoGrowBlockEditor(); + syncEditingRectFromEditor(); } function cssFontFamily(fontFamily) { @@ -1161,15 +1361,6 @@ function cssFontFamily(fontFamily) { return 'Arial, Helvetica, sans-serif'; } -function autoGrowBlockEditor() { - if (!state.editing) { - return; - } - const textarea = elements['block-editor-text']; - textarea.style.height = 'auto'; - textarea.style.height = `${clamp(textarea.scrollHeight + 4, 64, 420)}px`; -} - function cancelBlockEditor() { const rangeWasOpen = state.editing?.mode === 'range'; state.editing = null; @@ -1177,6 +1368,7 @@ function cancelBlockEditor() { elements['block-editor-text'].value = ''; if (rangeWasOpen) { state.textRange = null; + clearTextRangeHighlights(); } if (!state.selected) { clearSelection(); @@ -1199,19 +1391,37 @@ async function applyBlockEditor() { 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 + { + ...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); + engine.editTextBlock(state.currentPage, editing.blockIndex, { + ...values, + targetRect: editing.rect + }); await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { restoreText: text || null }); @@ -1586,6 +1796,7 @@ function updateDocumentInfo() { } 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'); @@ -1729,7 +1940,7 @@ function initializeEventHandlers() { } }); elements['image-file-input'].addEventListener('change', handleImageFile); - elements['block-editor-text'].addEventListener('input', autoGrowBlockEditor); + elements['block-editor-text'].addEventListener('input', syncEditingRectFromEditor); elements['block-editor-text'].addEventListener('keydown', (event) => { if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') { event.preventDefault(); @@ -1755,9 +1966,6 @@ function initializeEventHandlers() { clearSelection(); } }); - document.addEventListener('selectionchange', () => { - window.requestAnimationFrame(updateTextRangeFromSelection); - }); document.addEventListener('keydown', keyboardHandler); window.addEventListener('resize', debounce(async () => { if (state.zoomMode !== 'numeric' && state.pageCount > 0) { diff --git a/media/webview/pdf-engine.js b/media/webview/pdf-engine.js index 1cb9976..b07513e 100644 --- a/media/webview/pdf-engine.js +++ b/media/webview/pdf-engine.js @@ -120,11 +120,14 @@ export class PdfEngine { showExtras ); try { + const pixelBounds = [...pixmap.getBounds()]; return { width: pixmap.getWidth(), height: pixmap.getHeight(), pixels: new Uint8ClampedArray(pixmap.getPixels()).slice(), bounds: [...bounds], + pixelBounds, + pageToScreen: [scale, 0, 0, scale, -pixelBounds[0], -pixelBounds[1]], scale }; } finally { @@ -201,9 +204,23 @@ export class PdfEngine { return annotations.map((annotation, index) => { try { const type = annotation.getType(); - const rect = annotation.hasRect() - ? annotation.getRect() - : annotation.getBounds(); + 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 { @@ -212,16 +229,14 @@ export class PdfEngine { defaultAppearance = null; } } - const contents = safeCall(() => annotation.getContents(), ''); - const subject = safeCall(() => annotation.getSubject(), ''); return { index, type, - rect: [...rect], + rect, contents, author: safeCall(() => annotation.getAuthor(), ''), subject, - table: parseTableMetadata(type, subject, contents), + table, color: safeCall(() => [...annotation.getColor()], []), interiorColor: annotation.hasInteriorColor() ? safeCall(() => [...annotation.getInteriorColor()], []) @@ -332,9 +347,20 @@ export class PdfEngine { } const values = normalizeBlockProperties(block, properties); - const layout = layoutTextBlock(block.rect, values, block.metrics, block); - const oldHeight = Math.max(0, block.rect[3] - block.rect[1]); - const delta = layout.height - oldHeight; + 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) @@ -354,7 +380,7 @@ export class PdfEngine { ? [...layout.entries, ...shiftedEntries] : shiftedEntries; const maximumBottom = Math.max( - values.text ? block.rect[1] + layout.height : block.rect[1], + values.text ? newBottom : block.rect[1], ...followingBlocks.map((candidate) => candidate.rect[3] + delta) ); this.extendPageToFit(page, maximumBottom); @@ -365,7 +391,9 @@ export class PdfEngine { return { delta, shiftedBlocks: followingBlocks.length, - rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] + rect: values.text + ? [targetRect[0], targetRect[1], targetRect[2], newBottom] + : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] }; } @@ -398,14 +426,27 @@ export class PdfEngine { { 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( - block.rect, + layoutRect, runs, block.metrics, replacementValues.alignment ); - const oldHeight = Math.max(0, block.rect[3] - block.rect[1]); - const delta = layout.height - oldHeight; + 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) @@ -424,7 +465,7 @@ export class PdfEngine { ? [...layout.entries, ...shiftedEntries] : shiftedEntries; const maximumBottom = Math.max( - resultingText ? block.rect[1] + layout.height : block.rect[1], + resultingText ? newBottom : block.rect[1], ...followingBlocks.map((candidate) => candidate.rect[3] + delta) ); this.extendPageToFit(page, maximumBottom); @@ -436,7 +477,9 @@ export class PdfEngine { delta, shiftedBlocks: followingBlocks.length, text: resultingText, - rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] + rect: resultingText + ? [layoutRect[0], layoutRect[1], layoutRect[2], newBottom] + : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] }; } @@ -658,6 +701,29 @@ export class PdfEngine { } 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(); @@ -674,17 +740,37 @@ export class PdfEngine { 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, rect, rows, columns, { + 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 = {}) { @@ -721,7 +807,8 @@ export class PdfEngine { rows: rowCount, columns: columnCount, color, - borderWidth + borderWidth, + rect: normalizedRect })); annotation.update(); page.update(); @@ -1602,8 +1689,14 @@ function extractTextBlocks(structuredText) { 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 }); + 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, @@ -1734,6 +1827,7 @@ function buildVisualLines(lines) { let previous = null; for (const fragment of row.fragments) { if (previous && needsVisualSpace(previous, fragment)) { + const start = text.length; text += ' '; characters.push({ text: ' ', @@ -1742,11 +1836,18 @@ function buildVisualLines(lines) { 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 || [])); + 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); @@ -1950,18 +2051,29 @@ function decorateParagraphLines(paragraphLines, blockIndex) { const lines = []; for (const sourceLine of paragraphLines) { - const lineText = String(sourceLine.text || '').trim(); + 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, @@ -2320,17 +2432,44 @@ function parseTableMetadata(type, subject, 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))) + 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 [ diff --git a/media/webview/styles.css b/media/webview/styles.css index 72dac4f..b684b0c 100644 --- a/media/webview/styles.css +++ b/media/webview/styles.css @@ -338,6 +338,14 @@ input[type="color"] { 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; } @@ -350,6 +358,7 @@ input[type="color"] { .text-layer-line { position: absolute; + z-index: 1; overflow: hidden; color: transparent; border-radius: 2px; @@ -357,7 +366,8 @@ input[type="color"] { white-space: pre; cursor: text; pointer-events: auto; - user-select: text; + user-select: none; + touch-action: none; transform-origin: 0 0; transition: background-color 80ms ease; } @@ -366,19 +376,9 @@ input[type="color"] { background: color-mix(in srgb, var(--focus) 9%, transparent); } -.text-layer-line.selected { - background: color-mix(in srgb, var(--focus) 12%, transparent); - box-shadow: inset 2px 0 0 color-mix(in srgb, var(--focus) 75%, transparent); -} - -.text-layer-line::selection { - color: transparent; - background: transparent; -} - .text-range-highlight { position: absolute; - z-index: -1; + z-index: 0; box-sizing: border-box; border: 1px solid color-mix(in srgb, var(--focus) 82%, transparent); border-radius: 1px; @@ -499,25 +499,36 @@ input[type="color"] { .block-editor { position: absolute; z-index: 8; - min-width: 180px; - padding: 5px; - border: 2px solid var(--focus); - border-radius: 4px; - background: var(--surface); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + min-width: 0; + min-height: 0; + padding: 0; + overflow: visible; + background: transparent; } .block-editor textarea { display: block; + box-sizing: border-box; width: 100%; - min-height: 64px; - resize: vertical; - padding: 7px; - border: 0; + height: 100%; + min-width: 24px; + min-height: 18px; + resize: both; + padding: 2px 3px; + overflow: auto; + border: 2px solid var(--focus); + border-radius: 2px; + outline: 0; color: #111; - background: #fff; - line-height: 1.25; - overflow: hidden; + 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 { @@ -525,6 +536,8 @@ input[type="color"] { align-items: center; justify-content: flex-end; gap: 6px; + width: max-content; + min-width: 100%; padding-top: 5px; } @@ -644,3 +657,15 @@ input[type="color"] { 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 1138180..6c250e4 100644 --- a/media/webview/utils.js +++ b/media/webview/utils.js @@ -61,6 +61,21 @@ 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 = ''; diff --git a/package-lock.json b/package-lock.json index 8f674dc..77f3fc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-viewer-editor", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.6", + "version": "0.0.7", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 1302c99..d708ffb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "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.6", + "version": "0.0.7", "publisher": "suzdalenko-dev", "license": "AGPL-3.0-or-later", "repository": { diff --git a/scripts/apply-v006-fixes.js b/scripts/apply-v006-fixes.js deleted file mode 100644 index 203c389..0000000 --- a/scripts/apply-v006-fixes.js +++ /dev/null @@ -1,919 +0,0 @@ -'use strict'; - -const fs = require('node:fs'); - -function replaceOnce(source, before, after, label) { - const count = source.split(before).length - 1; - if (count !== 1) { - throw new Error(`${label}: expected exactly one match, found ${count}`); - } - return source.replace(before, after); -} - -function replaceRegex(source, pattern, replacement, label) { - const matches = source.match(pattern); - if (!matches) { - throw new Error(`${label}: pattern not found`); - } - return source.replace(pattern, replacement); -} - -// --- Release metadata ------------------------------------------------------- -const packagePath = 'package.json'; -const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -pkg.version = '0.0.6'; -pkg.icon = 'images/icon.png'; -pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default = 1; -fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); - -const lockPath = 'package-lock.json'; -const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); -lock.version = '0.0.6'; -if (lock.packages?.['']) { - lock.packages[''].version = '0.0.6'; -} -fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); - -// --- PDF engine: tight glyph geometry, resizable text, exact table rect ---- -const enginePath = 'media/webview/pdf-engine.js'; -let engine = fs.readFileSync(enginePath, 'utf8'); - -engine = replaceRegex( - engine, - / readAnnotations\(page\) \{[\s\S]*?\n \}\n\n readWidgets\(page\) \{/, -` 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()]; - const inkRect = table - ? rectFromInkStrokes(safeCall(() => annotation.getInkList(), []), table.rect || annotationRect) - : annotationRect; - let defaultAppearance = null; - if (type === 'FreeText') { - try { - defaultAppearance = annotation.getDefaultAppearance(); - } catch { - defaultAppearance = null; - } - } - return { - index, - type, - rect: normalizeRect(inkRect), - 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) {`, - 'readAnnotations' -); - -engine = replaceOnce( - engine, -` beginLine(bbox) { - currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map() - }; - },`, -` beginLine(bbox) { - currentLine = { - text: '', - sourceRect: normalizeRect([...bbox]), - rect: null, - baseline: null, - styles: new Map(), - characters: [] - }; - },`, - 'tight line initialization' -); - -engine = replaceOnce( - engine, -` currentLine.text += String(character); - currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; - currentLine.rect = unionRects([ - currentLine.rect, - quadToRect(quad) - ]);`, -` const characterText = String(character); - const characterRect = normalizeRect(quadToRect(quad)); - currentLine.text += characterText; - currentLine.characters.push({ - text: characterText, - rect: characterRect - }); - currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; - currentLine.rect = currentLine.rect - ? unionRects([currentLine.rect, characterRect]) - : [...characterRect];`, - 'character quad capture' -); - -engine = replaceOnce( - engine, -` 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 - });`, -` const style = dominantWeightedStyle(currentLine.styles); - const tightRect = currentLine.rect || currentLine.sourceRect; - currentBlock.lines.push({ - text: currentLine.text, - rect: tightRect, - baseline: currentLine.baseline || [tightRect[0], tightRect[3]], - font: style.font, - color: style.color, - characters: currentLine.characters - });`, - 'tight line finalization' -); - -engine = replaceRegex( - engine, - /function buildVisualLines\(lines\) \{[\s\S]*?\n\}\n\nfunction needsVisualSpace/, -`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 = ''; - let offset = 0; - let previous = null; - const characters = []; - for (const fragment of row.fragments) { - if (previous && needsVisualSpace(previous, fragment)) { - const spaceRect = normalizeRect([ - previous.rect[2], - Math.min(previous.rect[1], fragment.rect[1]), - fragment.rect[0], - Math.max(previous.rect[3], fragment.rect[3]) - ]); - characters.push({ text: ' ', rect: spaceRect, start: offset, end: offset + 1 }); - text += ' '; - offset += 1; - } - for (const character of fragment.characters || []) { - const characterText = String(character.text || ''); - const start = offset; - offset += characterText.length; - characters.push({ - text: characterText, - rect: [...character.rect], - start, - end: offset - }); - } - text += fragment.text; - 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`, - 'visual line character geometry' -); - -engine = replaceRegex( - engine, - /function decorateParagraphLines\(paragraphLines, blockIndex\) \{[\s\S]*?\n\}\n\nfunction layoutRichTextBlock/, -`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`, - 'paragraph character offsets' -); - -engine = replaceOnce( - engine, - ' editTextBlock(pageIndex, blockIndex, properties = {}) {', - ' editTextBlock(pageIndex, blockIndex, properties = {}, targetRect = null) {', - 'editTextBlock signature' -); -engine = replaceOnce( - engine, -` const values = normalizeBlockProperties(block, properties); - const layout = layoutTextBlock(block.rect, values, block.metrics, block);`, -` const values = normalizeBlockProperties(block, properties); - const layoutRect = textLayoutRect(block.rect, targetRect); - const layout = layoutTextBlock(layoutRect, values, block.metrics, block);`, - 'editTextBlock layout rect' -); -engine = replaceOnce( - engine, -` rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] - }; - } - - /** - * Replaces an arbitrary character range`, -` rect: [layoutRect[0], block.rect[1], layoutRect[2], block.rect[1] + layout.height] - }; - } - - /** - * Replaces an arbitrary character range`, - 'editTextBlock result rect' -); - -engine = replaceOnce( - engine, - ' editTextRange(pageIndex, blockIndex, start, end, replacement, properties = {}) {', - ' editTextRange(pageIndex, blockIndex, start, end, replacement, properties = {}, targetRect = null) {', - 'editTextRange signature' -); -engine = replaceOnce( - engine, -` const resultingText = runs.map((run) => run.text).join(''); - const layout = layoutRichTextBlock( - block.rect,`, -` const resultingText = runs.map((run) => run.text).join(''); - const layoutRect = textLayoutRect(block.rect, targetRect); - const layout = layoutRichTextBlock( - layoutRect,`, - 'editTextRange layout rect' -); -engine = replaceOnce( - engine, -` rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] - }; - } - - /** - * Inserts a new static text block`, -` rect: [layoutRect[0], block.rect[1], layoutRect[2], block.rect[1] + layout.height] - }; - } - - /** - * Inserts a new static text block`, - 'editTextRange result rect' -); - -engine = replaceOnce( - engine, -` annotation.setContents(JSON.stringify({ - type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth - }));`, -` annotation.setContents(JSON.stringify({ - type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth, - rect: normalizedRect - }));`, - 'table logical rect metadata' -); - -engine = replaceOnce( - engine, -`function defaultTextMetrics(fontSize) { - const size = Math.max(1, Number(fontSize || 12));`, -`function textLayoutRect(originalRect, targetRect) { - const original = normalizeRect(originalRect); - if (!Array.isArray(targetRect) || targetRect.length !== 4) { - return [...original]; - } - const target = normalizeRect(targetRect); - const minimumWidth = 24; - return [ - target[0], - original[1], - Math.max(target[0] + minimumWidth, target[2]), - original[3] - ]; -} - -function defaultTextMetrics(fontSize) { - const size = Math.max(1, Number(fontSize || 12));`, - 'text layout rect helper' -); - -engine = replaceOnce( - engine, -` 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))) - };`, -` 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: Array.isArray(value.rect) && value.rect.length === 4 - ? normalizeRect(value.rect.map(Number)) - : null - };`, - 'table metadata rect parsing' -); - -engine = replaceOnce( - engine, -`function textMatrixForPage(transform, baseline) {`, -`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) {`, - 'ink rect helper' -); - -fs.writeFileSync(enginePath, engine); - -// --- Webview HTML ----------------------------------------------------------- -const htmlPath = 'src/webview-html.js'; -let html = fs.readFileSync(htmlPath, 'utf8'); -html = replaceOnce( - html, - '
\n ', - ' \n \n ', - 'selection layer HTML' -); -fs.writeFileSync(htmlPath, html); - -// --- Webview JS ------------------------------------------------------------- -const mainPath = 'media/webview/main.js'; -let main = fs.readFileSync(mainPath, 'utf8'); -main = replaceOnce( - main, - " 'page-viewport', 'page-stage', 'pdf-canvas', 'text-layer', 'editor-canvas',", - " 'page-viewport', 'page-stage', 'pdf-canvas', 'selection-layer', 'text-layer', 'editor-canvas',", - 'selection layer element binding' -); - -main = replaceRegex( - main, - /function buildTextLayer\(\) \{[\s\S]*?\n\}\n\nfunction textLineClicked/, -`function buildTextLayer() { - const layer = elements['text-layer']; - const selectionLayer = elements['selection-layer']; - layer.replaceChildren(); - selectionLayer.replaceChildren(); - layer.style.width = \`${'${state.render.width}'}px\`; - layer.style.height = \`${'${state.render.height}'}px\`; - selectionLayer.style.width = \`${'${state.render.width}'}px\`; - selectionLayer.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 targetWidth = Math.max(1, screen[2] - screen[0]); - const targetHeight = Math.max(1, screen[3] - screen[1]); - 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'; - item.style.left = \`${'${screen[0]}'}px\`; - item.style.top = \`${'${screen[1]}'}px\`; - item.style.width = 'max-content'; - item.style.height = \`${'${targetHeight}'}px\`; - item.style.fontSize = \`${'${Math.max(4, line.font.size * state.render.scale)}'}px\`; - item.style.lineHeight = \`${'${targetHeight}'}px\`; - item.style.fontFamily = line.font.family; - item.style.fontWeight = line.font.weight; - item.style.fontStyle = line.font.style; - item.addEventListener('click', textLineClicked); - item.addEventListener('dblclick', () => window.requestAnimationFrame(updateTextRangeFromSelection)); - layer.appendChild(item); - - const naturalWidth = Math.max(1, item.getBoundingClientRect().width); - item.style.transform = \`scaleX(${'${targetWidth / naturalWidth}'})\`; - } -} - -function textLineClicked`, - 'precise text layer layout' -); - -main = replaceRegex( - main, - /function updateTextRangeFromSelection\(\) \{[\s\S]*?\n\}\n\nfunction selectionInsideTextLayer/, -`function updateTextRangeFromSelection() { - if (state.busy || state.editing) { - return; - } - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !selectionInsideTextLayer(selection)) { - state.textRange = null; - clearTextSelectionOverlay(); - hideEditRangeButton(); - return; - } - const range = selection.getRangeAt(0); - const start = selectionEndpointToTextOffset(range.startContainer, range.startOffset); - const end = selectionEndpointToTextOffset(range.endContainer, range.endOffset); - if (!start || !end || start.blockIndex !== end.blockIndex || end.offset <= start.offset) { - state.textRange = null; - clearTextSelectionOverlay(); - hideEditRangeButton(); - setStatus('Para editar, selecciona texto dentro de un mismo párrafo.'); - return; - } - const block = state.pageModel.textBlocks.find((candidate) => - candidate.index === start.blockIndex - ); - if (!block) { - clearTextSelectionOverlay(); - return; - } - state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - selectTextBlock(block.index, { preserveRange: true }); - const selectedRects = renderTextSelection(block, start.offset, end.offset); - showEditRangeButton(selectedRects); - setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); -} - -function selectionInsideTextLayer`, - 'exact selection overlay flow' -); - -main = replaceRegex( - main, - /function showEditRangeButton\(range\) \{[\s\S]*?\n\}\n\nfunction hideEditRangeButton/, -`function showEditRangeButton(pdfRects) { - if (!Array.isArray(pdfRects) || pdfRects.length === 0) { - hideEditRangeButton(); - return; - } - const screenRects = pdfRects.map(pdfRectToScreen); - const selectionRect = unionScreenRects(screenRects); - const button = elements['edit-range-button']; - 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`, - 'selection button exact position' -); - -main = replaceOnce( - main, -`function clearTextLineHighlights() { - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line.selected')) { - line.classList.remove('selected'); - } -} - -function clearNativeTextSelection() {`, -`function clearTextLineHighlights() { - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line.selected')) { - line.classList.remove('selected'); - } -} - -function clearTextSelectionOverlay() { - elements['selection-layer']?.replaceChildren(); -} - -function renderTextSelection(block, start, end) { - const layer = elements['selection-layer']; - layer.replaceChildren(); - const rects = []; - for (const line of block.visualLines || []) { - const selectedCharacters = (line.characters || []).filter((character) => - character.end > start && character.start < end - ); - if (selectedCharacters.length === 0) { - continue; - } - const rect = unionPdfRects(selectedCharacters.map((character) => character.rect)); - rects.push(rect); - const screen = pdfRectToScreen(rect); - const highlight = document.createElement('div'); - highlight.className = 'selection-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\`; - layer.appendChild(highlight); - } - return rects; -} - -function unionPdfRects(rects) { - if (!rects.length) { - return [0, 0, 0, 0]; - } - return [ - Math.min(...rects.map((rect) => rect[0])), - Math.min(...rects.map((rect) => rect[1])), - Math.max(...rects.map((rect) => rect[2])), - Math.max(...rects.map((rect) => rect[3])) - ]; -} - -function unionScreenRects(rects) { - return unionPdfRects(rects); -} - -function clearNativeTextSelection() {`, - 'custom selection helpers' -); - -main = replaceOnce( - main, -` state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights();`, -` state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights(); - clearTextSelectionOverlay();`, - 'clear selection overlay' -); - -main = replaceOnce( - main, -` hasRotatingPoint: false, - lockRotation: true, - selectable: options.selectable !== false,`, -` hasRotatingPoint: false, - lockRotation: true, - originX: 'left', - originY: 'top', - strokeUniform: true, - lockScalingFlip: true, - centeredScaling: false, - selectable: options.selectable !== false,`, - 'fabric exact geometry options' -); - -main = replaceOnce( - main, -` editor.style.left = \`${'${clamp(screen[0], 0, Math.max(0, state.render.width - 190))}'}px\`; - editor.style.top = \`${'${clamp(screen[1], 0, Math.max(0, state.render.height - 90))}'}px\`; - editor.style.width = \`${'${Math.max(180, Math.min(state.render.width, screen[2] - screen[0]))}'}px\`;`, -` const editorLeft = clamp(screen[0], 0, Math.max(0, state.render.width - 80)); - editor.style.left = \`${'${editorLeft}'}px\`; - editor.style.top = \`${'${clamp(screen[1], 0, Math.max(0, state.render.height - 50))}'}px\`; - editor.style.width = \`${'${Math.max(80, Math.min(state.render.width - editorLeft, screen[2] - screen[0]))}'}px\`; - editor.style.height = 'auto';`, - 'text editor tight width' -); - -main = replaceOnce( - main, - " textarea.style.height = `${clamp(textarea.scrollHeight + 4, 64, 420)}px`;", - " textarea.style.height = `${clamp(textarea.scrollHeight + 2, 28, 420)}px`;", - 'text editor tight height' -); - -main = replaceOnce( - main, -`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();`, -`function currentBlockEditorPdfRect(editing) { - const bounds = state.pageModel.bounds; - const editor = elements['block-editor']; - const width = clamp( - editor.getBoundingClientRect().width / state.render.scale, - 24, - Math.max(24, bounds[2] - editing.rect[0]) - ); - return [editing.rect[0], editing.rect[1], editing.rect[0] + width, editing.rect[3]]; -} - -async function applyBlockEditor() { - if (!state.editing || state.busy) { - return; - } - const editing = state.editing; - const text = elements['block-editor-text'].value; - const values = { text, ...getTextFormat() }; - const targetRect = currentBlockEditorPdfRect(editing); - cancelBlockEditor();`, - 'resizable text editor target rect' -); - -main = replaceOnce( - main, - ' engine.insertTextBlock(state.currentPage, editing.rect, values);', - ' engine.insertTextBlock(state.currentPage, targetRect, values);', - 'new text resized width' -); -main = replaceOnce( - main, -` text, - values - );`, -` text, - values, - targetRect - );`, - 'range edit resized width' -); -main = replaceOnce( - main, - ' engine.editTextBlock(state.currentPage, editing.blockIndex, values);', - ' engine.editTextBlock(state.currentPage, editing.blockIndex, values, targetRect);', - 'block edit resized width' -); - -main = replaceOnce( - main, - " setStatus('Tabla seleccionada: cambia filas/columnas, muévela o elimínala.');", - " setStatus('Tabla seleccionada: arrastra para mover y usa esquinas o lados para redimensionar exactamente.');", - 'table resize status' -); - -fs.writeFileSync(mainPath, main); - -// --- CSS: custom glyph selection + resizable text editor ------------------- -const cssPath = 'media/webview/styles.css'; -let css = fs.readFileSync(cssPath, 'utf8'); -css = replaceOnce( - css, -`#pdf-canvas, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container {`, -`#pdf-canvas, -.selection-layer, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container {`, - 'selection layer absolute positioning' -); -css = replaceOnce( - css, -`#pdf-canvas { - z-index: 1; -} - -.text-layer {`, -`#pdf-canvas { - z-index: 1; -} - -.selection-layer { - z-index: 2; - overflow: hidden; - pointer-events: none; -} - -.selection-highlight { - position: absolute; - border-radius: 1px; - background: color-mix(in srgb, var(--focus) 42%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--focus) 48%, transparent); -} - -.text-layer {`, - 'selection layer styling' -); -css = replaceOnce( - css, -` overflow: hidden; - color: transparent; - border-radius: 2px; - line-height: 1;`, -` overflow: visible; - color: transparent; - border-radius: 0; - line-height: 1;`, - 'text layer tight overflow' -); -css = replaceOnce( - css, -`.text-layer-line:hover { - background: color-mix(in srgb, var(--focus) 9%, transparent); -} - -.text-layer-line.selected { - background: color-mix(in srgb, var(--focus) 12%, transparent); - box-shadow: inset 2px 0 0 color-mix(in srgb, var(--focus) 75%, transparent); -} - -.text-layer-line::selection { - color: transparent; - background: color-mix(in srgb, var(--focus) 38%, transparent); -}`, -`.text-layer-line:hover { - background: transparent; -} - -.text-layer-line.selected { - background: transparent; - box-shadow: none; -} - -.text-layer-line::selection { - color: transparent; - background: transparent; -}`, - 'remove inaccurate browser selection painting' -); -css = replaceOnce( - css, -`.block-editor { - position: absolute; - z-index: 8; - min-width: 180px; - padding: 5px;`, -`.block-editor { - position: absolute; - z-index: 8; - min-width: 80px; - max-width: calc(100% - 2px); - padding: 4px; - box-sizing: border-box; - resize: horizontal; - overflow: auto;`, - 'resizable text editor frame' -); -css = replaceOnce( - css, -` width: 100%; - min-height: 64px; - resize: vertical; - padding: 7px;`, -` width: 100%; - min-height: 28px; - resize: none; - padding: 3px 4px;`, - 'tight text editor textarea' -); -css = replaceOnce( - css, -` justify-content: flex-end; - gap: 6px;`, -` justify-content: flex-end; - flex-wrap: wrap; - gap: 6px;`, - 'editor actions wrapping' -); -fs.writeFileSync(cssPath, css); - -// --- Contract tests --------------------------------------------------------- -const testPath = 'test/ui-contract.test.mjs'; -fs.writeFileSync(testPath, `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst read = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8');\nconst main = read('../media/webview/main.js');\nconst engine = read('../media/webview/pdf-engine.js');\nconst css = read('../media/webview/styles.css');\nconst html = read('../src/webview-html.js');\nconst provider = read('../src/pdf-editor-provider.js');\nconst pkg = JSON.parse(read('../package.json'));\n\ntest('v0.0.6 keeps 100 percent zoom and Marketplace icon metadata', () => {\n assert.equal(pkg.version, '0.0.6');\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n});\n\ntest('text selection is painted from PDF character quads instead of browser line boxes', () => {\n assert.match(engine, /characters: \[\]/);\n assert.match(engine, /characterRect = normalizeRect\\(quadToRect\\(quad\\)\\)/);\n assert.match(html, /id=\\"selection-layer\\"/);\n assert.match(main, /renderTextSelection\\(block, start\\.offset, end\\.offset\\)/);\n assert.match(css, /\\.text-layer-line::selection[\\s\\S]*background: transparent/);\n});\n\ntest('text blocks can change width and reflow using the resized editor frame', () => {\n assert.match(css, /resize: horizontal/);\n assert.match(main, /currentBlockEditorPdfRect/);\n assert.match(main, /editTextBlock\\(state\\.currentPage, editing\\.blockIndex, values, targetRect\\)/);\n assert.match(engine, /editTextBlock\\(pageIndex, blockIndex, properties = \{\}, targetRect = null\\)/);\n assert.match(engine, /textLayoutRect\\(block\\.rect, targetRect\\)/);\n});\n\ntest('editable table geometry uses exact ink strokes and remains resizable', () => {\n assert.match(engine, /rectFromInkStrokes/);\n assert.match(engine, /rect: normalizedRect/);\n assert.match(main, /lockScalingFlip: true/);\n assert.match(main, /resizable: true/);\n});\n\ntest('save still writes current custom-document bytes', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(main, /PDF guardado correctamente/);\n});\n`); - -// --- Documentation ---------------------------------------------------------- -const changelogPath = 'CHANGELOG.md'; -let changelog = fs.readFileSync(changelogPath, 'utf8'); -if (!changelog.includes('## 0.0.6 - 2026-08-24')) { - const marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n'; - const section = `\n## 0.0.6 - 2026-08-24\n\n### Corregido\n\n- Selección visual calculada desde los quads reales de cada carácter del PDF; desaparecen los rectángulos desplazados o sobredimensionados.\n- Los límites de cada línea se calculan con geometría de glifos, no con el bbox amplio de MuPDF.\n- El editor de texto se puede redimensionar horizontalmente; al aplicar, el párrafo usa el nuevo ancho y hace reflow real.\n- Las tablas usan el rectángulo exacto de sus trazos Ink y conservan controles de movimiento/redimensionado.\n- Se mantiene zoom inicial 100%, Guardar funcional e icono 256×256 incluido para Marketplace.\n\n`; - changelog = replaceOnce(changelog, marker, marker + section, 'CHANGELOG marker'); - fs.writeFileSync(changelogPath, changelog); -} - -const readmePath = 'README.md'; -let readme = fs.readFileSync(readmePath, 'utf8'); -readme = readme.replace('La versión `0.0.5`', 'La versión `0.0.6`'); -if (!readme.includes('quads reales de cada carácter')) { - readme += `\n### Precisión de edición en 0.0.6\n\n- La selección se dibuja sobre los quads reales de cada carácter, no sobre cajas HTML aproximadas.\n- El editor de texto permite cambiar el ancho del bloque; el texto se recompone y desplaza el contenido inferior cuando corresponde.\n- Las tablas se pueden mover y redimensionar con un marco alineado con sus trazos reales.\n`; -} -fs.writeFileSync(readmePath, readme); - -console.log('v0.0.6 geometry and resize fixes applied.'); diff --git a/src/webview-html.js b/src/webview-html.js index c15b08a..67b79bc 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -76,12 +76,13 @@ function getWebviewHtml(webview, extensionUri) { Haz clic en un párrafo, selecciona texto o usa + para insertar. + v0.0.7