From ee48ca666da07433af8ee31a414f57499216eacd Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 23:03:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=8D=E5=88=B6=E9=A1=B5=E9=9D=A2=E5=90=8E?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=8E=9F=E9=A1=B5=E5=8F=AF=E8=83=BD=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E5=AD=97=E4=BD=93=E3=80=81=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 13 ++- assets/webui/ofdgo.css | 14 +++- assets/webui/ofdgo.js | 80 +++++++++++++++--- assets/webui/ofdgo_edit.js | 110 ++++++++++++++++-------- assets/webui/ofdgo_font.js | 1 + internal/webui/wasm.go | 13 +++ ofdgo_editor.go | 28 +++++-- ofdgo_editor_history.go | 6 +- ofdgo_editor_import.go | 102 +++++++++++++++++----- ofdgo_editor_open.go | 5 +- ofdgo_editor_package.go | 5 +- ofdgo_editor_pages.go | 167 +++++++++++++++++++++++++++++++++++++ ofdgo_search.go | 4 +- 13 files changed, 464 insertions(+), 84 deletions(-) create mode 100644 ofdgo_editor_pages.go diff --git a/assets/webui/index.html b/assets/webui/index.html index fa54778..ac559d5 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -206,6 +206,14 @@
+ +
+ + + +
+
+
@@ -320,7 +328,10 @@
-
文档
+
+
文档
+ +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index c00fe60..e2534a3 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -225,6 +225,9 @@ a:focus-visible { } .font-picker #textFont { + flex: 1 1 0; + width: 0; + text-overflow: ellipsis; padding: 0 6px; border: 0; outline: 0; @@ -263,6 +266,7 @@ a:focus-visible { color: #1f2428; box-shadow: 0 5px 18px rgba(20, 40, 50, 0.14); overflow-y: auto; + overflow-x: hidden; font-size: 14px; } @@ -1250,6 +1254,7 @@ button.outline-link:hover { } .empty-state[hidden], +.panel-row > [hidden], .meta-list > [hidden], .navigation-tabs[hidden], .form-row[hidden], @@ -1387,7 +1392,7 @@ button.outline-link:hover { .edit-text { position: absolute; - overflow: hidden; + overflow: visible; z-index: 1; margin: -2px; padding: 1px; @@ -1395,7 +1400,6 @@ button.outline-link:hover { border-radius: 0; background: #fff; outline: none; - resize: none; transform-origin: 0 0; font-kerning: none; font-variant-ligatures: none; @@ -1404,6 +1408,12 @@ button.outline-link:hover { touch-action: auto; } +.edit-text div, +.edit-text p { + margin: 0; + text-indent: inherit; +} + .edit-text:focus-visible { outline: none; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a691146..53324ca 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js"; +import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -116,6 +116,14 @@ const el = { textFontList: document.querySelector("#textFontList"), textFontAdd: document.querySelector("#textFontAdd"), paragraphButton: document.querySelector("#paragraphButton"), + infoButton: document.querySelector("#infoButton"), + infoPanel: document.querySelector("#infoPanel"), + infoForm: document.querySelector("#infoForm"), + infoTitle: document.querySelector("#infoTitle"), + infoAuthor: document.querySelector("#infoAuthor"), + infoSubject: document.querySelector("#infoSubject"), + infoCancel: document.querySelector("#infoCancel"), + infoStatus: document.querySelector("#infoStatus"), paragraphPanel: document.querySelector("#paragraphPanel"), paragraphForm: document.querySelector("#paragraphForm"), paragraphCancel: document.querySelector("#paragraphCancel"), @@ -329,7 +337,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { function updatePendingChanges() { const editing = canvasEditor.input; setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision - || Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged())); + || Boolean(editing && (editTextValue(editing.input) !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged())); } el.editorTools.addEventListener("pointerdown", (event) => { @@ -383,6 +391,16 @@ editorClick(el.paragraphButton, () => { el.paragraphPanel.showModal(); }); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); +editorClick(el.infoButton, () => { + for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || ""; + el.infoStatus.textContent = ""; + el.infoPanel.showModal(); +}); +el.infoCancel.addEventListener("click", () => el.infoPanel.close()); +el.infoForm.addEventListener("submit", async event => { + event.preventDefault(); + if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close(); +}); el.paragraphForm.addEventListener("submit", async event => { event.preventDefault(); if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return; @@ -779,7 +797,7 @@ function handleKeyDown(event) { } function formDialogOpen() { - return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open; + return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open; } function openImportPanel() { @@ -1182,7 +1200,7 @@ async function changeTextFont() { setBusy(true); try { const data = await readTextFont(font); - await callWASM("ofdgoCheckTextFont", data, editing.input.value); + await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input)); const face = new FontFace("ofdgo-edit-font", data); await face.load(); if (openSeq !== state.openSeq || canvasEditor.input !== editing) return; @@ -1408,9 +1426,13 @@ async function changeDocument(name, item, ...args) { return true; } setEditorInfo(doc); - if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects" || name === "ofdgoEraseObjectsPath") { - canvasEditor.clear(); + if (name === "ofdgoUpdateInfo") { + Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject }); + renderMeta(); + updateControls(); + return true; } + const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { state.selectObjects = true; canvasEditor.setTool(""); @@ -1419,7 +1441,7 @@ async function changeDocument(name, item, ...args) { } openSeq = ++state.openSeq; if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { - await refreshEditorPage(doc, item ? item.index : args[0], openSeq); + await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { const samePage = doc.pages.findIndex((page) => page.id === previous.id); const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); @@ -1427,7 +1449,7 @@ async function changeDocument(name, item, ...args) { const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex && page.width === previous.width && page.height === previous.height; await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale, - keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null }); + keepPreview: true, clearSelection, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null }); } if (openSeq === state.openSeq) { if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true }); @@ -1435,7 +1457,9 @@ async function changeDocument(name, item, ...args) { } } catch (err) { if (openSeq === state.openSeq) { - if (el.pagePanel.open) { + if (el.infoPanel.open) { + el.infoStatus.textContent = err.message; + } else if (el.pagePanel.open) { el.pageStatus.textContent = err.message; } else if (el.paragraphPanel.open) { el.paragraphStatus.textContent = err.message; @@ -1453,7 +1477,7 @@ async function changeDocument(name, item, ...args) { } } -async function refreshEditorPage(doc, index, openSeq) { +async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { state.doc = doc; resetSearch(); state.documentSelection = null; @@ -1463,6 +1487,11 @@ async function refreshEditorPage(doc, index, openSeq) { if (openSeq !== state.openSeq) { return; } + if (clearSelection) { + const pending = canvasEditor.pendingSelection; + canvasEditor.clear(); + canvasEditor.pendingSelection = pending; + } mountPageSVG(index, page, openSeq); updateThumbnail(index, openSeq); setStatus(pageStatus(state.pageIndex, doc.pageCount)); @@ -1953,6 +1982,7 @@ async function openOFD(file) { el.insertPanel.close(); el.pagePanel.close(); el.paragraphPanel.close(); + el.infoPanel.close(); el.importPanel.close(); updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); @@ -2332,7 +2362,7 @@ async function openDocument(options = {}) { return; } const openSeq = options.openSeq || (state.openSeq += 1); - const previewIDs = options.keepPreview ? [...state.visiblePages].map((index) => state.doc.pages[index].id) : []; + const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : []; if (!state.ready || state.wasmExited) { await ensureWASM(); } @@ -2405,9 +2435,10 @@ async function openDocument(options = {}) { } } } + if (options.clearSelection) canvasEditor.clear(); resetPageFlow(options.keepPreview); renderPageList(); - if (options.resetScroll || el.outlineList.childElementCount === 0) { + if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) { renderOutlines(); } renderMeta(); @@ -2893,6 +2924,17 @@ async function processPageRenderQueue() { } page.imageNames = page.images.map((image) => image.name); delete page.images; + if (task.refresh && state.editing) { + await Promise.all(page.imageNames.map(name => { + const image = new Image(); + image.src = state.svgImages.get(name).url; + return image.decode(); + })); + } + if (task.openSeq !== state.openSeq) { + task.resolve(null); + continue; + } page.cacheBytes = (page.svg.length + page.text.length) * 2; page.text = JSON.parse(page.text); state.pageCache.set(task.index, page); @@ -3613,6 +3655,17 @@ async function selectSearchMatch(index) { } else { scrollToPage(match.page); } + if (state.editing) { + const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id); + const item = node && canvasEditor.nodes.get(node); + if (item?.type === "TextObject" && canEditObject(item, "update")) { + state.selectObjects = true; + setPan(false); + canvasEditor.setTool(""); + canvasEditor.select(item); + updateControls(); + } else canvasEditor.select(null); + } const button = el.searchResults.querySelector("[aria-current=true]"); const row = button.getBoundingClientRect(); const panel = el.searchResults.getBoundingClientRect(); @@ -3621,6 +3674,7 @@ async function selectSearchMatch(index) { } else if (row.bottom > panel.bottom) { el.searchResults.scrollTop += row.bottom - panel.bottom; } + return true; } function clearSearchHighlights() { @@ -4605,6 +4659,7 @@ function updateEditorTools() { el.editButton.title = state.editing ? "阅读" : "编辑"; el.editButton.setAttribute("aria-label", el.editButton.title); el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || ""; + el.infoButton.hidden = !state.editing; el.editNotice.hidden = !state.editing || !el.editNotice.textContent; el.editorTools.hidden = !state.editing; const pagesDisabled = !state.editing || !state.ready || state.exporting; @@ -4746,6 +4801,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.insertForm.inert = busy; el.pageForm.inert = busy; el.paragraphForm.inert = busy; + el.infoForm.inert = busy; el.importForm.inert = busy; el.editorTools.inert = busy; el.fontList.inert = busy; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index ca37b70..75a98d3 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -1,5 +1,16 @@ const PX_PER_MM = 96 / 25.4; +export function editTextValue(input) { + let text = "", previous; + for (const node of input.childNodes) { + const block = node.nodeName === "DIV" || node.nodeName === "P"; + if (previous && (block || previous.nodeName === "DIV" || previous.nodeName === "P") && previous.nodeName !== "BR") text += "\n"; + text += node.nodeType === 3 ? node.textContent : node.nodeName === "BR" ? (node.nextSibling ? "\n" : "") : editTextValue(node); + previous = node; + } + return text; +} + export function pagePoint(x, y, rect, page, rotation) { const u = (x - rect.left) / rect.width; const v = (y - rect.top) / rect.height; @@ -27,6 +38,21 @@ export function selectionBounds(items) { height: Math.max(...items.map(item => item.y + item.height)) - y }; } +function resizeCorner(node, event) { + let corner = event.target.dataset.corner || "", distance = Infinity; + if (!corner) return corner; + for (const handle of node.children) { + if (!handle.dataset.corner) continue; + const rect = handle.getBoundingClientRect(); + const next = (event.clientX - rect.left - rect.width / 2) ** 2 + (event.clientY - rect.top - rect.height / 2) ** 2; + if (next < distance || next === distance && handle.dataset.corner === event.target.dataset.corner) { + corner = handle.dataset.corner; + distance = next; + } + } + return corner; +} + function alignmentSnap(box, targets, tolerance) { const lines = []; const offset = { x: 0, y: 0 }; @@ -353,7 +379,7 @@ export class CanvasEditor { start(event) { if (this.input) { - if (event.target !== this.input.input) this.commitText(); + if (!this.input.input.contains(event.target)) this.commitText(); return; } if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) { @@ -367,7 +393,7 @@ export class CanvasEditor { event.preventDefault(); this.viewer.focus({ preventScroll: true }); const { item, box } = this.crop; - this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: event.target.dataset.corner || "", + this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: resizeCorner(this.crop.node, event), page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY }; this.viewer.setPointerCapture(event.pointerId); return; @@ -403,7 +429,7 @@ export class CanvasEditor { const item = this.selected; if (!canEditObject(item, "transform")) return; this.drag = { - item, pointerID: event.pointerId, corner: !item.items || target === item ? event.target.dataset.corner || "" : "", + item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "", rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY, }; @@ -568,10 +594,12 @@ export class CanvasEditor { }); return; } - this.cancel(); if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) { - this.options.onTransform(drag.item, drag.change); - } + this.drag = null; + drag.guides?.preview.remove(); + this.viewer.releasePointerCapture(drag.pointerID); + Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item)); + } else this.cancel(); } cancel() { @@ -796,34 +824,47 @@ export class CanvasEditor { this.closeText(); this.select(item); document.fonts.add(face); - const input = document.createElement("textarea"); + const input = document.createElement("div"); input.className = "edit-text"; input.setAttribute("aria-label", "编辑文字"); - input.wrap = item.wrap ? "soft" : "off"; + input.setAttribute("role", "textbox"); + input.setAttribute("aria-multiline", "true"); + input.contentEditable = "true"; input.spellcheck = false; - input.value = item.text; + for (const text of item.text.split("\n")) { + const paragraph = document.createElement("div"); + if (text) paragraph.textContent = text; + else paragraph.append(document.createElement("br")); + input.append(paragraph); + } + const left = item.leftIndent || 0, right = item.rightIndent || 0; + const frame = item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] }; + const [a, b, c, d, e, f] = frame.matrix; Object.assign(input.style, { - left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`, - width: `${(item.wrap ? item.width : Math.max(item.width, Math.min(50, item.page.width - item.x))) * PX_PER_MM + 4}px`, - minHeight: `${item.height * PX_PER_MM + 4}px`, + left: "0px", top: "0px", + width: `${(Math.max(frame.width, item.wrap ? frame.width : 50) - left - right) * PX_PER_MM + 4}px`, + minHeight: `${frame.height * PX_PER_MM + 4}px`, + transform: `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`, + whiteSpace: item.wrap ? "pre-wrap" : "pre", fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`, lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color, textAlign: item.align || "left", letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`, - paddingLeft: `${2 + Math.max(0, item.leftIndent || 0) * PX_PER_MM}px`, - paddingRight: `${2 + Math.max(0, item.rightIndent || 0) * PX_PER_MM}px`, textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`, }); - if (item.textFrame) { - const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame; - Object.assign(input.style, { left: "0px", top: "0px", width: `${Math.max(width, item.wrap ? width : 50) * PX_PER_MM + 4}px`, - minHeight: `${height * PX_PER_MM + 4}px`, transform: `matrix(${a},${b},${c},${d},${e * PX_PER_MM},${f * PX_PER_MM})` }); - } this.input = { input, item, face, fontChoice: item.fontChoice }; item.surface.append(input); - input.addEventListener("input", () => { - this.resizeText(); - this.options.onTextChange(); + input.addEventListener("input", () => this.options.onTextChange()); + input.addEventListener("paste", event => { + event.preventDefault(); + document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); + }); + input.addEventListener("beforeinput", event => { + if (event.inputType.startsWith("format")) event.preventDefault(); + if (event.inputType === "insertLineBreak") { + event.preventDefault(); + document.execCommand("insertParagraph"); + } }); input.addEventListener("blur", (event) => { if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText(); @@ -831,7 +872,7 @@ export class CanvasEditor { input.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return; event.stopPropagation(); - if (event.isComposing || input.readOnly) { + if (event.isComposing || this.input?.saving) { return; } if (event.key === "Escape") { @@ -843,15 +884,8 @@ export class CanvasEditor { this.commitText(); } }); - this.resizeText(); input.focus({ preventScroll: true }); - if (!item.draft) input.select(); - } - - resizeText() { - const { input } = this.input; - input.style.height = "0px"; - input.style.height = `${input.scrollHeight + 2}px`; + if (!item.draft) document.getSelection().selectAllChildren(input); } setTextFont(face, data) { @@ -861,7 +895,6 @@ export class CanvasEditor { editing.face = face; editing.fontData = data; editing.input.style.fontFamily = `"${face.family}"`; - this.resizeText(); this.options.onTextChange(); editing.input.focus({ preventScroll: true }); } @@ -869,20 +902,23 @@ export class CanvasEditor { async commitText() { const editing = this.input; if (!editing) return true; - if (editing.input.readOnly) return false; - if (editing.input.value === editing.item.text && !editing.fontData || editing.item.draft && !editing.input.value.trim()) { + if (editing.saving) return false; + const value = editTextValue(editing.input); + if (value === editing.item.text && !editing.fontData || editing.item.draft && !value.trim()) { this.closeText(); return true; } - editing.input.readOnly = true; - const saved = await this.options.onCommitText(editing.item, editing.input.value, editing.fontData); + editing.saving = true; + editing.input.contentEditable = "false"; + const saved = await this.options.onCommitText(editing.item, value, editing.fontData); if (this.input !== editing) { return Boolean(saved); } if (saved) { this.closeText(); } else { - editing.input.readOnly = false; + editing.saving = false; + editing.input.contentEditable = "true"; editing.input.focus({ preventScroll: true }); } return Boolean(saved); diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 6fe8e5a..8d0d55c 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -44,6 +44,7 @@ export class FontPicker { restore() { const font = this.fonts[Number(this.value)]; this.input.value = font?.fullName || font?.name || ""; + this.input.scrollLeft = 0; this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : ""; this.input.setAttribute("aria-expanded", String(this.open)); this.input.removeAttribute("aria-activedescendant"); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index ff8ce86..87bfbe6 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -108,6 +108,7 @@ func RunWASM() { registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoCreateDocument", createDocument) registerCallback("ofdgoEditDocument", editDocument) + registerCallback("ofdgoUpdateInfo", updateInfo) registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoLoadImport", loadImport) registerCallback("ofdgoImportPages", importPages) @@ -1091,6 +1092,18 @@ func createDocument(args []js.Value) (any, error) { return previewEditor(editor, args[3].Bool()) } +// updateInfo 修改标题、作者和主题,保留其他元数据及创建程序标识 +// 入参: args 标题、作者、主题 +// 返回: any 编辑状态, error 错误信息 +func updateInfo(args []js.Value) (any, error) { + return changeObjects(func() error { + info := currentEditor.Info + info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String() + currentEditor.SetInfo(info) + return nil + }) +} + // editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置 // 入参: args 浏览器参数 // 返回: any 编辑状态, error 错误信息 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index f4712a8..023bb86 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -23,6 +23,7 @@ import ( "fmt" "image" "math" + "reflect" "slices" "strconv" "strings" @@ -89,6 +90,20 @@ func (e *Editor) PageCount() int { return len(e.pages) } +// SetInfo 更新文档元数据,独立保存输入并计入修订和撤销记录 +// 入参: info 完整元数据,未修改字段由调用方保留 +func (e *Editor) SetInfo(info DocInfo) { + if reflect.DeepEqual(e.Info, info) { + return + } + before, after := cloneEditorData(e.Info), cloneEditorData(info) + e.Info = cloneEditorData(after) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.Info = cloneEditorData(before) } + change.redo = func(e *Editor) { e.Info = cloneEditorData(after) } + } +} + // Page 获取页面的独立副本,修改副本不影响文档 // 入参: index 页面索引 // 返回: *PageContent 页面内容, error 错误信息 @@ -160,7 +175,13 @@ func (e *Editor) AddPage(width, height float64) (int, error) { // 返回: int 副本页面索引, error 错误信息 func (e *Editor) CopyPage(index int) (int, error) { if e.originalPage(index) { - return 0, fmt.Errorf("copying imported pages is not supported") + source, err := e.Reader() + if err != nil { + return 0, err + } + at := len(e.pages) + _, err = e.importPages(source, []int{index}, at, true) + return at, err } source, err := e.page(index) if err != nil { @@ -203,13 +224,10 @@ func (e *Editor) CopyPage(index int) (int, error) { return index, nil } -// DeletePage 删除页面,不回收资源或复用标识,删除后页面索引随之变化 +// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据 // 入参: index 页面索引 // 返回: error 错误信息 func (e *Editor) DeletePage(index int) error { - if e.originalPage(index) { - return fmt.Errorf("deleting imported pages is not supported") - } page, err := e.page(index) if err != nil { return err diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index 5fbdf79..fefde7c 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -26,7 +26,7 @@ type editorChange struct { // SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录 // 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识 -// 记录页面和对象操作,不包含Info的直接修改及字体、图片注册,资源继续共享 +// 记录页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册,资源继续共享 // 入参: limit 最大记录数 func (e *Editor) SetHistoryLimit(limit int) { e.historyLimit = max(0, limit) @@ -55,7 +55,7 @@ func (e *Editor) CanRedo() bool { return e.historyIndex < len(e.history) } -// Undo 撤销最近一次页面或对象操作,保留资源和已分配的标识 +// Undo 撤销最近一次操作,保留资源和已分配的标识 // 返回: bool 是否执行了撤销 func (e *Editor) Undo() bool { if !e.CanUndo() { @@ -81,7 +81,7 @@ func (e *Editor) Redo() bool { return true } -// Revision 获取页面和对象的当前修订标识,撤销或重做时恢复对应标识 +// Revision 获取当前修订标识,撤销或重做时恢复对应标识 // Info的直接修改和资源注册不计入修订,标识仅在当前Editor实例内有效 // 返回: uint64 修订标识 func (e *Editor) Revision() uint64 { diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index f288851..584733f 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -47,6 +47,7 @@ type editorPageImport struct { templateParts map[string][]byte defaultCS string attachments map[string][]byte + copyPage bool } // ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 @@ -56,6 +57,13 @@ type editorPageImport struct { // 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置 // 返回: []string 新页面标识, error 错误信息 func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) { + return e.importPages(source, indexes, at, false) +} + +// importPages 迁移页面,同文档复制时复用原资源并保留其他页面的跳转 +// 入参: source 来源阅读器, indexes 来源页索引, at 插入位置, copyPage 是否同文档复制 +// 返回: []string 新页标识, error 错误信息 +func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage bool) ([]string, error) { if at < 0 || at > len(e.pages) { return nil, fmt.Errorf("page index %d out of range", at) } @@ -91,8 +99,15 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e for editorDirectoryExists(base.reader, prefix) { prefix += "_" } - m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID, + m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID, copyPage: copyPage, ids: make(map[string]string), pages: selected, files: make(map[string][]byte), resources: make(map[string]editorImportEntry), used: make(map[string][]byte), templates: make(map[string]TemplatePage), templateParts: make(map[string][]byte), attachments: make(map[string][]byte)} + if copyPage { + for _, page := range doc.Pages.Page { + if !selected[page.ID] { + m.ids[page.ID] = page.ID + } + } + } for _, name := range append(slices.Clone(doc.CommonData.PublicRes), doc.CommonData.DocumentRes...) { if err := m.resourceIndex(source.ResPath(name)); err != nil { return nil, err @@ -130,28 +145,30 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e if err != nil { return nil, err } - var resourceContent []byte - for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} { - var entries []byte - for _, id := range slices.Sorted(maps.Keys(m.used)) { - if m.resources[id].group == group { - entries = append(entries, m.used[id]...) + if !copyPage { + var resourceContent []byte + for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} { + var entries []byte + for _, id := range slices.Sorted(maps.Keys(m.used)) { + if m.resources[id].group == group { + entries = append(entries, m.used[id]...) + } } + if len(entries) == 0 { + continue + } + encoded, err := editorXMLContainer(group, nil, entries) + if err != nil { + return nil, err + } + resourceContent = append(resourceContent, encoded...) } - if len(entries) == 0 { - continue - } - encoded, err := editorXMLContainer(group, nil, entries) + resources, err := editorXMLContainer("Res", nil, resourceContent) if err != nil { return nil, err } - resourceContent = append(resourceContent, encoded...) + m.files[path.Join(prefix, "Resources.xml")] = resources } - resources, err := editorXMLContainer("Res", nil, resourceContent) - if err != nil { - return nil, err - } - m.files[path.Join(prefix, "Resources.xml")] = resources next, err := m.merge(base, refs, annotations, signatures) if err != nil { return nil, err @@ -160,6 +177,32 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e for i, ref := range refs { added[i] = PageContent{ID: ref.ID} } + if copyPage { + preview := *e + preview.source, preview.pages = next, added + for i, index := range indexes { + layouts := make(map[string]*textLayout) + for _, layer := range e.pages[index].Content.Layer { + for _, object := range layer.Objects { + if layout := object.TextObject.layout; object.Type == "TextObject" && layout != nil { + value := *layout + layouts[m.ids[object.TextObject.ID]] = &value + } + } + } + if err := preview.loadSourcePage(i); err != nil { + return nil, err + } + for j := range added[i].Content.Layer { + for k := range added[i].Content.Layer[j].Objects { + object := &added[i].Content.Layer[j].Objects[k] + if object.Type == "TextObject" { + object.TextObject.layout = layouts[object.TextObject.ID] + } + } + } + } + } if e.source == nil { for i := range e.resources { resource := &e.resources[i] @@ -268,6 +311,14 @@ func (m *editorPageImport) resourceIndex(name string) error { // 入参: value 来源资源标识 // 返回: string 新标识, error 错误信息 func (m *editorPageImport) reference(value string) (string, error) { + if m.copyPage { + if _, ok := m.resources[value]; ok { + return value, nil + } + if _, ok := m.templates[value]; ok { + return value, nil + } + } if entry, ok := m.resources[value]; ok { if _, seen := m.used[value]; !seen { m.used[value] = nil @@ -387,6 +438,13 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b } name := node.name.Local if name == "PageRes" { + if m.copyPage { + loc, err := m.resolve(entry.name, "", strings.TrimSpace(editorImportText(entry.data, node))) + if err != nil { + return nil, err + } + return editorXMLText("PageRes", "/"+loc), nil + } return nil, nil } if name == "StampAnnot" && !m.pages[node.attr("PageRef")] { @@ -402,7 +460,7 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b for _, bookmark := range m.doc.Bookmarks.Bookmark { bookmarks[bookmark.Name] = bookmark.Dest } - if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] { + if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] && !m.copyPage { return nil, nil } } @@ -586,6 +644,9 @@ func (m *editorPageImport) annotations() ([]byte, error) { // 入参: id 来源附件标识 // 返回: string 新标识, error 错误信息 func (m *editorPageImport) attachment(id string) (string, error) { + if m.copyPage { + return id, nil + } if _, exists := m.attachments[id]; exists { return m.id(id), nil } @@ -692,7 +753,10 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s break } } - content := editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml")) + var content []byte + if m.files[path.Join(m.prefix, "Resources.xml")] != nil { + content = editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml")) + } for _, id := range slices.Sorted(maps.Keys(m.templateParts)) { content = append(content, m.templateParts[id]...) } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index db2826e..12c1541 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -247,15 +247,14 @@ func (e *Editor) originalPage(index int) bool { return e.source != nil && index >= 0 && index < len(e.pages) && e.source.pages[e.pages[index].ID] != nil } -// PageCapabilities 获取页面操作范围;原始页面暂不复制、删除,避免破坏模板与外部引用 +// PageCapabilities 获取页面操作范围,复制和删除由库统一维护标准引用 // 入参: index 页面索引 // 返回: PageCapabilities 操作能力, error 错误信息 func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) { if index < 0 || index >= len(e.pages) { return PageCapabilities{}, fmt.Errorf("page index %d out of range", index) } - original := e.originalPage(index) - return PageCapabilities{Insert: e.sourceRGB(), Copy: !original, Delete: !original, Move: true, Resize: true}, nil + return PageCapabilities{Insert: e.sourceRGB(), Copy: true, Delete: true, Move: true, Resize: true}, nil } // sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间 diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 6be8c37..709d6de 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -28,7 +28,7 @@ import ( "strings" ) -// sourceParts 生成编辑过的XML和新增资源,不读取未修改页面或原始二进制资源 +// sourceParts 生成编辑快照的XML和新增资源,删页时同步清理标准引用 // 返回: map[string][]byte 替换及新增条目, error 错误信息 func (e *Editor) sourceParts() (map[string][]byte, error) { parts := make(map[string][]byte) @@ -159,6 +159,9 @@ func (e *Editor) sourceParts() (map[string][]byte, error) { } parts["OFD.xml"] = data } + if err := e.prunePageReferences(parts); err != nil { + return nil, err + } for name, data := range maps.Clone(parts) { if file, ok := reader.packageFile(name); ok { actual := cleanPackagePath(file.Name) diff --git a/ofdgo_editor_pages.go b/ofdgo_editor_pages.go new file mode 100644 index 0000000..b72a9f3 --- /dev/null +++ b/ofdgo_editor_pages.go @@ -0,0 +1,167 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "strings" +) + +// prunePageReferences 在输出快照中删除已移除页面的标准引用,不修改历史快照或其他文档 +// 沿主文档、页面、模板、资源、注释和签名索引检查,不进入附件、私有扩展和签名原始凭据 +// 入参: parts 输出覆盖条目 +// 返回: error 读取或解析错误 +func (e *Editor) prunePageReferences(parts map[string][]byte) error { + removed := make(map[string]bool) + for id := range e.source.pages { + removed[id] = true + } + for _, page := range e.pages { + delete(removed, page.ID) + } + if len(removed) == 0 { + return nil + } + reader := e.source.reader + bookmarks := make(map[string]bool) + for _, bookmark := range e.source.document.Bookmarks.Bookmark { + if removed[bookmark.Dest.PageID] { + bookmarks[bookmark.Name] = true + } + } + queue := []string{reader.ResPath(reader.OFD.DocBody[0].DocRoot)} + if name := e.source.document.Signatures; name != "" { + queue = append(queue, reader.ResPath(name)) + } + seen := make(map[string]bool) + for len(queue) > 0 { + name := queue[0] + queue = queue[1:] + if seen[strings.ToLower(name)] { + continue + } + seen[strings.ToLower(name)] = true + data, ok := parts[name] + if !ok { + var err error + data, err = reader.readFile(name) + if err != nil { + return err + } + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + var patches []editorXMLPatch + var walk func(*editorXML) error + walk = func(node *editorXML) error { + if node.name.Space != "" && node.name.Space != ofdNamespace && node.name.Space != "http://www.ofdspec.org" { + return nil + } + remove := false + switch node.name.Local { + case "Action": + if target := node.child("Goto"); target != nil { + if dest := target.child("Dest"); dest != nil { + remove = removed[dest.attr("PageID")] + } else if bookmark := target.child("Bookmark"); bookmark != nil { + remove = bookmarks[bookmark.attr("Name")] + } + } + case "Bookmark": + if node.parent != nil && node.parent.name.Local == "Bookmarks" { + remove = bookmarks[node.attr("Name")] + } + case "Page": + if root.name.Local == "Annotations" { + remove = removed[node.attr("PageID")] + } + case "Dest": + remove = removed[node.attr("PageID")] + case "StampAnnot": + remove = removed[node.attr("PageRef")] + case "Extension": + remove = removed[node.attr("RefId")] + } + if remove { + patches = append(patches, editorXMLPatch{start: node.start, end: node.end}) + return nil + } + var location string + switch node.name.Local { + case "Page", "TemplatePage", "Signature": + if node.parent != nil && (node.parent.name.Local == "Pages" || node.parent.name.Local == "CommonData" || node.parent.name.Local == "Signatures") { + location = node.attr("BaseLoc") + } + case "PublicRes", "DocumentRes", "PageRes", "Annotations", "Extensions": + if len(node.children) == 0 { + location = strings.TrimSpace(editorImportText(data, node)) + } + case "FileLoc": + if root.name.Local == "Annotations" { + location = strings.TrimSpace(editorImportText(data, node)) + } + case "CustomTags", "ExtendData", "Data": + return nil + } + if location != "" { + resolved, err := editorPageLocation(reader, parts, name, location) + if err != nil { + return err + } + queue = append(queue, resolved) + } + start := len(patches) + for _, child := range node.children { + if err := walk(child); err != nil { + return err + } + } + if (node.name.Local == "Actions" || node.name.Local == "Bookmarks") && len(node.children) > 0 && len(patches)-start == len(node.children) { + empty := true + for i, child := range node.children { + empty = empty && patches[start+i].start == child.start && patches[start+i].end == child.end + } + if empty { + patches = append(patches[:start], editorXMLPatch{start: node.start, end: node.end}) + } + } + return nil + } + if err := walk(root); err != nil { + return err + } + if len(patches) != 0 { + parts[name] = editorPatchXML(data, patches) + } + } + return nil +} + +// editorPageLocation 解析标准XML引用,优先使用所在文件的相对路径 +// 入参: reader 原包, parts 覆盖条目, name 所在XML, value 路径 +// 返回: string 包内路径, error 路径不存在 +func editorPageLocation(reader *Reader, parts map[string][]byte, name, value string) (string, error) { + for _, candidate := range []string{resolveResourcePath(name, "", value), reader.ResPath(value)} { + if _, ok := parts[candidate]; ok || reader.files[candidate] != nil { + return candidate, nil + } + if file, ok := reader.packageFile(candidate); ok { + return cleanPackagePath(file.Name), nil + } + } + return "", fmt.Errorf("page reference %q in %s not found", value, name) +} diff --git a/ofdgo_search.go b/ofdgo_search.go index c0da930..cbd243f 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -80,8 +80,9 @@ type TextSpan struct { Matrix [6]float64 `json:"matrix"` } -// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间 +// TextMatch 文字匹配结果,ID为对象标识,Run为文本对象索引,Start和End为原文Unicode字符区间 type TextMatch struct { + ID string `json:"id"` Run int `json:"run"` Start int `json:"start"` End int `json:"end"` @@ -169,6 +170,7 @@ func (p *PageText) Search(query string) []TextMatch { offset += found end := start + length match := TextMatch{ + ID: run.ID, Run: index, Start: start, End: end, Before: string(runes[max(0, start-16):start]), Text: string(runes[start:end]),