From 669bdc1801ac9bd03da1b7318a9bed900885bc2a Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:44:48 +0200 Subject: [PATCH 01/11] chore: stage v0.0.7 patch part 1 --- scripts/v007-part1.py | 205 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 scripts/v007-part1.py diff --git a/scripts/v007-part1.py b/scripts/v007-part1.py new file mode 100644 index 0000000..6f1a7ea --- /dev/null +++ b/scripts/v007-part1.py @@ -0,0 +1,205 @@ +from pathlib import Path +import json +import re + +ROOT = Path('.') + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise RuntimeError(f'{label}: expected 1 match, found {count}') + return text.replace(old, new, 1) + + +def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: + start_index = text.find(start) + if start_index < 0: + raise RuntimeError(f'{label}: start marker not found') + end_index = text.find(end, start_index + len(start)) + if end_index < 0: + raise RuntimeError(f'{label}: end marker not found') + if text.find(start, start_index + 1) >= 0: + raise RuntimeError(f'{label}: start marker is not unique') + return text[:start_index] + replacement + text[end_index:] + + +# --------------------------------------------------------------------------- +# package.json / package-lock.json +# --------------------------------------------------------------------------- +package_path = ROOT / 'package.json' +package_data = json.loads(package_path.read_text(encoding='utf-8')) +package_data['version'] = '0.0.7' +package_data['icon'] = 'images/icon.png' +package_path.write_text(json.dumps(package_data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + +lock_path = ROOT / 'package-lock.json' +lock_text = lock_path.read_text(encoding='utf-8') +lock_text = replace_once(lock_text, '"version": "0.0.6"', '"version": "0.0.7"', 'package-lock root version') +lock_text = replace_once(lock_text, '"version": "0.0.6"', '"version": "0.0.7"', 'package-lock package version') +lock_path.write_text(lock_text, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# media/webview/main.js +# --------------------------------------------------------------------------- +main_path = ROOT / 'media/webview/main.js' +main = main_path.read_text(encoding='utf-8') + +main = replace_once( + main, + "].map((id) => [id, document.getElementById(id)]));\n\nwindow.addEventListener('message', async (event) => {", + """].map((id) => [id, document.getElementById(id)])); + +const blockEditorResizeObserver = new window.ResizeObserver(() => { + if (state.editing && !elements['block-editor'].classList.contains('hidden')) { + syncEditingRectFromEditor(); + } +}); +blockEditorResizeObserver.observe(elements['block-editor-text']); + +window.addEventListener('message', async (event) => {""", + 'block editor ResizeObserver' +) + +main = replace_once( + main, + """ } + state.fabricCanvas.clear(); + state.overlayObjects = []; +} + +function buildTextLayer() {""", + """ } + syncFabricCanvasGeometry(); + state.fabricCanvas.clear(); + state.overlayObjects = []; +} + +function syncFabricCanvasGeometry() { + if (!state.fabricCanvas || !state.render) { + return; + } + const width = `${state.render.width}px`; + const height = `${state.render.height}px`; + const wrapper = state.fabricCanvas.wrapperEl; + if (wrapper) { + Object.assign(wrapper.style, { + position: 'absolute', + left: '0px', + top: '0px', + width, + height, + margin: '0px' + }); + } + for (const canvas of [state.fabricCanvas.lowerCanvasEl, state.fabricCanvas.upperCanvasEl]) { + if (!canvas) { + continue; + } + Object.assign(canvas.style, { + left: '0px', + top: '0px', + width, + height, + margin: '0px' + }); + } +} + +function buildTextLayer() {""", + 'Fabric canvas geometry sync' +) + +render_start = "function renderTextRangeHighlights() {" +render_end = "\nfunction setTextResizeMode(active) {" +render_replacement = """function getTextRangeRects(selectedRange) { + if (!selectedRange || !state.pageModel) { + return []; + } + + const rects = []; + const lines = state.pageModel.textLines.filter((line) => + line.blockIndex === selectedRange.blockIndex && + selectedRange.end > line.textStart && + selectedRange.start < line.textEnd + ); + + for (const line of lines) { + const localStart = clamp(selectedRange.start - line.textStart, 0, line.text.length); + const localEnd = clamp(selectedRange.end - line.textStart, 0, line.text.length); + if (localEnd <= localStart) { + continue; + } + + const characters = Array.isArray(line.characters) ? line.characters : []; + const selectedCharacters = characters.slice(localStart, localEnd) + .map((character) => character.rect) + .filter((candidate) => Array.isArray(candidate) && candidate.length === 4); + + if (selectedCharacters.length > 0) { + rects.push(selectedCharacters.reduce((result, candidate) => result + ? [ + Math.min(result[0], candidate[0]), + Math.min(result[1], candidate[1]), + Math.max(result[2], candidate[2]), + Math.max(result[3], candidate[3]) + ] + : [...candidate], null)); + continue; + } + + const lineWidth = Math.max(1, line.rect[2] - line.rect[0]); + const startRatio = localStart / Math.max(1, line.text.length); + const endRatio = localEnd / Math.max(1, line.text.length); + rects.push([ + line.rect[0] + lineWidth * startRatio, + line.rect[1], + line.rect[0] + lineWidth * endRatio, + line.rect[3] + ]); + } + + return rects; +} + +function getTextRangeRect(selectedRange) { + const rects = getTextRangeRects(selectedRange); + if (rects.length === 0) { + return null; + } + return rects.reduce((result, rect) => [ + Math.min(result[0], rect[0]), + Math.min(result[1], rect[1]), + Math.max(result[2], rect[2]), + Math.max(result[3], rect[3]) + ], [...rects[0]]); +} + +function renderTextRangeHighlights() { + clearTextRangeHighlights(); + for (const rect of getTextRangeRects(state.textRange)) { + const screen = pdfRectToScreen(rect); + const highlight = document.createElement('div'); + highlight.className = 'text-range-highlight'; + 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); + } +} +""" +main = replace_between(main, render_start, render_end, render_replacement, 'precise range geometry helpers') + +main = replace_once( + main, + """ left: screen[0], + top: screen[1], + width: Math.max(2, screen[2] - screen[0]), + height: Math.max(2, screen[3] - screen[1]), + fill: options.fill || 'rgba(0, 0, 0, 0.001)',""", + """ left: screen[0], + top: screen[1], + width: Math.max(2, screen[2] - screen[0]), + height: Math.max(2, screen[3] - screen[1]), From bed8d1779230815dbe970d7686db8c4bc4474d6f Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:45:17 +0200 Subject: [PATCH 02/11] chore: stage v0.0.7 patch part 2 --- scripts/v007-part2.py | 205 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 scripts/v007-part2.py diff --git a/scripts/v007-part2.py b/scripts/v007-part2.py new file mode 100644 index 0000000..cc2b977 --- /dev/null +++ b/scripts/v007-part2.py @@ -0,0 +1,205 @@ + originX: 'left', + originY: 'top', + strokeUniform: true, + centeredScaling: false, + padding: 0, + fill: options.fill || 'rgba(0, 0, 0, 0.001)',""", + 'Fabric object origin and stroke geometry' +) + +object_rect_start = "function objectScreenRectToPdf(object) {" +object_rect_end = "\nfunction setTextControlsFromBlock(block) {" +object_rect_replacement = """function objectScreenRectToPdf(object) { + // All editable Fabric objects are axis-aligned with a left/top origin. + // Using the object's content box (not getBoundingRect, which includes the + // visual stroke) keeps the blue frame and the PDF target rectangle identical. + const left = Number(object.left || 0); + const top = Number(object.top || 0); + const width = Math.max(2, Number(object.width || 0) * Math.abs(Number(object.scaleX || 1))); + const height = Math.max(2, Number(object.height || 0) * Math.abs(Number(object.scaleY || 1))); + return screenRectToPdf([left, top, left + width, top + height]); +} +""" +main = replace_between(main, object_rect_start, object_rect_end, object_rect_replacement, 'Fabric object rectangle conversion') + +main = replace_once( + main, + """ openBlockEditor({ + mode: 'range', + blockIndex: block.index, + start: selectedRange.start, + end: selectedRange.end, + rect: block.rect, + text: selectedRange.text + });""", + """ openBlockEditor({ + mode: 'range', + blockIndex: block.index, + start: selectedRange.start, + end: selectedRange.end, + sourceRect: block.rect, + rect: getTextRangeRect(selectedRange) || block.rect, + text: selectedRange.text + });""", + 'range editor exact rectangle' +) + +main = replace_once( + main, + """ openBlockEditor({ + mode: 'add', + blockIndex: null, + rect: [x, y, x + width, y + 18], + text: '' + });""", + """ openBlockEditor({ + mode: 'add', + blockIndex: null, + rect: [x, y, x + width, Math.min(bounds[3] - 8, y + 72)], + text: '' + });""", + 'new text initial box height' +) + +open_editor_start = "function openBlockEditor(editing) {" +open_editor_end = "\nfunction syncBlockEditorPreview() {" +open_editor_replacement = """function openBlockEditor(editing) { + state.editing = { ...editing }; + hideEditRangeButton(); + clearNativeTextSelection(); + const screen = pdfRectToScreen(editing.rect); + const editor = elements['block-editor']; + const textarea = elements['block-editor-text']; + const left = clamp(screen[0], 0, Math.max(0, state.render.width - 24)); + const top = clamp(screen[1], 0, Math.max(0, state.render.height - 18)); + const requestedWidth = Math.max(24, screen[2] - screen[0]); + const requestedHeight = Math.max(18, screen[3] - screen[1]); + const width = clamp(requestedWidth, 24, Math.max(24, state.render.width - left)); + const height = clamp(requestedHeight, 18, Math.max(18, state.render.height - top)); + + editor.style.left = `${left}px`; + editor.style.top = `${top}px`; + textarea.style.width = `${width}px`; + textarea.style.height = `${height}px`; + editor.classList.remove('hidden'); + textarea.value = editing.text; + syncBlockEditorPreview(); + autoGrowBlockEditor(); + syncEditingRectFromEditor(); + textarea.focus(); + textarea.select(); + setStatus(editing.mode === 'add' + ? 'Escribe dentro del marco azul. Puedes arrastrar su esquina para cambiar anchura y altura.' + : (editing.mode === 'range' + ? 'El marco azul coincide con la selección. Edita el texto y redimensiona el área si lo necesitas.' + : 'El marco azul es el área real del bloque. Redimensiónalo para cambiar el reflow.')); +} + +function syncEditingRectFromEditor() { + if (!state.editing || elements['block-editor'].classList.contains('hidden')) { + return; + } + const textareaRect = elements['block-editor-text'].getBoundingClientRect(); + const stageRect = elements['page-stage'].getBoundingClientRect(); + const screenRect = normalizeRect([ + clamp(textareaRect.left - stageRect.left, 0, state.render.width), + clamp(textareaRect.top - stageRect.top, 0, state.render.height), + clamp(textareaRect.right - stageRect.left, 0, state.render.width), + clamp(textareaRect.bottom - stageRect.top, 0, state.render.height) + ]); + state.editing.rect = screenRectToPdf(screenRect); +} +""" +main = replace_between(main, open_editor_start, open_editor_end, open_editor_replacement, 'block editor exact geometry') + +autogrow_start = "function autoGrowBlockEditor() {" +autogrow_end = "\nfunction cancelBlockEditor() {" +autogrow_replacement = """function autoGrowBlockEditor() { + if (!state.editing) { + return; + } + const textarea = elements['block-editor-text']; + const editorRect = textarea.getBoundingClientRect(); + const stageRect = elements['page-stage'].getBoundingClientRect(); + const maximumHeight = Math.max(18, state.render.height - (editorRect.top - stageRect.top)); + const requiredHeight = clamp(textarea.scrollHeight + 4, 18, maximumHeight); + if (requiredHeight > editorRect.height + 1) { + textarea.style.height = `${requiredHeight}px`; + } + syncEditingRectFromEditor(); +} +""" +main = replace_between(main, autogrow_start, autogrow_end, autogrow_replacement, 'block editor auto grow') + +main = replace_once( + main, + """ } else { + engine.editTextBlock(state.currentPage, editing.blockIndex, values); + await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { + restoreText: text || null + }); + }""", + """ } else { + engine.editTextBlock(state.currentPage, editing.blockIndex, { + ...values, + targetRect: editing.rect + }); + await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { + restoreText: text || null + }); + }""", + 'apply edited block target rectangle' +) + +main_path.write_text(main, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# media/webview/pdf-engine.js +# --------------------------------------------------------------------------- +engine_path = ROOT / 'media/webview/pdf-engine.js' +engine = engine_path.read_text(encoding='utf-8') + +read_annotations_start = " readAnnotations(page) {" +read_annotations_end = "\n readWidgets(page) {" +read_annotations_replacement = """ readAnnotations(page) { + const annotations = page.getAnnotations(); + return annotations.map((annotation, index) => { + try { + const type = annotation.getType(); + const contents = safeCall(() => annotation.getContents(), ''); + const subject = safeCall(() => annotation.getSubject(), ''); + const table = parseTableMetadata(type, subject, contents); + let rect = table?.rect ? [...table.rect] : null; + if (!rect && table) { + rect = safeCall(() => [...annotation.getBounds()], null); + } + if (!rect) { + rect = annotation.hasRect() + ? [...annotation.getRect()] + : [...annotation.getBounds()]; + } + + let defaultAppearance = null; + if (type === 'FreeText') { + try { + defaultAppearance = annotation.getDefaultAppearance(); + } catch { + defaultAppearance = null; + } + } + return { + index, + type, + rect, + contents, + author: safeCall(() => annotation.getAuthor(), ''), + subject, + table, + color: safeCall(() => [...annotation.getColor()], []), + interiorColor: annotation.hasInteriorColor() + ? safeCall(() => [...annotation.getInteriorColor()], []) + : [], + opacity: safeCall(() => annotation.getOpacity(), 1), + borderWidth: annotation.hasBorder() + ? safeCall(() => annotation.getBorderWidth(), 1) From e67558dbf7f30aecc8fa1949ac04dbafceaed668 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:45:37 +0200 Subject: [PATCH 03/11] chore: stage v0.0.7 patch part 3 --- scripts/v007-part3.py | 205 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 scripts/v007-part3.py diff --git a/scripts/v007-part3.py b/scripts/v007-part3.py new file mode 100644 index 0000000..2dc30b0 --- /dev/null +++ b/scripts/v007-part3.py @@ -0,0 +1,205 @@ + : 0, + alignment: type === 'FreeText' + ? safeCall(() => annotation.getQuadding(), 0) + : 0, + font: defaultAppearance?.font || 'Helv', + fontSize: defaultAppearance?.size || 12, + fontColor: defaultAppearance?.color || [] + }; + } finally { + annotation.destroy(); + } + }); + } +""" +engine = replace_between(engine, read_annotations_start, read_annotations_end, read_annotations_replacement, 'annotation geometry') + +edit_block_start = " editTextBlock(pageIndex, blockIndex, properties = {}) {" +edit_block_end = "\n /**\n * Replaces an arbitrary character range inside an extracted text block." +edit_block_replacement = """ editTextBlock(pageIndex, blockIndex, properties = {}) { + const model = this.getPageModel(pageIndex); + const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); + if (!block) { + throw new Error('The selected text block no longer exists. Select it again.'); + } + + const values = normalizeBlockProperties(block, properties); + const requested = values.text && Array.isArray(properties.targetRect) + ? normalizeRect(properties.targetRect) + : [...block.rect]; + const minimumWidth = Math.max(24, Number(values.fontSize || block.font?.size || 12) * 2); + const targetRect = [ + requested[0], + requested[1], + Math.max(requested[0] + minimumWidth, requested[2]), + requested[3] + ]; + const layoutRect = [targetRect[0], targetRect[1], targetRect[2], targetRect[1]]; + const layout = layoutTextBlock(layoutRect, values, block.metrics, block); + const newBottom = values.text ? targetRect[1] + layout.height : block.rect[1]; + const delta = newBottom - block.rect[3]; + const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); + const shiftedEntries = followingBlocks.flatMap((followingBlock) => + textEntriesForExistingBlock(followingBlock, delta) + ); + + this.withOperation(values.text ? 'Edit text block' : 'Delete text block', () => { + this.withPage(pageIndex, (page) => { + for (const sourceBlock of [block, ...followingBlocks]) { + this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { + images: false, + lineArt: false, + text: true + }); + } + + const entries = values.text + ? [...layout.entries, ...shiftedEntries] + : shiftedEntries; + const maximumBottom = Math.max( + values.text ? newBottom : block.rect[1], + ...followingBlocks.map((candidate) => candidate.rect[3] + delta) + ); + this.extendPageToFit(page, maximumBottom); + this.appendStaticText(page, entries); + }); + }); + + return { + delta, + shiftedBlocks: followingBlocks.length, + rect: values.text + ? [targetRect[0], targetRect[1], targetRect[2], newBottom] + : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] + }; + } +""" +engine = replace_between(engine, edit_block_start, edit_block_end, edit_block_replacement, 'edit text inside target box') + +engine = replace_once( + engine, + """ const annotation = page.createAnnotation('Ink'); + try { + annotation.setInkList(strokes);""", + """ const annotation = page.createAnnotation('Ink'); + try { + annotation.setRect(normalizedRect); + annotation.setInkList(strokes);""", + 'table annotation rectangle' +) + +engine = replace_once( + engine, + """ type: 'table', + rows: rowCount, + columns: columnCount, + color, + borderWidth + }));""", + """ type: 'table', + rows: rowCount, + columns: columnCount, + color, + borderWidth, + rect: normalizedRect + }));""", + 'table metadata rectangle' +) + +engine = replace_once( + engine, + """ return { + rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), + columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), + color: normalizeColor(value.color, [0, 0, 0]), + borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))) + };""", + """ return { + rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), + columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), + color: normalizeColor(value.color, [0, 0, 0]), + borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))), + rect: Array.isArray(value.rect) && value.rect.length === 4 + ? normalizeRect(value.rect.map(Number)) + : null + };""", + 'parse table rectangle metadata' +) + +engine_path.write_text(engine, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# media/webview/styles.css +# --------------------------------------------------------------------------- +styles_path = ROOT / 'media/webview/styles.css' +styles = styles_path.read_text(encoding='utf-8') + +styles = replace_once( + styles, + """#pdf-canvas, +.text-layer, +.insertion-layer, +.page-stage > .canvas-container { + position: absolute !important; + inset: 0; +} +""", + """#pdf-canvas, +.text-layer, +.insertion-layer, +.page-stage > .canvas-container { + position: absolute !important; + inset: 0; +} + +.page-stage > .canvas-container, +.page-stage > .canvas-container .lower-canvas, +.page-stage > .canvas-container .upper-canvas { + left: 0 !important; + top: 0 !important; + margin: 0 !important; +} +""", + 'canvas absolute origin CSS' +) + +block_css_start = ".block-editor {" +block_css_end = "\n.statusbar {" +block_css_replacement = """.block-editor { + position: absolute; + z-index: 8; + min-width: 0; + min-height: 0; + padding: 0; + overflow: visible; + background: transparent; +} + +.block-editor textarea { + display: block; + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 24px; + min-height: 18px; + resize: both; + padding: 2px 3px; + overflow: auto; + border: 2px solid var(--focus); + border-radius: 2px; + outline: 0; + color: #111; + background: rgba(255, 255, 255, 0.98); + line-height: 1.18; + white-space: pre-wrap; + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22); +} + +.block-editor textarea:focus { + outline: 0; + border-color: var(--focus); +} + +.block-editor-actions { + display: flex; From c99f2d62e27c8c7c437ac827b5d9ac6c0452b03c Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:46:03 +0200 Subject: [PATCH 04/11] chore: stage v0.0.7 patch part 4 --- scripts/v007-part4.py | 203 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/v007-part4.py diff --git a/scripts/v007-part4.py b/scripts/v007-part4.py new file mode 100644 index 0000000..f8049cb --- /dev/null +++ b/scripts/v007-part4.py @@ -0,0 +1,203 @@ + align-items: center; + justify-content: flex-end; + gap: 6px; + width: max-content; + min-width: 100%; + padding-top: 5px; +} + +.block-editor-actions span { + margin-right: auto; + color: var(--muted); + font-size: 11px; +} +""" +styles = replace_between(styles, block_css_start, block_css_end, block_css_replacement, 'resizable blue text editor') + +styles += """ + +.version-badge { + flex: 0 0 auto; + margin-left: auto; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} +""" +styles_path.write_text(styles, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# src/webview-html.js +# --------------------------------------------------------------------------- +html_path = ROOT / 'src/webview-html.js' +html = html_path.read_text(encoding='utf-8') +html = replace_once( + html, + """ Haz clic en un párrafo, selecciona texto o usa + para insertar. + """, + """ Haz clic en un párrafo, selecciona texto o usa + para insertar. + v0.0.7 + """, + 'visible installed version badge' +) +html = replace_once( + html, + '', + '', + 'clear resize button label' +) +html_path.write_text(html, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# test/ui-contract.test.mjs +# --------------------------------------------------------------------------- +ui_test_path = ROOT / 'test/ui-contract.test.mjs' +ui_test = ui_test_path.read_text(encoding='utf-8') +ui_test = replace_once( + ui_test, + "const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));", + """const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); +const styles = fs.readFileSync(new URL('../media/webview/styles.css', import.meta.url), 'utf8'); +const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');""", + 'UI contract load CSS and engine' +) +ui_test = ui_test.replace("assert.equal(pkg.version, '0.0.6');", "assert.equal(pkg.version, '0.0.7');") +ui_test = ui_test.replace("test('v0.0.6 keeps overlays in one coordinate system and supports text resizing'", "test('v0.0.7 keeps overlays in one coordinate system and supports text resizing'") +ui_test = ui_test.replace("test('v0.0.6 renders text selection from extracted PDF character rectangles'", "test('v0.0.7 renders text selection from extracted PDF character rectangles'") +ui_test = ui_test.replace(" const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');\n", "") +ui_test = ui_test.replace(" assert.match(main, /object\\.getBoundingRect\\(\\)/);", " assert.match(main, /object\\.width \\|\\| 0/);") +ui_test += """ + +test('v0.0.7 uses the blue editor box as the real text target rectangle', () => { + assert.match(main, /new window\.ResizeObserver/); + assert.match(main, /syncEditingRectFromEditor/); + assert.match(main, /targetRect: editing\.rect/); + assert.match(styles, /resize: both/); + assert.match(styles, /border: 2px solid var\(--focus\)/); +}); + +test('v0.0.7 stores exact table geometry and uses it for the overlay', () => { + assert.match(engine, /annotation\.setRect\(normalizedRect\)/); + assert.match(engine, /rect: normalizedRect/); + assert.match(engine, /table\?\.rect/); +}); +""" +ui_test_path.write_text(ui_test, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# test/pdf-engine.test.mjs +# --------------------------------------------------------------------------- +engine_test_path = ROOT / 'test/pdf-engine.test.mjs' +engine_test = engine_test_path.read_text(encoding='utf-8') +engine_test += r''' + +test('v0.0.7 text edits honor a new bounding box width and position', () => { + const engine = new PdfEngine(); + try { + engine.load(createFlowingTextPdf()); + const original = engine.getPageModel(0).textBlocks.find((block) => + block.text.includes('First paragraph') + ); + assert.ok(original); + const targetRect = [62, original.rect[1] + 4, 176, original.rect[3] + 4]; + const result = engine.editTextBlock(0, original.index, { + text: 'First paragraph edited inside a deliberately narrower resizable box with enough words to wrap.', + fontFamily: 'Helvetica', + fontSize: 12, + targetRect + }); + assert.ok(Math.abs(result.rect[0] - targetRect[0]) < 0.01); + assert.ok(Math.abs(result.rect[2] - targetRect[2]) < 0.01); + const edited = engine.getPageModel(0).textBlocks.find((block) => + block.text.includes('deliberately narrower') + ); + assert.ok(edited); + assert.ok(edited.rect[0] >= targetRect[0] - 3); + assert.ok(edited.rect[2] <= targetRect[2] + 3); + } finally { + engine.destroy(); + } +}); + +test('v0.0.7 table metadata keeps the blue selection rectangle equal to the table', () => { + const engine = new PdfEngine(); + try { + engine.load(createOnePagePdf()); + const initialRect = [42, 90, 242, 190]; + engine.addTable(0, initialRect, 3, 4, { borderWidth: 1.5 }); + let table = engine.getPageModel(0).annotations.find((annotation) => annotation.table); + assert.ok(table); + assert.deepEqual(table.rect.map((value) => Math.round(value)), initialRect); + + const resizedRect = [55, 105, 270, 235]; + engine.updateTable(0, table.index, resizedRect, 4, 5, { borderWidth: 2 }); + table = engine.getPageModel(0).annotations.find((annotation) => annotation.table); + assert.ok(table); + assert.deepEqual(table.rect.map((value) => Math.round(value)), resizedRect); + assert.equal(table.table.rows, 4); + assert.equal(table.table.columns, 5); + } finally { + engine.destroy(); + } +}); +''' +engine_test_path.write_text(engine_test, encoding='utf-8') + + +# --------------------------------------------------------------------------- +# CHANGELOG / README +# --------------------------------------------------------------------------- +changelog_path = ROOT / 'CHANGELOG.md' +changelog = changelog_path.read_text(encoding='utf-8') +section = """## 0.0.7 - 2026-08-24 + +### Corregido + +- El marco azul de edición de texto pasa a ser el área real del contenido editable. +- El editor de texto se puede redimensionar en anchura y altura; el reflow usa esa anchura al aplicar. +- La selección parcial usa el rectángulo exacto de los caracteres seleccionados. +- Las tablas guardan y recuperan su rectángulo exacto, evitando que el marco Fabric quede desplazado respecto a la cuadrícula. +- Las capas PDF, texto y Fabric se fuerzan al mismo origen de coordenadas. +- Se mantiene el icono `images/icon.png` dentro del VSIX y se añade un indicador visible `v0.0.7` para comprobar la versión instalada. + +""" +changelog = replace_once( + changelog, + 'Todos los cambios relevantes de este proyecto se documentan aquí.\n\n', + 'Todos los cambios relevantes de este proyecto se documentan aquí.\n\n' + section, + 'v0.0.7 changelog section' +) +changelog_path.write_text(changelog, encoding='utf-8') + +readme_path = ROOT / 'README.md' +readme = readme_path.read_text(encoding='utf-8') +readme = readme.replace('La versión `0.0.5`', 'La versión `0.0.7`', 1) +readme = readme.replace( + '- Editar un párrafo completo con `Editar contenido`.', + '- Editar un párrafo completo con `Editar contenido`; el marco azul coincide con el área real y se puede redimensionar.', + 1 +) +readme = readme.replace( + '- Seleccionar una tabla creada, moverla y redimensionarla.', + '- Seleccionar una tabla creada, moverla y redimensionarla con un marco que coincide exactamente con la cuadrícula.', + 1 +) +readme = readme.replace('pdf-viewer-editor-0.0.4.vsix', 'pdf-viewer-editor-0.0.7.vsix') +readme_path.write_text(readme, encoding='utf-8') + + +# Remove the accidental staging helper merged by PR #9. It is never shipped. +for stale in [ + ROOT / 'scripts/apply-v006-fixes.js', + ROOT / 'scripts/apply-v006-fixes.py' +]: + if stale.exists(): + stale.unlink() + +print('v0.0.7 unified edit-box fixes applied') From 3203ec75b8382933e3d8972565f535ce373078bc Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:46:22 +0200 Subject: [PATCH 05/11] ci: finalize and validate v0.0.7 --- .github/workflows/finalize-v007.yml | 99 +++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 .github/workflows/finalize-v007.yml diff --git a/.github/workflows/finalize-v007.yml b/.github/workflows/finalize-v007.yml new file mode 100644 index 0000000..6f28a33 --- /dev/null +++ b/.github/workflows/finalize-v007.yml @@ -0,0 +1,99 @@ +name: Finalize v0.0.7 + +on: + pull_request: + branches: + - main + +permissions: + contents: write + +jobs: + finalize: + if: ${{ github.head_ref == 'fix/v0.0.7-unified-edit-boxes' && github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - name: Checkout v0.0.7 branch + uses: actions/checkout@v4 + with: + ref: fix/v0.0.7-unified-edit-boxes + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Materialize v0.0.7 source + shell: bash + run: | + set -euo pipefail + cat \ + scripts/v007-part1.py \ + scripts/v007-part2.py \ + scripts/v007-part3.py \ + scripts/v007-part4.py \ + > /tmp/apply-v007.py + python -m py_compile /tmp/apply-v007.py + python /tmp/apply-v007.py + + - name: Install dependencies + run: npm ci + + - name: Verify source + run: npm run check + + - name: Verify v0.0.7 contracts + shell: bash + run: | + set -euo pipefail + node - <<'NODE' + const fs = require('node:fs'); + const pkg = require('./package.json'); + if (pkg.version !== '0.0.7') throw new Error('Expected version 0.0.7'); + if (pkg.icon !== 'images/icon.png') throw new Error('Marketplace icon missing'); + if (!fs.existsSync('images/icon.png')) throw new Error('images/icon.png missing'); + const main = fs.readFileSync('media/webview/main.js', 'utf8'); + const engine = fs.readFileSync('media/webview/pdf-engine.js', 'utf8'); + const styles = fs.readFileSync('media/webview/styles.css', 'utf8'); + const html = fs.readFileSync('src/webview-html.js', 'utf8'); + for (const token of ['syncEditingRectFromEditor', 'targetRect: editing.rect', "originX: 'left'", 'syncFabricCanvasGeometry']) { + if (!main.includes(token)) throw new Error(`Missing main.js contract: ${token}`); + } + for (const token of ['annotation.setRect(normalizedRect)', 'rect: normalizedRect', 'table?.rect']) { + if (!engine.includes(token)) throw new Error(`Missing pdf-engine.js contract: ${token}`); + } + if (!styles.includes('resize: both')) throw new Error('Resizable blue text box missing'); + if (!html.includes('v0.0.7')) throw new Error('Visible v0.0.7 badge missing'); + NODE + + - name: Package and inspect VSIX + shell: bash + run: | + set -euo pipefail + npm run package + test -f pdf-viewer-editor-0.0.7.vsix + unzip -l pdf-viewer-editor-0.0.7.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 'v007-part' /tmp/vsix.txt + ! grep -q 'apply-v006-fixes' /tmp/vsix.txt + + - name: Commit final source + shell: bash + run: | + set -euo pipefail + rm -f \ + scripts/v007-part1.py \ + scripts/v007-part2.py \ + scripts/v007-part3.py \ + scripts/v007-part4.py \ + .github/workflows/finalize-v007.yml \ + pdf-viewer-editor-0.0.7.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.7 unified resizable edit boxes' + git push origin HEAD:fix/v0.0.7-unified-edit-boxes From 683548239b310feb87250fc069bb68600a6449e8 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:34 +0200 Subject: [PATCH 06/11] ci: fix v0.0.7 package-lock materialization --- .github/workflows/finalize-v007.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/finalize-v007.yml b/.github/workflows/finalize-v007.yml index 6f28a33..5ef2053 100644 --- a/.github/workflows/finalize-v007.yml +++ b/.github/workflows/finalize-v007.yml @@ -35,6 +35,26 @@ jobs: scripts/v007-part3.py \ scripts/v007-part4.py \ > /tmp/apply-v007.py + python - <<'PY' + from pathlib import Path + path = Path('/tmp/apply-v007.py') + text = path.read_text() + old = """lock_path = ROOT / 'package-lock.json' + lock_text = lock_path.read_text(encoding='utf-8') + lock_text = replace_once(lock_text, '\"version\": \"0.0.6\"', '\"version\": \"0.0.7\"', 'package-lock root version') + lock_text = replace_once(lock_text, '\"version\": \"0.0.6\"', '\"version\": \"0.0.7\"', 'package-lock package version') + lock_path.write_text(lock_text, encoding='utf-8') + """ + new = """lock_path = ROOT / 'package-lock.json' + lock_data = json.loads(lock_path.read_text(encoding='utf-8')) + lock_data['version'] = '0.0.7' + lock_data['packages']['']['version'] = '0.0.7' + lock_path.write_text(json.dumps(lock_data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8') + """ + if old not in text: + raise SystemExit('package-lock patch target missing') + path.write_text(text.replace(old, new, 1)) + PY python -m py_compile /tmp/apply-v007.py python /tmp/apply-v007.py From 326b245e107e6b5a5d2bccb4a6d7b5c093dd304e Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:49:48 +0200 Subject: [PATCH 07/11] fix: use table metadata as canonical Ink geometry --- scripts/v007-part5.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 scripts/v007-part5.py diff --git a/scripts/v007-part5.py b/scripts/v007-part5.py new file mode 100644 index 0000000..8500300 --- /dev/null +++ b/scripts/v007-part5.py @@ -0,0 +1,26 @@ + +# --------------------------------------------------------------------------- +# MuPDF Ink annotations derive their geometry from the ink list and do not +# support an explicit Rect entry. Keep our canonical table rectangle in the +# JSON metadata instead; readAnnotations() already prioritizes table.rect. +# --------------------------------------------------------------------------- +engine_path = ROOT / 'media/webview/pdf-engine.js' +engine = engine_path.read_text(encoding='utf-8') +engine = replace_once( + engine, + " annotation.setRect(normalizedRect);\n annotation.setInkList(strokes);", + " annotation.setInkList(strokes);", + 'remove unsupported Ink setRect' +) +engine_path.write_text(engine, encoding='utf-8') + +ui_test_path = ROOT / 'test/ui-contract.test.mjs' +ui_test = ui_test_path.read_text(encoding='utf-8') +ui_test = ui_test.replace( + " assert.match(engine, /annotation\\.setRect\\(normalizedRect\\)/);\n", + " assert.doesNotMatch(engine, /annotation\\.setRect\\(normalizedRect\\)/);\n", + 1 +) +ui_test_path.write_text(ui_test, encoding='utf-8') + +print('v0.0.7 Ink table geometry compatibility applied') From cc7de285d5bed1036d396ec962b111a8fbc32f06 Mon Sep 17 00:00:00 2001 From: Alexey Suzdalenko <62180604+suzdalenko-dev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:50:05 +0200 Subject: [PATCH 08/11] ci: validate Ink table geometry without unsupported Rect --- .github/workflows/finalize-v007.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/finalize-v007.yml b/.github/workflows/finalize-v007.yml index 5ef2053..de2a9c7 100644 --- a/.github/workflows/finalize-v007.yml +++ b/.github/workflows/finalize-v007.yml @@ -34,6 +34,7 @@ jobs: scripts/v007-part2.py \ scripts/v007-part3.py \ scripts/v007-part4.py \ + scripts/v007-part5.py \ > /tmp/apply-v007.py python - <<'PY' from pathlib import Path @@ -81,9 +82,10 @@ jobs: for (const token of ['syncEditingRectFromEditor', 'targetRect: editing.rect', "originX: 'left'", 'syncFabricCanvasGeometry']) { if (!main.includes(token)) throw new Error(`Missing main.js contract: ${token}`); } - for (const token of ['annotation.setRect(normalizedRect)', 'rect: normalizedRect', 'table?.rect']) { + for (const token of ['rect: normalizedRect', 'table?.rect']) { if (!engine.includes(token)) throw new Error(`Missing pdf-engine.js contract: ${token}`); } + if (engine.includes('annotation.setRect(normalizedRect)')) throw new Error('Ink annotations must not use setRect'); if (!styles.includes('resize: both')) throw new Error('Resizable blue text box missing'); if (!html.includes('v0.0.7')) throw new Error('Visible v0.0.7 badge missing'); NODE @@ -110,6 +112,7 @@ jobs: scripts/v007-part2.py \ scripts/v007-part3.py \ scripts/v007-part4.py \ + scripts/v007-part5.py \ .github/workflows/finalize-v007.yml \ pdf-viewer-editor-0.0.7.vsix git config user.name 'github-actions[bot]' From 33c47d044f5f1c7d22912c8730c3c267476ce604 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:50:28 +0000 Subject: [PATCH 09/11] fix: release v0.0.7 unified resizable edit boxes --- .github/workflows/finalize-v007.yml | 122 ---- CHANGELOG.md | 11 + README.md | 4 +- media/webview/main.js | 177 ++++-- media/webview/pdf-engine.js | 53 +- media/webview/styles.css | 59 +- package-lock.json | 4 +- package.json | 2 +- scripts/apply-v006-fixes.js | 919 ---------------------------- scripts/v007-part1.py | 205 ------- scripts/v007-part2.py | 205 ------- scripts/v007-part3.py | 205 ------- scripts/v007-part4.py | 203 ------ scripts/v007-part5.py | 26 - src/webview-html.js | 3 +- test/pdf-engine.test.mjs | 51 ++ test/ui-contract.test.mjs | 26 +- 17 files changed, 313 insertions(+), 1962 deletions(-) delete mode 100644 .github/workflows/finalize-v007.yml delete mode 100644 scripts/apply-v006-fixes.js delete mode 100644 scripts/v007-part1.py delete mode 100644 scripts/v007-part2.py delete mode 100644 scripts/v007-part3.py delete mode 100644 scripts/v007-part4.py delete mode 100644 scripts/v007-part5.py diff --git a/.github/workflows/finalize-v007.yml b/.github/workflows/finalize-v007.yml deleted file mode 100644 index de2a9c7..0000000 --- a/.github/workflows/finalize-v007.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: Finalize v0.0.7 - -on: - pull_request: - branches: - - main - -permissions: - contents: write - -jobs: - finalize: - if: ${{ github.head_ref == 'fix/v0.0.7-unified-edit-boxes' && github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - name: Checkout v0.0.7 branch - uses: actions/checkout@v4 - with: - ref: fix/v0.0.7-unified-edit-boxes - fetch-depth: 0 - - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - - name: Materialize v0.0.7 source - shell: bash - run: | - set -euo pipefail - cat \ - scripts/v007-part1.py \ - scripts/v007-part2.py \ - scripts/v007-part3.py \ - scripts/v007-part4.py \ - scripts/v007-part5.py \ - > /tmp/apply-v007.py - python - <<'PY' - from pathlib import Path - path = Path('/tmp/apply-v007.py') - text = path.read_text() - old = """lock_path = ROOT / 'package-lock.json' - lock_text = lock_path.read_text(encoding='utf-8') - lock_text = replace_once(lock_text, '\"version\": \"0.0.6\"', '\"version\": \"0.0.7\"', 'package-lock root version') - lock_text = replace_once(lock_text, '\"version\": \"0.0.6\"', '\"version\": \"0.0.7\"', 'package-lock package version') - lock_path.write_text(lock_text, encoding='utf-8') - """ - new = """lock_path = ROOT / 'package-lock.json' - lock_data = json.loads(lock_path.read_text(encoding='utf-8')) - lock_data['version'] = '0.0.7' - lock_data['packages']['']['version'] = '0.0.7' - lock_path.write_text(json.dumps(lock_data, ensure_ascii=False, indent=2) + '\\n', encoding='utf-8') - """ - if old not in text: - raise SystemExit('package-lock patch target missing') - path.write_text(text.replace(old, new, 1)) - PY - python -m py_compile /tmp/apply-v007.py - python /tmp/apply-v007.py - - - name: Install dependencies - run: npm ci - - - name: Verify source - run: npm run check - - - name: Verify v0.0.7 contracts - shell: bash - run: | - set -euo pipefail - node - <<'NODE' - const fs = require('node:fs'); - const pkg = require('./package.json'); - if (pkg.version !== '0.0.7') throw new Error('Expected version 0.0.7'); - if (pkg.icon !== 'images/icon.png') throw new Error('Marketplace icon missing'); - if (!fs.existsSync('images/icon.png')) throw new Error('images/icon.png missing'); - const main = fs.readFileSync('media/webview/main.js', 'utf8'); - const engine = fs.readFileSync('media/webview/pdf-engine.js', 'utf8'); - const styles = fs.readFileSync('media/webview/styles.css', 'utf8'); - const html = fs.readFileSync('src/webview-html.js', 'utf8'); - for (const token of ['syncEditingRectFromEditor', 'targetRect: editing.rect', "originX: 'left'", 'syncFabricCanvasGeometry']) { - if (!main.includes(token)) throw new Error(`Missing main.js contract: ${token}`); - } - for (const token of ['rect: normalizedRect', 'table?.rect']) { - if (!engine.includes(token)) throw new Error(`Missing pdf-engine.js contract: ${token}`); - } - if (engine.includes('annotation.setRect(normalizedRect)')) throw new Error('Ink annotations must not use setRect'); - if (!styles.includes('resize: both')) throw new Error('Resizable blue text box missing'); - if (!html.includes('v0.0.7')) throw new Error('Visible v0.0.7 badge missing'); - NODE - - - name: Package and inspect VSIX - shell: bash - run: | - set -euo pipefail - npm run package - test -f pdf-viewer-editor-0.0.7.vsix - unzip -l pdf-viewer-editor-0.0.7.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 'v007-part' /tmp/vsix.txt - ! grep -q 'apply-v006-fixes' /tmp/vsix.txt - - - name: Commit final source - shell: bash - run: | - set -euo pipefail - rm -f \ - scripts/v007-part1.py \ - scripts/v007-part2.py \ - scripts/v007-part3.py \ - scripts/v007-part4.py \ - scripts/v007-part5.py \ - .github/workflows/finalize-v007.yml \ - pdf-viewer-editor-0.0.7.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.7 unified resizable edit boxes' - git push origin HEAD:fix/v0.0.7-unified-edit-boxes diff --git a/CHANGELOG.md b/CHANGELOG.md index 466a59a..f089dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ Todos los cambios relevantes de este proyecto se documentan aquí. +## 0.0.7 - 2026-08-24 + +### Corregido + +- El marco azul de edición de texto pasa a ser el área real del contenido editable. +- El editor de texto se puede redimensionar en anchura y altura; el reflow usa esa anchura al aplicar. +- La selección parcial usa el rectángulo exacto de los caracteres seleccionados. +- Las tablas guardan y recuperan su rectángulo exacto, evitando que el marco Fabric quede desplazado respecto a la cuadrícula. +- Las capas PDF, texto y Fabric se fuerzan al mismo origen de coordenadas. +- Se mantiene el icono `images/icon.png` dentro del VSIX y se añade un indicador visible `v0.0.7` para comprobar la versión instalada. + ## 0.0.6 - 2026-08-24 ### Corregido diff --git a/README.md b/README.md index 02180c1..a4c03a5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ La versión `0.0.6` se concentra en una tarea: **seleccionar y editar directamen - Hacer clic sobre una línea selecciona solo esa línea; arrastrar selecciona exactamente palabras o frases, sin rectángulos gigantes. - La selección visual usa los rectángulos de caracteres extraídos del PDF para ajustarse al contenido real. - Arrastrar sobre una palabra o frase y editar, borrar, copiar o cambiar su formato. -- Editar un párrafo completo con `Editar contenido`. +- Editar un párrafo completo con `Editar contenido`; el marco azul coincide con el área real y se puede redimensionar. - Mover o redimensionar un bloque de texto con `Mover / redimensionar`. - 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. @@ -39,7 +39,7 @@ Al aplicar una edición, MuPDF elimina físicamente el texto anterior, calcula l - Elegir el número de filas y columnas al crear la tabla. - Insertar la tabla entre párrafos sin cubrir el contenido existente. - Dibujar su zona directamente sobre la página. -- Seleccionar una tabla creada, moverla y redimensionarla. +- Seleccionar una tabla creada, moverla y redimensionarla con un marco que coincide exactamente con la cuadrícula. - 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`. diff --git a/media/webview/main.js b/media/webview/main.js index ab7cb4a..c729fe1 100644 --- a/media/webview/main.js +++ b/media/webview/main.js @@ -67,6 +67,13 @@ const elements = Object.fromEntries([ 'dialog-content', 'dialog-cancel', 'dialog-confirm', 'image-file-input' ].map((id) => [id, document.getElementById(id)])); +const blockEditorResizeObserver = new window.ResizeObserver(() => { + if (state.editing && !elements['block-editor'].classList.contains('hidden')) { + syncEditingRectFromEditor(); + } +}); +blockEditorResizeObserver.observe(elements['block-editor-text']); + window.addEventListener('message', async (event) => { const message = event.data; if (message.type === 'load-document') { @@ -228,10 +235,42 @@ function initializeOrResizeFabricCanvas() { height: state.render.height }); } + syncFabricCanvasGeometry(); state.fabricCanvas.clear(); state.overlayObjects = []; } +function syncFabricCanvasGeometry() { + if (!state.fabricCanvas || !state.render) { + return; + } + const width = `${state.render.width}px`; + const height = `${state.render.height}px`; + const wrapper = state.fabricCanvas.wrapperEl; + if (wrapper) { + Object.assign(wrapper.style, { + position: 'absolute', + left: '0px', + top: '0px', + width, + height, + margin: '0px' + }); + } + for (const canvas of [state.fabricCanvas.lowerCanvasEl, state.fabricCanvas.upperCanvasEl]) { + if (!canvas) { + continue; + } + Object.assign(canvas.style, { + left: '0px', + top: '0px', + width, + height, + margin: '0px' + }); + } +} + function buildTextLayer() { const layer = elements['text-layer']; layer.replaceChildren(); @@ -408,13 +447,12 @@ function clearTextRangeHighlights() { } } -function renderTextRangeHighlights() { - clearTextRangeHighlights(); - const selectedRange = state.textRange; +function getTextRangeRects(selectedRange) { if (!selectedRange || !state.pageModel) { - return; + return []; } + const rects = []; const lines = state.pageModel.textLines.filter((line) => line.blockIndex === selectedRange.blockIndex && selectedRange.end > line.textStart && @@ -428,33 +466,53 @@ function renderTextRangeHighlights() { 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 + rects.push(selectedCharacters.reduce((result, candidate) => result ? [ Math.min(result[0], candidate[0]), Math.min(result[1], candidate[1]), Math.max(result[2], candidate[2]), Math.max(result[3], candidate[3]) ] - : [...candidate], null); - } else { - const lineWidth = Math.max(1, line.rect[2] - line.rect[0]); - const startRatio = localStart / Math.max(1, line.text.length); - const endRatio = localEnd / Math.max(1, line.text.length); - rect = [ - line.rect[0] + lineWidth * startRatio, - line.rect[1], - line.rect[0] + lineWidth * endRatio, - line.rect[3] - ]; + : [...candidate], null)); + continue; } + const lineWidth = Math.max(1, line.rect[2] - line.rect[0]); + const startRatio = localStart / Math.max(1, line.text.length); + const endRatio = localEnd / Math.max(1, line.text.length); + rects.push([ + line.rect[0] + lineWidth * startRatio, + line.rect[1], + line.rect[0] + lineWidth * endRatio, + line.rect[3] + ]); + } + + return rects; +} + +function getTextRangeRect(selectedRange) { + const rects = getTextRangeRects(selectedRange); + if (rects.length === 0) { + return null; + } + return rects.reduce((result, rect) => [ + Math.min(result[0], rect[0]), + Math.min(result[1], rect[1]), + Math.max(result[2], rect[2]), + Math.max(result[3], rect[3]) + ], [...rects[0]]); +} + +function renderTextRangeHighlights() { + clearTextRangeHighlights(); + for (const rect of getTextRangeRects(state.textRange)) { const screen = pdfRectToScreen(rect); const highlight = document.createElement('div'); highlight.className = 'text-range-highlight'; @@ -678,6 +736,11 @@ function addOverlayObject(meta, options = {}) { top: screen[1], width: Math.max(2, screen[2] - screen[0]), height: Math.max(2, screen[3] - screen[1]), + originX: 'left', + originY: 'top', + strokeUniform: true, + centeredScaling: false, + padding: 0, fill: options.fill || 'rgba(0, 0, 0, 0.001)', stroke: options.selectable === false ? color : 'rgba(0, 0, 0, 0)', strokeWidth: 1.25, @@ -959,13 +1022,14 @@ function screenPointToPdf(point) { } function objectScreenRectToPdf(object) { - const bounds = object.getBoundingRect(); - return screenRectToPdf([ - bounds.left, - bounds.top, - bounds.left + Math.max(2, bounds.width), - bounds.top + Math.max(2, bounds.height) - ]); + // All editable Fabric objects are axis-aligned with a left/top origin. + // Using the object's content box (not getBoundingRect, which includes the + // visual stroke) keeps the blue frame and the PDF target rectangle identical. + const left = Number(object.left || 0); + const top = Number(object.top || 0); + const width = Math.max(2, Number(object.width || 0) * Math.abs(Number(object.scaleX || 1))); + const height = Math.max(2, Number(object.height || 0) * Math.abs(Number(object.scaleY || 1))); + return screenRectToPdf([left, top, left + width, top + height]); } function setTextControlsFromBlock(block) { @@ -1079,7 +1143,8 @@ function openSelectedRangeEditor() { blockIndex: block.index, start: selectedRange.start, end: selectedRange.end, - rect: block.rect, + sourceRect: block.rect, + rect: getTextRangeRect(selectedRange) || block.rect, text: selectedRange.text }); } @@ -1101,7 +1166,7 @@ function openNewTextEditor(pointOrAnchor) { openBlockEditor({ mode: 'add', blockIndex: null, - rect: [x, y, x + width, y + 18], + rect: [x, y, x + width, Math.min(bounds[3] - 8, y + 72)], text: '' }); setTool('edit'); @@ -1115,25 +1180,50 @@ function showTextContext() { } function openBlockEditor(editing) { - state.editing = editing; + state.editing = { ...editing }; hideEditRangeButton(); clearNativeTextSelection(); const screen = pdfRectToScreen(editing.rect); const editor = elements['block-editor']; - editor.style.left = `${clamp(screen[0], 0, Math.max(0, state.render.width - 190))}px`; - editor.style.top = `${clamp(screen[1], 0, Math.max(0, state.render.height - 90))}px`; - editor.style.width = `${Math.max(180, Math.min(state.render.width, screen[2] - screen[0]))}px`; + const textarea = elements['block-editor-text']; + const left = clamp(screen[0], 0, Math.max(0, state.render.width - 24)); + const top = clamp(screen[1], 0, Math.max(0, state.render.height - 18)); + const requestedWidth = Math.max(24, screen[2] - screen[0]); + const requestedHeight = Math.max(18, screen[3] - screen[1]); + const width = clamp(requestedWidth, 24, Math.max(24, state.render.width - left)); + const height = clamp(requestedHeight, 18, Math.max(18, state.render.height - top)); + + editor.style.left = `${left}px`; + editor.style.top = `${top}px`; + textarea.style.width = `${width}px`; + textarea.style.height = `${height}px`; editor.classList.remove('hidden'); - elements['block-editor-text'].value = editing.text; + textarea.value = editing.text; syncBlockEditorPreview(); autoGrowBlockEditor(); - elements['block-editor-text'].focus(); - elements['block-editor-text'].select(); + syncEditingRectFromEditor(); + textarea.focus(); + textarea.select(); setStatus(editing.mode === 'add' - ? 'Escribe el texto nuevo y pulsa Aplicar.' + ? 'Escribe dentro del marco azul. Puedes arrastrar su esquina para cambiar anchura y altura.' : (editing.mode === 'range' - ? 'Edita la selección; el párrafo se recompondrá automáticamente.' - : 'Edita el párrafo; el contenido inferior se desplazará automáticamente.')); + ? 'El marco azul coincide con la selección. Edita el texto y redimensiona el área si lo necesitas.' + : 'El marco azul es el área real del bloque. Redimensiónalo para cambiar el reflow.')); +} + +function syncEditingRectFromEditor() { + if (!state.editing || elements['block-editor'].classList.contains('hidden')) { + return; + } + const textareaRect = elements['block-editor-text'].getBoundingClientRect(); + const stageRect = elements['page-stage'].getBoundingClientRect(); + const screenRect = normalizeRect([ + clamp(textareaRect.left - stageRect.left, 0, state.render.width), + clamp(textareaRect.top - stageRect.top, 0, state.render.height), + clamp(textareaRect.right - stageRect.left, 0, state.render.width), + clamp(textareaRect.bottom - stageRect.top, 0, state.render.height) + ]); + state.editing.rect = screenRectToPdf(screenRect); } function syncBlockEditorPreview() { @@ -1166,8 +1256,14 @@ function autoGrowBlockEditor() { return; } const textarea = elements['block-editor-text']; - textarea.style.height = 'auto'; - textarea.style.height = `${clamp(textarea.scrollHeight + 4, 64, 420)}px`; + const editorRect = textarea.getBoundingClientRect(); + const stageRect = elements['page-stage'].getBoundingClientRect(); + const maximumHeight = Math.max(18, state.render.height - (editorRect.top - stageRect.top)); + const requiredHeight = clamp(textarea.scrollHeight + 4, 18, maximumHeight); + if (requiredHeight > editorRect.height + 1) { + textarea.style.height = `${requiredHeight}px`; + } + syncEditingRectFromEditor(); } function cancelBlockEditor() { @@ -1211,7 +1307,10 @@ async function applyBlockEditor() { restoreText: text || null }); } else { - engine.editTextBlock(state.currentPage, editing.blockIndex, values); + engine.editTextBlock(state.currentPage, editing.blockIndex, { + ...values, + targetRect: editing.rect + }); await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { restoreText: text || null }); diff --git a/media/webview/pdf-engine.js b/media/webview/pdf-engine.js index 1cb9976..cb39d53 100644 --- a/media/webview/pdf-engine.js +++ b/media/webview/pdf-engine.js @@ -201,9 +201,19 @@ export class PdfEngine { return annotations.map((annotation, index) => { try { const type = annotation.getType(); - const rect = annotation.hasRect() - ? annotation.getRect() - : annotation.getBounds(); + const contents = safeCall(() => annotation.getContents(), ''); + const subject = safeCall(() => annotation.getSubject(), ''); + const table = parseTableMetadata(type, subject, contents); + let rect = table?.rect ? [...table.rect] : null; + if (!rect && table) { + rect = safeCall(() => [...annotation.getBounds()], null); + } + if (!rect) { + rect = annotation.hasRect() + ? [...annotation.getRect()] + : [...annotation.getBounds()]; + } + let defaultAppearance = null; if (type === 'FreeText') { try { @@ -212,16 +222,14 @@ export class PdfEngine { defaultAppearance = null; } } - const contents = safeCall(() => annotation.getContents(), ''); - const subject = safeCall(() => annotation.getSubject(), ''); return { index, type, - rect: [...rect], + rect, contents, author: safeCall(() => annotation.getAuthor(), ''), subject, - table: parseTableMetadata(type, subject, contents), + table, color: safeCall(() => [...annotation.getColor()], []), interiorColor: annotation.hasInteriorColor() ? safeCall(() => [...annotation.getInteriorColor()], []) @@ -332,9 +340,20 @@ export class PdfEngine { } const values = normalizeBlockProperties(block, properties); - const layout = layoutTextBlock(block.rect, values, block.metrics, block); - const oldHeight = Math.max(0, block.rect[3] - block.rect[1]); - const delta = layout.height - oldHeight; + const requested = values.text && Array.isArray(properties.targetRect) + ? normalizeRect(properties.targetRect) + : [...block.rect]; + const minimumWidth = Math.max(24, Number(values.fontSize || block.font?.size || 12) * 2); + const targetRect = [ + requested[0], + requested[1], + Math.max(requested[0] + minimumWidth, requested[2]), + requested[3] + ]; + const layoutRect = [targetRect[0], targetRect[1], targetRect[2], targetRect[1]]; + const layout = layoutTextBlock(layoutRect, values, block.metrics, block); + const newBottom = values.text ? targetRect[1] + layout.height : block.rect[1]; + const delta = newBottom - block.rect[3]; const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); const shiftedEntries = followingBlocks.flatMap((followingBlock) => textEntriesForExistingBlock(followingBlock, delta) @@ -354,7 +373,7 @@ export class PdfEngine { ? [...layout.entries, ...shiftedEntries] : shiftedEntries; const maximumBottom = Math.max( - values.text ? block.rect[1] + layout.height : block.rect[1], + values.text ? newBottom : block.rect[1], ...followingBlocks.map((candidate) => candidate.rect[3] + delta) ); this.extendPageToFit(page, maximumBottom); @@ -365,7 +384,9 @@ export class PdfEngine { return { delta, shiftedBlocks: followingBlocks.length, - rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] + rect: values.text + ? [targetRect[0], targetRect[1], targetRect[2], newBottom] + : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] }; } @@ -721,7 +742,8 @@ export class PdfEngine { rows: rowCount, columns: columnCount, color, - borderWidth + borderWidth, + rect: normalizedRect })); annotation.update(); page.update(); @@ -2324,7 +2346,10 @@ function parseTableMetadata(type, subject, contents) { rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))) + borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))), + rect: Array.isArray(value.rect) && value.rect.length === 4 + ? normalizeRect(value.rect.map(Number)) + : null }; } catch { return null; diff --git a/media/webview/styles.css b/media/webview/styles.css index 72dac4f..79f707a 100644 --- a/media/webview/styles.css +++ b/media/webview/styles.css @@ -338,6 +338,14 @@ input[type="color"] { inset: 0; } +.page-stage > .canvas-container, +.page-stage > .canvas-container .lower-canvas, +.page-stage > .canvas-container .upper-canvas { + left: 0 !important; + top: 0 !important; + margin: 0 !important; +} + #pdf-canvas { z-index: 1; } @@ -499,25 +507,36 @@ input[type="color"] { .block-editor { position: absolute; z-index: 8; - min-width: 180px; - padding: 5px; - border: 2px solid var(--focus); - border-radius: 4px; - background: var(--surface); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + min-width: 0; + min-height: 0; + padding: 0; + overflow: visible; + background: transparent; } .block-editor textarea { display: block; + box-sizing: border-box; width: 100%; - min-height: 64px; - resize: vertical; - padding: 7px; - border: 0; + height: 100%; + min-width: 24px; + min-height: 18px; + resize: both; + padding: 2px 3px; + overflow: auto; + border: 2px solid var(--focus); + border-radius: 2px; + outline: 0; color: #111; - background: #fff; - line-height: 1.25; - overflow: hidden; + background: rgba(255, 255, 255, 0.98); + line-height: 1.18; + white-space: pre-wrap; + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22); +} + +.block-editor textarea:focus { + outline: 0; + border-color: var(--focus); } .block-editor-actions { @@ -525,6 +544,8 @@ input[type="color"] { align-items: center; justify-content: flex-end; gap: 6px; + width: max-content; + min-width: 100%; padding-top: 5px; } @@ -644,3 +665,15 @@ input[type="color"] { padding: 16px; } } + + +.version-badge { + flex: 0 0 auto; + margin-left: auto; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 11px; + white-space: nowrap; +} diff --git a/package-lock.json b/package-lock.json index 8f674dc..77f3fc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pdf-viewer-editor", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pdf-viewer-editor", - "version": "0.0.6", + "version": "0.0.7", "license": "AGPL-3.0-or-later", "devDependencies": { "@types/node": "^24.0.0", diff --git a/package.json b/package.json index 1302c99..d708ffb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "pdf-viewer-editor", "displayName": "PDF Viewer & Editor", "description": "A focused visual PDF editor with direct text selection, real reflow, image editing, and editable tables in Visual Studio Code.", - "version": "0.0.6", + "version": "0.0.7", "publisher": "suzdalenko-dev", "license": "AGPL-3.0-or-later", "repository": { diff --git a/scripts/apply-v006-fixes.js b/scripts/apply-v006-fixes.js deleted file mode 100644 index 203c389..0000000 --- a/scripts/apply-v006-fixes.js +++ /dev/null @@ -1,919 +0,0 @@ -'use strict'; - -const fs = require('node:fs'); - -function replaceOnce(source, before, after, label) { - const count = source.split(before).length - 1; - if (count !== 1) { - throw new Error(`${label}: expected exactly one match, found ${count}`); - } - return source.replace(before, after); -} - -function replaceRegex(source, pattern, replacement, label) { - const matches = source.match(pattern); - if (!matches) { - throw new Error(`${label}: pattern not found`); - } - return source.replace(pattern, replacement); -} - -// --- Release metadata ------------------------------------------------------- -const packagePath = 'package.json'; -const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); -pkg.version = '0.0.6'; -pkg.icon = 'images/icon.png'; -pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default = 1; -fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); - -const lockPath = 'package-lock.json'; -const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); -lock.version = '0.0.6'; -if (lock.packages?.['']) { - lock.packages[''].version = '0.0.6'; -} -fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`); - -// --- PDF engine: tight glyph geometry, resizable text, exact table rect ---- -const enginePath = 'media/webview/pdf-engine.js'; -let engine = fs.readFileSync(enginePath, 'utf8'); - -engine = replaceRegex( - engine, - / readAnnotations\(page\) \{[\s\S]*?\n \}\n\n readWidgets\(page\) \{/, -` readAnnotations(page) { - const annotations = page.getAnnotations(); - return annotations.map((annotation, index) => { - try { - const type = annotation.getType(); - const contents = safeCall(() => annotation.getContents(), ''); - const subject = safeCall(() => annotation.getSubject(), ''); - const table = parseTableMetadata(type, subject, contents); - const annotationRect = annotation.hasRect() - ? [...annotation.getRect()] - : [...annotation.getBounds()]; - const inkRect = table - ? rectFromInkStrokes(safeCall(() => annotation.getInkList(), []), table.rect || annotationRect) - : annotationRect; - let defaultAppearance = null; - if (type === 'FreeText') { - try { - defaultAppearance = annotation.getDefaultAppearance(); - } catch { - defaultAppearance = null; - } - } - return { - index, - type, - rect: normalizeRect(inkRect), - contents, - author: safeCall(() => annotation.getAuthor(), ''), - subject, - table, - color: safeCall(() => [...annotation.getColor()], []), - interiorColor: annotation.hasInteriorColor() - ? safeCall(() => [...annotation.getInteriorColor()], []) - : [], - opacity: safeCall(() => annotation.getOpacity(), 1), - borderWidth: annotation.hasBorder() - ? safeCall(() => annotation.getBorderWidth(), 1) - : 0, - alignment: type === 'FreeText' - ? safeCall(() => annotation.getQuadding(), 0) - : 0, - font: defaultAppearance?.font || 'Helv', - fontSize: defaultAppearance?.size || 12, - fontColor: defaultAppearance?.color || [] - }; - } finally { - annotation.destroy(); - } - }); - } - - readWidgets(page) {`, - 'readAnnotations' -); - -engine = replaceOnce( - engine, -` beginLine(bbox) { - currentLine = { - text: '', - rect: normalizeRect([...bbox]), - baseline: null, - styles: new Map() - }; - },`, -` beginLine(bbox) { - currentLine = { - text: '', - sourceRect: normalizeRect([...bbox]), - rect: null, - baseline: null, - styles: new Map(), - characters: [] - }; - },`, - 'tight line initialization' -); - -engine = replaceOnce( - engine, -` currentLine.text += String(character); - currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; - currentLine.rect = unionRects([ - currentLine.rect, - quadToRect(quad) - ]);`, -` const characterText = String(character); - const characterRect = normalizeRect(quadToRect(quad)); - currentLine.text += characterText; - currentLine.characters.push({ - text: characterText, - rect: characterRect - }); - currentLine.baseline ||= [Number(origin[0] || 0), Number(origin[1] || 0)]; - currentLine.rect = currentLine.rect - ? unionRects([currentLine.rect, characterRect]) - : [...characterRect];`, - 'character quad capture' -); - -engine = replaceOnce( - engine, -` const style = dominantWeightedStyle(currentLine.styles); - currentBlock.lines.push({ - text: currentLine.text, - rect: currentLine.rect, - baseline: currentLine.baseline || [currentLine.rect[0], currentLine.rect[3]], - font: style.font, - color: style.color - });`, -` const style = dominantWeightedStyle(currentLine.styles); - const tightRect = currentLine.rect || currentLine.sourceRect; - currentBlock.lines.push({ - text: currentLine.text, - rect: tightRect, - baseline: currentLine.baseline || [tightRect[0], tightRect[3]], - font: style.font, - color: style.color, - characters: currentLine.characters - });`, - 'tight line finalization' -); - -engine = replaceRegex( - engine, - /function buildVisualLines\(lines\) \{[\s\S]*?\n\}\n\nfunction needsVisualSpace/, -`function buildVisualLines(lines) { - const sortedLines = [...lines].sort((left, right) => { - const yDifference = left.baseline[1] - right.baseline[1]; - return Math.abs(yDifference) > 0.75 - ? yDifference - : left.rect[0] - right.rect[0]; - }); - const rows = []; - for (const line of sortedLines) { - const tolerance = Math.max(1, line.font.size * 0.2); - const row = rows.findLast((candidate) => - Math.abs(candidate.baseline[1] - line.baseline[1]) <= tolerance - ); - if (row) { - row.fragments.push(line); - row.rect = unionRects([row.rect, line.rect]); - } else { - rows.push({ - fragments: [line], - rect: [...line.rect], - baseline: [...line.baseline] - }); - } - } - - return rows.map((row) => { - row.fragments.sort((left, right) => left.rect[0] - right.rect[0]); - let text = ''; - let offset = 0; - let previous = null; - const characters = []; - for (const fragment of row.fragments) { - if (previous && needsVisualSpace(previous, fragment)) { - const spaceRect = normalizeRect([ - previous.rect[2], - Math.min(previous.rect[1], fragment.rect[1]), - fragment.rect[0], - Math.max(previous.rect[3], fragment.rect[3]) - ]); - characters.push({ text: ' ', rect: spaceRect, start: offset, end: offset + 1 }); - text += ' '; - offset += 1; - } - for (const character of fragment.characters || []) { - const characterText = String(character.text || ''); - const start = offset; - offset += characterText.length; - characters.push({ - text: characterText, - rect: [...character.rect], - start, - end: offset - }); - } - text += fragment.text; - previous = fragment; - } - const style = dominantLineStyle(row.fragments); - return { - text, - characters, - fragments: row.fragments, - rect: row.rect, - baseline: [row.fragments[0].baseline[0], row.baseline[1]], - font: style.font, - color: style.color - }; - }); -} - -function needsVisualSpace`, - 'visual line character geometry' -); - -engine = replaceRegex( - engine, - /function decorateParagraphLines\(paragraphLines, blockIndex\) \{[\s\S]*?\n\}\n\nfunction layoutRichTextBlock/, -`function decorateParagraphLines(paragraphLines, blockIndex) { - let text = ''; - const lines = []; - - for (const sourceLine of paragraphLines) { - const rawText = String(sourceLine.text || ''); - const lineText = rawText.trim(); - if (!lineText) { - continue; - } - const leadingTrim = rawText.length - rawText.trimStart().length; - const rawEnd = leadingTrim + lineText.length; - const separator = text ? ' ' : ''; - const start = text.length + separator.length; - text += separator + lineText; - const end = text.length; - const lineIndex = lines.length; - const characters = (sourceLine.characters || []) - .filter((character) => character.end > leadingTrim && character.start < rawEnd) - .map((character) => ({ - ...character, - start: start + Math.max(0, character.start - leadingTrim), - end: start + Math.min(lineText.length, character.end - leadingTrim) - })); - lines.push({ - ...sourceLine, - text: lineText, - characters, - blockIndex, - lineIndex, - start, - end, - startOffset: start, - endOffset: end, - textStart: start, - textEnd: end - }); - } - - return { text, lines }; -} - -function layoutRichTextBlock`, - 'paragraph character offsets' -); - -engine = replaceOnce( - engine, - ' editTextBlock(pageIndex, blockIndex, properties = {}) {', - ' editTextBlock(pageIndex, blockIndex, properties = {}, targetRect = null) {', - 'editTextBlock signature' -); -engine = replaceOnce( - engine, -` const values = normalizeBlockProperties(block, properties); - const layout = layoutTextBlock(block.rect, values, block.metrics, block);`, -` const values = normalizeBlockProperties(block, properties); - const layoutRect = textLayoutRect(block.rect, targetRect); - const layout = layoutTextBlock(layoutRect, values, block.metrics, block);`, - 'editTextBlock layout rect' -); -engine = replaceOnce( - engine, -` rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] - }; - } - - /** - * Replaces an arbitrary character range`, -` rect: [layoutRect[0], block.rect[1], layoutRect[2], block.rect[1] + layout.height] - }; - } - - /** - * Replaces an arbitrary character range`, - 'editTextBlock result rect' -); - -engine = replaceOnce( - engine, - ' editTextRange(pageIndex, blockIndex, start, end, replacement, properties = {}) {', - ' editTextRange(pageIndex, blockIndex, start, end, replacement, properties = {}, targetRect = null) {', - 'editTextRange signature' -); -engine = replaceOnce( - engine, -` const resultingText = runs.map((run) => run.text).join(''); - const layout = layoutRichTextBlock( - block.rect,`, -` const resultingText = runs.map((run) => run.text).join(''); - const layoutRect = textLayoutRect(block.rect, targetRect); - const layout = layoutRichTextBlock( - layoutRect,`, - 'editTextRange layout rect' -); -engine = replaceOnce( - engine, -` rect: [block.rect[0], block.rect[1], block.rect[2], block.rect[1] + layout.height] - }; - } - - /** - * Inserts a new static text block`, -` rect: [layoutRect[0], block.rect[1], layoutRect[2], block.rect[1] + layout.height] - }; - } - - /** - * Inserts a new static text block`, - 'editTextRange result rect' -); - -engine = replaceOnce( - engine, -` annotation.setContents(JSON.stringify({ - type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth - }));`, -` annotation.setContents(JSON.stringify({ - type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth, - rect: normalizedRect - }));`, - 'table logical rect metadata' -); - -engine = replaceOnce( - engine, -`function defaultTextMetrics(fontSize) { - const size = Math.max(1, Number(fontSize || 12));`, -`function textLayoutRect(originalRect, targetRect) { - const original = normalizeRect(originalRect); - if (!Array.isArray(targetRect) || targetRect.length !== 4) { - return [...original]; - } - const target = normalizeRect(targetRect); - const minimumWidth = 24; - return [ - target[0], - original[1], - Math.max(target[0] + minimumWidth, target[2]), - original[3] - ]; -} - -function defaultTextMetrics(fontSize) { - const size = Math.max(1, Number(fontSize || 12));`, - 'text layout rect helper' -); - -engine = replaceOnce( - engine, -` return { - rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), - columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), - color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))) - };`, -` return { - rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), - columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), - color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))), - rect: Array.isArray(value.rect) && value.rect.length === 4 - ? normalizeRect(value.rect.map(Number)) - : null - };`, - 'table metadata rect parsing' -); - -engine = replaceOnce( - engine, -`function textMatrixForPage(transform, baseline) {`, -`function rectFromInkStrokes(strokes, fallback) { - const points = []; - for (const stroke of Array.isArray(strokes) ? strokes : []) { - for (const point of Array.isArray(stroke) ? stroke : []) { - if (Array.isArray(point) && point.length >= 2 && - Number.isFinite(Number(point[0])) && Number.isFinite(Number(point[1]))) { - points.push([Number(point[0]), Number(point[1])]); - } - } - } - if (points.length === 0) { - return normalizeRect(fallback); - } - return [ - Math.min(...points.map((point) => point[0])), - Math.min(...points.map((point) => point[1])), - Math.max(...points.map((point) => point[0])), - Math.max(...points.map((point) => point[1])) - ]; -} - -function textMatrixForPage(transform, baseline) {`, - 'ink rect helper' -); - -fs.writeFileSync(enginePath, engine); - -// --- Webview HTML ----------------------------------------------------------- -const htmlPath = 'src/webview-html.js'; -let html = fs.readFileSync(htmlPath, 'utf8'); -html = replaceOnce( - html, - '
\n ', - ' \n \n ', - 'selection layer HTML' -); -fs.writeFileSync(htmlPath, html); - -// --- Webview JS ------------------------------------------------------------- -const mainPath = 'media/webview/main.js'; -let main = fs.readFileSync(mainPath, 'utf8'); -main = replaceOnce( - main, - " 'page-viewport', 'page-stage', 'pdf-canvas', 'text-layer', 'editor-canvas',", - " 'page-viewport', 'page-stage', 'pdf-canvas', 'selection-layer', 'text-layer', 'editor-canvas',", - 'selection layer element binding' -); - -main = replaceRegex( - main, - /function buildTextLayer\(\) \{[\s\S]*?\n\}\n\nfunction textLineClicked/, -`function buildTextLayer() { - const layer = elements['text-layer']; - const selectionLayer = elements['selection-layer']; - layer.replaceChildren(); - selectionLayer.replaceChildren(); - layer.style.width = \`${'${state.render.width}'}px\`; - layer.style.height = \`${'${state.render.height}'}px\`; - selectionLayer.style.width = \`${'${state.render.width}'}px\`; - selectionLayer.style.height = \`${'${state.render.height}'}px\`; - layer.classList.toggle('disabled', state.tool !== 'edit'); - layer.classList.toggle('placement-mode', state.tool !== 'edit'); - - for (const line of state.pageModel.textLines) { - const screen = pdfRectToScreen(line.rect); - const targetWidth = Math.max(1, screen[2] - screen[0]); - const targetHeight = Math.max(1, screen[3] - screen[1]); - const item = document.createElement('span'); - item.className = 'text-layer-line'; - item.textContent = line.text; - item.dataset.blockIndex = String(line.blockIndex); - item.dataset.lineIndex = String(line.lineIndex); - item.dataset.textStart = String(line.textStart); - item.dataset.textEnd = String(line.textEnd); - item.title = 'Haz clic para seleccionar la línea; arrastra para seleccionar texto'; - item.style.left = \`${'${screen[0]}'}px\`; - item.style.top = \`${'${screen[1]}'}px\`; - item.style.width = 'max-content'; - item.style.height = \`${'${targetHeight}'}px\`; - item.style.fontSize = \`${'${Math.max(4, line.font.size * state.render.scale)}'}px\`; - item.style.lineHeight = \`${'${targetHeight}'}px\`; - item.style.fontFamily = line.font.family; - item.style.fontWeight = line.font.weight; - item.style.fontStyle = line.font.style; - item.addEventListener('click', textLineClicked); - item.addEventListener('dblclick', () => window.requestAnimationFrame(updateTextRangeFromSelection)); - layer.appendChild(item); - - const naturalWidth = Math.max(1, item.getBoundingClientRect().width); - item.style.transform = \`scaleX(${'${targetWidth / naturalWidth}'})\`; - } -} - -function textLineClicked`, - 'precise text layer layout' -); - -main = replaceRegex( - main, - /function updateTextRangeFromSelection\(\) \{[\s\S]*?\n\}\n\nfunction selectionInsideTextLayer/, -`function updateTextRangeFromSelection() { - if (state.busy || state.editing) { - return; - } - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !selectionInsideTextLayer(selection)) { - state.textRange = null; - clearTextSelectionOverlay(); - hideEditRangeButton(); - return; - } - const range = selection.getRangeAt(0); - const start = selectionEndpointToTextOffset(range.startContainer, range.startOffset); - const end = selectionEndpointToTextOffset(range.endContainer, range.endOffset); - if (!start || !end || start.blockIndex !== end.blockIndex || end.offset <= start.offset) { - state.textRange = null; - clearTextSelectionOverlay(); - hideEditRangeButton(); - setStatus('Para editar, selecciona texto dentro de un mismo párrafo.'); - return; - } - const block = state.pageModel.textBlocks.find((candidate) => - candidate.index === start.blockIndex - ); - if (!block) { - clearTextSelectionOverlay(); - return; - } - state.textRange = { - blockIndex: block.index, - start: start.offset, - end: end.offset, - text: block.text.slice(start.offset, end.offset) - }; - selectTextBlock(block.index, { preserveRange: true }); - const selectedRects = renderTextSelection(block, start.offset, end.offset); - showEditRangeButton(selectedRects); - setStatus('Texto seleccionado: pulsa “Editar selección”, cambia su formato o elimínalo.'); -} - -function selectionInsideTextLayer`, - 'exact selection overlay flow' -); - -main = replaceRegex( - main, - /function showEditRangeButton\(range\) \{[\s\S]*?\n\}\n\nfunction hideEditRangeButton/, -`function showEditRangeButton(pdfRects) { - if (!Array.isArray(pdfRects) || pdfRects.length === 0) { - hideEditRangeButton(); - return; - } - const screenRects = pdfRects.map(pdfRectToScreen); - const selectionRect = unionScreenRects(screenRects); - const button = elements['edit-range-button']; - const width = 126; - button.style.left = \`${'${clamp(selectionRect[0] + (selectionRect[2] - selectionRect[0]) / 2 - width / 2, 4, Math.max(4, state.render.width - width - 4))}'}px\`; - button.style.top = \`${'${clamp(selectionRect[3] + 5, 4, Math.max(4, state.render.height - 34))}'}px\`; - button.classList.remove('hidden'); -} - -function hideEditRangeButton`, - 'selection button exact position' -); - -main = replaceOnce( - main, -`function clearTextLineHighlights() { - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line.selected')) { - line.classList.remove('selected'); - } -} - -function clearNativeTextSelection() {`, -`function clearTextLineHighlights() { - for (const line of elements['text-layer'].querySelectorAll('.text-layer-line.selected')) { - line.classList.remove('selected'); - } -} - -function clearTextSelectionOverlay() { - elements['selection-layer']?.replaceChildren(); -} - -function renderTextSelection(block, start, end) { - const layer = elements['selection-layer']; - layer.replaceChildren(); - const rects = []; - for (const line of block.visualLines || []) { - const selectedCharacters = (line.characters || []).filter((character) => - character.end > start && character.start < end - ); - if (selectedCharacters.length === 0) { - continue; - } - const rect = unionPdfRects(selectedCharacters.map((character) => character.rect)); - rects.push(rect); - const screen = pdfRectToScreen(rect); - const highlight = document.createElement('div'); - highlight.className = 'selection-highlight'; - highlight.style.left = \`${'${screen[0]}'}px\`; - highlight.style.top = \`${'${screen[1]}'}px\`; - highlight.style.width = \`${'${Math.max(1, screen[2] - screen[0])}'}px\`; - highlight.style.height = \`${'${Math.max(1, screen[3] - screen[1])}'}px\`; - layer.appendChild(highlight); - } - return rects; -} - -function unionPdfRects(rects) { - if (!rects.length) { - return [0, 0, 0, 0]; - } - return [ - Math.min(...rects.map((rect) => rect[0])), - Math.min(...rects.map((rect) => rect[1])), - Math.max(...rects.map((rect) => rect[2])), - Math.max(...rects.map((rect) => rect[3])) - ]; -} - -function unionScreenRects(rects) { - return unionPdfRects(rects); -} - -function clearNativeTextSelection() {`, - 'custom selection helpers' -); - -main = replaceOnce( - main, -` state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights();`, -` state.selected = null; - state.textRange = null; - hideEditRangeButton(); - clearTextLineHighlights(); - clearTextSelectionOverlay();`, - 'clear selection overlay' -); - -main = replaceOnce( - main, -` hasRotatingPoint: false, - lockRotation: true, - selectable: options.selectable !== false,`, -` hasRotatingPoint: false, - lockRotation: true, - originX: 'left', - originY: 'top', - strokeUniform: true, - lockScalingFlip: true, - centeredScaling: false, - selectable: options.selectable !== false,`, - 'fabric exact geometry options' -); - -main = replaceOnce( - main, -` editor.style.left = \`${'${clamp(screen[0], 0, Math.max(0, state.render.width - 190))}'}px\`; - editor.style.top = \`${'${clamp(screen[1], 0, Math.max(0, state.render.height - 90))}'}px\`; - editor.style.width = \`${'${Math.max(180, Math.min(state.render.width, screen[2] - screen[0]))}'}px\`;`, -` const editorLeft = clamp(screen[0], 0, Math.max(0, state.render.width - 80)); - editor.style.left = \`${'${editorLeft}'}px\`; - editor.style.top = \`${'${clamp(screen[1], 0, Math.max(0, state.render.height - 50))}'}px\`; - editor.style.width = \`${'${Math.max(80, Math.min(state.render.width - editorLeft, screen[2] - screen[0]))}'}px\`; - editor.style.height = 'auto';`, - 'text editor tight width' -); - -main = replaceOnce( - main, - " textarea.style.height = `${clamp(textarea.scrollHeight + 4, 64, 420)}px`;", - " textarea.style.height = `${clamp(textarea.scrollHeight + 2, 28, 420)}px`;", - 'text editor tight height' -); - -main = replaceOnce( - main, -`async function applyBlockEditor() { - if (!state.editing || state.busy) { - return; - } - const editing = state.editing; - const text = elements['block-editor-text'].value; - const values = { text, ...getTextFormat() }; - cancelBlockEditor();`, -`function currentBlockEditorPdfRect(editing) { - const bounds = state.pageModel.bounds; - const editor = elements['block-editor']; - const width = clamp( - editor.getBoundingClientRect().width / state.render.scale, - 24, - Math.max(24, bounds[2] - editing.rect[0]) - ); - return [editing.rect[0], editing.rect[1], editing.rect[0] + width, editing.rect[3]]; -} - -async function applyBlockEditor() { - if (!state.editing || state.busy) { - return; - } - const editing = state.editing; - const text = elements['block-editor-text'].value; - const values = { text, ...getTextFormat() }; - const targetRect = currentBlockEditorPdfRect(editing); - cancelBlockEditor();`, - 'resizable text editor target rect' -); - -main = replaceOnce( - main, - ' engine.insertTextBlock(state.currentPage, editing.rect, values);', - ' engine.insertTextBlock(state.currentPage, targetRect, values);', - 'new text resized width' -); -main = replaceOnce( - main, -` text, - values - );`, -` text, - values, - targetRect - );`, - 'range edit resized width' -); -main = replaceOnce( - main, - ' engine.editTextBlock(state.currentPage, editing.blockIndex, values);', - ' engine.editTextBlock(state.currentPage, editing.blockIndex, values, targetRect);', - 'block edit resized width' -); - -main = replaceOnce( - main, - " setStatus('Tabla seleccionada: cambia filas/columnas, muévela o elimínala.');", - " setStatus('Tabla seleccionada: arrastra para mover y usa esquinas o lados para redimensionar exactamente.');", - 'table resize status' -); - -fs.writeFileSync(mainPath, main); - -// --- CSS: custom glyph selection + resizable text editor ------------------- -const cssPath = 'media/webview/styles.css'; -let css = fs.readFileSync(cssPath, 'utf8'); -css = replaceOnce( - css, -`#pdf-canvas, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container {`, -`#pdf-canvas, -.selection-layer, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container {`, - 'selection layer absolute positioning' -); -css = replaceOnce( - css, -`#pdf-canvas { - z-index: 1; -} - -.text-layer {`, -`#pdf-canvas { - z-index: 1; -} - -.selection-layer { - z-index: 2; - overflow: hidden; - pointer-events: none; -} - -.selection-highlight { - position: absolute; - border-radius: 1px; - background: color-mix(in srgb, var(--focus) 42%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--focus) 48%, transparent); -} - -.text-layer {`, - 'selection layer styling' -); -css = replaceOnce( - css, -` overflow: hidden; - color: transparent; - border-radius: 2px; - line-height: 1;`, -` overflow: visible; - color: transparent; - border-radius: 0; - line-height: 1;`, - 'text layer tight overflow' -); -css = replaceOnce( - css, -`.text-layer-line:hover { - background: color-mix(in srgb, var(--focus) 9%, transparent); -} - -.text-layer-line.selected { - background: color-mix(in srgb, var(--focus) 12%, transparent); - box-shadow: inset 2px 0 0 color-mix(in srgb, var(--focus) 75%, transparent); -} - -.text-layer-line::selection { - color: transparent; - background: color-mix(in srgb, var(--focus) 38%, transparent); -}`, -`.text-layer-line:hover { - background: transparent; -} - -.text-layer-line.selected { - background: transparent; - box-shadow: none; -} - -.text-layer-line::selection { - color: transparent; - background: transparent; -}`, - 'remove inaccurate browser selection painting' -); -css = replaceOnce( - css, -`.block-editor { - position: absolute; - z-index: 8; - min-width: 180px; - padding: 5px;`, -`.block-editor { - position: absolute; - z-index: 8; - min-width: 80px; - max-width: calc(100% - 2px); - padding: 4px; - box-sizing: border-box; - resize: horizontal; - overflow: auto;`, - 'resizable text editor frame' -); -css = replaceOnce( - css, -` width: 100%; - min-height: 64px; - resize: vertical; - padding: 7px;`, -` width: 100%; - min-height: 28px; - resize: none; - padding: 3px 4px;`, - 'tight text editor textarea' -); -css = replaceOnce( - css, -` justify-content: flex-end; - gap: 6px;`, -` justify-content: flex-end; - flex-wrap: wrap; - gap: 6px;`, - 'editor actions wrapping' -); -fs.writeFileSync(cssPath, css); - -// --- Contract tests --------------------------------------------------------- -const testPath = 'test/ui-contract.test.mjs'; -fs.writeFileSync(testPath, `import assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport test from 'node:test';\nimport { URL } from 'node:url';\n\nconst read = (path) => fs.readFileSync(new URL(path, import.meta.url), 'utf8');\nconst main = read('../media/webview/main.js');\nconst engine = read('../media/webview/pdf-engine.js');\nconst css = read('../media/webview/styles.css');\nconst html = read('../src/webview-html.js');\nconst provider = read('../src/pdf-editor-provider.js');\nconst pkg = JSON.parse(read('../package.json'));\n\ntest('v0.0.6 keeps 100 percent zoom and Marketplace icon metadata', () => {\n assert.equal(pkg.version, '0.0.6');\n assert.equal(pkg.icon, 'images/icon.png');\n assert.equal(pkg.contributes.configuration.properties['pdfViewerEditor.defaultZoom'].default, 1);\n assert.match(provider, /configuration\\.get\\('defaultZoom', 1\\)/);\n assert.equal(fs.existsSync(new URL('../images/icon.png', import.meta.url)), true);\n});\n\ntest('text selection is painted from PDF character quads instead of browser line boxes', () => {\n assert.match(engine, /characters: \[\]/);\n assert.match(engine, /characterRect = normalizeRect\\(quadToRect\\(quad\\)\\)/);\n assert.match(html, /id=\\"selection-layer\\"/);\n assert.match(main, /renderTextSelection\\(block, start\\.offset, end\\.offset\\)/);\n assert.match(css, /\\.text-layer-line::selection[\\s\\S]*background: transparent/);\n});\n\ntest('text blocks can change width and reflow using the resized editor frame', () => {\n assert.match(css, /resize: horizontal/);\n assert.match(main, /currentBlockEditorPdfRect/);\n assert.match(main, /editTextBlock\\(state\\.currentPage, editing\\.blockIndex, values, targetRect\\)/);\n assert.match(engine, /editTextBlock\\(pageIndex, blockIndex, properties = \{\}, targetRect = null\\)/);\n assert.match(engine, /textLayoutRect\\(block\\.rect, targetRect\\)/);\n});\n\ntest('editable table geometry uses exact ink strokes and remains resizable', () => {\n assert.match(engine, /rectFromInkStrokes/);\n assert.match(engine, /rect: normalizedRect/);\n assert.match(main, /lockScalingFlip: true/);\n assert.match(main, /resizable: true/);\n});\n\ntest('save still writes current custom-document bytes', () => {\n assert.match(provider, /workspace\\.fs\\.writeFile\\(document\\.uri, document\\.data\\)/);\n assert.match(main, /PDF guardado correctamente/);\n});\n`); - -// --- Documentation ---------------------------------------------------------- -const changelogPath = 'CHANGELOG.md'; -let changelog = fs.readFileSync(changelogPath, 'utf8'); -if (!changelog.includes('## 0.0.6 - 2026-08-24')) { - const marker = 'Todos los cambios relevantes de este proyecto se documentan aquí.\n'; - const section = `\n## 0.0.6 - 2026-08-24\n\n### Corregido\n\n- Selección visual calculada desde los quads reales de cada carácter del PDF; desaparecen los rectángulos desplazados o sobredimensionados.\n- Los límites de cada línea se calculan con geometría de glifos, no con el bbox amplio de MuPDF.\n- El editor de texto se puede redimensionar horizontalmente; al aplicar, el párrafo usa el nuevo ancho y hace reflow real.\n- Las tablas usan el rectángulo exacto de sus trazos Ink y conservan controles de movimiento/redimensionado.\n- Se mantiene zoom inicial 100%, Guardar funcional e icono 256×256 incluido para Marketplace.\n\n`; - changelog = replaceOnce(changelog, marker, marker + section, 'CHANGELOG marker'); - fs.writeFileSync(changelogPath, changelog); -} - -const readmePath = 'README.md'; -let readme = fs.readFileSync(readmePath, 'utf8'); -readme = readme.replace('La versión `0.0.5`', 'La versión `0.0.6`'); -if (!readme.includes('quads reales de cada carácter')) { - readme += `\n### Precisión de edición en 0.0.6\n\n- La selección se dibuja sobre los quads reales de cada carácter, no sobre cajas HTML aproximadas.\n- El editor de texto permite cambiar el ancho del bloque; el texto se recompone y desplaza el contenido inferior cuando corresponde.\n- Las tablas se pueden mover y redimensionar con un marco alineado con sus trazos reales.\n`; -} -fs.writeFileSync(readmePath, readme); - -console.log('v0.0.6 geometry and resize fixes applied.'); diff --git a/scripts/v007-part1.py b/scripts/v007-part1.py deleted file mode 100644 index 6f1a7ea..0000000 --- a/scripts/v007-part1.py +++ /dev/null @@ -1,205 +0,0 @@ -from pathlib import Path -import json -import re - -ROOT = Path('.') - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise RuntimeError(f'{label}: expected 1 match, found {count}') - return text.replace(old, new, 1) - - -def replace_between(text: str, start: str, end: str, replacement: str, label: str) -> str: - start_index = text.find(start) - if start_index < 0: - raise RuntimeError(f'{label}: start marker not found') - end_index = text.find(end, start_index + len(start)) - if end_index < 0: - raise RuntimeError(f'{label}: end marker not found') - if text.find(start, start_index + 1) >= 0: - raise RuntimeError(f'{label}: start marker is not unique') - return text[:start_index] + replacement + text[end_index:] - - -# --------------------------------------------------------------------------- -# package.json / package-lock.json -# --------------------------------------------------------------------------- -package_path = ROOT / 'package.json' -package_data = json.loads(package_path.read_text(encoding='utf-8')) -package_data['version'] = '0.0.7' -package_data['icon'] = 'images/icon.png' -package_path.write_text(json.dumps(package_data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') - -lock_path = ROOT / 'package-lock.json' -lock_text = lock_path.read_text(encoding='utf-8') -lock_text = replace_once(lock_text, '"version": "0.0.6"', '"version": "0.0.7"', 'package-lock root version') -lock_text = replace_once(lock_text, '"version": "0.0.6"', '"version": "0.0.7"', 'package-lock package version') -lock_path.write_text(lock_text, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# media/webview/main.js -# --------------------------------------------------------------------------- -main_path = ROOT / 'media/webview/main.js' -main = main_path.read_text(encoding='utf-8') - -main = replace_once( - main, - "].map((id) => [id, document.getElementById(id)]));\n\nwindow.addEventListener('message', async (event) => {", - """].map((id) => [id, document.getElementById(id)])); - -const blockEditorResizeObserver = new window.ResizeObserver(() => { - if (state.editing && !elements['block-editor'].classList.contains('hidden')) { - syncEditingRectFromEditor(); - } -}); -blockEditorResizeObserver.observe(elements['block-editor-text']); - -window.addEventListener('message', async (event) => {""", - 'block editor ResizeObserver' -) - -main = replace_once( - main, - """ } - state.fabricCanvas.clear(); - state.overlayObjects = []; -} - -function buildTextLayer() {""", - """ } - syncFabricCanvasGeometry(); - state.fabricCanvas.clear(); - state.overlayObjects = []; -} - -function syncFabricCanvasGeometry() { - if (!state.fabricCanvas || !state.render) { - return; - } - const width = `${state.render.width}px`; - const height = `${state.render.height}px`; - const wrapper = state.fabricCanvas.wrapperEl; - if (wrapper) { - Object.assign(wrapper.style, { - position: 'absolute', - left: '0px', - top: '0px', - width, - height, - margin: '0px' - }); - } - for (const canvas of [state.fabricCanvas.lowerCanvasEl, state.fabricCanvas.upperCanvasEl]) { - if (!canvas) { - continue; - } - Object.assign(canvas.style, { - left: '0px', - top: '0px', - width, - height, - margin: '0px' - }); - } -} - -function buildTextLayer() {""", - 'Fabric canvas geometry sync' -) - -render_start = "function renderTextRangeHighlights() {" -render_end = "\nfunction setTextResizeMode(active) {" -render_replacement = """function getTextRangeRects(selectedRange) { - if (!selectedRange || !state.pageModel) { - return []; - } - - const rects = []; - const lines = state.pageModel.textLines.filter((line) => - line.blockIndex === selectedRange.blockIndex && - selectedRange.end > line.textStart && - selectedRange.start < line.textEnd - ); - - for (const line of lines) { - const localStart = clamp(selectedRange.start - line.textStart, 0, line.text.length); - const localEnd = clamp(selectedRange.end - line.textStart, 0, line.text.length); - if (localEnd <= localStart) { - continue; - } - - const characters = Array.isArray(line.characters) ? line.characters : []; - const selectedCharacters = characters.slice(localStart, localEnd) - .map((character) => character.rect) - .filter((candidate) => Array.isArray(candidate) && candidate.length === 4); - - if (selectedCharacters.length > 0) { - rects.push(selectedCharacters.reduce((result, candidate) => result - ? [ - Math.min(result[0], candidate[0]), - Math.min(result[1], candidate[1]), - Math.max(result[2], candidate[2]), - Math.max(result[3], candidate[3]) - ] - : [...candidate], null)); - continue; - } - - const lineWidth = Math.max(1, line.rect[2] - line.rect[0]); - const startRatio = localStart / Math.max(1, line.text.length); - const endRatio = localEnd / Math.max(1, line.text.length); - rects.push([ - line.rect[0] + lineWidth * startRatio, - line.rect[1], - line.rect[0] + lineWidth * endRatio, - line.rect[3] - ]); - } - - return rects; -} - -function getTextRangeRect(selectedRange) { - const rects = getTextRangeRects(selectedRange); - if (rects.length === 0) { - return null; - } - return rects.reduce((result, rect) => [ - Math.min(result[0], rect[0]), - Math.min(result[1], rect[1]), - Math.max(result[2], rect[2]), - Math.max(result[3], rect[3]) - ], [...rects[0]]); -} - -function renderTextRangeHighlights() { - clearTextRangeHighlights(); - for (const rect of getTextRangeRects(state.textRange)) { - const screen = pdfRectToScreen(rect); - const highlight = document.createElement('div'); - highlight.className = 'text-range-highlight'; - 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); - } -} -""" -main = replace_between(main, render_start, render_end, render_replacement, 'precise range geometry helpers') - -main = replace_once( - main, - """ left: screen[0], - top: screen[1], - width: Math.max(2, screen[2] - screen[0]), - height: Math.max(2, screen[3] - screen[1]), - fill: options.fill || 'rgba(0, 0, 0, 0.001)',""", - """ left: screen[0], - top: screen[1], - width: Math.max(2, screen[2] - screen[0]), - height: Math.max(2, screen[3] - screen[1]), diff --git a/scripts/v007-part2.py b/scripts/v007-part2.py deleted file mode 100644 index cc2b977..0000000 --- a/scripts/v007-part2.py +++ /dev/null @@ -1,205 +0,0 @@ - originX: 'left', - originY: 'top', - strokeUniform: true, - centeredScaling: false, - padding: 0, - fill: options.fill || 'rgba(0, 0, 0, 0.001)',""", - 'Fabric object origin and stroke geometry' -) - -object_rect_start = "function objectScreenRectToPdf(object) {" -object_rect_end = "\nfunction setTextControlsFromBlock(block) {" -object_rect_replacement = """function objectScreenRectToPdf(object) { - // All editable Fabric objects are axis-aligned with a left/top origin. - // Using the object's content box (not getBoundingRect, which includes the - // visual stroke) keeps the blue frame and the PDF target rectangle identical. - const left = Number(object.left || 0); - const top = Number(object.top || 0); - const width = Math.max(2, Number(object.width || 0) * Math.abs(Number(object.scaleX || 1))); - const height = Math.max(2, Number(object.height || 0) * Math.abs(Number(object.scaleY || 1))); - return screenRectToPdf([left, top, left + width, top + height]); -} -""" -main = replace_between(main, object_rect_start, object_rect_end, object_rect_replacement, 'Fabric object rectangle conversion') - -main = replace_once( - main, - """ openBlockEditor({ - mode: 'range', - blockIndex: block.index, - start: selectedRange.start, - end: selectedRange.end, - rect: block.rect, - text: selectedRange.text - });""", - """ openBlockEditor({ - mode: 'range', - blockIndex: block.index, - start: selectedRange.start, - end: selectedRange.end, - sourceRect: block.rect, - rect: getTextRangeRect(selectedRange) || block.rect, - text: selectedRange.text - });""", - 'range editor exact rectangle' -) - -main = replace_once( - main, - """ openBlockEditor({ - mode: 'add', - blockIndex: null, - rect: [x, y, x + width, y + 18], - text: '' - });""", - """ openBlockEditor({ - mode: 'add', - blockIndex: null, - rect: [x, y, x + width, Math.min(bounds[3] - 8, y + 72)], - text: '' - });""", - 'new text initial box height' -) - -open_editor_start = "function openBlockEditor(editing) {" -open_editor_end = "\nfunction syncBlockEditorPreview() {" -open_editor_replacement = """function openBlockEditor(editing) { - state.editing = { ...editing }; - hideEditRangeButton(); - clearNativeTextSelection(); - const screen = pdfRectToScreen(editing.rect); - const editor = elements['block-editor']; - const textarea = elements['block-editor-text']; - const left = clamp(screen[0], 0, Math.max(0, state.render.width - 24)); - const top = clamp(screen[1], 0, Math.max(0, state.render.height - 18)); - const requestedWidth = Math.max(24, screen[2] - screen[0]); - const requestedHeight = Math.max(18, screen[3] - screen[1]); - const width = clamp(requestedWidth, 24, Math.max(24, state.render.width - left)); - const height = clamp(requestedHeight, 18, Math.max(18, state.render.height - top)); - - editor.style.left = `${left}px`; - editor.style.top = `${top}px`; - textarea.style.width = `${width}px`; - textarea.style.height = `${height}px`; - editor.classList.remove('hidden'); - textarea.value = editing.text; - syncBlockEditorPreview(); - autoGrowBlockEditor(); - syncEditingRectFromEditor(); - textarea.focus(); - textarea.select(); - setStatus(editing.mode === 'add' - ? 'Escribe dentro del marco azul. Puedes arrastrar su esquina para cambiar anchura y altura.' - : (editing.mode === 'range' - ? 'El marco azul coincide con la selección. Edita el texto y redimensiona el área si lo necesitas.' - : 'El marco azul es el área real del bloque. Redimensiónalo para cambiar el reflow.')); -} - -function syncEditingRectFromEditor() { - if (!state.editing || elements['block-editor'].classList.contains('hidden')) { - return; - } - const textareaRect = elements['block-editor-text'].getBoundingClientRect(); - const stageRect = elements['page-stage'].getBoundingClientRect(); - const screenRect = normalizeRect([ - clamp(textareaRect.left - stageRect.left, 0, state.render.width), - clamp(textareaRect.top - stageRect.top, 0, state.render.height), - clamp(textareaRect.right - stageRect.left, 0, state.render.width), - clamp(textareaRect.bottom - stageRect.top, 0, state.render.height) - ]); - state.editing.rect = screenRectToPdf(screenRect); -} -""" -main = replace_between(main, open_editor_start, open_editor_end, open_editor_replacement, 'block editor exact geometry') - -autogrow_start = "function autoGrowBlockEditor() {" -autogrow_end = "\nfunction cancelBlockEditor() {" -autogrow_replacement = """function autoGrowBlockEditor() { - if (!state.editing) { - return; - } - const textarea = elements['block-editor-text']; - const editorRect = textarea.getBoundingClientRect(); - const stageRect = elements['page-stage'].getBoundingClientRect(); - const maximumHeight = Math.max(18, state.render.height - (editorRect.top - stageRect.top)); - const requiredHeight = clamp(textarea.scrollHeight + 4, 18, maximumHeight); - if (requiredHeight > editorRect.height + 1) { - textarea.style.height = `${requiredHeight}px`; - } - syncEditingRectFromEditor(); -} -""" -main = replace_between(main, autogrow_start, autogrow_end, autogrow_replacement, 'block editor auto grow') - -main = replace_once( - main, - """ } else { - engine.editTextBlock(state.currentPage, editing.blockIndex, values); - await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { - restoreText: text || null - }); - }""", - """ } else { - engine.editTextBlock(state.currentPage, editing.blockIndex, { - ...values, - targetRect: editing.rect - }); - await commitAndRefresh(text ? 'Editar texto' : 'Eliminar texto', { - restoreText: text || null - }); - }""", - 'apply edited block target rectangle' -) - -main_path.write_text(main, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# media/webview/pdf-engine.js -# --------------------------------------------------------------------------- -engine_path = ROOT / 'media/webview/pdf-engine.js' -engine = engine_path.read_text(encoding='utf-8') - -read_annotations_start = " readAnnotations(page) {" -read_annotations_end = "\n readWidgets(page) {" -read_annotations_replacement = """ readAnnotations(page) { - const annotations = page.getAnnotations(); - return annotations.map((annotation, index) => { - try { - const type = annotation.getType(); - const contents = safeCall(() => annotation.getContents(), ''); - const subject = safeCall(() => annotation.getSubject(), ''); - const table = parseTableMetadata(type, subject, contents); - let rect = table?.rect ? [...table.rect] : null; - if (!rect && table) { - rect = safeCall(() => [...annotation.getBounds()], null); - } - if (!rect) { - rect = annotation.hasRect() - ? [...annotation.getRect()] - : [...annotation.getBounds()]; - } - - let defaultAppearance = null; - if (type === 'FreeText') { - try { - defaultAppearance = annotation.getDefaultAppearance(); - } catch { - defaultAppearance = null; - } - } - return { - index, - type, - rect, - contents, - author: safeCall(() => annotation.getAuthor(), ''), - subject, - table, - color: safeCall(() => [...annotation.getColor()], []), - interiorColor: annotation.hasInteriorColor() - ? safeCall(() => [...annotation.getInteriorColor()], []) - : [], - opacity: safeCall(() => annotation.getOpacity(), 1), - borderWidth: annotation.hasBorder() - ? safeCall(() => annotation.getBorderWidth(), 1) diff --git a/scripts/v007-part3.py b/scripts/v007-part3.py deleted file mode 100644 index 2dc30b0..0000000 --- a/scripts/v007-part3.py +++ /dev/null @@ -1,205 +0,0 @@ - : 0, - alignment: type === 'FreeText' - ? safeCall(() => annotation.getQuadding(), 0) - : 0, - font: defaultAppearance?.font || 'Helv', - fontSize: defaultAppearance?.size || 12, - fontColor: defaultAppearance?.color || [] - }; - } finally { - annotation.destroy(); - } - }); - } -""" -engine = replace_between(engine, read_annotations_start, read_annotations_end, read_annotations_replacement, 'annotation geometry') - -edit_block_start = " editTextBlock(pageIndex, blockIndex, properties = {}) {" -edit_block_end = "\n /**\n * Replaces an arbitrary character range inside an extracted text block." -edit_block_replacement = """ editTextBlock(pageIndex, blockIndex, properties = {}) { - const model = this.getPageModel(pageIndex); - const block = model.textBlocks.find((candidate) => candidate.index === blockIndex); - if (!block) { - throw new Error('The selected text block no longer exists. Select it again.'); - } - - const values = normalizeBlockProperties(block, properties); - const requested = values.text && Array.isArray(properties.targetRect) - ? normalizeRect(properties.targetRect) - : [...block.rect]; - const minimumWidth = Math.max(24, Number(values.fontSize || block.font?.size || 12) * 2); - const targetRect = [ - requested[0], - requested[1], - Math.max(requested[0] + minimumWidth, requested[2]), - requested[3] - ]; - const layoutRect = [targetRect[0], targetRect[1], targetRect[2], targetRect[1]]; - const layout = layoutTextBlock(layoutRect, values, block.metrics, block); - const newBottom = values.text ? targetRect[1] + layout.height : block.rect[1]; - const delta = newBottom - block.rect[3]; - const followingBlocks = findFollowingBlocks(model.textBlocks, block.rect, block.index); - const shiftedEntries = followingBlocks.flatMap((followingBlock) => - textEntriesForExistingBlock(followingBlock, delta) - ); - - this.withOperation(values.text ? 'Edit text block' : 'Delete text block', () => { - this.withPage(pageIndex, (page) => { - for (const sourceBlock of [block, ...followingBlocks]) { - this.removeContentInRect(page, expandRect(sourceBlock.rect, 0.35), { - images: false, - lineArt: false, - text: true - }); - } - - const entries = values.text - ? [...layout.entries, ...shiftedEntries] - : shiftedEntries; - const maximumBottom = Math.max( - values.text ? newBottom : block.rect[1], - ...followingBlocks.map((candidate) => candidate.rect[3] + delta) - ); - this.extendPageToFit(page, maximumBottom); - this.appendStaticText(page, entries); - }); - }); - - return { - delta, - shiftedBlocks: followingBlocks.length, - rect: values.text - ? [targetRect[0], targetRect[1], targetRect[2], newBottom] - : [block.rect[0], block.rect[1], block.rect[2], block.rect[1]] - }; - } -""" -engine = replace_between(engine, edit_block_start, edit_block_end, edit_block_replacement, 'edit text inside target box') - -engine = replace_once( - engine, - """ const annotation = page.createAnnotation('Ink'); - try { - annotation.setInkList(strokes);""", - """ const annotation = page.createAnnotation('Ink'); - try { - annotation.setRect(normalizedRect); - annotation.setInkList(strokes);""", - 'table annotation rectangle' -) - -engine = replace_once( - engine, - """ type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth - }));""", - """ type: 'table', - rows: rowCount, - columns: columnCount, - color, - borderWidth, - rect: normalizedRect - }));""", - 'table metadata rectangle' -) - -engine = replace_once( - engine, - """ return { - rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), - columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), - color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))) - };""", - """ return { - rows: Math.max(1, Math.min(30, Math.round(Number(value.rows) || 2))), - columns: Math.max(1, Math.min(20, Math.round(Number(value.columns) || 2))), - color: normalizeColor(value.color, [0, 0, 0]), - borderWidth: Math.max(0.25, Math.min(8, Number(value.borderWidth || 1))), - rect: Array.isArray(value.rect) && value.rect.length === 4 - ? normalizeRect(value.rect.map(Number)) - : null - };""", - 'parse table rectangle metadata' -) - -engine_path.write_text(engine, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# media/webview/styles.css -# --------------------------------------------------------------------------- -styles_path = ROOT / 'media/webview/styles.css' -styles = styles_path.read_text(encoding='utf-8') - -styles = replace_once( - styles, - """#pdf-canvas, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container { - position: absolute !important; - inset: 0; -} -""", - """#pdf-canvas, -.text-layer, -.insertion-layer, -.page-stage > .canvas-container { - position: absolute !important; - inset: 0; -} - -.page-stage > .canvas-container, -.page-stage > .canvas-container .lower-canvas, -.page-stage > .canvas-container .upper-canvas { - left: 0 !important; - top: 0 !important; - margin: 0 !important; -} -""", - 'canvas absolute origin CSS' -) - -block_css_start = ".block-editor {" -block_css_end = "\n.statusbar {" -block_css_replacement = """.block-editor { - position: absolute; - z-index: 8; - min-width: 0; - min-height: 0; - padding: 0; - overflow: visible; - background: transparent; -} - -.block-editor textarea { - display: block; - box-sizing: border-box; - width: 100%; - height: 100%; - min-width: 24px; - min-height: 18px; - resize: both; - padding: 2px 3px; - overflow: auto; - border: 2px solid var(--focus); - border-radius: 2px; - outline: 0; - color: #111; - background: rgba(255, 255, 255, 0.98); - line-height: 1.18; - white-space: pre-wrap; - box-shadow: 0 3px 14px rgba(0, 0, 0, 0.22); -} - -.block-editor textarea:focus { - outline: 0; - border-color: var(--focus); -} - -.block-editor-actions { - display: flex; diff --git a/scripts/v007-part4.py b/scripts/v007-part4.py deleted file mode 100644 index f8049cb..0000000 --- a/scripts/v007-part4.py +++ /dev/null @@ -1,203 +0,0 @@ - align-items: center; - justify-content: flex-end; - gap: 6px; - width: max-content; - min-width: 100%; - padding-top: 5px; -} - -.block-editor-actions span { - margin-right: auto; - color: var(--muted); - font-size: 11px; -} -""" -styles = replace_between(styles, block_css_start, block_css_end, block_css_replacement, 'resizable blue text editor') - -styles += """ - -.version-badge { - flex: 0 0 auto; - margin-left: auto; - padding: 2px 6px; - border: 1px solid var(--border); - border-radius: 999px; - color: var(--muted); - font-size: 11px; - white-space: nowrap; -} -""" -styles_path.write_text(styles, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# src/webview-html.js -# --------------------------------------------------------------------------- -html_path = ROOT / 'src/webview-html.js' -html = html_path.read_text(encoding='utf-8') -html = replace_once( - html, - """ Haz clic en un párrafo, selecciona texto o usa + para insertar. - """, - """ Haz clic en un párrafo, selecciona texto o usa + para insertar. - v0.0.7 - """, - 'visible installed version badge' -) -html = replace_once( - html, - '', - '', - 'clear resize button label' -) -html_path.write_text(html, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# test/ui-contract.test.mjs -# --------------------------------------------------------------------------- -ui_test_path = ROOT / 'test/ui-contract.test.mjs' -ui_test = ui_test_path.read_text(encoding='utf-8') -ui_test = replace_once( - ui_test, - "const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));", - """const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); -const styles = fs.readFileSync(new URL('../media/webview/styles.css', import.meta.url), 'utf8'); -const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');""", - 'UI contract load CSS and engine' -) -ui_test = ui_test.replace("assert.equal(pkg.version, '0.0.6');", "assert.equal(pkg.version, '0.0.7');") -ui_test = ui_test.replace("test('v0.0.6 keeps overlays in one coordinate system and supports text resizing'", "test('v0.0.7 keeps overlays in one coordinate system and supports text resizing'") -ui_test = ui_test.replace("test('v0.0.6 renders text selection from extracted PDF character rectangles'", "test('v0.0.7 renders text selection from extracted PDF character rectangles'") -ui_test = ui_test.replace(" const engine = fs.readFileSync(new URL('../media/webview/pdf-engine.js', import.meta.url), 'utf8');\n", "") -ui_test = ui_test.replace(" assert.match(main, /object\\.getBoundingRect\\(\\)/);", " assert.match(main, /object\\.width \\|\\| 0/);") -ui_test += """ - -test('v0.0.7 uses the blue editor box as the real text target rectangle', () => { - assert.match(main, /new window\.ResizeObserver/); - assert.match(main, /syncEditingRectFromEditor/); - assert.match(main, /targetRect: editing\.rect/); - assert.match(styles, /resize: both/); - assert.match(styles, /border: 2px solid var\(--focus\)/); -}); - -test('v0.0.7 stores exact table geometry and uses it for the overlay', () => { - assert.match(engine, /annotation\.setRect\(normalizedRect\)/); - assert.match(engine, /rect: normalizedRect/); - assert.match(engine, /table\?\.rect/); -}); -""" -ui_test_path.write_text(ui_test, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# test/pdf-engine.test.mjs -# --------------------------------------------------------------------------- -engine_test_path = ROOT / 'test/pdf-engine.test.mjs' -engine_test = engine_test_path.read_text(encoding='utf-8') -engine_test += r''' - -test('v0.0.7 text edits honor a new bounding box width and position', () => { - const engine = new PdfEngine(); - try { - engine.load(createFlowingTextPdf()); - const original = engine.getPageModel(0).textBlocks.find((block) => - block.text.includes('First paragraph') - ); - assert.ok(original); - const targetRect = [62, original.rect[1] + 4, 176, original.rect[3] + 4]; - const result = engine.editTextBlock(0, original.index, { - text: 'First paragraph edited inside a deliberately narrower resizable box with enough words to wrap.', - fontFamily: 'Helvetica', - fontSize: 12, - targetRect - }); - assert.ok(Math.abs(result.rect[0] - targetRect[0]) < 0.01); - assert.ok(Math.abs(result.rect[2] - targetRect[2]) < 0.01); - const edited = engine.getPageModel(0).textBlocks.find((block) => - block.text.includes('deliberately narrower') - ); - assert.ok(edited); - assert.ok(edited.rect[0] >= targetRect[0] - 3); - assert.ok(edited.rect[2] <= targetRect[2] + 3); - } finally { - engine.destroy(); - } -}); - -test('v0.0.7 table metadata keeps the blue selection rectangle equal to the table', () => { - const engine = new PdfEngine(); - try { - engine.load(createOnePagePdf()); - const initialRect = [42, 90, 242, 190]; - engine.addTable(0, initialRect, 3, 4, { borderWidth: 1.5 }); - let table = engine.getPageModel(0).annotations.find((annotation) => annotation.table); - assert.ok(table); - assert.deepEqual(table.rect.map((value) => Math.round(value)), initialRect); - - const resizedRect = [55, 105, 270, 235]; - engine.updateTable(0, table.index, resizedRect, 4, 5, { borderWidth: 2 }); - table = engine.getPageModel(0).annotations.find((annotation) => annotation.table); - assert.ok(table); - assert.deepEqual(table.rect.map((value) => Math.round(value)), resizedRect); - assert.equal(table.table.rows, 4); - assert.equal(table.table.columns, 5); - } finally { - engine.destroy(); - } -}); -''' -engine_test_path.write_text(engine_test, encoding='utf-8') - - -# --------------------------------------------------------------------------- -# CHANGELOG / README -# --------------------------------------------------------------------------- -changelog_path = ROOT / 'CHANGELOG.md' -changelog = changelog_path.read_text(encoding='utf-8') -section = """## 0.0.7 - 2026-08-24 - -### Corregido - -- El marco azul de edición de texto pasa a ser el área real del contenido editable. -- El editor de texto se puede redimensionar en anchura y altura; el reflow usa esa anchura al aplicar. -- La selección parcial usa el rectángulo exacto de los caracteres seleccionados. -- Las tablas guardan y recuperan su rectángulo exacto, evitando que el marco Fabric quede desplazado respecto a la cuadrícula. -- Las capas PDF, texto y Fabric se fuerzan al mismo origen de coordenadas. -- Se mantiene el icono `images/icon.png` dentro del VSIX y se añade un indicador visible `v0.0.7` para comprobar la versión instalada. - -""" -changelog = replace_once( - changelog, - 'Todos los cambios relevantes de este proyecto se documentan aquí.\n\n', - 'Todos los cambios relevantes de este proyecto se documentan aquí.\n\n' + section, - 'v0.0.7 changelog section' -) -changelog_path.write_text(changelog, encoding='utf-8') - -readme_path = ROOT / 'README.md' -readme = readme_path.read_text(encoding='utf-8') -readme = readme.replace('La versión `0.0.5`', 'La versión `0.0.7`', 1) -readme = readme.replace( - '- Editar un párrafo completo con `Editar contenido`.', - '- Editar un párrafo completo con `Editar contenido`; el marco azul coincide con el área real y se puede redimensionar.', - 1 -) -readme = readme.replace( - '- Seleccionar una tabla creada, moverla y redimensionarla.', - '- Seleccionar una tabla creada, moverla y redimensionarla con un marco que coincide exactamente con la cuadrícula.', - 1 -) -readme = readme.replace('pdf-viewer-editor-0.0.4.vsix', 'pdf-viewer-editor-0.0.7.vsix') -readme_path.write_text(readme, encoding='utf-8') - - -# Remove the accidental staging helper merged by PR #9. It is never shipped. -for stale in [ - ROOT / 'scripts/apply-v006-fixes.js', - ROOT / 'scripts/apply-v006-fixes.py' -]: - if stale.exists(): - stale.unlink() - -print('v0.0.7 unified edit-box fixes applied') diff --git a/scripts/v007-part5.py b/scripts/v007-part5.py deleted file mode 100644 index 8500300..0000000 --- a/scripts/v007-part5.py +++ /dev/null @@ -1,26 +0,0 @@ - -# --------------------------------------------------------------------------- -# MuPDF Ink annotations derive their geometry from the ink list and do not -# support an explicit Rect entry. Keep our canonical table rectangle in the -# JSON metadata instead; readAnnotations() already prioritizes table.rect. -# --------------------------------------------------------------------------- -engine_path = ROOT / 'media/webview/pdf-engine.js' -engine = engine_path.read_text(encoding='utf-8') -engine = replace_once( - engine, - " annotation.setRect(normalizedRect);\n annotation.setInkList(strokes);", - " annotation.setInkList(strokes);", - 'remove unsupported Ink setRect' -) -engine_path.write_text(engine, encoding='utf-8') - -ui_test_path = ROOT / 'test/ui-contract.test.mjs' -ui_test = ui_test_path.read_text(encoding='utf-8') -ui_test = ui_test.replace( - " assert.match(engine, /annotation\\.setRect\\(normalizedRect\\)/);\n", - " assert.doesNotMatch(engine, /annotation\\.setRect\\(normalizedRect\\)/);\n", - 1 -) -ui_test_path.write_text(ui_test, encoding='utf-8') - -print('v0.0.7 Ink table geometry compatibility applied') diff --git a/src/webview-html.js b/src/webview-html.js index c15b08a..67b79bc 100644 --- a/src/webview-html.js +++ b/src/webview-html.js @@ -76,12 +76,13 @@ function getWebviewHtml(webview, extensionUri) { Haz clic en un párrafo, selecciona texto o usa + para insertar. + v0.0.7