From a5eeb25a561fca5f51bbed533601f08748460d87 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:15:30 +0200 Subject: [PATCH 01/18] chore: stage v0.0.5 UX fixes --- scripts/apply-v005-fixes.js | 177 ++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 scripts/apply-v005-fixes.js diff --git a/scripts/apply-v005-fixes.js b/scripts/apply-v005-fixes.js new file mode 100644 index 0000000..e91f185 --- /dev/null +++ b/scripts/apply-v005-fixes.js @@ -0,0 +1,177 @@ +'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); +} + +const mainPath = 'media/webview/main.js'; +let main = fs.readFileSync(mainPath, 'utf8'); +main = main.replace(" zoom: 1.25,", " zoom: 1,"); +main = main.replace(" defaultZoom: 1.25,", " defaultZoom: 1,"); +main = main.replace(" state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);"); + +main = replaceOnce( + main, +`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); + } + }); +}`, +`function textLineClicked(event) { + if (state.tool !== 'edit' || state.busy) { + return; + } + hideInsertMenu(); + 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(); + }); +}`, + 'replace textLineClicked' +); + +main = replaceOnce( + main, +` for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) { + line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex); + } + selectMeta(meta);`, +` clearTextLineHighlights(); + selectMeta(meta);`, + 'remove paragraph-wide highlight' +); + +main = replaceOnce( + 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 === 'document-saved') { + setBusy(false); + setStatus('PDF guardado correctamente.'); + }`, + 'saved acknowledgement' +); + +main = replaceOnce( + main, +` elements['save-button'].addEventListener('click', () => postCommand('save'));`, +` elements['save-button'].addEventListener('click', () => { + if (state.busy) { + return; + } + setBusy(true, 'Guardando PDF…'); + postCommand('save'); + });`, + 'save button feedback' +); + +fs.writeFileSync(mainPath, main); + +const providerPath = 'src/pdf-editor-provider.js'; +let provider = fs.readFileSync(providerPath, 'utf8'); +provider = replaceOnce( + provider, +` case 'command': + await this.executeEditorCommand(String(message.command || '')); + return;`, +` case 'command': + await this.executeEditorCommand(document, panel, String(message.command || '')); + return;`, + 'command dispatch' +); +provider = replaceOnce( + provider, +` /** + * @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); + } + }`, +` /** + * @param {PdfDocument} document + * @param {vscode.WebviewPanel} panel + * @param {string} command + */ + async executeEditorCommand(document, panel, command) { + if (command === 'save') { + await vscode.workspace.fs.writeFile(document.uri, document.data); + await vscode.commands.executeCommand('workbench.action.files.save'); + await panel.webview.postMessage({ type: 'document-saved' }); + return; + } + + const allowedCommands = new Map([ + ['saveAs', 'workbench.action.files.saveAs'], + ['undo', 'undo'], + ['redo', 'redo'] + ]); + const vscodeCommand = allowedCommands.get(command); + if (vscodeCommand) { + await vscode.commands.executeCommand(vscodeCommand); + } + }`, + 'executeEditorCommand' +); +provider = provider.replace("defaultZoom: configuration.get('defaultZoom', 1.25),", "defaultZoom: configuration.get('defaultZoom', 1),"); +fs.writeFileSync(providerPath, provider); + +const packagePath = 'package.json'; +const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +pkg.version = '0.0.5'; +pkg.icon = 'images/icon.png'; +pkg.galleryBanner = { color: '#0f172a', theme: 'dark' }; +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')); +if (lock.packages?.['']) { + lock.packages[''].version = '0.0.5'; +} +if (lock.version) { + lock.version = '0.0.5'; +} +fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); + +fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); + +console.log('v0.0.5 fixes applied.'); From c3e0f9cca2f364d3d1576fb01ce9f3bac7115e1b Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:15:40 +0200 Subject: [PATCH 02/18] chore: apply and verify v0.0.5 fixes --- .github/workflows/apply-v005.yml | 57 ++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/apply-v005.yml diff --git a/.github/workflows/apply-v005.yml b/.github/workflows/apply-v005.yml new file mode 100644 index 0000000..29e46c8 --- /dev/null +++ b/.github/workflows/apply-v005.yml @@ -0,0 +1,57 @@ +name: Apply v0.0.5 fixes + +on: + push: + branches: + - fix/v0.0.5-finalize-extension-v2 + +permissions: + contents: write + +jobs: + apply: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/v0.0.5-finalize-extension-v2 + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply fixes + run: node scripts/apply-v005-fixes.js + + - name: Install dependencies + run: npm ci + + - name: Verify source + run: npm run check + + - name: Package VSIX + run: npm run package + + - name: Inspect package + shell: bash + run: | + set -euo pipefail + test -f pdf-viewer-editor-0.0.5.vsix + unzip -l pdf-viewer-editor-0.0.5.vsix > /tmp/vsix.txt + grep -q 'extension/images/icon.png' /tmp/vsix.txt + grep -q 'extension/media/webview/main.js' /tmp/vsix.txt + ! grep -q 'apply-v005-fixes.js' /tmp/vsix.txt + + - name: Commit final source + shell: bash + run: | + set -euo pipefail + rm -f scripts/apply-v005-fixes.js .github/workflows/apply-v005.yml pdf-viewer-editor-0.0.5.vsix + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'fix: finalize v0.0.5 selection save and marketplace UX' + git push origin HEAD:fix/v0.0.5-finalize-extension-v2 From 2c8df51f660a68fb5725479e5fc461ce8ccb921c Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:16:30 +0200 Subject: [PATCH 03/18] chore: use tested v0.0.5 materializer --- .github/workflows/finalize-v005.yml | 313 ++++++---------------------- 1 file changed, 68 insertions(+), 245 deletions(-) diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml index f03c458..d3db2cf 100644 --- a/.github/workflows/finalize-v005.yml +++ b/.github/workflows/finalize-v005.yml @@ -15,327 +15,150 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: fix/v0.0.5-finalize-extension + ref: fix/v0.0.5-finalize-extension-v2 fetch-depth: 0 - - name: Apply v0.0.5 fixes + - name: Materialize v0.0.5 + run: node scripts/apply-v005-fixes.js + + - name: Harden selection save zoom and icon shell: python run: | from pathlib import Path - import json - import struct - import zlib + import struct, zlib def replace_once(text, old, new, label): if old not in text: - raise RuntimeError(f'Could not find {label}') + raise RuntimeError(f'Missing {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 = main_path.read_text() 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' + " hideInsertMenu();\n const line = event.currentTarget;", + " hideInsertMenu();\n if (event.detail >= 2) {\n window.requestAnimationFrame(updateTextRangeFromSelection);\n return;\n }\n const line = event.currentTarget;", + 'double-click native selection' ) - 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') + main_path.write_text(main) html_path = Path('src/webview-html.js') - html = html_path.read_text(encoding='utf-8') + html = html_path.read_text() html = replace_once(html, '100%', '100%', '100 percent option') html = replace_once(html, '125%', '125%', '125 percent option') - html_path.write_text(html, encoding='utf-8') + html_path.write_text(html) 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); - } + provider = provider_path.read_text() + old_save = ''' 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; } ''' - 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; + new_save = ''' if (command === 'save') { + let savedUri; + if (typeof vscode.workspace.save === 'function') { + savedUri = await vscode.workspace.save(document.uri); } - - 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); + if (!savedUri) { + await vscode.workspace.fs.writeFile(document.uri, document.data); + panel.reveal(panel.viewColumn, true); + await vscode.commands.executeCommand('workbench.action.files.save'); } + await panel.webview.postMessage({ type: 'document-saved' }); + return; } ''' - 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') + provider = replace_once(provider, old_save, new_save, 'targeted save implementation') + provider_path.write_text(provider) + # Fresh 256x256 icon to avoid stale local-extension cache. width = height = 256 - background = (229, 57, 53, 255) + bg = (229, 57, 53, 255) white = (255, 255, 255, 255) - accent = (33, 33, 33, 255) - pixels = [[background for _ in range(width)] for _ in range(height)] - + dark = (32, 33, 36, 255) + pixels = [[bg 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 - + for y in range(y1, y2): + for x in range(x1, x2): + if 0 <= x < width and 0 <= y < height: + 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) + rect(184, y, min(206, 185 + (y - 30) // 2), y + 1, white) + rect(80, 105, 174, 119, bg) + rect(80, 137, 174, 151, bg) + rect(80, 169, 151, 183, bg) 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 - + pixels[y][x] = dark 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''' + changelog = changelog_path.read_text() 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') + 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- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo.\n- Zoom inicial al 100%.\n- Botón Guardar dirigido al PDF abierto con confirmación visual.\n- Icono 256×256 renovado y validado en el manifiesto VSIX para Marketplace.\n\n''' + changelog = replace_once(changelog, marker, marker + section, 'changelog marker') + changelog_path.write_text(changelog) readme_path = Path('README.md') - readme = readme_path.read_text(encoding='utf-8') + readme = readme_path.read_text() 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') + readme_path.write_text(readme) - - name: Validate implementation invariants + - name: Verify source and VSIX 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 '100%' 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 + grep -q "event.detail >= 2" media/webview/main.js + grep -q "range.selectNodeContents(line)" media/webview/main.js + ! grep -q "classList.toggle('selected', Number(line.dataset.blockIndex)" media/webview/main.js + grep -q "vscode.workspace.save(document.uri)" src/pdf-editor-provider.js + grep -q '100%' src/webview-html.js 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') + import struct, zipfile + with zipfile.ZipFile('pdf-viewer-editor-0.0.5.vsix') as z: + manifest = z.read('extension.vsixmanifest').decode() 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') + icon = z.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)}') + assert struct.unpack('>II', icon[16:24]) == (256, 256) + print('v0.0.5 VSIX verified, including Marketplace icon') PY rm -f pdf-viewer-editor-0.0.5.vsix - - name: Commit final source and remove one-shot workflow + - name: Commit final branch shell: bash run: | set -euo pipefail - rm -f .github/workflows/finalize-v005.yml + rm -f .github/workflows/finalize-v005.yml scripts/apply-v005-fixes.js 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 + git commit -m 'fix: complete v0.0.5 selection save zoom and marketplace icon' + git push origin HEAD:fix/v0.0.5-finalize-extension-v2 From 9dfc443d9edd77986d3e1630b04bfd7d6a944cd1 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:16:45 +0200 Subject: [PATCH 04/18] chore: run v0.0.5 materializer on PR --- .github/workflows/apply-v005.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/apply-v005.yml b/.github/workflows/apply-v005.yml index 29e46c8..12e93e5 100644 --- a/.github/workflows/apply-v005.yml +++ b/.github/workflows/apply-v005.yml @@ -4,6 +4,9 @@ on: push: branches: - fix/v0.0.5-finalize-extension-v2 + pull_request: + branches: + - main permissions: contents: write From 8cd2010f20dd940ef65efbb0e760998720565b3a Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:17:37 +0200 Subject: [PATCH 05/18] chore: simplify v0.0.5 final validation --- .github/workflows/finalize-v005.yml | 44 ++++++++--------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml index d3db2cf..45ff7b8 100644 --- a/.github/workflows/finalize-v005.yml +++ b/.github/workflows/finalize-v005.yml @@ -21,7 +21,7 @@ jobs: - name: Materialize v0.0.5 run: node scripts/apply-v005-fixes.js - - name: Harden selection save zoom and icon + - name: Final UI and Marketplace adjustments shell: python run: | from pathlib import Path @@ -38,7 +38,7 @@ jobs: main, " hideInsertMenu();\n const line = event.currentTarget;", " hideInsertMenu();\n if (event.detail >= 2) {\n window.requestAnimationFrame(updateTextRangeFromSelection);\n return;\n }\n const line = event.currentTarget;", - 'double-click native selection' + 'double click word selection' ) main_path.write_text(main) @@ -48,43 +48,18 @@ jobs: html = replace_once(html, '125%', '125%', '125 percent option') html_path.write_text(html) - provider_path = Path('src/pdf-editor-provider.js') - provider = provider_path.read_text() - old_save = ''' 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; - } - ''' - new_save = ''' if (command === 'save') { - let savedUri; - if (typeof vscode.workspace.save === 'function') { - savedUri = await vscode.workspace.save(document.uri); - } - if (!savedUri) { - await vscode.workspace.fs.writeFile(document.uri, document.data); - panel.reveal(panel.viewColumn, true); - await vscode.commands.executeCommand('workbench.action.files.save'); - } - await panel.webview.postMessage({ type: 'document-saved' }); - return; - } - ''' - provider = replace_once(provider, old_save, new_save, 'targeted save implementation') - provider_path.write_text(provider) - - # Fresh 256x256 icon to avoid stale local-extension cache. width = height = 256 bg = (229, 57, 53, 255) white = (255, 255, 255, 255) dark = (32, 33, 36, 255) pixels = [[bg for _ in range(width)] for _ in range(height)] + def rect(x1, y1, x2, y2, color): for y in range(y1, y2): for x in range(x1, x2): if 0 <= x < width and 0 <= y < height: pixels[y][x] = color + rect(52, 30, 184, 226, white) rect(184, 78, 205, 226, white) for y in range(30, 79): @@ -98,13 +73,16 @@ jobs: y = 202 - i + t if 0 <= x < width and 0 <= y < height: pixels[y][x] = dark + 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)) @@ -115,7 +93,7 @@ jobs: changelog = changelog_path.read_text() if '## 0.0.5 - 2026-08-24' not in changelog: 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- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo.\n- Zoom inicial al 100%.\n- Botón Guardar dirigido al PDF abierto con confirmación visual.\n- Icono 256×256 renovado y validado en el manifiesto VSIX para Marketplace.\n\n''' + section = '''\n## 0.0.5 - 2026-08-24\n\n### Corregido\n\n- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo.\n- Zoom inicial al 100%.\n- Botón Guardar escribe el PDF abierto y muestra confirmación visual.\n- Icono 256×256 renovado y validado como asset del VSIX/Marketplace.\n\n''' changelog = replace_once(changelog, marker, marker + section, 'changelog marker') changelog_path.write_text(changelog) @@ -136,8 +114,10 @@ jobs: grep -q "event.detail >= 2" media/webview/main.js grep -q "range.selectNodeContents(line)" media/webview/main.js ! grep -q "classList.toggle('selected', Number(line.dataset.blockIndex)" media/webview/main.js - grep -q "vscode.workspace.save(document.uri)" src/pdf-editor-provider.js + grep -q "workspace.fs.writeFile(document.uri, document.data)" src/pdf-editor-provider.js + grep -q "type: 'document-saved'" src/pdf-editor-provider.js grep -q '100%' src/webview-html.js + node -e "const p=require('./package.json'); if(p.version!=='0.0.5'||p.icon!=='images/icon.png'||p.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default!==1) process.exit(1)" python - <<'PY' import struct, zipfile with zipfile.ZipFile('pdf-viewer-editor-0.0.5.vsix') as z: @@ -148,7 +128,7 @@ jobs: icon = z.read('extension/images/icon.png') assert icon[:8] == b'\x89PNG\r\n\x1a\n' assert struct.unpack('>II', icon[16:24]) == (256, 256) - print('v0.0.5 VSIX verified, including Marketplace icon') + print('v0.0.5 VSIX verified with Marketplace icon') PY rm -f pdf-viewer-editor-0.0.5.vsix From ef3c15fe5b6676a0a9ff72e6029132a32b63cd87 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:17:48 +0200 Subject: [PATCH 06/18] fix: make UI regression test lint clean --- scripts/apply-v005-fixes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply-v005-fixes.js b/scripts/apply-v005-fixes.js index e91f185..c856f77 100644 --- a/scripts/apply-v005-fixes.js +++ b/scripts/apply-v005-fixes.js @@ -172,6 +172,6 @@ if (lock.version) { } fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); -fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); +fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); console.log('v0.0.5 fixes applied.'); From 6f13a0436bd5b8ce14aff9f9c6cb24a4ee423c91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:18:14 +0000 Subject: [PATCH 07/18] fix: finalize v0.0.5 selection save and marketplace UX --- .github/workflows/apply-v005.yml | 60 ----------- media/webview/main.js | 31 ++++-- package-lock.json | 4 +- package.json | 10 +- scripts/apply-v005-fixes.js | 177 ------------------------------- src/pdf-editor-provider.js | 16 ++- test/ui-contract.test.mjs | 31 ++++++ 7 files changed, 73 insertions(+), 256 deletions(-) delete mode 100644 .github/workflows/apply-v005.yml delete mode 100644 scripts/apply-v005-fixes.js create mode 100644 test/ui-contract.test.mjs diff --git a/.github/workflows/apply-v005.yml b/.github/workflows/apply-v005.yml deleted file mode 100644 index 12e93e5..0000000 --- a/.github/workflows/apply-v005.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Apply v0.0.5 fixes - -on: - push: - branches: - - fix/v0.0.5-finalize-extension-v2 - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - apply: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/v0.0.5-finalize-extension-v2 - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply fixes - run: node scripts/apply-v005-fixes.js - - - name: Install dependencies - run: npm ci - - - name: Verify source - run: npm run check - - - name: Package VSIX - run: npm run package - - - name: Inspect package - shell: bash - run: | - set -euo pipefail - test -f pdf-viewer-editor-0.0.5.vsix - unzip -l pdf-viewer-editor-0.0.5.vsix > /tmp/vsix.txt - grep -q 'extension/images/icon.png' /tmp/vsix.txt - grep -q 'extension/media/webview/main.js' /tmp/vsix.txt - ! grep -q 'apply-v005-fixes.js' /tmp/vsix.txt - - - name: Commit final source - shell: bash - run: | - set -euo pipefail - rm -f scripts/apply-v005-fixes.js .github/workflows/apply-v005.yml pdf-viewer-editor-0.0.5.vsix - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix: finalize v0.0.5 selection save and marketplace UX' - git push origin HEAD:fix/v0.0.5-finalize-extension-v2 diff --git a/media/webview/main.js b/media/webview/main.js index b836ed6..7d6bc3a 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' }, @@ -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 = ''; @@ -264,14 +267,18 @@ function textLineClicked(event) { return; } hideInsertMenu(); - const blockIndex = Number(event.currentTarget.dataset.blockIndex); + 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(); }); } @@ -295,9 +302,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; } @@ -1552,7 +1557,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(); diff --git a/package-lock.json b/package-lock.json index 28239b0..a2af34b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-viewer-editor", - "version": "0.0.4", + "version": "0.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.4", + "version": "0.0.5", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 006cdaa..71cc502 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.5", "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/scripts/apply-v005-fixes.js b/scripts/apply-v005-fixes.js deleted file mode 100644 index c856f77..0000000 --- a/scripts/apply-v005-fixes.js +++ /dev/null @@ -1,177 +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); -} - -const mainPath = 'media/webview/main.js'; -let main = fs.readFileSync(mainPath, 'utf8'); -main = main.replace(" zoom: 1.25,", " zoom: 1,"); -main = main.replace(" defaultZoom: 1.25,", " defaultZoom: 1,"); -main = main.replace(" state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);"); - -main = replaceOnce( - main, -`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); - } - }); -}`, -`function textLineClicked(event) { - if (state.tool !== 'edit' || state.busy) { - return; - } - hideInsertMenu(); - 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(); - }); -}`, - 'replace textLineClicked' -); - -main = replaceOnce( - main, -` for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) { - line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex); - } - selectMeta(meta);`, -` clearTextLineHighlights(); - selectMeta(meta);`, - 'remove paragraph-wide highlight' -); - -main = replaceOnce( - 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 === 'document-saved') { - setBusy(false); - setStatus('PDF guardado correctamente.'); - }`, - 'saved acknowledgement' -); - -main = replaceOnce( - main, -` elements['save-button'].addEventListener('click', () => postCommand('save'));`, -` elements['save-button'].addEventListener('click', () => { - if (state.busy) { - return; - } - setBusy(true, 'Guardando PDF…'); - postCommand('save'); - });`, - 'save button feedback' -); - -fs.writeFileSync(mainPath, main); - -const providerPath = 'src/pdf-editor-provider.js'; -let provider = fs.readFileSync(providerPath, 'utf8'); -provider = replaceOnce( - provider, -` case 'command': - await this.executeEditorCommand(String(message.command || '')); - return;`, -` case 'command': - await this.executeEditorCommand(document, panel, String(message.command || '')); - return;`, - 'command dispatch' -); -provider = replaceOnce( - provider, -` /** - * @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); - } - }`, -` /** - * @param {PdfDocument} document - * @param {vscode.WebviewPanel} panel - * @param {string} command - */ - async executeEditorCommand(document, panel, command) { - if (command === 'save') { - await vscode.workspace.fs.writeFile(document.uri, document.data); - await vscode.commands.executeCommand('workbench.action.files.save'); - await panel.webview.postMessage({ type: 'document-saved' }); - return; - } - - const allowedCommands = new Map([ - ['saveAs', 'workbench.action.files.saveAs'], - ['undo', 'undo'], - ['redo', 'redo'] - ]); - const vscodeCommand = allowedCommands.get(command); - if (vscodeCommand) { - await vscode.commands.executeCommand(vscodeCommand); - } - }`, - 'executeEditorCommand' -); -provider = provider.replace("defaultZoom: configuration.get('defaultZoom', 1.25),", "defaultZoom: configuration.get('defaultZoom', 1),"); -fs.writeFileSync(providerPath, provider); - -const packagePath = 'package.json'; -const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -pkg.version = '0.0.5'; -pkg.icon = 'images/icon.png'; -pkg.galleryBanner = { color: '#0f172a', theme: 'dark' }; -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')); -if (lock.packages?.['']) { - lock.packages[''].version = '0.0.5'; -} -if (lock.version) { - lock.version = '0.0.5'; -} -fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); - -fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); - -console.log('v0.0.5 fixes applied.'); 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/test/ui-contract.test.mjs b/test/ui-contract.test.mjs new file mode 100644 index 0000000..d2dc6ff --- /dev/null +++ b/test/ui-contract.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import { URL } from 'node:url'; + +const main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8'); +const provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8'); +const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + +test('default zoom is 100 percent throughout the shipped extension', () => { + assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1); + assert.match(main, /zoom: 1,/); + assert.match(provider, /configuration\.get\('defaultZoom', 1\)/); +}); + +test('single-click text selection uses a native line range instead of paragraph-wide highlight', () => { + assert.match(main, /range\.selectNodeContents\(line\)/); + assert.doesNotMatch(main, /line\.classList\.toggle\('selected', Number\(line\.dataset\.blockIndex\) === blockIndex\)/); +}); + +test('save writes the custom document and sends visible acknowledgement', () => { + assert.match(provider, /workspace\.fs\.writeFile\(document\.uri, document\.data\)/); + assert.match(provider, /type: 'document-saved'/); + assert.match(main, /PDF guardado correctamente/); +}); + +test('marketplace icon metadata is present', () => { + assert.equal(pkg.icon, 'images/icon.png'); + assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true); + assert.equal(pkg.version, '0.0.5'); +}); From 1c438173012043d8320e1bfcfdb177959b9386ce Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:18:49 +0200 Subject: [PATCH 08/18] test: import URL in v0.0.5 contract test --- scripts/apply-v005-fixes.js | 177 ++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 scripts/apply-v005-fixes.js diff --git a/scripts/apply-v005-fixes.js b/scripts/apply-v005-fixes.js new file mode 100644 index 0000000..9e9834c --- /dev/null +++ b/scripts/apply-v005-fixes.js @@ -0,0 +1,177 @@ +'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); +} + +const mainPath = 'media/webview/main.js'; +let main = fs.readFileSync(mainPath, 'utf8'); +main = main.replace(" zoom: 1.25,", " zoom: 1,"); +main = main.replace(" defaultZoom: 1.25,", " defaultZoom: 1,"); +main = main.replace(" state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);"); + +main = replaceOnce( + main, +`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); + } + }); +}`, +`function textLineClicked(event) { + if (state.tool !== 'edit' || state.busy) { + return; + } + hideInsertMenu(); + 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(); + }); +}`, + 'replace textLineClicked' +); + +main = replaceOnce( + main, +` for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) { + line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex); + } + selectMeta(meta);`, +` clearTextLineHighlights(); + selectMeta(meta);`, + 'remove paragraph-wide highlight' +); + +main = replaceOnce( + 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 === 'document-saved') { + setBusy(false); + setStatus('PDF guardado correctamente.'); + }`, + 'saved acknowledgement' +); + +main = replaceOnce( + main, +` elements['save-button'].addEventListener('click', () => postCommand('save'));`, +` elements['save-button'].addEventListener('click', () => { + if (state.busy) { + return; + } + setBusy(true, 'Guardando PDF…'); + postCommand('save'); + });`, + 'save button feedback' +); + +fs.writeFileSync(mainPath, main); + +const providerPath = 'src/pdf-editor-provider.js'; +let provider = fs.readFileSync(providerPath, 'utf8'); +provider = replaceOnce( + provider, +` case 'command': + await this.executeEditorCommand(String(message.command || '')); + return;`, +` case 'command': + await this.executeEditorCommand(document, panel, String(message.command || '')); + return;`, + 'command dispatch' +); +provider = replaceOnce( + provider, +` /** + * @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); + } + }`, +` /** + * @param {PdfDocument} document + * @param {vscode.WebviewPanel} panel + * @param {string} command + */ + async executeEditorCommand(document, panel, command) { + if (command === 'save') { + await vscode.workspace.fs.writeFile(document.uri, document.data); + await vscode.commands.executeCommand('workbench.action.files.save'); + await panel.webview.postMessage({ type: 'document-saved' }); + return; + } + + const allowedCommands = new Map([ + ['saveAs', 'workbench.action.files.saveAs'], + ['undo', 'undo'], + ['redo', 'redo'] + ]); + const vscodeCommand = allowedCommands.get(command); + if (vscodeCommand) { + await vscode.commands.executeCommand(vscodeCommand); + } + }`, + 'executeEditorCommand' +); +provider = provider.replace("defaultZoom: configuration.get('defaultZoom', 1.25),", "defaultZoom: configuration.get('defaultZoom', 1),"); +fs.writeFileSync(providerPath, provider); + +const packagePath = 'package.json'; +const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +pkg.version = '0.0.5'; +pkg.icon = 'images/icon.png'; +pkg.galleryBanner = { color: '#0f172a', theme: 'dark' }; +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')); +if (lock.packages?.['']) { + lock.packages[''].version = '0.0.5'; +} +if (lock.version) { + lock.version = '0.0.5'; +} +fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); + +fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); + +console.log('v0.0.5 fixes applied.'); \ No newline at end of file From 0af7aa3a00297814a3f19c176a1c95c6bab8e394 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:19:41 +0200 Subject: [PATCH 09/18] chore: finalize v0.0.5 without competing materializer --- .github/workflows/finalize-v005.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml index 45ff7b8..d64a7a8 100644 --- a/.github/workflows/finalize-v005.yml +++ b/.github/workflows/finalize-v005.yml @@ -18,9 +18,6 @@ jobs: ref: fix/v0.0.5-finalize-extension-v2 fetch-depth: 0 - - name: Materialize v0.0.5 - run: node scripts/apply-v005-fixes.js - - name: Final UI and Marketplace adjustments shell: python run: | @@ -34,18 +31,19 @@ jobs: main_path = Path('media/webview/main.js') main = main_path.read_text() - main = replace_once( - main, - " hideInsertMenu();\n const line = event.currentTarget;", - " hideInsertMenu();\n if (event.detail >= 2) {\n window.requestAnimationFrame(updateTextRangeFromSelection);\n return;\n }\n const line = event.currentTarget;", - 'double click word selection' - ) + if 'event.detail >= 2' not in main: + main = replace_once( + main, + " hideInsertMenu();\n const line = event.currentTarget;", + " hideInsertMenu();\n if (event.detail >= 2) {\n window.requestAnimationFrame(updateTextRangeFromSelection);\n return;\n }\n const line = event.currentTarget;", + 'double click word selection' + ) main_path.write_text(main) html_path = Path('src/webview-html.js') html = html_path.read_text() - html = replace_once(html, '100%', '100%', '100 percent option') - html = replace_once(html, '125%', '125%', '125 percent option') + html = html.replace('100%', '100%', 1) + html = html.replace('125%', '125%', 1) html_path.write_text(html) width = height = 256 @@ -124,7 +122,6 @@ jobs: manifest = z.read('extension.vsixmanifest').decode() 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 = z.read('extension/images/icon.png') assert icon[:8] == b'\x89PNG\r\n\x1a\n' assert struct.unpack('>II', icon[16:24]) == (256, 256) @@ -136,7 +133,7 @@ jobs: shell: bash run: | set -euo pipefail - rm -f .github/workflows/finalize-v005.yml scripts/apply-v005-fixes.js + 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 From 081c5b7918f780d4d976e98206687be84bb18720 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:20:13 +0000 Subject: [PATCH 10/18] fix: complete v0.0.5 selection save zoom and marketplace icon --- .github/workflows/finalize-v005.yml | 141 ---------------------------- CHANGELOG.md | 10 ++ README.md | 4 +- images/icon.png | Bin 1160 -> 1298 bytes media/webview/main.js | 4 + src/webview-html.js | 4 +- 6 files changed, 18 insertions(+), 145 deletions(-) delete mode 100644 .github/workflows/finalize-v005.yml diff --git a/.github/workflows/finalize-v005.yml b/.github/workflows/finalize-v005.yml deleted file mode 100644 index d64a7a8..0000000 --- a/.github/workflows/finalize-v005.yml +++ /dev/null @@ -1,141 +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-v2 - fetch-depth: 0 - - - name: Final UI and Marketplace adjustments - shell: python - run: | - from pathlib import Path - import struct, zlib - - def replace_once(text, old, new, label): - if old not in text: - raise RuntimeError(f'Missing {label}') - return text.replace(old, new, 1) - - main_path = Path('media/webview/main.js') - main = main_path.read_text() - if 'event.detail >= 2' not in main: - main = replace_once( - main, - " hideInsertMenu();\n const line = event.currentTarget;", - " hideInsertMenu();\n if (event.detail >= 2) {\n window.requestAnimationFrame(updateTextRangeFromSelection);\n return;\n }\n const line = event.currentTarget;", - 'double click word selection' - ) - main_path.write_text(main) - - html_path = Path('src/webview-html.js') - html = html_path.read_text() - html = html.replace('100%', '100%', 1) - html = html.replace('125%', '125%', 1) - html_path.write_text(html) - - width = height = 256 - bg = (229, 57, 53, 255) - white = (255, 255, 255, 255) - dark = (32, 33, 36, 255) - pixels = [[bg for _ in range(width)] for _ in range(height)] - - def rect(x1, y1, x2, y2, color): - for y in range(y1, y2): - for x in range(x1, x2): - if 0 <= x < width and 0 <= y < height: - pixels[y][x] = color - - rect(52, 30, 184, 226, white) - rect(184, 78, 205, 226, white) - for y in range(30, 79): - rect(184, y, min(206, 185 + (y - 30) // 2), y + 1, white) - rect(80, 105, 174, 119, bg) - rect(80, 137, 174, 151, bg) - rect(80, 169, 151, 183, bg) - 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] = dark - - 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/icon.png').write_bytes(png) - - changelog_path = Path('CHANGELOG.md') - changelog = changelog_path.read_text() - if '## 0.0.5 - 2026-08-24' not in changelog: - 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- Selección nativa exacta de líneas, palabras y frases sin rectángulo azul de párrafo.\n- Zoom inicial al 100%.\n- Botón Guardar escribe el PDF abierto y muestra confirmación visual.\n- Icono 256×256 renovado y validado como asset del VSIX/Marketplace.\n\n''' - changelog = replace_once(changelog, marker, marker + section, 'changelog marker') - changelog_path.write_text(changelog) - - readme_path = Path('README.md') - readme = readme_path.read_text() - 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) - - - name: Verify source and VSIX - shell: bash - run: | - set -euo pipefail - npm ci - npm run check - npm run package - test -f pdf-viewer-editor-0.0.5.vsix - grep -q "event.detail >= 2" media/webview/main.js - grep -q "range.selectNodeContents(line)" media/webview/main.js - ! grep -q "classList.toggle('selected', Number(line.dataset.blockIndex)" media/webview/main.js - grep -q "workspace.fs.writeFile(document.uri, document.data)" src/pdf-editor-provider.js - grep -q "type: 'document-saved'" src/pdf-editor-provider.js - grep -q '100%' src/webview-html.js - node -e "const p=require('./package.json'); if(p.version!=='0.0.5'||p.icon!=='images/icon.png'||p.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default!==1) process.exit(1)" - python - <<'PY' - import struct, zipfile - with zipfile.ZipFile('pdf-viewer-editor-0.0.5.vsix') as z: - manifest = z.read('extension.vsixmanifest').decode() - assert 'Version="0.0.5" Publisher="suzdalenko-dev"' in manifest - assert 'extension/images/icon.png' in manifest - icon = z.read('extension/images/icon.png') - assert icon[:8] == b'\x89PNG\r\n\x1a\n' - assert struct.unpack('>II', icon[16:24]) == (256, 256) - print('v0.0.5 VSIX verified with Marketplace icon') - PY - rm -f pdf-viewer-editor-0.0.5.vsix - - - name: Commit final branch - 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 commit -m 'fix: complete v0.0.5 selection save zoom and marketplace icon' - git push origin HEAD:fix/v0.0.5-finalize-extension-v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 783d7ec..a0e143d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Todos los cambios relevantes de este proyecto se documentan aquí. +## 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..6f797bd 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 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.5` 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. > 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. - Arrastrar sobre una palabra o frase y editar, borrar, copiar o cambiar su formato. - Editar un párrafo completo con `Editar contenido`. - Añadir texto en cualquier punto de la página. diff --git a/images/icon.png b/images/icon.png index 5e13d7932c2235fedf64af6a88a8eb54d85064ac..afb21c796b6ded985da5ef973ace4c6ca8364437 100644 GIT binary patch literal 1298 zcmcgsQAiX~6g{)EI;JCdK~Usi8!YlrA_B`_ z!NRNrzalb>_9I3X^usNPsJ23OQZUiPSt;01vHNCeO$ii(KHi;s@45HAbKbkmt)|AB z%yfG?fXupD?@2%)S3p`SeOjUjVEh>%5-R{!fdaCvRIk+1|0Tl)@IBt&b{Ft)rr# zbL#A=+~T8BM{xStr4N%o4;7ER%J*1UA!`5KXs(g36)J47qMJcMHe?IHILFd7GMuGW zK*mN3-Lw?${I{Yo9af$OO(s5c`k!HaH9)t=a)}es>?6*irftduB`>y;J3lJC+O#(T}x#*1w?b<PZ^tuqMCeKnPg!ZX z|K_j(v423MhTRj|dM6Wl<;pA!Tc9oKfU|q)TfeBCH<|k7m*uFE5q}M%6ndqog}|0% zPB;M&!%Y&t*tiH94WnII2!>)L!#5uzwzF{p>MJ`*q2oB+-NlJ4dXyOnII_s0_Eoxp zp*#-V$*0%cfLA!2ag29%^%C?%H@o7LW~04>uJLuk{3;@Q;G$M%9y*{6&q%CBV$IPAei27Ud=9fB)y+oBMv}+`HTz3zrmD7Xm1WMnXM+LRNw1 zq+wuUXAH1+MMFlfdHG-&EB0ql|($&K}!NuKt+#^>m2ic|= z6_be}p0H>&?tXRv*6}R0nx{=2@oyAjaqR1*{PlDA;fiw4L^PvT308cFP1GXtYnhtt V+v?9aD(^IIShO=1+U*#ge*-jrtla= 2) { + window.requestAnimationFrame(updateTextRangeFromSelection); + return; + } const line = event.currentTarget; window.requestAnimationFrame(() => { const selection = window.getSelection(); diff --git a/src/webview-html.js b/src/webview-html.js index 94b2ff7..224665a 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -48,8 +48,8 @@ function getWebviewHtml(webview, extensionUri) { Ajustar ancho 50% 75% - 100% - 125% + 100% + 125% 150% 200% 300% From e8d88e29499b0ea1b7cc3dd4bd574bd409777cc7 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:20:44 +0200 Subject: [PATCH 11/18] chore: remove temporary v0.0.5 materializer --- scripts/apply-v005-fixes.js | 177 ------------------------------------ 1 file changed, 177 deletions(-) delete mode 100644 scripts/apply-v005-fixes.js diff --git a/scripts/apply-v005-fixes.js b/scripts/apply-v005-fixes.js deleted file mode 100644 index 9e9834c..0000000 --- a/scripts/apply-v005-fixes.js +++ /dev/null @@ -1,177 +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); -} - -const mainPath = 'media/webview/main.js'; -let main = fs.readFileSync(mainPath, 'utf8'); -main = main.replace(" zoom: 1.25,", " zoom: 1,"); -main = main.replace(" defaultZoom: 1.25,", " defaultZoom: 1,"); -main = main.replace(" state.zoom = Number(state.settings.defaultZoom || 1.25);", " state.zoom = Number(state.settings.defaultZoom || 1);"); - -main = replaceOnce( - main, -`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); - } - }); -}`, -`function textLineClicked(event) { - if (state.tool !== 'edit' || state.busy) { - return; - } - hideInsertMenu(); - 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(); - }); -}`, - 'replace textLineClicked' -); - -main = replaceOnce( - main, -` for (const line of elements['text-layer'].querySelectorAll('.text-layer-line')) { - line.classList.toggle('selected', Number(line.dataset.blockIndex) === blockIndex); - } - selectMeta(meta);`, -` clearTextLineHighlights(); - selectMeta(meta);`, - 'remove paragraph-wide highlight' -); - -main = replaceOnce( - 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 === 'document-saved') { - setBusy(false); - setStatus('PDF guardado correctamente.'); - }`, - 'saved acknowledgement' -); - -main = replaceOnce( - main, -` elements['save-button'].addEventListener('click', () => postCommand('save'));`, -` elements['save-button'].addEventListener('click', () => { - if (state.busy) { - return; - } - setBusy(true, 'Guardando PDF…'); - postCommand('save'); - });`, - 'save button feedback' -); - -fs.writeFileSync(mainPath, main); - -const providerPath = 'src/pdf-editor-provider.js'; -let provider = fs.readFileSync(providerPath, 'utf8'); -provider = replaceOnce( - provider, -` case 'command': - await this.executeEditorCommand(String(message.command || '')); - return;`, -` case 'command': - await this.executeEditorCommand(document, panel, String(message.command || '')); - return;`, - 'command dispatch' -); -provider = replaceOnce( - provider, -` /** - * @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); - } - }`, -` /** - * @param {PdfDocument} document - * @param {vscode.WebviewPanel} panel - * @param {string} command - */ - async executeEditorCommand(document, panel, command) { - if (command === 'save') { - await vscode.workspace.fs.writeFile(document.uri, document.data); - await vscode.commands.executeCommand('workbench.action.files.save'); - await panel.webview.postMessage({ type: 'document-saved' }); - return; - } - - const allowedCommands = new Map([ - ['saveAs', 'workbench.action.files.saveAs'], - ['undo', 'undo'], - ['redo', 'redo'] - ]); - const vscodeCommand = allowedCommands.get(command); - if (vscodeCommand) { - await vscode.commands.executeCommand(vscodeCommand); - } - }`, - 'executeEditorCommand' -); -provider = provider.replace("defaultZoom: configuration.get('defaultZoom', 1.25),", "defaultZoom: configuration.get('defaultZoom', 1),"); -fs.writeFileSync(providerPath, provider); - -const packagePath = 'package.json'; -const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -pkg.version = '0.0.5'; -pkg.icon = 'images/icon.png'; -pkg.galleryBanner = { color: '#0f172a', theme: 'dark' }; -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')); -if (lock.packages?.['']) { - lock.packages[''].version = '0.0.5'; -} -if (lock.version) { - lock.version = '0.0.5'; -} -fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); - -fs.writeFileSync('test/ui-contract.test.mjs', `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst main = fs.readFileSync(new URL('../media/webview/main.js', import.meta.url), 'utf8');\nconst provider = fs.readFileSync(new URL('../src/pdf-editor-provider.js', import.meta.url), 'utf8');\nconst pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));\n\ntest('default zoom is 100 percent throughout the shipped extension', () => {\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(main, /zoom: 1,/);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n});\n\ntest('single-click text selection uses a native line range instead of paragraph-wide highlight', () => {\n assert.match(main, /range\\.selectNodeContents\\(line\\)/);\n assert.doesNotMatch(main, /line\\.classList\\.toggle\\('selected', Number\\(line\\.dataset\\.blockIndex\\) === blockIndex\\)/);\n});\n\ntest('save writes the custom document and sends visible acknowledgement', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(provider, /type: 'document-saved'/);\n assert.match(main, /PDF guardado correctamente/);\n});\n\ntest('marketplace icon metadata is present', () => {\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n assert.equal(pkg.version, '0.0.5');\n});\n`); - -console.log('v0.0.5 fixes applied.'); \ No newline at end of file From eac39626f4007153b2b42f21f57180c38a312484 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:24:30 +0200 Subject: [PATCH 12/18] chore: stage v0.0.6 geometry and resize fixes --- scripts/apply-v006-fixes.js | 518 ++++++++++++++++++++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 scripts/apply-v006-fixes.js diff --git a/scripts/apply-v006-fixes.js b/scripts/apply-v006-fixes.js new file mode 100644 index 0000000..678ac95 --- /dev/null +++ b/scripts/apply-v006-fixes.js @@ -0,0 +1,518 @@ +'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 one match, found ${count}`); + } + return source.replace(before, after); +} + +function replaceRegexOnce(source, regex, after, label) { + const matches = source.match(regex); + if (!matches) { + throw new Error(`${label}: no match`); + } + return source.replace(regex, after); +} + +// --- main.js --------------------------------------------------------------- +const mainPath = 'media/webview/main.js'; +let main = fs.readFileSync(mainPath, 'utf8'); + +main = replaceOnce( + main, + " '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',", + 'register resize text button' +); + +main = replaceOnce( + main, +` preserveObjectStacking: true, + selection: false, + stopContextMenu: true`, +` preserveObjectStacking: true, + selection: false, + enableRetinaScaling: false, + stopContextMenu: true`, + 'disable Fabric retina scaling' +); + +main = replaceOnce( + main, +` if (!options.preserveRange) { + state.textRange = null; + hideEditRangeButton(); + }`, +` if (!options.preserveRange) { + state.textRange = null; + hideEditRangeButton(); + clearTextRangeHighlights(); + }`, + 'clear custom selection highlight' +); + +main = replaceOnce( + main, +` state.textRange = { + blockIndex: block.index, + start: start.offset, + end: end.offset, + text: block.text.slice(start.offset, end.offset) + }; + selectTextBlock(block.index, { preserveRange: true });`, +` state.textRange = { + blockIndex: block.index, + start: start.offset, + end: end.offset, + text: block.text.slice(start.offset, end.offset) + }; + renderTextRangeHighlights(); + selectTextBlock(block.index, { preserveRange: true });`, + 'render precise text selection' +); + +main = replaceOnce( + main, +`function hideEditRangeButton() { + elements['edit-range-button'].classList.add('hidden'); +} +`, +`function hideEditRangeButton() { + elements['edit-range-button'].classList.add('hidden'); +} + +function clearTextRangeHighlights() { + for (const highlight of elements['text-layer'].querySelectorAll('.text-range-highlight')) { + highlight.remove(); + } +} + +function 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.'); +} +`, + 'selection highlight and text resize helpers' +); + +main = replaceOnce( + main, +`function clearSelection() { + state.selected = null; + state.textRange = null; + hideEditRangeButton(); + clearTextLineHighlights();`, +`function clearSelection() { + state.selected = null; + state.textRange = null; + hideEditRangeButton(); + clearTextRangeHighlights(); + setTextResizeMode(false); + clearTextLineHighlights();`, + 'clear selection state' +); + +main = replaceOnce( + main, +`function objectScreenRectToPdf(object) { + const width = Math.max(2, object.width * object.scaleX); + const height = Math.max(2, object.height * object.scaleY); + return screenRectToPdf([ + object.left, + object.top, + object.left + width, + object.top + height + ]); +}`, +`function objectScreenRectToPdf(object) { + // Fabric's getBoundingRect() returns the actual scene rectangle after scale, + // controls and origin transforms. Using left/top + width*scale caused the + // editor frame to drift away from the rendered PDF 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) + ]); +}`, + 'precise Fabric geometry conversion' +); + +main = replaceOnce( + main, +` } else if (meta.kind === 'table') { + engine.updateTable(`, +` } 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(`, + 'handle text resize modification' +); + +main = replaceOnce( + main, +` elements['edit-content'].addEventListener('click', () => { + if (state.selected?.kind === 'text') { + openExistingTextEditor(state.selected.block); + } + }); + elements['copy-text'].addEventListener('click', copySelectedText);`, +` elements['edit-content'].addEventListener('click', () => { + if (state.selected?.kind === 'text') { + openExistingTextEditor(state.selected.block); + } + }); + elements['resize-text-block'].addEventListener('click', activateTextResizeMode); + elements['copy-text'].addEventListener('click', copySelectedText);`, + 'resize button handler' +); + +fs.writeFileSync(mainPath, main); + +// --- pdf-engine.js ---------------------------------------------------------- +const enginePath = 'media/webview/pdf-engine.js'; +let engine = fs.readFileSync(enginePath, 'utf8'); + +engine = replaceOnce( + engine, +` currentLine = { + text: '', + rect: normalizeRect([...bbox]), + baseline: null, + styles: new Map() + };`, +` currentLine = { + text: '', + rect: normalizeRect([...bbox]), + baseline: null, + styles: new Map(), + characters: [] + };`, + 'capture character boxes init' +); + +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 = 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, + characterRect + ]);`, + 'capture character boxes' +); + +engine = replaceOnce( + engine, +` baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], + font: style.font, + color: style.color`, +` baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], + font: style.font, + color: style.color, + characters: currentLine.characters`, + 'expose line character boxes' +); + +engine = replaceOnce( + engine, +` let text = ''; + let previous = null; + for (const fragment of row.fragments) { + if (previous && needsVisualSpace(previous, fragment)) { + text += ' '; + } + text += fragment.text; + previous = fragment; + } + const style = dominantLineStyle(row.fragments); + return { + text, + fragments: row.fragments, + rect: row.rect, + baseline: [row.fragments[0].baseline[0], row.baseline[1]], + font: style.font, + color: style.color + };`, +` 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]], + font: style.font, + color: style.color + };`, + 'propagate visual character boxes' +); + +engine = replaceOnce( + engine, +` moveTextBlock(pageIndex, blockIndex, targetRect) { + const model = this.getPageModel(pageIndex);`, +` 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);`, + 'add resizeTextBlock method' +); + +fs.writeFileSync(enginePath, engine); + +// --- HTML ------------------------------------------------------------------ +const htmlPath = 'src/webview-html.js'; +let html = fs.readFileSync(htmlPath, 'utf8'); +html = replaceOnce( + html, +` Editar contenido + Copiar`, +` Editar contenido + Mover / redimensionar + Copiar`, + 'add text resize button' +); +fs.writeFileSync(htmlPath, html); + +// --- CSS ------------------------------------------------------------------- +const cssPath = 'media/webview/styles.css'; +let css = fs.readFileSync(cssPath, 'utf8'); +css = replaceOnce( + css, +`.text-layer-line::selection { + color: transparent; + background: color-mix(in srgb, var(--focus) 38%, transparent); +} +`, +`.text-layer-line::selection { + color: 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 { + pointer-events: none; +} + +.text-layer.resize-mode .text-layer-line { + pointer-events: none; + user-select: none; +} + +.insertion-layer.resize-mode { + pointer-events: none; +} +`, + 'precise selection styles' +); +fs.writeFileSync(cssPath, css); + +// --- package metadata ------------------------------------------------------- +const packagePath = 'package.json'; +const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +pkg.version = '0.0.6'; +pkg.icon = 'images/icon.png'; +fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + +const lockPath = 'package-lock.json'; +const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); +if (lock.packages?.['']) { + lock.packages[''].version = '0.0.6'; +} +if (lock.version) { + lock.version = '0.0.6'; +} +fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); + +// --- regression contract --------------------------------------------------- +const testPath = 'test/ui-contract.test.mjs'; +let test = fs.readFileSync(testPath, 'utf8'); +test = test.replace("assert.equal(pkg.version, '0.0.5');", "assert.equal(pkg.version, '0.0.6');"); +test += `\n\ntest('v0.0.6 keeps overlays in one coordinate system and supports text resizing', () => {\n assert.match(main, /enableRetinaScaling: false/);\n assert.match(main, /object\\.getBoundingRect\\(\\)/);\n assert.match(main, /resize-text-block/);\n assert.match(main, /engine\\.resizeTextBlock/);\n});\n\ntest('v0.0.6 renders text selection from extracted PDF character rectangles', () => {\n const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');\n assert.match(engine, /characters: currentLine\\.characters/);\n assert.match(main, /text-range-highlight/);\n assert.match(main, /selectedCharacters/);\n});\n`; +fs.writeFileSync(testPath, test); + +// changelog +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- La selección visual se calcula con los rectángulos reales de caracteres extraídos del PDF.\n- Capa Fabric y página usan el mismo sistema de coordenadas, evitando desplazamientos de tablas y marcos.\n- Las tablas mantienen el marco de edición alineado al objeto real.\n- Los bloques de texto se pueden mover y redimensionar; al cambiar su anchura el texto se recompone y desplaza el contenido inferior.\n- Se conserva el icono de Marketplace y la extensión se empaqueta como 0.0.6.\n\n`; + changelog = changelog.replace(marker, marker + section); +} +fs.writeFileSync(changelogPath, changelog); + +console.log('v0.0.6 geometry and resize fixes applied.'); From 1e0afcc23bba8e4672d8fc04308b0b914765a4e3 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:24:45 +0200 Subject: [PATCH 13/18] chore: validate and materialize v0.0.6 --- .github/workflows/finalize-v006.yml | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/finalize-v006.yml diff --git a/.github/workflows/finalize-v006.yml b/.github/workflows/finalize-v006.yml new file mode 100644 index 0000000..05ee9d2 --- /dev/null +++ b/.github/workflows/finalize-v006.yml @@ -0,0 +1,60 @@ +name: Finalize v0.0.6 + +on: + push: + branches: + - fix/v0.0.6-geometry-resize + 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.6-geometry-resize + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Apply v0.0.6 fixes + run: node scripts/apply-v006-fixes.js + + - name: Install dependencies + run: npm ci + + - name: Verify source + run: npm run check + + - name: Package and inspect VSIX + shell: bash + run: | + set -euo pipefail + npm run package + test -f pdf-viewer-editor-0.0.6.vsix + unzip -l pdf-viewer-editor-0.0.6.vsix > /tmp/vsix.txt + grep -q 'extension/images/icon.png' /tmp/vsix.txt + grep -q 'extension/media/webview/main.js' /tmp/vsix.txt + grep -q 'extension/media/webview/pdf-engine.js' /tmp/vsix.txt + ! grep -q 'apply-v006-fixes.js' /tmp/vsix.txt + node -e "const p=require('./package.json'); if(p.version!=='0.0.6'||p.icon!=='images/icon.png') process.exit(1)" + + - name: Commit final source + shell: bash + run: | + set -euo pipefail + rm -f scripts/apply-v006-fixes.js .github/workflows/finalize-v006.yml pdf-viewer-editor-0.0.6.vsix + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git commit -m 'fix: release v0.0.6 geometry and resizable text blocks' + git push origin HEAD:fix/v0.0.6-geometry-resize From ca1f1ddf9f8a3f2b617c10d9610ed81645a450d7 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:26:14 +0200 Subject: [PATCH 14/18] fix: replace v0.0.6 materializer with Python patcher --- scripts/apply-v006-fixes.py | 497 ++++++++++++++++++++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 scripts/apply-v006-fixes.py diff --git a/scripts/apply-v006-fixes.py b/scripts/apply-v006-fixes.py new file mode 100644 index 0000000..655fb83 --- /dev/null +++ b/scripts/apply-v006-fixes.py @@ -0,0 +1,497 @@ +from pathlib import Path +import json + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise RuntimeError(f'{label}: expected one match, found {count}') + return text.replace(old, new, 1) + + +main_path = Path('media/webview/main.js') +main = main_path.read_text(encoding='utf-8') +main = replace_once( + main, + " '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',", + 'register resize text button' +) +main = replace_once( + main, + """ preserveObjectStacking: true, + selection: false, + stopContextMenu: true""", + """ preserveObjectStacking: true, + selection: false, + enableRetinaScaling: false, + stopContextMenu: true""", + 'disable Fabric retina scaling' +) +main = replace_once( + main, + """ if (!options.preserveRange) { + state.textRange = null; + hideEditRangeButton(); + }""", + """ if (!options.preserveRange) { + state.textRange = null; + hideEditRangeButton(); + clearTextRangeHighlights(); + }""", + 'clear selection highlight' +) +main = replace_once( + main, + """ state.textRange = { + blockIndex: block.index, + start: start.offset, + end: end.offset, + text: block.text.slice(start.offset, end.offset) + }; + selectTextBlock(block.index, { preserveRange: true });""", + """ state.textRange = { + blockIndex: block.index, + start: start.offset, + end: end.offset, + text: block.text.slice(start.offset, end.offset) + }; + renderTextRangeHighlights(); + selectTextBlock(block.index, { preserveRange: true });""", + 'render precise selection' +) +main = replace_once( + main, + """function hideEditRangeButton() { + elements['edit-range-button'].classList.add('hidden'); +} +""", + """function hideEditRangeButton() { + elements['edit-range-button'].classList.add('hidden'); +} + +function clearTextRangeHighlights() { + for (const highlight of elements['text-layer'].querySelectorAll('.text-range-highlight')) { + highlight.remove(); + } +} + +function 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.'); +} +""", + 'selection geometry helpers' +) +main = replace_once( + main, + """function clearSelection() { + state.selected = null; + state.textRange = null; + hideEditRangeButton(); + clearTextLineHighlights();""", + """function clearSelection() { + state.selected = null; + state.textRange = null; + hideEditRangeButton(); + clearTextRangeHighlights(); + setTextResizeMode(false); + clearTextLineHighlights();""", + 'clear resize mode' +) +main = replace_once( + main, + """function objectScreenRectToPdf(object) { + const width = Math.max(2, object.width * object.scaleX); + const height = Math.max(2, object.height * object.scaleY); + return screenRectToPdf([ + object.left, + object.top, + object.left + width, + object.top + height + ]); +}""", + """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) + ]); +}""", + 'precise Fabric bounds' +) +main = replace_once( + main, + """ } else if (meta.kind === 'table') { + engine.updateTable(""", + """ } 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(""", + 'text resize commit' +) +main = replace_once( + main, + """ elements['edit-content'].addEventListener('click', () => { + if (state.selected?.kind === 'text') { + openExistingTextEditor(state.selected.block); + } + }); + elements['copy-text'].addEventListener('click', copySelectedText);""", + """ elements['edit-content'].addEventListener('click', () => { + if (state.selected?.kind === 'text') { + openExistingTextEditor(state.selected.block); + } + }); + elements['resize-text-block'].addEventListener('click', activateTextResizeMode); + elements['copy-text'].addEventListener('click', copySelectedText);""", + 'resize button handler' +) +main_path.write_text(main, encoding='utf-8') + +engine_path = Path('media/webview/pdf-engine.js') +engine = engine_path.read_text(encoding='utf-8') +engine = replace_once( + engine, + """ currentLine = { + text: '', + rect: normalizeRect([...bbox]), + baseline: null, + styles: new Map() + };""", + """ currentLine = { + text: '', + rect: normalizeRect([...bbox]), + baseline: null, + styles: new Map(), + characters: [] + };""", + 'character boxes init' +) +engine = replace_once( + 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 = 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, + characterRect + ]);""", + 'capture character boxes' +) +engine = replace_once( + engine, + """ baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], + font: style.font, + color: style.color""", + """ baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], + font: style.font, + color: style.color, + characters: currentLine.characters""", + 'expose character boxes' +) +engine = replace_once( + engine, + """ let text = ''; + let previous = null; + for (const fragment of row.fragments) { + if (previous && needsVisualSpace(previous, fragment)) { + text += ' '; + } + text += fragment.text; + previous = fragment; + } + const style = dominantLineStyle(row.fragments); + return { + text, + fragments: row.fragments, + rect: row.rect, + baseline: [row.fragments[0].baseline[0], row.baseline[1]], + font: style.font, + color: style.color + };""", + """ 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]], + font: style.font, + color: style.color + };""", + 'propagate visual characters' +) +engine = replace_once( + engine, + """ moveTextBlock(pageIndex, blockIndex, targetRect) { + const model = this.getPageModel(pageIndex);""", + """ 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);""", + 'resize text block engine' +) +engine_path.write_text(engine, encoding='utf-8') + +html_path = Path('src/webview-html.js') +html = html_path.read_text(encoding='utf-8') +html = replace_once( + html, + """ Editar contenido + Copiar""", + """ Editar contenido + Mover / redimensionar + Copiar""", + 'resize text HTML button' +) +html_path.write_text(html, 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::selection { + color: transparent; + background: color-mix(in srgb, var(--focus) 38%, transparent); +} +""", + """.text-layer-line::selection { + color: 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; +} +""", + 'precise selection CSS' +) +css_path.write_text(css, encoding='utf-8') + +package_path = Path('package.json') +pkg = json.loads(package_path.read_text(encoding='utf-8')) +pkg['version'] = '0.0.6' +pkg['icon'] = 'images/icon.png' +package_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + +lock_path = Path('package-lock.json') +lock = json.loads(lock_path.read_text(encoding='utf-8')) +if '' in lock.get('packages', {}): + lock['packages']['']['version'] = '0.0.6' +if 'version' in lock: + lock['version'] = '0.0.6' +lock_path.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + +test_path = Path('test/ui-contract.test.mjs') +test = test_path.read_text(encoding='utf-8').replace("assert.equal(pkg.version, '0.0.5');", "assert.equal(pkg.version, '0.0.6');") +if "v0.0.6 keeps overlays" not in test: + test += """ + +test('v0.0.6 keeps overlays in one coordinate system and supports text resizing', () => { + assert.match(main, /enableRetinaScaling: false/); + assert.match(main, /object\.getBoundingRect\(\)/); + assert.match(main, /resize-text-block/); + assert.match(main, /engine\.resizeTextBlock/); +}); + +test('v0.0.6 renders text selection from extracted PDF character rectangles', () => { + const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8'); + assert.match(engine, /characters: currentLine\.characters/); + assert.match(main, /text-range-highlight/); + assert.match(main, /selectedCharacters/); +}); +""" +test_path.write_text(test, encoding='utf-8') + +changelog_path = Path('CHANGELOG.md') +changelog = changelog_path.read_text(encoding='utf-8') +if '## 0.0.6 - 2026-08-24' not in changelog: + marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n' + section = """ +## 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. + +""" + changelog = changelog.replace(marker, marker + section, 1) +changelog_path.write_text(changelog, encoding='utf-8') + +print('v0.0.6 geometry and resize fixes applied') From 9eb8bec6d3166ba8ae0a42e46a1591698df3ebef Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:26:33 +0200 Subject: [PATCH 15/18] fix: run Python v0.0.6 materializer --- .github/workflows/finalize-v006.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/finalize-v006.yml b/.github/workflows/finalize-v006.yml index 05ee9d2..1a3d889 100644 --- a/.github/workflows/finalize-v006.yml +++ b/.github/workflows/finalize-v006.yml @@ -27,7 +27,7 @@ jobs: cache: npm - name: Apply v0.0.6 fixes - run: node scripts/apply-v006-fixes.js + run: python scripts/apply-v006-fixes.py - name: Install dependencies run: npm ci @@ -45,14 +45,14 @@ jobs: grep -q 'extension/images/icon.png' /tmp/vsix.txt grep -q 'extension/media/webview/main.js' /tmp/vsix.txt grep -q 'extension/media/webview/pdf-engine.js' /tmp/vsix.txt - ! grep -q 'apply-v006-fixes.js' /tmp/vsix.txt + ! grep -q 'apply-v006-fixes' /tmp/vsix.txt node -e "const p=require('./package.json'); if(p.version!=='0.0.6'||p.icon!=='images/icon.png') process.exit(1)" - name: Commit final source shell: bash run: | set -euo pipefail - rm -f scripts/apply-v006-fixes.js .github/workflows/finalize-v006.yml pdf-viewer-editor-0.0.6.vsix + rm -f scripts/apply-v006-fixes.js scripts/apply-v006-fixes.py .github/workflows/finalize-v006.yml pdf-viewer-editor-0.0.6.vsix git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A From 59dfc67c82a801ad7638f2f182afd29512a64fdf Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:27:09 +0200 Subject: [PATCH 16/18] chore: remove broken v0.0.6 Node materializer --- scripts/apply-v006-fixes.js | 518 ------------------------------------ 1 file changed, 518 deletions(-) delete mode 100644 scripts/apply-v006-fixes.js diff --git a/scripts/apply-v006-fixes.js b/scripts/apply-v006-fixes.js deleted file mode 100644 index 678ac95..0000000 --- a/scripts/apply-v006-fixes.js +++ /dev/null @@ -1,518 +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 one match, found ${count}`); - } - return source.replace(before, after); -} - -function replaceRegexOnce(source, regex, after, label) { - const matches = source.match(regex); - if (!matches) { - throw new Error(`${label}: no match`); - } - return source.replace(regex, after); -} - -// --- main.js --------------------------------------------------------------- -const mainPath = 'media/webview/main.js'; -let main = fs.readFileSync(mainPath, 'utf8'); - -main = replaceOnce( - main, - " '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',", - 'register resize text button' -); - -main = replaceOnce( - main, -` preserveObjectStacking: true, - selection: false, - stopContextMenu: true`, -` preserveObjectStacking: true, - selection: false, - enableRetinaScaling: false, - stopContextMenu: true`, - 'disable Fabric retina scaling' -); - -main = replaceOnce( - main, -` if (!options.preserveRange) { - state.textRange = null; - hideEditRangeButton(); - }`, -` if (!options.preserveRange) { - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - }`, - 'clear custom selection highlight' -); - -main = replaceOnce( - main, -` state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - selectTextBlock(block.index, { preserveRange: true });`, -` state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - renderTextRangeHighlights(); - selectTextBlock(block.index, { preserveRange: true });`, - 'render precise text selection' -); - -main = replaceOnce( - main, -`function hideEditRangeButton() { - elements['edit-range-button'].classList.add('hidden'); -} -`, -`function hideEditRangeButton() { - elements['edit-range-button'].classList.add('hidden'); -} - -function clearTextRangeHighlights() { - for (const highlight of elements['text-layer'].querySelectorAll('.text-range-highlight')) { - highlight.remove(); - } -} - -function 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.'); -} -`, - 'selection highlight and text resize helpers' -); - -main = replaceOnce( - main, -`function clearSelection() { - state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights();`, -`function clearSelection() { - state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - setTextResizeMode(false); - clearTextLineHighlights();`, - 'clear selection state' -); - -main = replaceOnce( - main, -`function objectScreenRectToPdf(object) { - const width = Math.max(2, object.width * object.scaleX); - const height = Math.max(2, object.height * object.scaleY); - return screenRectToPdf([ - object.left, - object.top, - object.left + width, - object.top + height - ]); -}`, -`function objectScreenRectToPdf(object) { - // Fabric's getBoundingRect() returns the actual scene rectangle after scale, - // controls and origin transforms. Using left/top + width*scale caused the - // editor frame to drift away from the rendered PDF 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) - ]); -}`, - 'precise Fabric geometry conversion' -); - -main = replaceOnce( - main, -` } else if (meta.kind === 'table') { - engine.updateTable(`, -` } 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(`, - 'handle text resize modification' -); - -main = replaceOnce( - main, -` elements['edit-content'].addEventListener('click', () => { - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['copy-text'].addEventListener('click', copySelectedText);`, -` elements['edit-content'].addEventListener('click', () => { - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['resize-text-block'].addEventListener('click', activateTextResizeMode); - elements['copy-text'].addEventListener('click', copySelectedText);`, - 'resize button handler' -); - -fs.writeFileSync(mainPath, main); - -// --- pdf-engine.js ---------------------------------------------------------- -const enginePath = 'media/webview/pdf-engine.js'; -let engine = fs.readFileSync(enginePath, 'utf8'); - -engine = replaceOnce( - engine, -` currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map() - };`, -` currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map(), - characters: [] - };`, - 'capture character boxes init' -); - -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 = 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, - characterRect - ]);`, - 'capture character boxes' -); - -engine = replaceOnce( - engine, -` baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color`, -` baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color, - characters: currentLine.characters`, - 'expose line character boxes' -); - -engine = replaceOnce( - engine, -` let text = ''; - let previous = null; - for (const fragment of row.fragments) { - if (previous && needsVisualSpace(previous, fragment)) { - text += ' '; - } - text += fragment.text; - previous = fragment; - } - const style = dominantLineStyle(row.fragments); - return { - text, - fragments: row.fragments, - rect: row.rect, - baseline: [row.fragments[0].baseline[0], row.baseline[1]], - font: style.font, - color: style.color - };`, -` 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]], - font: style.font, - color: style.color - };`, - 'propagate visual character boxes' -); - -engine = replaceOnce( - engine, -` moveTextBlock(pageIndex, blockIndex, targetRect) { - const model = this.getPageModel(pageIndex);`, -` 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);`, - 'add resizeTextBlock method' -); - -fs.writeFileSync(enginePath, engine); - -// --- HTML ------------------------------------------------------------------ -const htmlPath = 'src/webview-html.js'; -let html = fs.readFileSync(htmlPath, 'utf8'); -html = replaceOnce( - html, -` Editar contenido - Copiar`, -` Editar contenido - Mover / redimensionar - Copiar`, - 'add text resize button' -); -fs.writeFileSync(htmlPath, html); - -// --- CSS ------------------------------------------------------------------- -const cssPath = 'media/webview/styles.css'; -let css = fs.readFileSync(cssPath, 'utf8'); -css = replaceOnce( - css, -`.text-layer-line::selection { - color: transparent; - background: color-mix(in srgb, var(--focus) 38%, transparent); -} -`, -`.text-layer-line::selection { - color: 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 { - pointer-events: none; -} - -.text-layer.resize-mode .text-layer-line { - pointer-events: none; - user-select: none; -} - -.insertion-layer.resize-mode { - pointer-events: none; -} -`, - 'precise selection styles' -); -fs.writeFileSync(cssPath, css); - -// --- package metadata ------------------------------------------------------- -const packagePath = 'package.json'; -const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -pkg.version = '0.0.6'; -pkg.icon = 'images/icon.png'; -fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); - -const lockPath = 'package-lock.json'; -const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); -if (lock.packages?.['']) { - lock.packages[''].version = '0.0.6'; -} -if (lock.version) { - lock.version = '0.0.6'; -} -fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); - -// --- regression contract --------------------------------------------------- -const testPath = 'test/ui-contract.test.mjs'; -let test = fs.readFileSync(testPath, 'utf8'); -test = test.replace("assert.equal(pkg.version, '0.0.5');", "assert.equal(pkg.version, '0.0.6');"); -test += `\n\ntest('v0.0.6 keeps overlays in one coordinate system and supports text resizing', () => {\n assert.match(main, /enableRetinaScaling: false/);\n assert.match(main, /object\\.getBoundingRect\\(\\)/);\n assert.match(main, /resize-text-block/);\n assert.match(main, /engine\\.resizeTextBlock/);\n});\n\ntest('v0.0.6 renders text selection from extracted PDF character rectangles', () => {\n const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');\n assert.match(engine, /characters: currentLine\\.characters/);\n assert.match(main, /text-range-highlight/);\n assert.match(main, /selectedCharacters/);\n});\n`; -fs.writeFileSync(testPath, test); - -// changelog -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- La selección visual se calcula con los rectángulos reales de caracteres extraídos del PDF.\n- Capa Fabric y página usan el mismo sistema de coordenadas, evitando desplazamientos de tablas y marcos.\n- Las tablas mantienen el marco de edición alineado al objeto real.\n- Los bloques de texto se pueden mover y redimensionar; al cambiar su anchura el texto se recompone y desplaza el contenido inferior.\n- Se conserva el icono de Marketplace y la extensión se empaqueta como 0.0.6.\n\n`; - changelog = changelog.replace(marker, marker + section); -} -fs.writeFileSync(changelogPath, changelog); - -console.log('v0.0.6 geometry and resize fixes applied.'); From 5da9894fb7e683c72f88fb1ba6ba519f866895af Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:27:31 +0000 Subject: [PATCH 17/18] fix: release v0.0.6 geometry and resizable text blocks --- .github/workflows/finalize-v006.yml | 60 ---- CHANGELOG.md | 11 + media/webview/main.js | 118 ++++++- media/webview/pdf-engine.js | 69 +++- media/webview/styles.css | 19 +- package-lock.json | 4 +- package.json | 2 +- scripts/apply-v006-fixes.py | 497 ---------------------------- src/webview-html.js | 1 + test/ui-contract.test.mjs | 17 +- 10 files changed, 225 insertions(+), 573 deletions(-) delete mode 100644 .github/workflows/finalize-v006.yml delete mode 100644 scripts/apply-v006-fixes.py diff --git a/.github/workflows/finalize-v006.yml b/.github/workflows/finalize-v006.yml deleted file mode 100644 index 1a3d889..0000000 --- a/.github/workflows/finalize-v006.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Finalize v0.0.6 - -on: - push: - branches: - - fix/v0.0.6-geometry-resize - 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.6-geometry-resize - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Apply v0.0.6 fixes - run: python scripts/apply-v006-fixes.py - - - name: Install dependencies - run: npm ci - - - name: Verify source - run: npm run check - - - name: Package and inspect VSIX - shell: bash - run: | - set -euo pipefail - npm run package - test -f pdf-viewer-editor-0.0.6.vsix - unzip -l pdf-viewer-editor-0.0.6.vsix > /tmp/vsix.txt - grep -q 'extension/images/icon.png' /tmp/vsix.txt - grep -q 'extension/media/webview/main.js' /tmp/vsix.txt - grep -q 'extension/media/webview/pdf-engine.js' /tmp/vsix.txt - ! grep -q 'apply-v006-fixes' /tmp/vsix.txt - node -e "const p=require('./package.json'); if(p.version!=='0.0.6'||p.icon!=='images/icon.png') process.exit(1)" - - - name: Commit final source - shell: bash - run: | - set -euo pipefail - rm -f scripts/apply-v006-fixes.js scripts/apply-v006-fixes.py .github/workflows/finalize-v006.yml pdf-viewer-editor-0.0.6.vsix - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'fix: release v0.0.6 geometry and resizable text blocks' - git push origin HEAD:fix/v0.0.6-geometry-resize diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e143d..466a59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ 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 diff --git a/media/webview/main.js b/media/webview/main.js index ac44d98..ab7cb4a 100644 --- a/media/webview/main.js +++ b/media/webview/main.js @@ -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', @@ -205,6 +205,7 @@ function initializeOrResizeFabricCanvas() { height: state.render.height, preserveObjectStacking: true, selection: false, + enableRetinaScaling: false, stopContextMenu: true }); state.fabricCanvas.on('selection:created', selectionChanged); @@ -294,6 +295,7 @@ function selectTextBlock(blockIndex, options = {}) { if (!options.preserveRange) { state.textRange = null; hideEditRangeButton(); + clearTextRangeHighlights(); } const meta = { kind: 'text', @@ -341,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.'); @@ -399,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(); @@ -680,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'); @@ -804,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, @@ -855,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) ]); } @@ -1597,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 a2af34b..8f674dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-viewer-editor", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.5", + "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 71cc502..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.5", + "version": "0.0.6", "publisher": "suzdalenko-dev", "license": "AGPL-3.0-or-later", "repository": { diff --git a/scripts/apply-v006-fixes.py b/scripts/apply-v006-fixes.py deleted file mode 100644 index 655fb83..0000000 --- a/scripts/apply-v006-fixes.py +++ /dev/null @@ -1,497 +0,0 @@ -from pathlib import Path -import json - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise RuntimeError(f'{label}: expected one match, found {count}') - return text.replace(old, new, 1) - - -main_path = Path('media/webview/main.js') -main = main_path.read_text(encoding='utf-8') -main = replace_once( - main, - " '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',", - 'register resize text button' -) -main = replace_once( - main, - """ preserveObjectStacking: true, - selection: false, - stopContextMenu: true""", - """ preserveObjectStacking: true, - selection: false, - enableRetinaScaling: false, - stopContextMenu: true""", - 'disable Fabric retina scaling' -) -main = replace_once( - main, - """ if (!options.preserveRange) { - state.textRange = null; - hideEditRangeButton(); - }""", - """ if (!options.preserveRange) { - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - }""", - 'clear selection highlight' -) -main = replace_once( - main, - """ state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - selectTextBlock(block.index, { preserveRange: true });""", - """ state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - renderTextRangeHighlights(); - selectTextBlock(block.index, { preserveRange: true });""", - 'render precise selection' -) -main = replace_once( - main, - """function hideEditRangeButton() { - elements['edit-range-button'].classList.add('hidden'); -} -""", - """function hideEditRangeButton() { - elements['edit-range-button'].classList.add('hidden'); -} - -function clearTextRangeHighlights() { - for (const highlight of elements['text-layer'].querySelectorAll('.text-range-highlight')) { - highlight.remove(); - } -} - -function 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.'); -} -""", - 'selection geometry helpers' -) -main = replace_once( - main, - """function clearSelection() { - state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights();""", - """function clearSelection() { - state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextRangeHighlights(); - setTextResizeMode(false); - clearTextLineHighlights();""", - 'clear resize mode' -) -main = replace_once( - main, - """function objectScreenRectToPdf(object) { - const width = Math.max(2, object.width * object.scaleX); - const height = Math.max(2, object.height * object.scaleY); - return screenRectToPdf([ - object.left, - object.top, - object.left + width, - object.top + height - ]); -}""", - """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) - ]); -}""", - 'precise Fabric bounds' -) -main = replace_once( - main, - """ } else if (meta.kind === 'table') { - engine.updateTable(""", - """ } 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(""", - 'text resize commit' -) -main = replace_once( - main, - """ elements['edit-content'].addEventListener('click', () => { - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['copy-text'].addEventListener('click', copySelectedText);""", - """ elements['edit-content'].addEventListener('click', () => { - if (state.selected?.kind === 'text') { - openExistingTextEditor(state.selected.block); - } - }); - elements['resize-text-block'].addEventListener('click', activateTextResizeMode); - elements['copy-text'].addEventListener('click', copySelectedText);""", - 'resize button handler' -) -main_path.write_text(main, encoding='utf-8') - -engine_path = Path('media/webview/pdf-engine.js') -engine = engine_path.read_text(encoding='utf-8') -engine = replace_once( - engine, - """ currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map() - };""", - """ currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map(), - characters: [] - };""", - 'character boxes init' -) -engine = replace_once( - 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 = 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, - characterRect - ]);""", - 'capture character boxes' -) -engine = replace_once( - engine, - """ baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color""", - """ baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color, - characters: currentLine.characters""", - 'expose character boxes' -) -engine = replace_once( - engine, - """ let text = ''; - let previous = null; - for (const fragment of row.fragments) { - if (previous && needsVisualSpace(previous, fragment)) { - text += ' '; - } - text += fragment.text; - previous = fragment; - } - const style = dominantLineStyle(row.fragments); - return { - text, - fragments: row.fragments, - rect: row.rect, - baseline: [row.fragments[0].baseline[0], row.baseline[1]], - font: style.font, - color: style.color - };""", - """ 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]], - font: style.font, - color: style.color - };""", - 'propagate visual characters' -) -engine = replace_once( - engine, - """ moveTextBlock(pageIndex, blockIndex, targetRect) { - const model = this.getPageModel(pageIndex);""", - """ 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);""", - 'resize text block engine' -) -engine_path.write_text(engine, encoding='utf-8') - -html_path = Path('src/webview-html.js') -html = html_path.read_text(encoding='utf-8') -html = replace_once( - html, - """ Editar contenido - Copiar""", - """ Editar contenido - Mover / redimensionar - Copiar""", - 'resize text HTML button' -) -html_path.write_text(html, 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::selection { - color: transparent; - background: color-mix(in srgb, var(--focus) 38%, transparent); -} -""", - """.text-layer-line::selection { - color: 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; -} -""", - 'precise selection CSS' -) -css_path.write_text(css, encoding='utf-8') - -package_path = Path('package.json') -pkg = json.loads(package_path.read_text(encoding='utf-8')) -pkg['version'] = '0.0.6' -pkg['icon'] = 'images/icon.png' -package_path.write_text(json.dumps(pkg, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') - -lock_path = Path('package-lock.json') -lock = json.loads(lock_path.read_text(encoding='utf-8')) -if '' in lock.get('packages', {}): - lock['packages']['']['version'] = '0.0.6' -if 'version' in lock: - lock['version'] = '0.0.6' -lock_path.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') - -test_path = Path('test/ui-contract.test.mjs') -test = test_path.read_text(encoding='utf-8').replace("assert.equal(pkg.version, '0.0.5');", "assert.equal(pkg.version, '0.0.6');") -if "v0.0.6 keeps overlays" not in test: - test += """ - -test('v0.0.6 keeps overlays in one coordinate system and supports text resizing', () => { - assert.match(main, /enableRetinaScaling: false/); - assert.match(main, /object\.getBoundingRect\(\)/); - assert.match(main, /resize-text-block/); - assert.match(main, /engine\.resizeTextBlock/); -}); - -test('v0.0.6 renders text selection from extracted PDF character rectangles', () => { - const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8'); - assert.match(engine, /characters: currentLine\.characters/); - assert.match(main, /text-range-highlight/); - assert.match(main, /selectedCharacters/); -}); -""" -test_path.write_text(test, encoding='utf-8') - -changelog_path = Path('CHANGELOG.md') -changelog = changelog_path.read_text(encoding='utf-8') -if '## 0.0.6 - 2026-08-24' not in changelog: - marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n' - section = """ -## 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. - -""" - changelog = changelog.replace(marker, marker + section, 1) -changelog_path.write_text(changelog, encoding='utf-8') - -print('v0.0.6 geometry and resize fixes applied') diff --git a/src/webview-html.js b/src/webview-html.js index 224665a..c15b08a 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -81,6 +81,7 @@ function getWebviewHtml(webview, extensionUri) { Editar contenido + Mover / redimensionar Copiar Fuente diff --git a/test/ui-contract.test.mjs b/test/ui-contract.test.mjs index d2dc6ff..3e043a5 100644 --- a/test/ui-contract.test.mjs +++ b/test/ui-contract.test.mjs @@ -27,5 +27,20 @@ test('save writes the custom document and sends visible acknowledgement', () => test('marketplace icon metadata is present', () => { assert.equal(pkg.icon, 'images/icon.png'); assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true); - assert.equal(pkg.version, '0.0.5'); + assert.equal(pkg.version, '0.0.6'); +}); + + +test('v0.0.6 keeps overlays in one coordinate system and supports text resizing', () => { + assert.match(main, /enableRetinaScaling: false/); + assert.match(main, /object\.getBoundingRect\(\)/); + assert.match(main, /resize-text-block/); + assert.match(main, /engine\.resizeTextBlock/); +}); + +test('v0.0.6 renders text selection from extracted PDF character rectangles', () => { + const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8'); + assert.match(engine, /characters: currentLine\.characters/); + assert.match(main, /text-range-highlight/); + assert.match(main, /selectedCharacters/); }); From eabaaa57909fb37219ef3c9ed587890744278fef Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:28:42 +0200 Subject: [PATCH 18/18] docs: describe v0.0.6 geometry and resize behavior --- README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f797bd..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.5` 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 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.