diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml deleted file mode 100644 index f03c458..0000000 --- a/.github/workflows/finalize-v005.yml +++ /dev/null @@ -1,341 +0,0 @@ -name: Finalize v0.0.5 - -on: - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - finalize: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/v0.0.5-finalize-extension - fetch-depth: 0 - - - name: Apply v0.0.5 fixes - shell: python - run: | - from pathlib import Path - import json - import struct - import zlib - - def replace_once(text, old, new, label): - if old not in text: - raise RuntimeError(f'Could not find {label}') - return text.replace(old, new, 1) - - package_path = Path('package.json') - package = json.loads(package_path.read_text(encoding='utf-8')) - package['version'] = '0.0.5' - package['contributes']['configuration']['properties']['pdfViewerEditor.defaultZoom']['default'] = 1 - package['icon'] = 'images/icon.png' - package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - - lock_path = Path('package-lock.json') - lock = lock_path.read_text(encoding='utf-8') - lock = lock.replace('"version": "0.0.4"', '"version": "0.0.5"', 2) - lock_path.write_text(lock, encoding='utf-8') - - main_path = Path('media/webview/main.js') - main = main_path.read_text(encoding='utf-8') - main = replace_once(main, ' zoom: 1.25,', ' zoom: 1,', 'initial zoom') - main = replace_once(main, ' defaultZoom: 1.25,', ' defaultZoom: 1,', 'settings default zoom') - main = replace_once(main, " state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);", 'load zoom fallback') - - old_click = '''function textLineClicked(event) { - if (state.tool !== 'edit' || state.busy) { - return; - } - hideInsertMenu(); - const blockIndex = Number(event.currentTarget.dataset.blockIndex); - window.requestAnimationFrame(() => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) { - updateTextRangeFromSelection(); - } else { - selectTextBlock(blockIndex); - } - }); - } - ''' - new_click = '''function textLineClicked(event) { - if (state.tool !== 'edit' || state.busy) { - return; - } - hideInsertMenu(); - - if (event.detail >= 2) { - window.requestAnimationFrame(updateTextRangeFromSelection); - return; - } - - window.requestAnimationFrame(() => { - const selection = window.getSelection(); - if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) { - updateTextRangeFromSelection(); - return; - } - selectTextLine(event.currentTarget); - }); - } - - function selectTextLine(lineElement) { - if (!(lineElement instanceof window.HTMLElement)) { - return false; - } - const selection = window.getSelection(); - if (!selection) { - return false; - } - const range = document.createRange(); - range.selectNodeContents(lineElement); - selection.removeAllRanges(); - selection.addRange(range); - updateTextRangeFromSelection(); - return true; - } - ''' - main = replace_once(main, old_click, new_click, 'textLineClicked implementation') - main = replace_once( - main, - " line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex);", - " line.classList.toggle(\n 'selected',\n !options.preserveRange && Number(line.dataset.blockIndex) === blockIndex\n );", - 'block highlight toggle' - ) - main = replace_once( - main, - ''' } else if (message.type === 'operation-error') { - setBusy(false); - setStatus(message.message, 'error'); - } - ''', - ''' } else if (message.type === 'operation-error') { - setBusy(false); - setStatus(message.message, 'error'); - } else if (message.type === 'save-complete') { - setBusy(false); - setStatus(String(message.message || 'PDF guardado.')); - } - ''', - 'save-complete message handler' - ) - main_path.write_text(main, encoding='utf-8') - - css_path = Path('media/webview/styles.css') - css = css_path.read_text(encoding='utf-8') - css = replace_once( - css, - '''.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.selected { - background: transparent; - box-shadow: none; - } - ''', - 'selected text line style' - ) - css_path.write_text(css, encoding='utf-8') - - html_path = Path('src/webview-html.js') - html = html_path.read_text(encoding='utf-8') - html = replace_once(html, '', '', '100 percent option') - html = replace_once(html, '', '', '125 percent option') - html_path.write_text(html, encoding='utf-8') - - provider_path = Path('src/pdf-editor-provider.js') - provider = provider_path.read_text(encoding='utf-8') - provider = replace_once(provider, " await this.executeEditorCommand(String(message.command || ''));", " await this.executeEditorCommand(document, panel, String(message.command || ''));", 'command invocation') - old_execute = ''' /** - * @param {string} command - */ - async executeEditorCommand(command) { - const allowedCommands = new Map([ - ['save', 'workbench.action.files.save'], - ['saveAs', 'workbench.action.files.saveAs'], - ['undo', 'undo'], - ['redo', 'redo'] - ]); - const vscodeCommand = allowedCommands.get(command); - if (vscodeCommand) { - await vscode.commands.executeCommand(vscodeCommand); - } - } - ''' - new_execute = ''' /** - * @param {PdfDocument} document - * @param {vscode.WebviewPanel} panel - * @param {string} command - */ - async executeEditorCommand(document, panel, command) { - if (command === 'save') { - let savedUri; - if (typeof vscode.workspace.save === 'function') { - savedUri = await vscode.workspace.save(document.uri); - } else { - panel.reveal(panel.viewColumn, true); - await vscode.commands.executeCommand('workbench.action.files.save'); - savedUri = document.uri; - } - if (!savedUri) { - await this.saveCustomDocument(document); - savedUri = document.uri; - } - await panel.webview.postMessage({ - type: 'save-complete', - message: `Guardado: ${path.basename(savedUri.fsPath || savedUri.path)}` - }); - return; - } - - if (command === 'saveAs') { - let savedUri; - if (typeof vscode.workspace.saveAs === 'function') { - savedUri = await vscode.workspace.saveAs(document.uri); - } else { - panel.reveal(panel.viewColumn, true); - await vscode.commands.executeCommand('workbench.action.files.saveAs'); - } - await panel.webview.postMessage({ - type: 'save-complete', - message: savedUri - ? `Guardado como: ${path.basename(savedUri.fsPath || savedUri.path)}` - : 'Guardar como cancelado.' - }); - return; - } - - const allowedCommands = new Map([ - ['undo', 'undo'], - ['redo', 'redo'] - ]); - const vscodeCommand = allowedCommands.get(command); - if (vscodeCommand) { - await vscode.commands.executeCommand(vscodeCommand); - } - } - ''' - provider = replace_once(provider, old_execute, new_execute, 'executeEditorCommand') - provider = replace_once(provider, " defaultZoom: configuration.get('defaultZoom', 1.25),", " defaultZoom: configuration.get('defaultZoom', 1),", 'provider zoom fallback') - provider_path.write_text(provider, encoding='utf-8') - - width = height = 256 - background = (229, 57, 53, 255) - white = (255, 255, 255, 255) - accent = (33, 33, 33, 255) - pixels = [[background for _ in range(width)] for _ in range(height)] - - def rect(x1, y1, x2, y2, color): - for y in range(max(0, y1), min(height, y2)): - for x in range(max(0, x1), min(width, x2)): - pixels[y][x] = color - - rect(52, 30, 184, 226, white) - rect(184, 78, 205, 226, white) - for y in range(30, 79): - x2 = min(205, 184 + max(0, (y - 30) // 2)) - rect(184, y, x2 + 1, y + 1, white) - rect(80, 105, 174, 119, background) - rect(80, 137, 174, 151, background) - rect(80, 169, 151, 183, background) - for i in range(52): - for t in range(10): - x = 151 + i - t // 2 - y = 202 - i + t - if 0 <= x < width and 0 <= y < height: - pixels[y][x] = accent - - raw = bytearray() - for row in pixels: - raw.append(0) - for rgba in row: - raw.extend(rgba) - - def chunk(kind, data): - return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data) & 0xffffffff) - - png = bytearray(b'\x89PNG\r\n\x1a\n') - png += chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0)) - png += chunk(b'IDAT', zlib.compress(bytes(raw), 9)) - png += chunk(b'IEND', b'') - Path('images').mkdir(exist_ok=True) - Path('images/icon.png').write_bytes(png) - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text(encoding='utf-8') - marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n' - section = '''\n## 0.0.5 - 2026-08-24\n\n### Corregido\n\n- La selección de texto usa exclusivamente el rango nativo: arrastrar selecciona solo palabras/frases y ya no pinta el párrafo completo en azul.\n- Un clic selecciona únicamente la línea visible; doble clic conserva la selección nativa de palabra.\n- El zoom inicial pasa de 125% a 100% en configuración, webview y proveedor.\n- `Guardar` y `Guardar como` se dirigen al PDF abierto y muestran confirmación en el editor.\n- Icono de extensión renovado y validado dentro del manifiesto VSIX para VS Code Marketplace.\n\n### Pruebas\n\n- El empaquetado comprueba versión 0.0.5, PNG 256×256, asset de icono en `extension.vsixmanifest`, ausencia del resaltado azul de bloque y presencia del guardado dirigido por URI.\n''' - if '## 0.0.5 - 2026-08-24' not in changelog: - changelog = replace_once(changelog, marker, marker + section, 'changelog insertion point') - changelog_path.write_text(changelog, encoding='utf-8') - - readme_path = Path('README.md') - readme = readme_path.read_text(encoding='utf-8') - readme = readme.replace('La versión `0.0.4`', 'La versión `0.0.5`', 1) - readme = readme.replace('- Hacer clic sobre cualquier línea para seleccionar solo su párrafo visual, sin rectángulos gigantes.', '- Hacer clic sobre una línea selecciona solo esa línea; arrastrar selecciona exactamente palabras o frases, sin rectángulos gigantes.', 1) - readme_path.write_text(readme, encoding='utf-8') - - - name: Validate implementation invariants - shell: bash - run: | - set -euo pipefail - node -e "const p=require('./package.json'); if(p.version!=='0.0.5') process.exit(1); if(p.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default!==1) process.exit(2); if(p.icon!=='images/icon.png') process.exit(3)" - grep -q "selectTextLine(event.currentTarget)" media/webview/main.js - grep -q "!options.preserveRange" media/webview/main.js - grep -q "background: transparent" media/webview/styles.css - grep -q "vscode.workspace.save(document.uri)" src/pdf-editor-provider.js - grep -q "defaultZoom: configuration.get('defaultZoom', 1)" src/pdf-editor-provider.js - grep -q '' src/webview-html.js - - - name: Install and test - run: | - npm ci - npm run check - - - name: Package and verify Marketplace assets - shell: bash - run: | - set -euo pipefail - npm run package - test -f pdf-viewer-editor-0.0.5.vsix - python - <<'PY' - from pathlib import Path - import struct - import zipfile - - vsix = Path('pdf-viewer-editor-0.0.5.vsix') - with zipfile.ZipFile(vsix) as archive: - names = set(archive.namelist()) - assert 'extension/images/icon.png' in names - manifest = archive.read('extension.vsixmanifest').decode('utf-8') - assert 'Version="0.0.5" Publisher="suzdalenko-dev"' in manifest - assert 'extension/images/icon.png' in manifest - assert 'Microsoft.VisualStudio.Services.Icons.Default' in manifest - icon = archive.read('extension/images/icon.png') - assert icon[:8] == b'\x89PNG\r\n\x1a\n' - width, height = struct.unpack('>II', icon[16:24]) - assert (width, height) == (256, 256) - print(f'VSIX OK: {vsix.name}; icon={width}x{height}; files={len(names)}') - PY - rm -f pdf-viewer-editor-0.0.5.vsix - - - name: Commit final source and remove one-shot workflow - shell: bash - run: | - set -euo pipefail - rm -f .github/workflows/finalize-v005.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git status --short - git commit -m 'fix: finalize v0.0.5 editor interactions and marketplace package' - git push origin HEAD:fix/v0.0.5-finalize-extension diff --git a/CHANGELOG.md b/CHANGELOG.md index 783d7ec..466a59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ Todos los cambios relevantes de este proyecto se documentan aquí. +## 0.0.6 - 2026-08-24 + +### Corregido + +- Selección visual calculada con rectángulos reales de caracteres extraídos del PDF. +- Capa Fabric y página usando el mismo sistema de coordenadas para evitar desplazamientos. +- Marcos de tablas alineados con el objeto PDF real. +- Bloques de texto movibles y redimensionables con reflow al cambiar su anchura. +- Icono de Marketplace conservado y VSIX actualizado a 0.0.6. + + +## 0.0.5 - 2026-08-24 + +### Corregido + +- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo. +- Zoom inicial al 100%. +- Botón Guardar escribe el PDF abierto y muestra confirmación visual. +- Icono 256×256 renovado y validado como asset del VSIX/Marketplace. + + ## 0.0.4 - 2026-08-23 ### Corregido diff --git a/README.md b/README.md index bfc2b16..02180c1 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,18 @@ Editor visual de PDF gratuito para Visual Studio Code, construido en JavaScript con MuPDF.js y Fabric.js. -La versión `0.0.4` se concentra en una tarea: **seleccionar y editar directamente el contenido visible de forma natural**. 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.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. > 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 cualquier línea para seleccionar solo su párrafo visual, sin rectángulos gigantes. +- 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`. +- Al cambiar la anchura de un bloque, el texto se recompone dentro del nuevo ancho. - 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. @@ -37,6 +40,7 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - 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. +- El marco de edición de la tabla comparte el mismo sistema de coordenadas que el PDF renderizado. - 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`. @@ -45,6 +49,7 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - 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. @@ -57,12 +62,13 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l 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 su párrafo, o arrastra sobre cualquier palabra o frase. +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. Cambia el tamaño de fuente: la selección se recompone y el contenido inferior se desplaza. -6. Usa el punto `+` entre párrafos para insertar texto, imagen o tabla sin solapar nada. -7. También puedes usar `Añadir texto`, `Imagen` o `Tabla` para colocar contenido libremente. -8. Guarda con `Ctrl/Cmd+S`. +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`. | Acción | Atajo | |---|---| @@ -103,7 +109,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.4.vsix` | +| `npm run package` | Genera `pdf-viewer-editor-0.0.6.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 5e13d79..afb21c7 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 b836ed6..ab7cb4a 100644 --- a/media/webview/main.js +++ b/media/webview/main.js @@ -17,11 +17,11 @@ const state = { fileName: 'document.pdf', currentPage: 0, pageCount: 0, - zoom: 1.25, + zoom: 1, zoomMode: 'numeric', tool: 'edit', settings: { - defaultZoom: 1.25, + defaultZoom: 1, maxRenderPixels: 24_000_000, defaultSaveMode: 'incremental' }, @@ -54,7 +54,7 @@ const elements = Object.fromEntries([ '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', 'copy-text', 'text-font-family', 'text-font-size', + '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', @@ -74,6 +74,9 @@ window.addEventListener('message', async (event) => { } else if (message.type === 'operation-error') { setBusy(false); setStatus(message.message, 'error'); + } else if (message.type === 'document-saved') { + setBusy(false); + setStatus('PDF guardado correctamente.'); } }); @@ -90,7 +93,7 @@ async function loadDocument(message) { state.revision = Number(message.revision || 0); state.fileName = String(message.fileName || 'document.pdf'); state.settings = { ...state.settings, ...(message.settings || {}) }; - state.zoom = Number(state.settings.defaultZoom || 1.25); + state.zoom = Number(state.settings.defaultZoom || 1); state.zoomMode = 'numeric'; state.password = ''; state.searchQuery = ''; @@ -202,6 +205,7 @@ function initializeOrResizeFabricCanvas() { height: state.render.height, preserveObjectStacking: true, selection: false, + enableRetinaScaling: false, stopContextMenu: true }); state.fabricCanvas.on('selection:created', selectionChanged); @@ -264,14 +268,22 @@ function textLineClicked(event) { return; } hideInsertMenu(); - const blockIndex = Number(event.currentTarget.dataset.blockIndex); + if (event.detail >= 2) { + window.requestAnimationFrame(updateTextRangeFromSelection); + return; + } + const line = event.currentTarget; window.requestAnimationFrame(() => { const selection = window.getSelection(); if (selection && !selection.isCollapsed && selectionInsideTextLayer(selection)) { updateTextRangeFromSelection(); - } else { - selectTextBlock(blockIndex); + return; } + const range = document.createRange(); + range.selectNodeContents(line); + selection?.removeAllRanges(); + selection?.addRange(range); + updateTextRangeFromSelection(); }); } @@ -283,6 +295,7 @@ function selectTextBlock(blockIndex, options = {}) { if (!options.preserveRange) { state.textRange = null; hideEditRangeButton(); + clearTextRangeHighlights(); } const meta = { kind: 'text', @@ -295,9 +308,7 @@ function selectTextBlock(blockIndex, options = {}) { state.fabricCanvas.discardActiveObject(); state.fabricCanvas.requestRenderAll(); } - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) { - line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex); - } + clearTextLineHighlights(); selectMeta(meta); return true; } @@ -332,6 +343,7 @@ function updateTextRangeFromSelection() { end: end.offset, text: block.text.slice(start.offset, end.offset) }; + renderTextRangeHighlights(); selectTextBlock(block.index, { preserveRange: true }); showEditRangeButton(range); setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); @@ -390,6 +402,99 @@ 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 renderTextRangeHighlights() { + clearTextRangeHighlights(); + const selectedRange = state.textRange; + if (!selectedRange || !state.pageModel) { + return; + } + + 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; + } + + let rect = null; + const characters = Array.isArray(line.characters) ? line.characters : []; + const selectedCharacters = characters.slice(localStart, localEnd) + .map((character) => character.rect) + .filter((candidate) => Array.isArray(candidate) && candidate.length === 4); + + if (selectedCharacters.length > 0) { + rect = 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] + ]; + } + + const screen = pdfRectToScreen(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) { + 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(true); + const resizeMeta = { ...selected, resizeMode: true }; + const object = addOverlayObject(resizeMeta, { + stroke: '#1473e6', + fill: 'rgba(20, 115, 230, 0.025)', + movable: true, + resizable: true + }); + 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(); @@ -671,6 +776,8 @@ function clearSelection() { state.selected = null; state.textRange = null; hideEditRangeButton(); + clearTextRangeHighlights(); + setTextResizeMode(false); clearTextLineHighlights(); elements['delete-tool'].disabled = true; elements['selection-context'].classList.add('hidden'); @@ -795,6 +902,12 @@ async function overlayObjectModified(event) { 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, @@ -846,13 +959,12 @@ function screenPointToPdf(point) { } function objectScreenRectToPdf(object) { - const width = Math.max(2, object.width * object.scaleX); - const height = Math.max(2, object.height * object.scaleY); + const bounds = object.getBoundingRect(); return screenRectToPdf([ - object.left, - object.top, - object.left + width, - object.top + height + bounds.left, + bounds.top, + bounds.left + Math.max(2, bounds.width), + bounds.top + Math.max(2, bounds.height) ]); } @@ -1552,7 +1664,13 @@ function initializeEventHandlers() { }); elements['undo-button'].addEventListener('click', () => postCommand('undo')); elements['redo-button'].addEventListener('click', () => postCommand('redo')); - elements['save-button'].addEventListener('click', () => postCommand('save')); + elements['save-button'].addEventListener('click', () => { + if (state.busy) { + return; + } + setBusy(true, 'Guardando PDF…'); + postCommand('save'); + }); elements['edit-text-tool'].addEventListener('click', () => { hideInsertMenu(); @@ -1582,6 +1700,7 @@ function initializeEventHandlers() { 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); diff --git a/media/webview/pdf-engine.js b/media/webview/pdf-engine.js index ab95f09..1cb9976 100644 --- a/media/webview/pdf-engine.js +++ b/media/webview/pdf-engine.js @@ -571,6 +571,50 @@ export class PdfEngine { }; } + 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); @@ -1523,7 +1567,8 @@ function extractTextBlocks(structuredText) { text: '', rect: normalizeRect([...bbox]), baseline: null, - styles: new Map() + styles: new Map(), + characters: [] }; }, onChar(character, origin, font, size, quad, color) { @@ -1555,11 +1600,14 @@ function extractTextBlocks(structuredText) { }; weightedStyle.weight += Math.max(1, String(character).trim().length); currentLine.styles.set(styleKey, weightedStyle); - currentLine.text += String(character); + const characterText = String(character); + const characterRect = quadToRect(quad); + currentLine.text += characterText; + currentLine.characters.push({ text: characterText, rect: characterRect }); currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; currentLine.rect = unionRects([ currentLine.rect, - quadToRect(quad) + characterRect ]); } finally { font.destroy(); @@ -1576,7 +1624,8 @@ function extractTextBlocks(structuredText) { rect: currentLine.rect, baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], font: style.font, - color: style.color + color: style.color, + characters: currentLine.characters }); currentLine = null; }, @@ -1681,17 +1730,29 @@ function buildVisualLines(lines) { 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)) { 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]) + ] + }); } text += fragment.text; + characters.push(...(fragment.characters || [])); 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]], diff --git a/media/webview/styles.css b/media/webview/styles.css index 3c52e12..72dac4f 100644 --- a/media/webview/styles.css +++ b/media/webview/styles.css @@ -373,7 +373,24 @@ input[type="color"] { .text-layer-line::selection { color: transparent; - background: color-mix(in srgb, var(--focus) 38%, transparent); + background: transparent; +} + +.text-range-highlight { + position: absolute; + z-index: -1; + 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 { diff --git a/package-lock.json b/package-lock.json index 28239b0..8f674dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-viewer-editor", - "version": "0.0.4", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.4", + "version": "0.0.6", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 006cdaa..1302c99 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.4", + "version": "0.0.6", "publisher": "suzdalenko-dev", "license": "AGPL-3.0-or-later", "repository": { @@ -82,7 +82,7 @@ "properties": { "pdfViewerEditor.defaultZoom": { "type": "number", - "default": 1.25, + "default": 1, "minimum": 0.25, "maximum": 5, "description": "Default PDF page zoom factor." @@ -128,5 +128,9 @@ "mupdf": "1.28.0", "typescript": "^5.9.0" }, - "icon": "images/icon.png" + "icon": "images/icon.png", + "galleryBanner": { + "color": "#0f172a", + "theme": "dark" + } } diff --git a/src/pdf-editor-provider.js b/src/pdf-editor-provider.js index 85114be..ac108eb 100644 --- a/src/pdf-editor-provider.js +++ b/src/pdf-editor-provider.js @@ -110,7 +110,7 @@ class PdfEditorProvider { } case 'command': - await this.executeEditorCommand(String(message.command || '')); + await this.executeEditorCommand(document, panel, String(message.command || '')); return; case 'export': @@ -150,11 +150,19 @@ class PdfEditorProvider { } /** + * @param {PdfDocument} document + * @param {vscode.WebviewPanel} panel * @param {string} command */ - async executeEditorCommand(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' }); + return; + } + const allowedCommands = new Map([ - ['save', 'workbench.action.files.save'], ['saveAs', 'workbench.action.files.saveAs'], ['undo', 'undo'], ['redo', 'redo'] @@ -222,7 +230,7 @@ class PdfEditorProvider { revision: document.revision, fileName: path.basename(document.uri.fsPath || document.uri.path), settings: { - defaultZoom: configuration.get('defaultZoom', 1.25), + defaultZoom: configuration.get('defaultZoom', 1), maxRenderPixels: configuration.get('maxRenderPixels', 24000000), defaultSaveMode: configuration.get('defaultSaveMode', 'incremental') } diff --git a/src/webview-html.js b/src/webview-html.js index 94b2ff7..c15b08a 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -48,8 +48,8 @@ function getWebviewHtml(webview, extensionUri) { - - + + @@ -81,6 +81,7 @@ function getWebviewHtml(webview, extensionUri) {