diff --git a/assets/webui/index.html b/assets/webui/index.html index bbc48e7..e9ddfb3 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -265,9 +265,13 @@ - + - 关闭 + + 说明 + 作者 + + 关闭确定 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index ae6031a..6434569 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -798,7 +798,8 @@ button:not(:disabled):hover { gap: 8px; } -.form-actions > button:only-child { +.form-actions > button:only-child, +.form-actions > button:has(+ button[hidden]) { grid-column: -2; } @@ -1297,6 +1298,8 @@ button.outline-link:hover { .navigation-tabs[hidden], .page-selection-tools[hidden], .form-row[hidden], +.form-actions > [hidden], +.annotation-detail[hidden], .tool-group[hidden], .page-list[hidden], .page-frame[hidden], @@ -1513,6 +1516,10 @@ button.outline-link:hover { cursor: default; } +.edit-object.edit-background:not(.selected):not(.erasing) { + outline: none; +} + .edit-object.edit-contour, .edit-object.edit-contour.selected, .edit-object.edit-contour.erasing { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 71be5a7..e04af32 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -27,6 +27,7 @@ let textMeasure = null; const state = { composite: null, + annotationEdit: null, ready: false, wasmExited: false, wasmSeq: 0, @@ -334,6 +335,13 @@ const el = { signatureSummary: document.querySelector("#signatureSummary"), signatureList: document.querySelector("#signatureList"), annotationNote: document.querySelector("#annotationNote"), + annotationForm: document.querySelector("#annotationForm"), + annotationFields: document.querySelector("#annotationFields"), + annotationRemark: document.querySelector("#annotationRemark"), + annotationCreator: document.querySelector("#annotationCreator"), + annotationStatus: document.querySelector("#annotationStatus"), + annotationClose: document.querySelector("#annotationClose"), + annotationSubmit: document.querySelector("#annotationSubmit"), annotationDetail: document.querySelector("#annotationDetail"), docFontList: document.querySelector("#docFontList"), docFontSummary: document.querySelector("#docFontSummary"), @@ -363,7 +371,9 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount)); } }, - onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, + onTransform: (item, change) => (item.items || [item]).every(object => object.type === "Annotation") + ? changeAnnotations(item, "move", change.x, change.y) + : changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onNudgeChange: () => { updatePendingChanges(); @@ -379,8 +389,13 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { syncSelection(); if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); }, - onDelete: (item) => changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), + onDelete: (item) => (item.items || [item]).every(object => object.type === "Annotation") + ? changeAnnotations(item, "delete") : changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), onErase: (items, box, points) => { + if (items.some(item => item.type === "Annotation")) { + if (box || !items.every(item => item.type === "Annotation")) { setStatus(box ? "注解暂不支持局部擦除" : "注解与正文需分别操作"); return false; } + return changeAnnotations({ items, index: items[0].index, id: items.map(item => item.id) }, "delete"); + } const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete")); if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; } const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) }; @@ -480,6 +495,16 @@ 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.annotationClose.addEventListener("click", () => el.annotationNote.close()); +el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; }); +el.annotationForm.addEventListener("submit", async event => { + if (!state.annotationEdit) return; + event.preventDefault(); + if (await changeAnnotations(state.annotationEdit, "update", el.annotationRemark.value, el.annotationCreator.value)) { + el.annotationNote.close(); + canvasEditor.focus(); + } +}); el.paragraphForm.addEventListener("submit", async event => { event.preventDefault(); if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return; @@ -1370,6 +1395,18 @@ async function createDocument(event) { async function editCanvasObject(item) { if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return; + if (item.type === "Annotation") { + state.annotationEdit = item; + el.annotationRemark.value = item.remark || ""; + el.annotationCreator.value = item.creator || ""; + el.annotationFields.hidden = el.annotationSubmit.hidden = false; + el.annotationDetail.hidden = true; + el.annotationStatus.textContent = ""; + el.annotationClose.textContent = "取消"; + el.annotationNote.showModal(); + el.annotationRemark.focus(); + return; + } if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); if (item.type === "TextObject") { if (!confirmTextReflow(item)) return; @@ -1403,7 +1440,17 @@ async function editCanvasObject(item) { function mountEditorObjects(index, page, surface) { const scope = state.composite?.index === index ? state.composite : null; - canvasEditor.mount(index, scope ? { ...page, objects: scope.objects } : page, surface); + const annotations = state.renderAnnotations ? (page.annotations || []).filter(annotation => annotation.visible).map(annotation => ({ + ...annotation, id: `annotation:${annotation.id}`, type: "Annotation", width: annotation.width || 6, height: annotation.height || 6, + background: annotation.type === "Watermark" || annotation.x <= 0.1 && annotation.y <= 0.1 + && annotation.x + annotation.width >= page.width - 0.1 && annotation.y + annotation.height >= page.height - 0.1, + capabilities: { update: true, move: true, delete: true }, + })) : []; + canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : [...page.objects, ...annotations] }, surface); +} + +function changeAnnotations(item, action, ...args) { + return changeDocument("ofdgoChangeAnnotations", { ...item, id: item.items ? item.id : [item.id] }, action, ...args); } function resetCompositeScope() { @@ -1953,7 +2000,8 @@ async function changeDocument(name, item, ...args) { rememberEditorView(before, revision); return true; } - const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); + const clearSelection = !item || name === "ofdgoChangeAnnotations" && args[0] === "delete" + || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { state.selectObjects = true; canvasEditor.setTool(""); @@ -1981,7 +2029,9 @@ async function changeDocument(name, item, ...args) { } } catch (err) { if (openSeq === state.openSeq) { - if (el.batchPagesPanel.open) { + if (el.annotationNote.open) { + el.annotationStatus.textContent = err.message; + } else if (el.batchPagesPanel.open) { el.batchPagesStatus.textContent = err.message; } else if (el.objectStylePanel.open) { el.objectStyleStatus.textContent = err.message; @@ -5270,6 +5320,10 @@ function mountAnnotationNotes(index, surface, annotations) { note.addEventListener("click", () => { if (state.editing || document.body.hasAttribute("aria-busy")) return; el.annotationDetail.replaceChildren(); + el.annotationFields.hidden = el.annotationSubmit.hidden = true; + el.annotationDetail.hidden = false; + el.annotationStatus.textContent = ""; + el.annotationClose.textContent = "关闭"; const content = document.createElement("div"); content.textContent = annotation.remark; el.annotationDetail.append(content); @@ -5746,7 +5800,7 @@ function updateObjectControls(item, reset = false) { el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject" || !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update")); el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改"; - el.editObjectButton.title = canEditObject(item, "enter") ? "进入组合" : item?.type === "ImageObject" ? "替换图片" : "修改对象"; + el.editObjectButton.title = canEditObject(item, "enter") ? "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象"; el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title); el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const selection = selectedText(item); @@ -5866,6 +5920,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.pageForm.inert = busy; el.paragraphForm.inert = busy; el.infoForm.inert = busy; + el.annotationForm.inert = busy; el.importForm.inert = busy && !state.importing; for (const input of [el.importFile, el.importRange, el.importPosition, el.importOutlines]) input.disabled = busy; el.importPages.disabled = busy || el.importRange.value !== "custom"; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 82294e3..4a12b80 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -176,7 +176,10 @@ function paintShape(node, shape, box) { } export function canEditObject(item, capability) { - return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability])); + if (!item) return false; + const objects = item.items || [item]; + if (objects.some(object => object.type === "Annotation") && objects.some(object => object.type !== "Annotation")) return false; + return objects.every(object => Boolean(object.capabilities?.[capability] || capability === "move" && object.capabilities?.transform)); } export function missingGlyphMessage(diagnostic, action = "请更换字体") { @@ -188,6 +191,7 @@ export function missingGlyphMessage(diagnostic, action = "请更换字体") { } export function objectEditReason(item) { + if (item?.items?.some(object => object.type === "Annotation") && item.items.some(object => object.type !== "Annotation")) return "注解与正文需分别操作"; const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式", unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" }; const reasons = (item?.items || (item ? [item] : [])).map(object => { @@ -278,18 +282,20 @@ export class CanvasEditor { const layer = document.createElement("div"); layer.className = "edit-layer"; const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }])); - const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); + const objects = [...page.objects].sort((a, b) => Number(Boolean(b.background)) - Number(Boolean(a.background)) + || (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); for (const object of objects) { const node = document.createElement("div"); node.className = "edit-object"; - node.classList.toggle("read-only", !canEditObject(object, "transform")); + node.classList.toggle("edit-background", Boolean(object.background)); + node.classList.toggle("read-only", !canEditObject(object, "move")); node.title = objectEditReason(object); if (lineShape(object.shape)) { node.classList.add("edit-line"); } node.tabIndex = 0; node.setAttribute("role", "button"); - node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[object.type]); + node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象", Annotation: "注解对象" }[object.type]); const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; item.artwork = artwork.get(object.id)?.node; if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) { @@ -527,8 +533,9 @@ export class CanvasEditor { node = target?.node; this.focus(); const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple; - if (!target) { + if (!target || target.background && !this.items().includes(target)) { this.startMarquee(event, additive); + if (this.drag?.marquee) this.drag.target = target; return; } if (additive && !event.target.dataset.corner) { @@ -540,7 +547,7 @@ export class CanvasEditor { return; } const item = this.selected; - if (!canEditObject(item, "transform")) return; + if (!canEditObject(item, "move")) return; this.drag = { item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "", rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), @@ -739,6 +746,7 @@ export class CanvasEditor { this.drag = null; drag.preview.remove(); this.viewer.releasePointerCapture(drag.pointerID); + if (drag.target && !drag.moved) this.setSelection([...drag.base, drag.target]); return; } if (drag.shape) { @@ -810,7 +818,8 @@ export class CanvasEditor { if (!page) return; event.preventDefault(); this.focus(); - this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)), + this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)) + .filter(item => this.tool === "erase-object" || !item.background), erased: new Set(), shape: "rectangle", pointerID: event.pointerId, clientX: event.clientX, clientY: event.clientY, rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; if (this.tool === "erase-region") Object.assign(this.drag, this.createPreview(surface, page, "rectangle", @@ -866,9 +875,11 @@ export class CanvasEditor { const box = { x: Math.min(from.x, to.x), y: Math.min(from.y, to.y), width: Math.abs(to.x-from.x), height: Math.abs(to.y-from.y) }; paintShape(drag.node, "rectangle", box); if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return; + drag.moved = true; const items = [...drag.base]; for (const node of drag.surface.querySelectorAll(".edit-object")) { const item = this.nodes.get(node); + if (item.background && (item.x < box.x || item.y < box.y || item.x + item.width > box.x + box.width || item.y + item.height > box.y + box.height)) continue; if (!items.includes(item) && item.x <= box.x+box.width && item.x+item.width >= box.x && item.y <= box.y+box.height && item.y+item.height >= box.y) { items.push(item); } @@ -1030,7 +1041,7 @@ export class CanvasEditor { } else if (edit && !this.drag && !this.selected.items) { if (this.nudge) this.commitNudge().then(saved => { if (saved && this.selected) this.options.onEdit(this.selected); }); else this.options.onEdit(this.selected); - } else if (!this.drag && directions[event.key] && canEditObject(this.selected, "transform")) { + } else if (!this.drag && directions[event.key] && canEditObject(this.selected, "move")) { const [x, y] = directions[event.key]; const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90]; const step = event.shiftKey ? 10 : 1; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 93967bb..1876b54 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -118,6 +118,7 @@ func RunWASM() { registerCallback("ofdgoChangePage", changePage) registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoChangeOutline", changeOutline) + registerCallback("ofdgoChangeAnnotations", changeAnnotations) registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoCaptureStyle", captureStyle) @@ -854,6 +855,31 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return objects, nil } +// changeAnnotations 修改注解说明、平移或删除同页注解,复用会话历史与预览 +// 入参: args 页面索引、注解标识数组、操作及参数 +// 返回: any 编辑状态, error 错误信息 +func changeAnnotations(args []js.Value) (any, error) { + return changeObjects(func() error { + ids := stringsFromJS(args[1]) + for i := range ids { + ids[i] = strings.TrimPrefix(ids[i], "annotation:") + } + switch args[2].String() { + case "update": + if len(ids) != 1 { + return fmt.Errorf("select one annotation to update") + } + return currentEditor.UpdateAnnotation(args[0].Int(), ids[0], args[3].String(), args[4].String()) + case "move": + return currentEditor.MoveAnnotations(args[0].Int(), ids, args[3].Float(), args[4].Float()) + case "delete": + return currentEditor.DeleteAnnotations(args[0].Int(), ids) + default: + return fmt.Errorf("unsupported annotation action %q", args[2].String()) + } + }) +} + // editorCapabilities 统一顶层与内部对象的操作能力和受限原因 // 入参: capability 库层能力, kind 对象类型 // 返回: map[string]any 前端能力 diff --git a/ofdgo_editor_annot.go b/ofdgo_editor_annot.go new file mode 100644 index 0000000..deb48ed --- /dev/null +++ b/ofdgo_editor_annot.go @@ -0,0 +1,308 @@ +// 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 ( + "bytes" + "encoding/xml" + "fmt" + "maps" + "path" + "strings" + "time" +) + +// Annotations 获取指定页面的注解信息,包含不可见注解,返回值与编辑器相互独立 +// 入参: index 页面索引 +// 返回: []AnnotationInfo 注解信息, error 错误信息 +func (e *Editor) Annotations(index int) ([]AnnotationInfo, error) { + if index < 0 || index >= len(e.pages) { + return nil, fmt.Errorf("page index %d out of range", index) + } + if e.source == nil { + return nil, nil + } + return e.source.reader.annotationInfos(index, e.pages[index].ID), nil +} + +// UpdateAnnotation 修改注解说明和作者,保留外观、动作、参数及扩展字段 +// 有效修改更新LastModDate并计入一次撤销记录,不受原文件ReadOnly声明限制 +// 入参: index 页面索引, id 注解标识, remark 说明, creator 作者 +// 返回: error 错误信息 +func (e *Editor) UpdateAnnotation(index int, id, remark, creator string) error { + return e.editAnnotations(index, []string{id}, func(data []byte, node *editorXML) ([]byte, error) { + standalone, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + var annotation Annotation + if err := xml.Unmarshal(standalone, &annotation); err != nil { + return nil, err + } + if annotation.Remark == remark && annotation.Creator == creator { + return data[node.start:node.end], nil + } + data = standalone + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if annotation.Remark != remark { + var escaped bytes.Buffer + if err := xml.EscapeText(&escaped, []byte(remark)); err != nil { + return nil, err + } + if child := root.child("Remark"); child != nil { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, child, escaped.Bytes())}) + } else if root.open == root.end { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, editorXMLText("Remark", remark))}) + } else { + data = editorPatchXML(data, []editorXMLPatch{{root.open, root.open, editorXMLText("Remark", remark)}}) + } + } + root, err = parseEditorXML(data) + if err != nil { + return nil, err + } + if annotation.Creator != creator { + data, err = editorXMLAttribute(data, root, "Creator", creator) + if err != nil { + return nil, err + } + } + return editorAnnotationDate(data) + }) +} + +// MoveAnnotations 平移同页注解,保留内部图元、动作坐标及原始尺寸,一次操作计入一条撤销记录 +// 入参: index 页面索引, ids 不重复的注解标识, dx 横向毫米偏移, dy 纵向毫米偏移 +// 返回: error 错误信息 +func (e *Editor) MoveAnnotations(index int, ids []string, dx, dy float64) error { + if !finite(dx) || !finite(dy) { + return fmt.Errorf("annotation offset must be finite") + } + return e.editAnnotations(index, ids, func(data []byte, node *editorXML) ([]byte, error) { + appearance := node.child("Appearance") + if appearance == nil { + return nil, fmt.Errorf("annotation %q has no appearance", node.attr("ID")) + } + box, err := ParseBox(appearance.attr("Boundary")) + if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) || !finite(box.W) || !finite(box.H) || box.W < 0 || box.H < 0 { + return nil, fmt.Errorf("annotation %q has invalid boundary", node.attr("ID")) + } + if dx == 0 && dy == 0 { + return data[node.start:node.end], nil + } + data, err = editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + appearance = root.child("Appearance") + boundary := fmt.Sprintf("%s %s %s %s", ofdNumber(box.X+dx), ofdNumber(box.Y+dy), ofdNumber(box.W), ofdNumber(box.H)) + updated, err := editorXMLAttribute(data, appearance, "Boundary", boundary) + if err != nil { + return nil, err + } + return editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, updated}})) + }) +} + +// DeleteAnnotations 删除同页注解并清理空的页面注解引用,一次操作计入一条撤销记录 +// 入参: index 页面索引, ids 不重复的注解标识 +// 返回: error 错误信息 +func (e *Editor) DeleteAnnotations(index int, ids []string) error { + return e.editAnnotations(index, ids, func([]byte, *editorXML) ([]byte, error) { return nil, nil }) +} + +// editorAnnotationDate 更新注解修改日期,采用标准xs:date格式 +// 入参: data 注解XML片段 +// 返回: []byte 新片段, error 错误信息 +func editorAnnotationDate(data []byte) ([]byte, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + return editorXMLAttribute(data, root, "LastModDate", time.Now().Format("2006-01-02")) +} + +// editAnnotations 原子修改页面注解原文,按需分离共用文件并保留输入包与历史快照 +// 入参: index 页面索引, ids 注解标识, edit 单个注解修改函数 +// 返回: error 错误信息 +func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *editorXML) ([]byte, error)) error { + if index < 0 || index >= len(e.pages) { + return fmt.Errorf("page index %d out of range", index) + } + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + if id == "" || wanted[id] { + return fmt.Errorf("missing or duplicate annotation ID %q", id) + } + wanted[id] = true + } + if len(wanted) == 0 { + return nil + } + if e.source == nil || e.source.document.Annotations == "" { + return fmt.Errorf("page has no annotations") + } + reader := e.source.reader + name, err := editorPageLocation(reader, nil, "", "/"+reader.ResPath(e.source.document.Annotations)) + if err != nil { + return err + } + data, err := reader.readFile(name) + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + parts := make(map[string][]byte) + var patches []editorXMLPatch + found := make(map[string]bool, len(ids)) + remaining := 0 + for _, ref := range root.children { + if ref.name.Local != "Page" || ref.name.Space != root.name.Space { + continue + } + remaining++ + if ref.attr("PageID") != e.pages[index].ID { + continue + } + loc := ref.child("FileLoc") + if loc == nil { + return fmt.Errorf("annotation page has no FileLoc") + } + file, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, loc))) + if err != nil { + return err + } + content, err := reader.readFile(file) + if err != nil { + return err + } + page, err := parseEditorXML(content) + if err != nil { + return err + } + var changes []editorXMLPatch + count := 0 + for _, node := range page.children { + if node.name.Local != "Annot" || node.name.Space != page.name.Space { + continue + } + count++ + id := node.attr("ID") + if !wanted[id] { + continue + } + if found[id] { + return fmt.Errorf("ambiguous annotation ID %q", id) + } + found[id] = true + value, err := edit(content, node) + if err != nil { + return err + } + if value == nil { + count-- + } + if !bytes.Equal(value, content[node.start:node.end]) { + changes = append(changes, editorXMLPatch{node.start, node.end, value}) + } + } + if len(changes) == 0 { + continue + } + sharedFile := false + for _, other := range root.children { + if other == ref || other.name.Local != "Page" || other.name.Space != root.name.Space || other.child("FileLoc") == nil { + continue + } + shared, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, other.child("FileLoc")))) + if err == nil && shared == file { + sharedFile = true + break + } + } + if count == 0 { + patches = append(patches, editorXMLPatch{ref.start, ref.end, nil}) + remaining-- + if !sharedFile { + parts[file] = editorPatchXML(content, changes) + } + continue + } + if sharedFile { + file = path.Join(e.source.directory, "Annotations", e.pages[index].ID, file) + var escaped bytes.Buffer + _ = xml.EscapeText(&escaped, []byte("/"+file)) + patches = append(patches, editorXMLContent(data, loc, escaped.Bytes())) + } + parts[file] = editorPatchXML(content, changes) + } + for _, id := range ids { + if !found[id] { + return fmt.Errorf("annotation %q not found", id) + } + } + if len(patches) != 0 { + parts[name] = editorPatchXML(data, patches) + if remaining == 0 { + docName, err := editorPageLocation(reader, nil, "", "/"+cleanPackagePath(reader.OFD.DocBody[0].DocRoot)) + if err != nil { + return err + } + docData, err := reader.readFile(docName) + if err != nil { + return err + } + doc, err := parseEditorXML(docData) + if err != nil { + return err + } + parts[docName] = editorXMLSetText(docData, doc, [][2]string{{"Annotations", ""}}) + } + } + if len(parts) == 0 { + return nil + } + files := maps.Clone(reader.files) + if files == nil { + files = make(map[string][]byte) + } + maps.Copy(files, parts) + updated := &Reader{Zip: reader.Zip, files: files} + if err := updated.initRoot(); err != nil { + return err + } + doc, err := updated.Doc() + if err != nil { + return err + } + before, after := e.source, *e.source + after.reader, after.document = updated, doc + e.source = &after + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.source = before } + change.redo = func(e *Editor) { e.source = &after } + } + return nil +} diff --git a/ofdgo_editor_outline.go b/ofdgo_editor_outline.go index 2aa4040..07082dd 100644 --- a/ofdgo_editor_outline.go +++ b/ofdgo_editor_outline.go @@ -158,7 +158,7 @@ func (e *Editor) UpdateOutline(path []int, title string, page int) error { if err != nil { return err } - value, err := editorOutlineAttribute(updated, node, "Title", title) + value, err := editorXMLAttribute(updated, node, "Title", title) if err != nil { return err } @@ -326,21 +326,6 @@ func editorOutlineNode(root *editorXML, path []int) (*editorXML, error) { return root, nil } -// editorOutlineAttribute 修改单个标准属性,保留节点其余原文 -// 入参: data 原文, node 节点, name 属性名, value 新值 -// 返回: []byte 修改后的节点, error 错误信息 -func editorOutlineAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) { - before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil) - if err != nil { - return nil, err - } - after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil) - if err != nil { - return nil, err - } - return editorXMLMerge(data, node, before, after) -} - // setOutlineXML 更新目录及可选叶节点计数,保存一次撤销记录 // 入参: data 新目录原文 // 返回: error 错误信息 @@ -369,7 +354,7 @@ func (e *Editor) setOutlineXML(data []byte) error { for _, child := range editorOutlineChildren(node) { count += leaves(child) } - value, err := editorOutlineAttribute(data, node, "Count", strconv.Itoa(count)) + value, err := editorXMLAttribute(data, node, "Count", strconv.Itoa(count)) if err != nil { return err } diff --git a/ofdgo_editor_resources.go b/ofdgo_editor_resources.go index d99efa6..02941fa 100644 --- a/ofdgo_editor_resources.go +++ b/ofdgo_editor_resources.go @@ -39,7 +39,7 @@ type editorResourceRefs struct { // 入参: parts 已修改和新增的包内条目, progress 保存进度回调 // 返回: map[string]bool 可移除的二进制条目, error 读取错误 func (e *Editor) compactSourceResources(parts map[string][]byte, progress editorProgress) (map[string]bool, error) { - if len(parts) == 0 { + if len(parts) == 0 && e.revision == 0 { return nil, nil } reader := e.source.reader diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index 1d9cd6c..113b884 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -546,6 +546,21 @@ func editorXMLEncodedFragment(data []byte, node *editorXML) []byte { return editorPatchXML(fragment, []editorXMLPatch{{position, position, []byte(" xmlns:ofd=\"" + ofdNamespace + "\"")}}) } +// editorXMLAttribute 修改单个标准属性,保留节点其余原文 +// 入参: data 原文, node 节点, name 属性名, value 新值 +// 返回: []byte 修改后的节点, error 错误信息 +func editorXMLAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) { + before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil) + if err != nil { + return nil, err + } + after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil) + if err != nil { + return nil, err + } + return editorXMLMerge(data, node, before, after) +} + // editorXMLContainer 用标准编码器封装已编码的子节点 // 入参: name 节点名称, attrs 属性, content 子节点XML // 返回: []byte XML片段, error 错误信息 diff --git a/ofdgo_renderer_layer.go b/ofdgo_renderer_layer.go index 53d2498..9a168da 100644 --- a/ofdgo_renderer_layer.go +++ b/ofdgo_renderer_layer.go @@ -23,11 +23,19 @@ func (r *Renderer) renderAnnotations(ctx *canvas.Context, pageID string, pageH f if annot.Visible != nil && !*annot.Visible { continue } + groups, grouped := ctx.Renderer.(interface { + beginAnnotation(string) bool + endObject() + }) + grouped = grouped && groups.beginAnnotation(annot.ID) box, _ := ParseBox(annot.Appearance.Boundary) ctm := Matrix{a: 1, d: 1, e: box.X, f: box.Y} for i := range annot.Appearance.Objects { r.renderObject(ctx, &annot.Appearance.Objects[i], pageH, nil, &ctm, false, nil) } + if grouped { + groups.endObject() + } } } diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index 9ed5afa..d767064 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -88,7 +88,7 @@ func (r *Renderer) RenderToSVGWithResources(page *PageContent, writer io.Writer) return r.renderSVGResources(page, writer, true, false) } -// RenderToSVGWithObjects 渲染SVG并为页面直接对象添加data-ofd-object分组,保持原绘制顺序和分离资源 +// RenderToSVGWithObjects 渲染SVG并为页面对象和注解添加data-ofd-object分组,注解标识使用annotation:前缀 // 入参: page 页面内容, writer 输出流 // 返回: SVGResources 字体和图片资源, error 错误信息 func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (SVGResources, error) { @@ -143,7 +143,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() } -// beginObject 标记直接对象及复合成员,模板与注释不单独标记 +// beginObject 标记直接对象及复合成员,模板与注解内部图元不单独标记 // 入参: object 图形对象 // 返回: bool 是否写入分组 func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool { @@ -169,6 +169,18 @@ func (s *svgResourceRenderer) endObject() { fmt.Fprint(s.writer, ``) } +// beginAnnotation 标记完整注解外观,内部图元保持原有层级和绘制顺序 +// 入参: id 注解标识 +// 返回: bool 是否写入分组 +func (s *svgResourceRenderer) beginAnnotation(id string) bool { + if s.objects == nil { + return false + } + s.objectStack = append(s.objectStack, svgObjectGroup{}) + fmt.Fprintf(s.writer, ``, html.EscapeString(id)) + return true +} + // skipObjects 为不可见资源保留内部成员序号 // 入参: count 直接成员数量 func (s *svgResourceRenderer) skipObjects(count int) {