From a8346a60064e9cc90236e2a0bd35df4b930f308e Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Fri, 18 Sep 2026 08:34:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=BB=E7=AC=94=E5=92=8C=E6=B3=A8=E8=A7=A3?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=EF=BC=8C=E5=AE=8C=E5=96=84=E6=B0=B4=E5=8D=B0?= =?UTF-8?q?=E3=80=81=E5=8D=B0=E7=AB=A0=E4=B8=8E=E9=93=BE=E6=8E=A5=E7=BC=96?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E9=80=89=E5=8F=96=E5=92=8C?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 33 ++ assets/webui/ofdgo.css | 28 +- assets/webui/ofdgo.js | 241 +++++++++-- assets/webui/ofdgo_edit.js | 112 +++++- internal/webui/wasm.go | 665 ++++++++++++++++++++++++++++++- ofdgo_annot.go | 5 + ofdgo_editor_annot.go | 23 +- ofdgo_editor_annot_content.go | 455 +++++++++++++++++++++ ofdgo_editor_annot_ops.go | 283 +++++++++++++ ofdgo_editor_composite.go | 17 +- ofdgo_editor_composite_copy.go | 9 +- ofdgo_editor_composite_insert.go | 6 + ofdgo_editor_font.go | 10 + ofdgo_editor_history.go | 69 +++- ofdgo_editor_link.go | 250 ++++++++++++ ofdgo_editor_open.go | 16 +- ofdgo_editor_pages.go | 3 + ofdgo_editor_transform.go | 16 + ofdgo_ink.go | 101 +++++ ofdgo_reader.go | 1 + ofdgo_renderer_bounds.go | 29 ++ ofdgo_renderer_svg.go | 4 +- ofdgo_writer.go | 25 ++ 23 files changed, 2297 insertions(+), 104 deletions(-) create mode 100644 ofdgo_editor_annot_content.go create mode 100644 ofdgo_editor_annot_ops.go create mode 100644 ofdgo_editor_link.go create mode 100644 ofdgo_ink.go diff --git a/assets/webui/index.html b/assets/webui/index.html index e9ddfb3..204e66e 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -99,6 +99,8 @@ + + 插入
@@ -264,6 +266,37 @@
+ +
+ + + + + + + + + +
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ +
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 6434569..fb9ba85 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -134,6 +134,24 @@ a:focus-visible { grid-template-columns: repeat(4, max-content); } +.editor-group:has(#penTool) { + grid-template-columns: repeat(4, max-content); +} + +.object-picker-list { + display: grid; + gap: 4px; + max-height: 55vh; + overflow-y: auto; +} + +.object-picker-list .button { + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .editor-field { display: grid; align-items: center; @@ -1516,10 +1534,6 @@ 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 { @@ -2310,16 +2324,16 @@ button.outline-link:hover { @media (min-width: 901px) and (max-width: 1600px) and (pointer: fine) { .editor-group { - padding-inline: 5px; + padding-inline: 3px; column-gap: 3px; } .editor-toolbar .button { - padding-inline: 7px; + padding-inline: 6px; } .editor-group:has(#textFont) { - grid-template-columns: 144px 32px; + grid-template-columns: 136px 32px; } .editor-paragraph { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index e04af32..8ecbf84 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -28,6 +28,7 @@ let textMeasure = null; const state = { composite: null, annotationEdit: null, + annotationCreate: null, ready: false, wasmExited: false, wasmSeq: 0, @@ -335,6 +336,12 @@ const el = { signatureSummary: document.querySelector("#signatureSummary"), signatureList: document.querySelector("#signatureList"), annotationNote: document.querySelector("#annotationNote"), + annotationTool: document.querySelector("#annotationTool"), + penTool: document.querySelector("#penTool"), + annotationCreate: document.querySelector("#annotationCreate"), + annotationCreateStatus: document.querySelector("#annotationCreateStatus"), + objectPicker: document.querySelector("#objectPicker"), + objectPickerList: document.querySelector("#objectPickerList"), annotationForm: document.querySelector("#annotationForm"), annotationFields: document.querySelector("#annotationFields"), annotationRemark: document.querySelector("#annotationRemark"), @@ -356,6 +363,7 @@ const fontManager = new FontManager({ onPermissionChange: updateFontPermissionHint, }); const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); +const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)])); el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { @@ -371,9 +379,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount)); } }, - onTransform: (item, change) => (item.items || [item]).every(object => object.type === "Annotation") - ? changeAnnotations(item, "move", change.x, change.y) - : changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, + onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onNudgeChange: () => { updatePendingChanges(); @@ -389,13 +395,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { syncSelection(); if (state.fontRenderPending) window.setTimeout(refreshPendingFonts, 0); }, - onDelete: (item) => (item.items || [item]).every(object => object.type === "Annotation") - ? changeAnnotations(item, "delete") : changeDocument(item.items ? "ofdgoDeleteObjects" : "ofdgoDeleteObject", item), + onDelete: (item) => 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) }; @@ -404,6 +405,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { : changeDocument("ofdgoDeleteObjects", item); }, onEdit: editCanvasObject, + onPick: openObjectPicker, onExitScope: () => { if (!state.composite) return false; exitCompositeScope(); @@ -414,6 +416,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onDrawText: beginCanvasText, + onDrawAnnotation: (index, kind, box) => openAnnotationCreate(kind, index, box), + onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor), onCommitText: (item, value, fontData, color = null) => item.draft ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item)) : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color), @@ -497,6 +501,86 @@ el.infoForm.addEventListener("submit", async event => { }); el.annotationClose.addEventListener("click", () => el.annotationNote.close()); el.annotationNote.addEventListener("close", () => { state.annotationEdit = null; }); +el.penTool.addEventListener("change", () => { + const tool = el.penTool.value; + el.penTool.value = ""; + canvasEditor.select(null); + toggleDrawingTool(tool); +}); +el.annotationTool.addEventListener("change", async () => { + const kind = el.annotationTool.value; + el.annotationTool.value = ""; + if (kind === "objects") { openObjectPicker(); return; } + let selected = canvasEditor.selected; + if (state.composite?.key.startsWith("annotation:")) { + const id = state.composite.key.split("/")[0]; + selected = { ...state.pageCache.get(state.composite.index)?.objects.find(item => item.id === id), index: state.composite.index, id, type: "Annotation" }; + } + if (kind === "details" || kind === "replace") { + if (selected?.type !== "Annotation") { setStatus("请先选择注解"); return; } + try { + const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id); + if (kind === "details") editAnnotationDetails({ ...selected, ...info }); + else openAnnotationCreate(kind, selected.index, null, selected, info); + } catch (error) { showError(error, false); } + return; + } + if (kind === "link" && selected?.annotationType === "Link") { + try { + const info = await callWASM("ofdgoReadAnnotation", selected.index, selected.id); + openAnnotationCreate(kind, selected.index, selected, selected, info); + } catch (error) { showError(error, false); } + } else { + resetCompositeScope(); + updateControls(); + if (kind === "watermark") openAnnotationCreate(kind, state.pageIndex); + else { + canvasEditor.select(null); + toggleDrawingTool(`annotation:${kind}`); + } + } +}); +annotationFields.CreateCancel.addEventListener("click", () => el.annotationCreate.close()); +el.annotationCreate.addEventListener("close", () => { state.annotationCreate = null; }); +annotationFields.Content.addEventListener("change", updateAnnotationFields); +annotationFields.Height.addEventListener("input", () => { state.annotationCreate.height = annotationFields.Height.value; }); +annotationFields.LinkKind.addEventListener("change", updateAnnotationFields); +annotationFields.Old.addEventListener("change", () => { annotationFields.Text.value = annotationFields.Old.value; }); +annotationFields.Tile.addEventListener("change", () => { + if (annotationFields.Tile.value === "tile") { + annotationFields.X.value = 10; + annotationFields.Y.value = 10; + } +}); +annotationFields.CreateForm.addEventListener("submit", async event => { + event.preventDefault(); + const entry = state.annotationCreate; + if (!entry || !annotationFields.CreateForm.reportValidity() || document.body.hasAttribute("aria-busy")) return; + const openSeq = state.openSeq; + const value = key => annotationFields[key].value; + const image = entry.kind === "stamp" || entry.kind === "watermark" && value("Content") === "image"; + let font = null, data = null; + try { + if (image) data = new Uint8Array(await annotationFields.Image.files[0].arrayBuffer()); + if (entry.kind === "watermark" && !image || entry.kind === "replace" && value("Font") === "current") { + const choice = state.textDefaults.fontChoice || state.textFonts[Number(fontPicker.value)]; + if (!choice || choice.disabled) throw new Error("请先选择可用字体"); + font = await readTextFont(choice); + } + if (openSeq !== state.openSeq || state.annotationCreate !== entry) return; + const options = { kind: entry.kind, id: entry.item?.id.replace("annotation:", "") || "", text: value("Text"), old: value("Old"), + creator: value("Author"), pages: value("Pages"), uri: value("Address"), target: value("LinkKind") === "page" ? Number(value("Target")) : 0, + x: Number(value("X")), y: Number(value("Y")), width: entry.kind === "note" ? 5 : Number(value("Width")), height: entry.kind === "note" ? 5 : Number(value("Height")), + angle: Number(value("Angle")), alpha: Math.round((100 - Number(value("Opacity"))) * 255 / 100), tile: value("Tile") === "tile", + size: state.textDefaults.size, color: state.textDefaults.color }; + if (entry.kind === "link" && entry.item) { + options.base = entry.info?.linkBase || ""; + options.keep = value("LinkKind") === "keep" || value("LinkKind") === entry.info?.linkKind + && (value("LinkKind") === "uri" ? value("Address") === entry.info.linkURI : Number(value("Target")) === entry.info.linkPage); + } + if (await changeDocument("ofdgoWriteAnnotation", null, entry.index, JSON.stringify(options), font, data)) el.annotationCreate.close(); + } catch (error) { el.annotationCreateStatus.textContent = error.message; } +}); el.annotationForm.addEventListener("submit", async event => { if (!state.annotationEdit) return; event.preventDefault(); @@ -1021,7 +1105,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 || el.infoPanel.open - || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open; + || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } function setObjectDimension(input, value) { @@ -1053,6 +1137,7 @@ function openObjectBounds() { } function canCopyStyle(item) { + if (item?.type === "Annotation") return false; if (!item?.scoped) return canEditObject(item, "update"); if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint"); if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint"); @@ -1068,7 +1153,7 @@ function openObjectStyle() { el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]); el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item)); const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; - const alpha = shared("alpha", 255), dash = shared("dashPattern", ""); + const alpha = items.some(item => item.alphaMixed) ? null : shared("alpha", 255), dash = shared("dashPattern", ""); el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); el.objectOpacity.placeholder = alpha === null ? "混合" : ""; el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint"))); @@ -1393,21 +1478,96 @@ async function createDocument(event) { } } +function editAnnotationDetails(item) { + 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(); +} + +function openObjectPicker(items = canvasEditor.pageItems(state.pageIndex)) { + el.objectPickerList.replaceChildren(); + const labels = { TextObject: "文字", PathObject: "图形", ImageObject: "图片", CompositeObject: "组合", CompositeGraphicUnit: "组合", Annotation: "注解" }; + const kinds = { Path: "批注", Stamp: "印章", Watermark: "水印", Link: "链接", Highlight: "高亮" }; + for (const item of [...items].reverse()) { + const button = document.createElement("button"); + button.className = "button"; + button.type = "button"; + button.textContent = `${kinds[item.annotationType] || labels[item.type] || "对象"} · ${item.text || item.remark || item.id.replace("annotation:", "")}`; + button.title = button.textContent; + button.addEventListener("click", () => { + el.objectPicker.close(); + canvasEditor.select(item); + canvasEditor.focus(); + }); + el.objectPickerList.append(button); + } + if (!items.length) { setStatus("页面暂无对象"); return; } + el.objectPicker.showModal(); +} + +function openAnnotationCreate(kind, index, box = null, item = null, info = null) { + const page = state.doc.pages[index]; + const width = Math.min(60, page.width), height = 12; + state.annotationCreate = { kind, index, item, info, height: box?.height || height }; + annotationFields.CreateForm.reset(); + annotationFields.Text.value = kind === "watermark" ? "水印" : ""; + annotationFields.Pages.value = String(index + 1); + annotationFields.Target.max = state.doc.pageCount; + annotationFields.Target.value = String(index + 1); + annotationFields.LinkKind.querySelector('[value="keep"]').hidden = !item; + if (kind === "link" && info) { + annotationFields.LinkKind.value = info.linkKind; + annotationFields.Address.value = info.linkURI || ""; + if (info.linkPage) annotationFields.Target.value = String(info.linkPage); + } + annotationFields.X.value = box?.x ?? (page.width - width) / 2; + annotationFields.Y.value = box?.y ?? (page.height - height) / 2; + annotationFields.Width.value = box?.width || width; + annotationFields.Angle.value = kind === "watermark" ? -30 : 0; + annotationFields.Opacity.value = kind === "watermark" ? 80 : 0; + annotationFields.Old.replaceChildren(...(info?.texts || []).map(text => new Option(text, text))); + if (kind === "replace") annotationFields.Text.value = info?.texts[0] || ""; + el.annotationCreateStatus.textContent = ""; + el.annotationCreate.setAttribute("aria-label", {note:"添加批注",watermark:"添加水印",stamp:"添加印章",link: item ? "修改链接" : "添加链接",replace:"替换文字"}[kind]); + updateAnnotationFields(); + el.annotationCreate.showModal(); + const focus = [annotationFields.Text, annotationFields.Image, annotationFields.Address].find(input => !input.disabled); + focus?.focus(); +} + +function updateAnnotationFields() { + const { kind, item } = state.annotationCreate; + for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = false; + for (const row of el.annotationCreate.querySelectorAll("[data-annotation]")) row.hidden = !row.dataset.annotation.split(" ").includes(kind); + const image = kind === "stamp" || kind === "watermark" && annotationFields.Content.value === "image"; + annotationFields.ImageRow.hidden = !image; + annotationFields.Image.required = image; + annotationFields.TextRow.hidden = !["note", "replace", "watermark"].includes(kind) || image; + annotationFields.AddressRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "uri"; + annotationFields.TargetRow.hidden = kind !== "link" || annotationFields.LinkKind.value !== "page"; + annotationFields.Height.value = image ? "" : state.annotationCreate.height; + annotationFields.Height.placeholder = image ? "等比" : ""; + if (kind === "link" && item) { + for (const key of ["X", "Y", "Width", "Height", "Pages", "Author"]) annotationFields[key].closest(".form-row").hidden = true; + } + for (const input of annotationFields.CreateForm.querySelectorAll("input,select,textarea")) { + input.disabled = Boolean(input.closest("[hidden]")) || image && input === annotationFields.Height; + } +} + async function editCanvasObject(item) { if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return; + if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); 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(); + editAnnotationDetails(item); return; } - if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); if (item.type === "TextObject") { if (!confirmTextReflow(item)) return; } else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return; @@ -1440,13 +1600,7 @@ async function editCanvasObject(item) { function mountEditorObjects(index, page, surface) { const scope = state.composite?.index === index ? state.composite : null; - 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); + canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface); } function changeAnnotations(item, action, ...args) { @@ -1904,7 +2058,7 @@ function updateDrawingControls() { const tool = canvasEditor.tool; const path = selectedPath(canvasEditor.selected); el.insertTextButton.setAttribute("aria-pressed", String(tool === "text")); - const line = lineShape(tool) || !tool && lineShape(path?.shape); + const line = lineShape(tool) || tool.startsWith("pen-") || !tool && lineShape(path?.shape); if (line) { el.shapeFill.checked = false; el.shapeStroke.checked = true; @@ -1913,6 +2067,9 @@ function updateDrawingControls() { } const disabled = !state.editing || !state.ready || state.exporting; el.arrowTool.disabled = disabled || !pageCan("insert"); + el.penTool.disabled = el.arrowTool.disabled; + el.penTool.classList.toggle("active", tool.startsWith("pen-")); + el.annotationTool.disabled = disabled; for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); @@ -1953,11 +2110,15 @@ async function changeDocument(name, item, ...args) { ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order", ofdgoGroupObjects: "group" }[name]; const members = item?.items || (item ? [item] : []); const scoped = scope && members.length && members.every(member => member.scoped); - const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage"].includes(name); + const ids = item ? Array.isArray(item.id) ? item.id : members.map(member => member.id) : []; + const annotated = !scoped && ids.some(id => id.startsWith("annotation:")); + const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name); + const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item); if (scope && inserting) args.push(scope.key); if (scoped && !operation) throw new Error("内部对象暂不支持此操作"); const doc = scoped ? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args) + : annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args) : await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); if (openSeq !== state.openSeq) { return; @@ -1975,7 +2136,7 @@ async function changeDocument(name, item, ...args) { const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1; if (restoredIndex >= 0) { state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null; - } else if (!item && !inserting && !(name === "ofdgoPasteObjects" && args[4])) { + } else if (!item && !inserting && !editingAnnotation && !(name === "ofdgoPasteObjects" && args[4])) { resetCompositeScope(); } if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) { @@ -2000,17 +2161,17 @@ async function changeDocument(name, item, ...args) { rememberEditorView(before, revision); return true; } - const clearSelection = !item || name === "ofdgoChangeAnnotations" && args[0] === "delete" + const clearSelection = !item && !editingAnnotation || name === "ofdgoChangeAnnotations" && args[0] === "delete" || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); - if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { + if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || inserting || name === "ofdgoWriteAnnotation" && !editingAnnotation) { state.selectObjects = true; - canvasEditor.setTool(""); + if (name !== "ofdgoInsertInk") canvasEditor.setTool(""); setPan(false); - canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs }; + canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: name === "ofdgoInsertInk" ? [] : doc.selectedIDs }; } if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs }; openSeq = ++state.openSeq; - if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { + if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { const samePage = doc.pages.findIndex((page) => page.id === previous.id); @@ -2029,7 +2190,9 @@ async function changeDocument(name, item, ...args) { } } catch (err) { if (openSeq === state.openSeq) { - if (el.annotationNote.open) { + if (el.annotationCreate.open) { + el.annotationCreateStatus.textContent = err.message; + } else if (el.annotationNote.open) { el.annotationStatus.textContent = err.message; } else if (el.batchPagesPanel.open) { el.batchPagesStatus.textContent = err.message; @@ -2588,6 +2751,8 @@ async function openOFD(file) { el.paragraphPanel.close(); el.infoPanel.close(); el.annotationNote.close(); + el.annotationCreate.close(); + el.objectPicker.close(); el.importPanel.close(); el.batchPagesPanel.close(); el.objectStylePanel.close(); @@ -5800,7 +5965,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" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象"; + el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : 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); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 4a12b80..66f6ba3 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -178,7 +178,6 @@ function paintShape(node, shape, box) { export function canEditObject(item, 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)); } @@ -191,7 +190,6 @@ 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 => { @@ -231,6 +229,15 @@ export class CanvasEditor { viewer.addEventListener("pointermove", (event) => this.move(event)); viewer.addEventListener("pointerup", (event) => this.end(event)); viewer.addEventListener("pointercancel", () => this.cancel()); + viewer.addEventListener("contextmenu", event => { + if (!this.enabled || this.input || this.options.busy()) return; + const items = this.itemsAt(event); + if (items.length > 1) { + event.preventDefault(); + this.cancel(); + this.options.onPick?.(items); + } + }); viewer.addEventListener("lostpointercapture", () => this.cancel()); viewer.addEventListener("keydown", (event) => this.keyDown(event)); viewer.addEventListener("keyup", (event) => this.keyUp(event)); @@ -282,12 +289,10 @@ 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) => Number(Boolean(b.background)) - Number(Boolean(a.background)) - || (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); + const objects = [...page.objects].sort((a, b) => (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("edit-background", Boolean(object.background)); node.classList.toggle("read-only", !canEditObject(object, "move")); node.title = objectEditReason(object); if (lineShape(object.shape)) { @@ -298,7 +303,7 @@ export class CanvasEditor { 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) { + if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit" || object.type === "Annotation") && object.contours?.length) { node.classList.add("edit-contour"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("edit-outline"); @@ -519,6 +524,7 @@ export class CanvasEditor { } if (this.tool) { if (this.tool.startsWith("erase-")) this.startErase(event); + else if (this.tool.startsWith("pen-")) this.startInk(event); else this.startShape(event); return; } @@ -533,9 +539,8 @@ export class CanvasEditor { node = target?.node; this.focus(); const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple; - if (!target || target.background && !this.items().includes(target)) { + if (!target) { this.startMarquee(event, additive); - if (this.drag?.marquee) this.drag.target = target; return; } if (additive && !event.target.dataset.corner) { @@ -587,6 +592,10 @@ export class CanvasEditor { return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => this.contains(item, point, tolerance)); } + pageItems(index) { + return [...this.viewer.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).filter(item => item?.index === index); + } + cycleSelection(event) { const items = this.itemsAt(event); if (!items.length) return; @@ -602,6 +611,10 @@ export class CanvasEditor { } event.preventDefault(); drag.pointer = event; + if (drag.ink) { + this.moveInk(event, drag); + return; + } if (drag.erase) { this.moveErase(event, drag); return; @@ -729,6 +742,16 @@ export class CanvasEditor { return; } this.move(event); + if (drag.ink) { + this.drag = null; + this.viewer.releasePointerCapture(drag.pointerID); + if (drag.pressure && drag.points.every(point => point.pressure === 0)) { + drag.preview.remove(); + return; + } + Promise.resolve(this.options.onDrawInk(drag.page.index, drag.points, drag.style, drag.pressure)).finally(() => drag.preview.remove()); + return; + } if (drag.erase) { const items = drag.erase === "erase-object" ? [...drag.erased] : drag.box ? drag.items.filter(item => item.x < drag.box.x + drag.box.width && item.x + item.width > drag.box.x @@ -746,12 +769,19 @@ 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) { this.drag = null; this.viewer.releasePointerCapture(drag.pointerID); + if (drag.annotation) { + const point = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); + const box = drag.box || { ...point, width: drag.annotation === "note" ? 5 : 40, height: drag.annotation === "note" ? 5 : 12 }; + drag.preview.remove(); + this.setTool(""); + this.options.onDrawAnnotation(drag.page.index, drag.annotation, box); + return; + } if (drag.shape === "text") { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const x = Math.max(0, Math.min(from.x, drag.page.width - 1)); @@ -812,14 +842,56 @@ export class CanvasEditor { } } + startInk(event) { + const surface = event.target.closest(".page-surface") || this.nearestSurface(event), page = this.pages.get(surface); + if (!page || !this.options.canInsert(page.index)) return; + event.preventDefault(); + this.focus(); + const style = { ...this.options.drawStyle(), fill: false, stroke: true }; + const pressure = this.tool === "pen-pressure"; + const preview = this.createPreview(surface, page, "path", pressure ? { ...style, fill: true, fillColor: style.strokeColor, stroke: false } : style); + preview.node.setAttribute("stroke-linecap", "round"); + preview.node.setAttribute("stroke-linejoin", "round"); + this.drag = { ink: true, pressure, points: [], parts: [], page, surface, style, ...preview, pointerID: event.pointerId, + rect: surface.getBoundingClientRect(), rotation: this.options.rotation() }; + this.viewer.setPointerCapture(event.pointerId); + this.moveInk(event, this.drag); + } + + moveInk(event, drag) { + const coalesced = event.getCoalescedEvents?.() || []; + for (const sample of coalesced.length ? coalesced : [event]) { + const point = pagePoint(sample.clientX, sample.clientY, drag.rect, drag.page, drag.rotation); + const last = drag.points.at(-1); + point.pressure = sample.pointerType === "pen" ? event.type === "pointerup" ? last?.pressure ?? sample.pressure : sample.pressure : 0.5; + if (last && Math.hypot(point.x-last.x, point.y-last.y) < 0.05 && Math.abs(point.pressure-last.pressure) < 0.01) continue; + drag.points.push(point); + if (!drag.pressure) { + drag.parts.push(`${last ? "L" : "M"}${point.x} ${point.y}`); + } else { + const radius = drag.style.lineWidth * point.pressure / 2; + if (radius > 0) drag.parts.push(`M${point.x+radius} ${point.y}a${radius} ${radius} 0 1 1 ${-2*radius} 0a${radius} ${radius} 0 1 1 ${2*radius} 0Z`); + if (last) { + const r = drag.style.lineWidth * last.pressure / 2, dx = point.x-last.x, dy = point.y-last.y, length = Math.hypot(dx,dy); + if ((radius > 0 || r > 0) && length > Math.abs(radius-r)) { + const x = dx/length, y = dy/length, a = (r-radius)/length, b = Math.sqrt(1-a*a); + const nx = a*x-b*y, ny = a*y+b*x, mx = a*x+b*y, my = a*y-b*x; + drag.parts.push(`M${last.x+r*mx} ${last.y+r*my}L${point.x+radius*mx} ${point.y+radius*my}L${point.x+radius*nx} ${point.y+radius*ny}L${last.x+r*nx} ${last.y+r*ny}Z`); + } + } + } + } + const dot = drag.points.length === 1 && !drag.pressure ? `l0.001 0` : ""; + drag.node.setAttribute("d", drag.parts.join(" ") + dot); + } + startErase(event) { const surface = event.target.closest(".page-surface") || this.nearestSurface(event); const page = this.pages.get(surface); 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)) - .filter(item => this.tool === "erase-object" || !item.background), + this.drag = { erase: this.tool, page, surface, items: [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)), 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", @@ -875,12 +947,10 @@ 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) { + if (!items.includes(item) && item.x >= box.x && item.x+item.width <= box.x+box.width && item.y >= box.y && item.y+item.height <= box.y+box.height) { items.push(item); } } @@ -895,13 +965,14 @@ export class CanvasEditor { } event.preventDefault(); this.focus(); - const shape = this.tool; + const annotation = this.tool.startsWith("annotation:") ? this.tool.slice(11) : ""; + const shape = annotation ? "rectangle" : this.tool; const style = this.options.drawStyle(); if (lineShape(shape)) { style.fill = false; style.stroke = true; } - this.drag = { shape, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId, + this.drag = { shape, annotation, page, surface, ...this.createPreview(surface, page, shape === "text" ? "rectangle" : shape, style), style, pointerID: event.pointerId, rect: surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY }; this.viewer.setPointerCapture(event.pointerId); @@ -1026,6 +1097,15 @@ export class CanvasEditor { if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { return; } + if (event.key === "Tab" && !this.drag && !this.nudge) { + const items = this.pageItems(this.selected.index); + if (items.length) { + event.preventDefault(); + event.stopPropagation(); + this.select(items[(items.indexOf(this.selected) + (event.shiftKey ? -1 : 1) + items.length) % items.length]); + } + return; + } const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; const edit = event.key === "Enter" || event.key === "F2"; if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 1876b54..55292f1 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -29,6 +29,7 @@ import ( "io" "math" "path" + "slices" "strconv" "strings" "syscall/js" @@ -119,6 +120,10 @@ func RunWASM() { registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoChangeOutline", changeOutline) registerCallback("ofdgoChangeAnnotations", changeAnnotations) + registerCallback("ofdgoChangeSelection", changeSelection) + registerCallback("ofdgoWriteAnnotation", writeAnnotation) + registerCallback("ofdgoReadAnnotation", readAnnotation) + registerCallback("ofdgoInsertInk", insertInk) registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoCaptureStyle", captureStyle) @@ -635,9 +640,10 @@ var currentEditor *ofdgo.Editor // editorClipboard 当前编辑文档中的对象快照与剪贴板标识 type editorClipboard struct { - token string - objects []ofdgo.GraphicObject - composite *ofdgo.CompositeSelection + token string + objects []ofdgo.GraphicObject + composite *ofdgo.CompositeSelection + annotations *ofdgo.AnnotationSelection } // copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 @@ -852,6 +858,50 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } } } + for _, annotation := range currentSession.Reader.Annots[page.ID] { + if annotation.Visible != nil && !*annotation.Visible { + continue + } + box, contours, err := currentSession.Renderer.AnnotationGeometry(annotation) + capabilities := map[string]any{"update": true, "transform": true, "arrange": true, "copy": true, "delete": true, "enter": len(annotation.Appearance.Objects) != 0} + if err != nil { + capabilities["transform"], capabilities["arrange"], capabilities["enter"] = false, false, false + capabilities["reason"], capabilities["reasonCode"] = err.Error(), "invalidObject" + } + if box.W <= 0 || box.H <= 0 { + box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary) + } + paths := make([]any, len(contours)) + for i, contour := range contours { + paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} + } + alpha, mixed := 255, false + for i, object := range annotation.Appearance.Objects { + var value *int + switch object.Type { + case "TextObject": + value = object.TextObject.Alpha + case "PathObject": + value = object.PathObject.Alpha + case "ImageObject": + value = object.ImageObject.Alpha + case "CompositeObject", "CompositeGraphicUnit": + value = object.CompositeGraphicUnit.Alpha + } + current := 255 + if value != nil { + current = *value + } + if i == 0 { + alpha = current + } else if current != alpha { + mixed = true + } + } + objects = append(objects, map[string]any{"id": "annotation:" + annotation.ID, "type": "Annotation", "annotationType": annotation.Type, + "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "contours": paths, "remark": annotation.Remark, "creator": annotation.Creator, + "alpha": alpha, "alphaMixed": mixed, "capabilities": capabilities}) + } return objects, nil } @@ -880,6 +930,597 @@ func changeAnnotations(args []js.Value) (any, error) { }) } +// annotationOptions 保存注解面板的输入,资源字节单独传输 +type annotationOptions struct { + Kind, ID, Text, Old, Creator, Pages, URI, Color string + Base string + X, Y, Width, Height, Size, Angle float64 + Alpha int + Tile bool + Keep bool + Target int +} + +// readAnnotation 获取编辑面板所需的注解内容,不修改文件 +// 入参: args 页面索引与注解标识 +// 返回: any 面板数据, error 错误信息 +func readAnnotation(args []js.Value) (any, error) { + annotation, err := currentEditor.Annotation(args[0].Int(), strings.TrimPrefix(args[1].String(), "annotation:")) + if err != nil { + return nil, err + } + texts := make([]string, 0) + visiting := make(map[string]bool) + var collect func([]ofdgo.GraphicObject) + collect = func(objects []ofdgo.GraphicObject) { + for _, object := range objects { + switch object.Type { + case "TextObject": + value := object.TextObject.Text() + if !slices.Contains(texts, value) { + texts = append(texts, value) + } + case "CompositeObject", "CompositeGraphicUnit": + collect(object.CompositeGraphicUnit.Objects) + id := object.CompositeGraphicUnit.ResourceID + if unit := currentSession.Renderer.CompositeGraphicUnits[id]; unit != nil && !visiting[id] { + visiting[id] = true + collect(unit.Objects) + delete(visiting, id) + } + } + } + } + collect(annotation.Appearance.Objects) + info := map[string]any{"texts": texts, "remark": annotation.Remark, "creator": annotation.Creator} + if annotation.Type == "Link" { + info["linkKind"] = "keep" + link, err := currentEditor.AnnotationLink(args[0].Int(), annotation.ID) + if err == nil && link.URI != nil { + info["linkKind"], info["linkURI"], info["linkBase"] = "uri", link.URI.URI, link.URI.Base + } else if err == nil && link.Goto != nil && link.Goto.Dest != nil { + for i, page := range currentSession.doc.Pages.Page { + if page.ID == link.Goto.Dest.PageID { + info["linkKind"], info["linkPage"] = "page", i+1 + break + } + } + } + } + return info, nil +} + +// writeAnnotation 原子创建多页注解或修改现有链接、重复文字 +// 入参: args 当前页面、JSON选项、字体字节、图片字节 +// 返回: any 文档信息及新选区, error 错误信息 +func writeAnnotation(args []js.Value) (any, error) { + var options annotationOptions + if err := json.Unmarshal([]byte(args[1].String()), &options); err != nil { + return nil, err + } + if options.Kind == "link" && options.ID != "" && options.Keep { + return editorSummary(), nil + } + page := args[0].Int() + var ids []string + result, err := changeObjects(func() error { + font, resource := "", "" + if !args[2].IsNull() { + data, err := bytesFromJS(args[2]) + if err != nil { + return err + } + font, err = currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[3].IsNull() { + data, err := bytesFromJS(args[3]) + if err != nil { + return err + } + resource, err = currentEditor.AddImage(data) + if err != nil { + return err + } + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return err + } + options.Height = options.Width * float64(config.Height) / float64(config.Width) + } + if options.Kind == "replace" { + style := ofdgo.TextStyle{Font: font} + _, err := currentEditor.ReplaceAnnotationText(page, options.ID, options.Old, options.Text, style) + return err + } + target := ofdgo.AnnotationLink{URI: &ofdgo.URI{URI: options.URI, Base: options.Base}} + if options.Target > 0 { + if options.Target > currentEditor.PageCount() { + return fmt.Errorf("destination page is out of range") + } + page, err := currentEditor.Page(options.Target - 1) + if err != nil { + return err + } + target = ofdgo.AnnotationLink{Dest: &ofdgo.Dest{Type: "Fit", PageID: page.ID}} + } + if options.Kind == "link" && options.ID != "" { + return currentEditor.UpdateAnnotationLink(page, options.ID, target) + } + if options.Width <= 0 || options.Height <= 0 || options.Alpha < 0 || options.Alpha > 255 { + return fmt.Errorf("invalid annotation dimensions or opacity") + } + pages, err := ofdgo.ParsePageRange(options.Pages, currentEditor.PageCount()) + if err != nil { + return err + } + for _, index := range pages { + box := ofdgo.Box{X: options.X, Y: options.Y, W: options.Width, H: options.Height} + var id string + if options.Kind == "link" { + id, err = currentEditor.AddLinkAnnotation(index, box, target, options.Creator) + } else { + annotation := ofdgo.Annotation{Creator: options.Creator, Appearance: ofdgo.Appearance{Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H)}} + switch options.Kind { + case "note": + annotation.Type, annotation.Remark = "Path", options.Text + shape, shapeErr := ofdgo.NewShape(ofdgo.ShapeRectangle, ofdgo.Box{W: box.W, H: box.H}) + if shapeErr != nil { + return shapeErr + } + shape.LineWidth = 0.3 + shape.StrokeColor = &ofdgo.StrokeColor{Value: "0 128 112"} + annotation.Appearance.Objects = []ofdgo.GraphicObject{{Type: "PathObject", PathObject: shape}} + case "watermark", "stamp": + annotation.Type = "Watermark" + if options.Kind == "stamp" { + annotation.Type = "Stamp" + } + var object ofdgo.GraphicObject + if resource != "" { + object = ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{ResourceID: resource, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), CTM: fmt.Sprintf("%g 0 0 %g 0 0", box.W, box.H), Alpha: &options.Alpha}} + } else { + if options.Kind == "stamp" { + return fmt.Errorf("stamp requires an image") + } + text := ofdgo.TextObject{Font: font, Size: options.Size, Boundary: fmt.Sprintf("0 0 %g %g", box.W, box.H), Alpha: &options.Alpha} + if err := currentEditor.LayoutText(&text, options.Text, ofdgo.TextLayout{Align: "center"}); err != nil { + return err + } + if err := setTextColor(&text, options.Color); err != nil { + return err + } + object = ofdgo.GraphicObject{Type: "TextObject", TextObject: text} + } + angle := options.Angle * math.Pi / 180 + matrix := ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) + matrix = ofdgo.TranslationMatrix(box.W/2, box.H/2).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-box.W/2, -box.H/2)) + if object.Type == "TextObject" { + object.TextObject.CTM = matrix.String() + } else { + object.ImageObject.CTM = matrix.Multiply(ofdgo.NewMatrix(object.ImageObject.CTM)).String() + } + annotation.Appearance.Objects = []ofdgo.GraphicObject{object} + default: + return fmt.Errorf("unsupported annotation kind %q", options.Kind) + } + if options.Tile && options.Kind == "watermark" { + content, err := currentSession.pageContent(index) + if err != nil { + return err + } + page, err := currentSession.pageBox(index, content) + if err != nil { + return err + } + annotation.Appearance.Boundary = fmt.Sprintf("0 0 %g %g", page.W, page.H) + base := annotation.Appearance.Objects[0] + annotation.Appearance.Objects = nil + for y := box.Y; y < page.H; y += box.H + 10 { + for x := box.X; x < page.W; x += box.W + 10 { + object := base + boundary := fmt.Sprintf("%g %g %g %g", x, y, box.W, box.H) + if object.Type == "TextObject" { + object.TextObject.Boundary = boundary + } else { + object.ImageObject.Boundary = boundary + } + annotation.Appearance.Objects = append(annotation.Appearance.Objects, object) + } + } + } + id, err = currentEditor.AddAnnotation(index, annotation) + } + if err != nil { + return err + } + if index == page { + ids = append(ids, "annotation:"+id) + } + } + return nil + }) + if err != nil { + return nil, err + } + return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: ids}, nil +} + +// insertInk 将一笔采样写为一个标准矢量对象,复用内部插入与撤销逻辑 +// 入参: args 页面、JSON采样、线宽、压感开关、颜色及可选父路径 +// 返回: any 文档和选区, error 错误信息 +func insertInk(args []js.Value) (any, error) { + var points []ofdgo.InkPoint + if err := json.Unmarshal([]byte(args[1].String()), &points); err != nil { + return nil, err + } + path, err := ofdgo.NewInk(points, args[2].Float(), args[3].Bool()) + if err != nil { + return nil, err + } + if path.Fill != nil && *path.Fill { + err = setEditorColor(path.FillColor, args[4].String()) + } else { + err = setEditorColor((*ofdgo.FillColor)(path.StrokeColor), args[4].String()) + } + if err != nil { + return nil, err + } + return insertEditorObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}, args[5:]) +} + +// splitEditorSelection 区分页面对象与注解标识,不以外观大小或类型猜测 +// 入参: ids 画布选区 +// 返回: []string 正文标识, []string 注解标识 +func splitEditorSelection(ids []string) ([]string, []string) { + var objects, annotations []string + for _, id := range ids { + if value, ok := strings.CutPrefix(id, "annotation:"); ok { + annotations = append(annotations, value) + } else { + objects = append(objects, id) + } + } + return objects, annotations +} + +// changeSelection 原子处理正文和注解混选,保持绘制顺序及统一历史 +// 入参: args 页面索引、标识列表、操作与参数 +// 返回: any 文档与新选区, error 错误信息 +func changeSelection(args []js.Value) (any, error) { + var selected []string + result, err := changeObjects(func() error { + page, operation := args[0].Int(), args[2].String() + objects, annotations := splitEditorSelection(stringsFromJS(args[1])) + switch operation { + case "align", "distribute": + return arrangeEditorSelection(page, stringsFromJS(args[1]), operation, args[3].String()) + case "delete": + if err := currentEditor.DeleteObjects(page, objects); err != nil { + return err + } + return currentEditor.DeleteAnnotations(page, annotations) + case "copy": + clipboard, err := captureEditorSelection(page, stringsFromJS(args[1])) + if err != nil { + return err + } + selected, err = pasteEditorSelection(page, clipboard, args[3].Float(), args[4].Float()) + return err + case "style": + style := objectStyle(args[3]) + if err := currentEditor.StyleObjects(page, objects, style); err != nil { + return err + } + for _, id := range annotations { + path := ofdgo.ObjectPath{Annotation: id} + members, err := currentEditor.CompositeObjects(page, path) + if err != nil { + return err + } + indexes := make([]int, len(members)) + for i := range indexes { + indexes[i] = i + } + if err := currentEditor.StyleCompositeObjects(page, path, indexes, style); err != nil { + return err + } + } + return nil + case "erase", "erasePath": + var box ofdgo.Box + var points []ofdgo.Point + if operation == "erase" { + box = ofdgo.Box{X: args[3].Float(), Y: args[4].Float(), W: args[5].Float(), H: args[6].Float()} + if err := currentEditor.EraseObjects(page, objects, box); err != nil { + return err + } + } else { + points = pointsFromJS(args[3]) + if err := currentEditor.EraseObjectsPath(page, objects, points); err != nil { + return err + } + } + for _, id := range annotations { + path := ofdgo.ObjectPath{Annotation: id} + members, err := currentEditor.CompositeObjects(page, path) + if err != nil { + return err + } + indexes := make([]int, len(members)) + for i := range indexes { + indexes[i] = i + } + if operation == "erase" { + err = currentEditor.EraseCompositeObjects(page, path, indexes, box) + } else { + err = currentEditor.EraseCompositeObjectsPath(page, path, indexes, points) + } + if err != nil { + return err + } + } + return nil + } + matrix := ofdgo.IdentityMatrix + if operation == "transform" { + dx, dy, scale := args[3].Float(), args[4].Float(), args[5].Float() + if scale <= 0 { + return fmt.Errorf("scale must be positive") + } + matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", scale, scale, dx, dy)) + } else { + bounds, err := editorSelectionBounds(page, objects, annotations) + if err != nil { + return err + } + x, y := bounds.X+bounds.W/2, bounds.Y+bounds.H/2 + switch operation { + case "rotate": + angle := float64(args[3].Int()) * math.Pi / 180 + matrix = ofdgo.NewMatrix(fmt.Sprintf("%g %g %g %g 0 0", math.Cos(angle), math.Sin(angle), -math.Sin(angle), math.Cos(angle))) + case "flip": + if args[3].String() == "horizontal" { + matrix = ofdgo.NewMatrix("-1 0 0 1 0 0") + } else if args[3].String() == "vertical" { + matrix = ofdgo.NewMatrix("1 0 0 -1 0 0") + } else { + return fmt.Errorf("invalid flip axis") + } + case "resize": + sx, sy := args[5].Float()/bounds.W, args[6].Float()/bounds.H + if sx <= 0 || sy <= 0 { + return fmt.Errorf("size must be positive") + } + matrix = ofdgo.NewMatrix(fmt.Sprintf("%g 0 0 %g %g %g", sx, sy, args[3].Float()-bounds.X*sx, args[4].Float()-bounds.Y*sy)) + default: + return fmt.Errorf("unsupported mixed selection operation %q", operation) + } + if operation != "resize" { + matrix = ofdgo.TranslationMatrix(x, y).Multiply(matrix).Multiply(ofdgo.TranslationMatrix(-x, -y)) + } + } + if err := currentEditor.TransformObjectsMatrix(page, objects, matrix); err != nil { + return err + } + return currentEditor.TransformAnnotations(page, annotations, matrix) + }) + if err != nil || selected == nil { + return result, err + } + return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil +} + +// arrangeEditorSelection 对正文和注解统一对齐或分布,不改变各自绘制层级 +// 入参: page 页面, ids 标识, operation 操作, direction 方向 +// 返回: error 错误信息 +func arrangeEditorSelection(page int, ids []string, operation, direction string) error { + if operation == "align" && !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, direction) || operation == "distribute" && direction != "horizontal" && direction != "vertical" { + return fmt.Errorf("invalid arrangement direction") + } + boxes := make([]ofdgo.Box, len(ids)) + var target ofdgo.Box + for i, id := range ids { + objects, annotations := splitEditorSelection([]string{id}) + box, err := editorSelectionBounds(page, objects, annotations) + if err != nil { + return err + } + boxes[i] = box + if i == 0 { + target = box + } else { + x, y := math.Min(target.X, box.X), math.Min(target.Y, box.Y) + target = ofdgo.Box{X: x, Y: y, W: math.Max(target.X+target.W, box.X+box.W) - x, H: math.Max(target.Y+target.H, box.Y+box.H) - y} + } + } + if len(ids) == 1 { + content, err := currentSession.pageContent(page) + if err != nil { + return err + } + target, err = currentSession.pageBox(page, content) + if err != nil { + return err + } + } + translations := make([]ofdgo.Point, len(ids)) + if operation == "align" { + for i, box := range boxes { + switch direction { + case "left": + translations[i].X = target.X - box.X + case "center": + translations[i].X = target.X + (target.W-box.W)/2 - box.X + case "right": + translations[i].X = target.X + target.W - box.X - box.W + case "top": + translations[i].Y = target.Y - box.Y + case "middle": + translations[i].Y = target.Y + (target.H-box.H)/2 - box.Y + case "bottom": + translations[i].Y = target.Y + target.H - box.Y - box.H + } + } + } else { + if len(ids) < 3 { + return nil + } + order := make([]int, len(ids)) + position := func(i int) float64 { + if direction == "vertical" { + return boxes[i].Y + } + return boxes[i].X + } + size := func(i int) float64 { + if direction == "vertical" { + return boxes[i].H + } + return boxes[i].W + } + for i := range order { + order[i] = i + } + slices.SortStableFunc(order, func(a, b int) int { + if position(a) < position(b) { + return -1 + } + if position(a) > position(b) { + return 1 + } + return 0 + }) + total := 0.0 + for _, i := range order { + total += size(i) + } + first, last := order[0], order[len(order)-1] + gap := (position(last) + size(last) - position(first) - total) / float64(len(order)-1) + next := position(first) + for _, i := range order { + if direction == "vertical" { + translations[i].Y = next - position(i) + } else { + translations[i].X = next - position(i) + } + next += size(i) + gap + } + } + for i, id := range ids { + objects, annotations := splitEditorSelection([]string{id}) + matrix := ofdgo.TranslationMatrix(translations[i].X, translations[i].Y) + if err := currentEditor.TransformObjectsMatrix(page, objects, matrix); err != nil { + return err + } + if err := currentEditor.TransformAnnotations(page, annotations, matrix); err != nil { + return err + } + } + return nil +} + +// editorSelectionBounds 计算混合选区的实际内容范围 +// 入参: page 页面索引, objects 正文标识, annotations 注解标识 +// 返回: ofdgo.Box 页面毫米范围, error 错误信息 +func editorSelectionBounds(page int, objects, annotations []string) (ofdgo.Box, error) { + content, err := currentEditor.Page(page) + if err != nil { + return ofdgo.Box{}, err + } + var bounds ofdgo.Box + add := func(box ofdgo.Box) { + if box.W <= 0 || box.H <= 0 { + return + } + if bounds.W <= 0 || bounds.H <= 0 { + bounds = box + return + } + x, y := math.Min(bounds.X, box.X), math.Min(bounds.Y, box.Y) + bounds = ofdgo.Box{X: x, Y: y, W: math.Max(bounds.X+bounds.W, box.X+box.W) - x, H: math.Max(bounds.Y+bounds.H, box.Y+box.H) - y} + } + for _, layer := range content.Content.Layer { + for _, object := range layer.Objects { + var id string + switch object.Type { + case "TextObject": + id = object.TextObject.ID + case "PathObject": + id = object.PathObject.ID + case "ImageObject": + id = object.ImageObject.ID + case "CompositeObject", "CompositeGraphicUnit": + id = object.CompositeGraphicUnit.ID + } + if slices.Contains(objects, id) { + box, err := currentSession.Renderer.ObjectBounds(object, layer.DrawParam) + if err != nil { + return bounds, err + } + add(box) + } + } + } + for _, annotation := range currentSession.Reader.Annots[content.ID] { + if slices.Contains(annotations, annotation.ID) { + box, _, err := currentSession.Renderer.AnnotationGeometry(annotation) + if err != nil { + return bounds, err + } + if box.W <= 0 || box.H <= 0 { + box, _ = ofdgo.ParseBox(annotation.Appearance.Boundary) + } + add(box) + } + } + if bounds.W <= 0 || bounds.H <= 0 { + return bounds, fmt.Errorf("selection has no bounds") + } + return bounds, nil +} + +// captureEditorSelection 捕获正文与注解的独立副本 +// 入参: page 页面索引, ids 画布标识 +// 返回: *editorClipboard 快照, error 错误信息 +func captureEditorSelection(page int, ids []string) (*editorClipboard, error) { + objects, annotations := splitEditorSelection(ids) + result := &editorClipboard{} + var err error + result.objects, err = currentEditor.Objects(page, objects) + if err == nil && len(annotations) != 0 { + result.annotations, err = currentEditor.CaptureAnnotations(page, annotations) + } + return result, err +} + +// pasteEditorSelection 原子粘贴正文和注解,共享字体与图片数据 +// 入参: page 目标页, clipboard 快照, dx、dy 位移 +// 返回: []string 新画布标识, error 错误信息 +func pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64) ([]string, error) { + var ids []string + err := currentEditor.Transaction(func(edit *ofdgo.Editor) error { + var err error + ids, err = edit.CopyObjects(page, clipboard.objects, dx, dy) + if err != nil { + return err + } + if clipboard.annotations != nil { + annotations, err := edit.PasteAnnotations(page, clipboard.annotations, dx, dy) + if err != nil { + return err + } + for _, id := range annotations { + ids = append(ids, "annotation:"+id) + } + } + return nil + }) + return ids, err +} + // editorCapabilities 统一顶层与内部对象的操作能力和受限原因 // 入参: capability 库层能力, kind 对象类型 // 返回: map[string]any 前端能力 @@ -932,6 +1573,9 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo func compositePath(value string) (ofdgo.ObjectPath, error) { parts := strings.Split(value, "/") path := ofdgo.ObjectPath{ID: parts[0]} + if id, ok := strings.CutPrefix(path.ID, "annotation:"); ok { + path.ID, path.Annotation = "", id + } for _, part := range parts[1:] { index, err := strconv.Atoi(part) if err != nil || index < 0 { @@ -1413,7 +2057,8 @@ func captureObjects(args []js.Value) (any, error) { } clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) } else { - clipboard.objects, err = currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) + clipboard, err = captureEditorSelection(args[0].Int(), stringsFromJS(args[1])) + clipboard.token = args[2].String() } if err != nil { return nil, err @@ -1435,6 +2080,9 @@ func pasteObjects(args []js.Value) (any, error) { } page, dx, dy := args[0].Int(), args[2].Float(), args[3].Float() if key != "" { + if copiedObjects.annotations != nil { + return nil, fmt.Errorf("annotations cannot be nested in a page object") + } path, err := compositePath(key) if err != nil { return nil, err @@ -1463,7 +2111,12 @@ func pasteObjects(args []js.Value) (any, error) { info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) return editorSelectionInfo{info, ids}, err } - return copyEditorObjects(page, copiedObjects.objects, dx, dy) + ids, err := pasteEditorSelection(page, copiedObjects, dx, dy) + if err != nil { + return nil, err + } + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + return editorSelectionInfo{info, ids}, err } // copyEditorObjects 复制快照并更新预览,返回新对象标识 @@ -2448,7 +3101,7 @@ func changeObjects(apply func() error) (any, error) { return nil, fmt.Errorf("no document is being edited") } revision := currentEditor.Revision() - if err := apply(); err != nil { + if err := currentEditor.Transaction(func(*ofdgo.Editor) error { return apply() }); err != nil { return nil, err } if currentEditor.Revision() == revision { diff --git a/ofdgo_annot.go b/ofdgo_annot.go index 91ee4e3..ad4f455 100644 --- a/ofdgo_annot.go +++ b/ofdgo_annot.go @@ -156,8 +156,13 @@ func (r *Reader) parseAnnotations(doc *Document) error { if r.Annots == nil { r.Annots = make(map[string][]Annotation) } + r.annotationFiles = make(map[string][]string) for _, page := range annotations.Page { annotPath := resolveResourcePath(annPath, "", page.FileLoc) + if file, ok := r.packageFile(annotPath); ok { + annotPath = cleanPackagePath(file.Name) + } + r.annotationFiles[annotPath] = append(r.annotationFiles[annotPath], page.PageID) af, err := r.openFile(annotPath) if err != nil { continue diff --git a/ofdgo_editor_annot.go b/ofdgo_editor_annot.go index deb48ed..a29cfa0 100644 --- a/ofdgo_editor_annot.go +++ b/ofdgo_editor_annot.go @@ -18,7 +18,6 @@ import ( "bytes" "encoding/xml" "fmt" - "maps" "path" "strings" "time" @@ -284,25 +283,5 @@ func (e *Editor) editAnnotations(index int, ids []string, edit func([]byte, *edi 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 + return e.commitAnnotationParts(e.source, parts) } diff --git a/ofdgo_editor_annot_content.go b/ofdgo_editor_annot_content.go new file mode 100644 index 0000000..30942ed --- /dev/null +++ b/ofdgo_editor_annot_content.go @@ -0,0 +1,455 @@ +// 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" + "slices" + "strings" + "time" +) + +// AddAnnotation 新增标准注解并分配独立标识,外观中的基本对象使用注解局部毫米坐标 +// Appearance.Objects为空时使用分类对象集合,输入对象及资源不被修改 +// 入参: index 页面索引, annotation 注解内容,ID和LastModDate由编辑器生成 +// 返回: string 注解标识, error 错误信息 +func (e *Editor) AddAnnotation(index int, annotation Annotation) (string, error) { + var id string + err := e.Transaction(func(next *Editor) error { + if index < 0 || index >= len(next.pages) { + return fmt.Errorf("page index %d out of range", index) + } + if !slices.Contains([]string{"Link", "Path", "Highlight", "Stamp", "Watermark"}, annotation.Type) { + return fmt.Errorf("invalid annotation type %q", annotation.Type) + } + if _, err := creationBox(annotation.Appearance.Boundary); err != nil { + return err + } + if err := next.prepareSourceIDs(); err != nil { + return err + } + id = next.nextID() + objects := annotation.Appearance.Objects + if len(objects) == 0 { + for _, object := range annotation.Appearance.TextObject { + objects = append(objects, GraphicObject{Type: "TextObject", TextObject: object}) + } + for _, object := range annotation.Appearance.PathObject { + objects = append(objects, GraphicObject{Type: "PathObject", PathObject: object}) + } + for _, object := range annotation.Appearance.ImageObject { + objects = append(objects, GraphicObject{Type: "ImageObject", ImageObject: object}) + } + if len(annotation.Appearance.CompositeGraphicUnit) != 0 { + return fmt.Errorf("use composite copy to insert existing complex content") + } + } + var content []byte + for _, object := range objects { + prepared, err := next.prepareObject(next.nextID(), cloneEditorData(object)) + if err != nil { + return err + } + data, err := editorObjectXML(prepared) + if err != nil { + return err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + } + appearance, err := editorXMLContainer("Appearance", ofdAttrs{{Name: xml.Name{Local: "Boundary"}, Value: annotation.Appearance.Boundary}}, content) + if err != nil { + return err + } + attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Type"}, Value: annotation.Type}, + {Name: xml.Name{Local: "Creator"}, Value: annotation.Creator}, {Name: xml.Name{Local: "LastModDate"}, Value: time.Now().Format("2006-01-02")}} + attrs.add("Subtype", annotation.Subtype) + attrs.flag("Visible", annotation.Visible) + if annotation.NoZoom { + attrs.add("NoZoom", "true") + } + if annotation.NoRotate { + attrs.add("NoRotate", "true") + } + content = nil + if annotation.Remark != "" { + content = editorXMLText("Remark", annotation.Remark) + } + content = append(content, bytes.TrimPrefix(appearance, []byte(xml.Header))...) + data, err := editorXMLContainer("Annot", attrs, content) + if err != nil { + return err + } + return next.appendAnnotations(index, data) + }) + if err != nil { + return "", err + } + return id, nil +} + +// Annotation 获取注解的独立内容快照,修改原注解应使用局部编辑方法以保留扩展字段 +// 入参: index 页面索引, id 注解标识 +// 返回: Annotation 注解内容, error 错误信息 +func (e *Editor) Annotation(index int, id string) (Annotation, error) { + data, err := e.annotationXML(index, id) + var annotation Annotation + if err == nil { + err = xml.Unmarshal(data, &annotation) + } + return annotation, err +} + +// annotationXML 读取唯一注解的独立原文,不以类型、大小或可见性筛选 +// 入参: index 页面索引, id 注解标识 +// 返回: []byte 注解XML, error 错误信息 +func (e *Editor) annotationXML(index int, id string) ([]byte, error) { + if index < 0 || index >= len(e.pages) { + return nil, fmt.Errorf("page index %d out of range", index) + } + if e.source == nil || e.source.document.Annotations == "" { + return nil, fmt.Errorf("page has no annotations") + } + reader := e.source.reader + name := reader.ResPath(e.source.document.Annotations) + data, err := reader.readFile(name) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var result []byte + for _, ref := range root.children { + if ref.name.Space != root.name.Space || ref.name.Local != "Page" || ref.attr("PageID") != e.pages[index].ID || ref.child("FileLoc") == nil { + continue + } + file, err := editorPageLocation(reader, nil, name, strings.TrimSpace(editorImportText(data, ref.child("FileLoc")))) + if err != nil { + return nil, err + } + content, err := reader.readFile(file) + if err != nil { + return nil, err + } + page, err := parseEditorXML(content) + if err != nil { + return nil, err + } + for _, node := range page.children { + if node.name.Local != "Annot" || node.name.Space != page.name.Space || node.attr("ID") != id { + continue + } + if result != nil { + return nil, fmt.Errorf("ambiguous annotation ID %q", id) + } + result, err = editorXMLStandalone(content[node.start:node.end], node) + if err != nil { + return nil, err + } + } + } + if result == nil { + return nil, fmt.Errorf("annotation %q not found", id) + } + return result, nil +} + +// appendAnnotations 在独立页面注解文件末尾追加原文,保留原索引及未知内容 +// 入参: index 页面索引, annotations 标准注解XML片段 +// 返回: error 错误信息 +func (e *Editor) appendAnnotations(index int, annotations []byte) error { + base := e.source + var err error + if base == nil { + base, err = e.importBase() + if err != nil { + return err + } + } + reader := base.reader + name := reader.ResPath(base.document.Annotations) + var data []byte + if base.document.Annotations == "" { + name = path.Join(base.directory, "Annotations.xml") + data, err = editorXMLContainer("Annotations", nil, nil) + } else { + if file, ok := reader.packageFile(name); ok { + name = cleanPackagePath(file.Name) + } + data, err = reader.readFile(name) + } + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + var ref *editorXML + var existing string + content, err := editorXMLContainer("PageAnnot", nil, nil) + if err != nil { + return err + } + for _, item := range root.children { + if item.name.Local == "Page" && item.name.Space == root.name.Space && item.attr("PageID") == e.pages[index].ID { + ref = item + loc := item.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 + } + existing = file + break + } + } + page, err := parseEditorXML(content) + if err != nil { + return err + } + added := bytes.TrimPrefix(annotations, []byte(xml.Header)) + if page.open == page.end { + content = editorPatchXML(content, []editorXMLPatch{editorXMLContent(content, page, added)}) + } else { + content = editorPatchXML(content, []editorXMLPatch{{page.close, page.close, added}}) + } + file := existing + if file != "" { + 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 == existing { + file = "" + break + } + } + } + if file == "" { + file = path.Join(base.directory, "Annotations", e.nextID()+".xml") + } + if ref != nil { + loc := ref.child("FileLoc") + var value bytes.Buffer + _ = xml.EscapeText(&value, []byte("/"+file)) + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, loc, value.Bytes())}) + } else { + entry, err := editorXMLContainer("Page", ofdAttrs{{Name: xml.Name{Local: "PageID"}, Value: e.pages[index].ID}}, editorXMLText("FileLoc", "/"+file)) + if err != nil { + return err + } + entry = bytes.TrimPrefix(entry, []byte(xml.Header)) + if root.open == root.end { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, entry)}) + } else { + data = editorPatchXML(data, []editorXMLPatch{{root.close, root.close, entry}}) + } + } + parts := map[string][]byte{name: data, file: content} + if base.document.Annotations == "" { + docName := cleanPackagePath(reader.OFD.DocBody[0].DocRoot) + docData, err := reader.readFile(docName) + if err != nil { + return err + } + doc, err := parseEditorXML(docData) + if err != nil { + return err + } + position := doc.close + for _, child := range doc.children { + if slices.Contains([]string{"CustomTags", "Attachments", "Extensions"}, child.name.Local) { + position = child.start + break + } + } + parts[docName] = editorPatchXML(docData, []editorXMLPatch{{position, position, editorXMLText("Annotations", "/"+name)}}) + } + if err := e.commitAnnotationParts(base, parts); err != nil { + return err + } + pages := maps.Clone(e.source.annotationPages) + if pages == nil { + pages = make(map[string]bool) + } + pages[e.pages[index].ID] = true + e.source.annotationPages = pages + return nil +} + +// annotationFiles 获取仍被引用的注解文件,保留原文件中的未知页引用 +// 返回: []string 文件路径 +func (e *Editor) annotationFiles() []string { + removed := make(map[string]bool) + for id := range e.source.pages { + removed[id] = true + } + for id := range e.source.annotationPages { + removed[id] = true + } + for _, page := range e.pages { + delete(removed, page.ID) + } + var files []string + for name, pages := range e.source.reader.annotationFiles { + for _, page := range pages { + if !removed[page] { + files = append(files, name) + break + } + } + } + return files +} + +// commitAnnotationParts 提交独立注解包快照,新建文档复用导入包基底 +// 入参: base 原包, parts 修改的文件 +// 返回: error 错误信息 +func (e *Editor) commitAnnotationParts(base *editorSource, parts map[string][]byte) error { + files := maps.Clone(base.reader.files) + if files == nil { + files = make(map[string][]byte) + } + maps.Copy(files, parts) + reader := &Reader{Zip: base.reader.Zip, files: files} + if err := reader.initRoot(); err != nil { + return err + } + doc, err := reader.Doc() + if err != nil { + return err + } + before, after := e.source, *base + after.reader, after.document = reader, doc + if before == nil { + for i := range e.resources { + resource := &e.resources[i] + if resource.font != nil { + resource.font = cloneEditorData(resource.font) + resource.font.FontFile = "/" + resource.name + } + if resource.image != nil { + resource.image = cloneEditorData(resource.image) + resource.image.MediaFile = "/" + resource.name + } + } + } + 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 +} + +// annotationScope 将注解外观作为内部编辑根容器,保留标准页块结构和坐标约定 +// 入参: page 页面索引, path 注解内部路径 +// 返回: *Reader 预览, *Renderer 度量器, *editorCompositeNode 根, []*editorCompositeNode 成员, error 错误信息 +func (e *Editor) annotationScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) { + data, err := e.annotationXML(page, path.Annotation) + if err != nil { + return nil, nil, nil, nil, err + } + annotation, err := parseEditorXML(data) + if err != nil { + return nil, nil, nil, nil, err + } + appearance := annotation.child("Appearance") + if appearance == nil { + return nil, nil, nil, nil, fmt.Errorf("annotation has no appearance") + } + data, err = editorXMLStandalone(data[appearance.start:appearance.end], appearance) + if err != nil { + return nil, nil, nil, nil, err + } + root, err := newEditorCompositeNode(data) + if err != nil { + return nil, nil, nil, nil, err + } + root.setStates(e.source.annotationStates[e.pages[page].ID+"/"+path.Annotation]) + reader, err := e.Reader() + if err != nil { + return nil, nil, nil, nil, err + } + if _, err = reader.PageContentByIndex(page); err != nil { + reader.Close() + return nil, nil, nil, nil, err + } + renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) + root.parent, root.visible = IdentityMatrix, true + scope := root + for depth := 0; ; depth++ { + members, loadErr := e.compositeMembers(scope, reader, renderer, make(map[string]bool)) + if loadErr != nil { + err = loadErr + break + } + if depth == len(path.Children) { + for i, member := range members { + member.index = i + } + return reader, renderer, root, members, nil + } + i := path.Children[depth] + if i < 0 || i >= len(members) { + err = fmt.Errorf("annotation member index %d out of range", i) + break + } + scope = members[i] + } + reader.Close() + return nil, nil, nil, nil, err +} + +// writeAnnotationScope 只替换外观片段并保存会话排版状态,不改写注解元数据和动作 +// 入参: page 页面索引, id 注解标识, data 外观XML, states 内部编辑状态 +// 返回: error 错误信息 +func (e *Editor) writeAnnotationScope(page int, id string, data []byte, states map[string]editorCompositeState) error { + revision := e.Revision() + err := e.editAnnotations(page, []string{id}, func(original []byte, node *editorXML) ([]byte, error) { + fragment, err := editorXMLStandalone(original[node.start:node.end], node) + if err != nil { + return nil, err + } + root, err := parseEditorXML(fragment) + if err != nil { + return nil, err + } + appearance := root.child("Appearance") + return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, bytes.TrimPrefix(data, []byte(xml.Header))}})) + }) + if err == nil && revision != e.Revision() { + statesByID := maps.Clone(e.source.annotationStates) + if statesByID == nil { + statesByID = make(map[string]map[string]editorCompositeState) + } + statesByID[e.pages[page].ID+"/"+id] = maps.Clone(states) + e.source.annotationStates = statesByID + } + return err +} diff --git a/ofdgo_editor_annot_ops.go b/ofdgo_editor_annot_ops.go new file mode 100644 index 0000000..c90b9f3 --- /dev/null +++ b/ofdgo_editor_annot_ops.go @@ -0,0 +1,283 @@ +// 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" + "slices" +) + +// AnnotationSelection 保存当前编辑器内可重复粘贴的注解原文及资源上下文 +// 来源修改或删除不影响快照,不用于跨文档传输 +type AnnotationSelection struct { + editor *Editor + data [][]byte + resources []string + states map[string]map[string]editorCompositeState +} + +// CaptureAnnotations 捕获注解选区,保留未知字段、外观及动作 +// 入参: page 页面索引, ids 不重复的注解标识 +// 返回: *AnnotationSelection 独立快照, error 错误信息 +func (e *Editor) CaptureAnnotations(page int, ids []string) (*AnnotationSelection, error) { + content, err := e.page(page) + if err != nil { + return nil, err + } + selection := &AnnotationSelection{editor: e, states: make(map[string]map[string]editorCompositeState)} + seen := make(map[string]bool) + for _, id := range ids { + if seen[id] { + return nil, fmt.Errorf("duplicate annotation ID %q", id) + } + seen[id] = true + data, err := e.annotationXML(page, id) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if !editorXMLCopyable(root) { + return nil, fmt.Errorf("annotation contains unsupported identifiers") + } + selection.data = append(selection.data, data) + selection.states[id] = maps.Clone(e.source.annotationStates[e.pages[page].ID+"/"+id]) + } + if e.originalPage(page) { + origin := e.source.pages[e.pages[page].ID] + for _, name := range content.PageRes { + selection.resources = append(selection.resources, e.source.reader.ResPath(resolveResourcePath(origin.ref.BaseLoc, "", name))) + } + } + return selection, nil +} + +// PasteAnnotations 将注解快照粘贴到指定页并平移,保留动作目标和原有外观 +// 全部标识重新分配,页级资源提升为文档资源,一次操作计入一条撤销记录 +// 入参: page 目标页面索引, selection 当前编辑器快照, dx、dy 页面毫米位移 +// 返回: []string 新注解标识, error 错误信息 +func (e *Editor) PasteAnnotations(page int, selection *AnnotationSelection, dx, dy float64) ([]string, error) { + if selection == nil || selection.editor != e || !finite(dx) || !finite(dy) { + return nil, fmt.Errorf("invalid annotation selection or offset") + } + var result []string + err := e.Transaction(func(edit *Editor) error { + if page < 0 || page >= len(edit.pages) { + return fmt.Errorf("page index %d out of range", page) + } + if len(selection.data) == 0 { + return nil + } + if err := edit.prepareSourceIDs(); err != nil { + return err + } + var content []byte + var states map[string]map[string]editorCompositeState + if edit.source != nil { + states = maps.Clone(edit.source.annotationStates) + } + if states == nil { + states = make(map[string]map[string]editorCompositeState) + } + for _, data := range selection.data { + root, err := parseEditorXML(data) + if err != nil { + return err + } + ids := make(map[string]string) + if err := edit.collectCompositeIDs(root, ids); err != nil { + return err + } + id := ids[editorResourceID(root.attr("ID"))] + states[edit.pages[page].ID+"/"+id] = remapCompositeStates(selection.states[root.attr("ID")], ids) + data, err = editorXMLRemapIDs(data, root, ids) + if err != nil { + return err + } + root, err = parseEditorXML(data) + if err != nil { + return err + } + appearance := root.child("Appearance") + if appearance == nil { + return fmt.Errorf("annotation has no appearance") + } + box, err := ParseBox(appearance.attr("Boundary")) + if err != nil || !finite(box.X+dx) || !finite(box.Y+dy) { + return fmt.Errorf("invalid annotation boundary") + } + box.X, box.Y = box.X+dx, box.Y+dy + fragment, err := editorXMLAttribute(data, appearance, "Boundary", editorBoxString(box)) + if err != nil { + return err + } + data, err = editorAnnotationDate(editorPatchXML(data, []editorXMLPatch{{appearance.start, appearance.end, fragment}})) + if err != nil { + return err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + result = append(result, id) + } + if err := edit.appendAnnotations(page, content); err != nil { + return err + } + edit.source.annotationStates = states + if len(selection.resources) != 0 { + reader := edit.source.reader + name := cleanPackagePath(reader.OFD.DocBody[0].DocRoot) + data, err := reader.readFile(name) + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + common := root.child("CommonData") + var added []byte + for _, name := range selection.resources { + if !slices.ContainsFunc(append(slices.Clone(edit.source.document.CommonData.PublicRes), edit.source.document.CommonData.DocumentRes...), func(value string) bool { return reader.ResPath(value) == name }) { + added = append(added, editorXMLText("DocumentRes", "/"+name)...) + } + } + if len(added) != 0 { + position := common.close + for _, node := range common.children { + if node.name.Local == "TemplatePage" || node.name.Local == "DefaultCS" { + position = node.start + break + } + } + return edit.commitAnnotationParts(edit.source, map[string][]byte{name: editorPatchXML(data, []editorXMLPatch{{position, position, added}})}) + } + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// TransformAnnotations 对注解选区施加页面仿射变换,保持内部图元与外观边界同步 +// 入参: page 页面索引, ids 注解标识, matrix 可逆页面变换 +// 返回: error 错误信息 +func (e *Editor) TransformAnnotations(page int, ids []string, matrix Matrix) error { + if _, ok := matrix.Invert(); !ok || !finite(matrix.a) || !finite(matrix.b) || !finite(matrix.c) || !finite(matrix.d) || !finite(matrix.e) || !finite(matrix.f) { + return fmt.Errorf("annotation transform must be finite and invertible") + } + return e.Transaction(func(edit *Editor) error { + seen := make(map[string]bool) + for _, id := range ids { + if seen[id] { + return fmt.Errorf("duplicate annotation ID %q", id) + } + seen[id] = true + annotation, err := edit.Annotation(page, id) + if err != nil { + return err + } + if matrix == IdentityMatrix { + continue + } + box, err := ParseBox(annotation.Appearance.Boundary) + if err != nil { + return err + } + bounds := matrix.TransformBox(box) + path := ObjectPath{Annotation: id} + reader, _, _, members, err := edit.compositeScope(page, path) + if err != nil { + return err + } + reader.Close() + indexes := make([]int, len(members)) + for i := range indexes { + indexes[i] = i + } + local := TranslationMatrix(box.X-bounds.X, box.Y-bounds.Y).Multiply(matrix) + if err := edit.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return local }); err != nil { + return err + } + if err := edit.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) { + fragment, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + root, err := parseEditorXML(fragment) + if err != nil { + return nil, err + } + appearance := root.child("Appearance") + updated, err := editorXMLAttribute(fragment, appearance, "Boundary", editorBoxString(bounds)) + if err != nil { + return nil, err + } + return editorAnnotationDate(editorPatchXML(fragment, []editorXMLPatch{{appearance.start, appearance.end, updated}})) + }); err != nil { + return err + } + } + return nil + }) +} + +// ReplaceAnnotationText 修改注解内部匹配的普通文字,保留各成员位置、旋转及未修改属性 +// 入参: page 页面索引, id 注解标识, old 原文字, value 新文字, style 文字样式增量 +// 返回: int 修改数量, error 错误信息 +func (e *Editor) ReplaceAnnotationText(page int, id, old, value string, style TextStyle) (int, error) { + count := 0 + err := e.Transaction(func(edit *Editor) error { + return edit.editCompositeScope(page, ObjectPath{Annotation: id}, func(renderer *Renderer, _ *editorCompositeNode, nodes []*editorCompositeNode) error { + visiting := make(map[string]bool) + var replace func([]*editorCompositeNode) error + replace = func(nodes []*editorCompositeNode) error { + for i, member := range edit.measureCompositeMembers(renderer, nodes) { + if member.Object.Type == "TextObject" && member.Object.TextObject.Text() == old { + if err := edit.updateCompositeText(nodes[i], member, &value, style, nil, nil); err != nil { + return err + } + count++ + } else if member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit" { + resource := member.Object.CompositeGraphicUnit.ResourceID + if resource != "" && visiting[resource] { + return fmt.Errorf("cyclic composite resource %q", resource) + } + children, err := edit.compositeMembers(nodes[i], renderer.Reader, renderer, visiting) + if err != nil { + return err + } + visiting[resource] = true + if err := replace(children); err != nil { + return err + } + delete(visiting, resource) + } + } + return nil + } + return replace(nodes) + }) + }) + if err != nil { + return 0, err + } + return count, nil +} diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 728f3a3..7dc15d0 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -27,11 +27,13 @@ import ( "github.com/tdewolff/canvas" ) -// ObjectPath 定位页面顶层对象及复合对象内的成员,Children为从0开始的逐层绘制序号 +// ObjectPath 定位页面顶层对象或注解外观内的成员,Children为从0开始的逐层绘制序号 +// Annotation非空时定位该注解的Appearance,ID留空 // 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举 type ObjectPath struct { - ID string `json:"id"` - Children []int `json:"children,omitempty"` + ID string `json:"id"` + Annotation string `json:"annotation,omitempty"` + Children []int `json:"children,omitempty"` } // CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象 @@ -294,6 +296,9 @@ func compositeOrientation(box Box, matrix Matrix) Matrix { // 入参: page 页面索引, path 复合对象路径 // 返回: *Reader 预览读取器, *Renderer 度量器, *editorCompositeNode 顶层节点, []*editorCompositeNode 成员, error 错误信息 func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) { + if path.Annotation != "" { + return e.annotationScope(page, path) + } layer, index, err := e.findObject(page, path.ID) if err != nil { return nil, nil, nil, nil, err @@ -361,6 +366,9 @@ func newEditorCompositeNode(data []byte) (*editorCompositeNode, error) { var object GraphicObject object.Type = node.name.Local switch object.Type { + case "Appearance": + object.Type = "CompositeObject" + err = xml.Unmarshal(data, &object.CompositeGraphicUnit) case "TextObject": err = xml.Unmarshal(data, &object.TextObject) case "PathObject": @@ -772,6 +780,9 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render if err != nil || !changed { return err } + if path.Annotation != "" { + return e.writeAnnotationScope(page, path.Annotation, data, root.states) + } next, err := newEditorCompositeNode(data) if err != nil { return err diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index 67bd50a..207d375 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -278,7 +278,12 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in slices.Sort(indexes) var owner *editorCompositeNode var container *editorXML - selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Children: slices.Clone(path.Children)}, source: e.objectOrigin(path.ID).page} + selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}} + if path.Annotation == "" { + selection.source = e.objectOrigin(path.ID).page + } else if e.source != nil { + selection.source = e.source.pages[e.pages[page].ID] + } for j, i := range indexes { if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i { return nil, fmt.Errorf("invalid composite copy selection") @@ -324,7 +329,7 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com if _, err := e.page(page); err != nil { return nil, err } - if e.pages[page].ID != selection.page || path.ID != selection.path.ID || !slices.Equal(path.Children, selection.path.Children) { + if e.pages[page].ID != selection.page || path.ID != selection.path.ID || path.Annotation != selection.path.Annotation || !slices.Equal(path.Children, selection.path.Children) { var result []int err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { var err error diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index be8b71e..806d047 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -138,6 +138,12 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra } else { owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, content}) } + } else if owner.node.name.Local == "Appearance" { + if owner.node.open == owner.node.end { + owner.patches = append(owner.patches, editorXMLContent(owner.data, owner.node, content)) + } else { + owner.patches = append(owner.patches, editorXMLPatch{owner.node.close, owner.node.close, content}) + } } else { content, err := editorXMLContainer("Content", nil, content) if err != nil { diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 1243502..8d30b9b 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -201,6 +201,16 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} composite := false + if e.source != nil { + for _, name := range e.annotationFiles() { + if data, changed := e.source.reader.files[name]; changed { + composite = true + if _, safe := refs.scan(bytes.NewReader(data), name); !safe { + return nil, nil + } + } + } + } total := len(e.pages) + len(e.resources) for i, page := range e.pages { if err := progress.report("fonts", i, total); err != nil { diff --git a/ofdgo_editor_history.go b/ofdgo_editor_history.go index fefde7c..f1b0275 100644 --- a/ofdgo_editor_history.go +++ b/ofdgo_editor_history.go @@ -14,7 +14,74 @@ package ofdgo -import "slices" +import ( + "maps" + "slices" +) + +// Transaction 将多项修改作为一次原子操作提交,失败时保留原文档及历史 +// 回调仅操作传入的编辑器,不可保留该实例供后续使用 +// 入参: edit 批量修改回调 +// 返回: error 修改错误 +func (e *Editor) Transaction(edit func(*Editor) error) error { + before := e.transactionSnapshot() + next := e.transactionSnapshot() + next.history, next.historyIndex, next.historyLimit = nil, 0, 0 + *e = next + if err := edit(e); err != nil { + *e = before + return err + } + next = e.transactionSnapshot() + *e = before + changed := next.revision != before.revision + e.restoreTransaction(next) + if changed { + e.serial, e.revision = before.serial, before.revision + if change := e.recordChange(); change != nil { + after := e.transactionSnapshot() + before.history, after.history = nil, nil + change.undo = func(e *Editor) { e.restoreTransaction(before) } + change.redo = func(e *Editor) { e.restoreTransaction(after) } + } + } + return nil +} + +// transactionSnapshot 复制可变容器,保留只读XML、图元及二进制资源的共享 +// 返回: Editor 独立事务状态 +func (e *Editor) transactionSnapshot() Editor { + next := *e + next.Info = cloneEditorData(e.Info) + next.pages = make([]PageContent, len(e.pages)) + for i, page := range e.pages { + next.pages[i] = copyEditorPage(page) + } + next.resources = slices.Clone(e.resources) + next.fonts, next.images, next.resourceID = maps.Clone(e.fonts), maps.Clone(e.images), maps.Clone(e.resourceID) + next.origins = maps.Clone(e.origins) + if e.source != nil { + source := *e.source + source.pages, source.origins = maps.Clone(source.pages), maps.Clone(source.origins) + next.source = &source + } + return next +} + +// restoreTransaction 恢复文档状态并保留历史容器、共享资源和递增标识 +// 入参: state 文档状态 +func (e *Editor) restoreTransaction(state Editor) { + state = state.transactionSnapshot() + if len(e.resources) > len(state.resources) { + state.resources = append(state.resources, e.resources[len(state.resources):]...) + } + maps.Copy(state.fonts, e.fonts) + maps.Copy(state.images, e.images) + maps.Copy(state.resourceID, e.resourceID) + state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit + state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID) + *e = state +} // editorChange 页面或对象操作及其修订标识 type editorChange struct { diff --git a/ofdgo_editor_link.go b/ofdgo_editor_link.go new file mode 100644 index 0000000..c152888 --- /dev/null +++ b/ofdgo_editor_link.go @@ -0,0 +1,250 @@ +// 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" + "slices" +) + +// AnnotationLink 表示外部地址或文档内目标,两者必须且只能指定一项 +type AnnotationLink struct { + URI *URI + Dest *Dest +} + +// AnnotationLink 读取注解中唯一的点击跳转,书签目标保持原始动作 +// 入参: page 页面索引, id 注解标识 +// 返回: Action 跳转动作, error 错误信息 +func (e *Editor) AnnotationLink(page int, id string) (Action, error) { + data, err := e.annotationXML(page, id) + if err != nil { + return Action{}, err + } + root, err := parseEditorXML(data) + if err != nil { + return Action{}, err + } + targets, _ := annotationLinkNodes(root) + if len(targets) != 1 { + return Action{}, fmt.Errorf("annotation does not contain one link target") + } + var action Action + node := targets[0].parent + err = xml.Unmarshal(data[node.start:node.end], &action) + return action, err +} + +// annotationLinkNodes 获取外观内的点击跳转和基本对象,不进入私有扩展 +// 入参: root 注解根节点 +// 返回: []*editorXML 跳转目标, []*editorXML 基本对象 +func annotationLinkNodes(root *editorXML) ([]*editorXML, []*editorXML) { + var targets, objects []*editorXML + var walk func(*editorXML) + walk = func(n *editorXML) { + if n.name.Space != root.name.Space { + return + } + if n.name.Local == "Action" && n.attr("Event") == "CLICK" { + for _, child := range n.children { + if child.name.Space == n.name.Space && (child.name.Local == "URI" || child.name.Local == "Goto") { + targets = append(targets, child) + } + } + } + if slices.Contains([]string{"TextObject", "PathObject", "ImageObject"}, n.name.Local) { + objects = append(objects, n) + } + for _, child := range n.children { + walk(child) + } + } + if appearance := root.child("Appearance"); appearance != nil { + walk(appearance) + } + return targets, objects +} + +// AddLinkAnnotation 新增标准链接注解,区域位于页面毫米坐标,不检查目标是否可达 +// 入参: page 页面索引, box 点击区域, target 链接目标, creator 作者 +// 返回: string 注解标识, error 错误信息 +func (e *Editor) AddLinkAnnotation(page int, box Box, target AnnotationLink, creator string) (string, error) { + var id string + err := e.Transaction(func(edit *Editor) error { + if _, err := annotationLinkXML(target); err != nil { + return err + } + object, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H}) + if err != nil { + return err + } + no := false + object.Fill, object.Stroke = &no, &no + id, err = edit.AddAnnotation(page, Annotation{Type: "Link", Creator: creator, Appearance: Appearance{ + Boundary: editorBoxString(box), Objects: []GraphicObject{{Type: "PathObject", PathObject: object}}, + }}) + if err != nil { + return err + } + return edit.UpdateAnnotationLink(page, id, target) + }) + if err != nil { + return "", err + } + return id, nil +} + +// UpdateAnnotationLink 修改注解中唯一的点击跳转,保留动作区域、其他动作及扩展属性 +// 无跳转时仅允许在单个基本对象上添加,多个跳转不隐式合并 +// 入参: page 页面索引, id 注解标识, target 新目标,不验证目标可达性 +// 返回: error 错误信息 +func (e *Editor) UpdateAnnotationLink(page int, id string, target AnnotationLink) error { + link, err := annotationLinkXML(target) + if err != nil { + return err + } + return e.editAnnotations(page, []string{id}, func(data []byte, node *editorXML) ([]byte, error) { + 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 + } + targets, objects := annotationLinkNodes(root) + if len(targets) > 1 { + return nil, fmt.Errorf("annotation contains multiple link targets") + } + if len(targets) == 1 { + n := targets[0] + updated := link + if n.name.Local == "URI" && target.URI != nil { + var old URI + if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil { + return nil, err + } + before, err := annotationLinkXML(AnnotationLink{URI: &old}) + if err != nil { + return nil, err + } + updated, err = editorXMLMerge(data, n, before, link) + if err != nil { + return nil, err + } + } else if n.name.Local == "Goto" && target.Dest != nil && n.child("Dest") != nil { + var old Goto + if err := xml.Unmarshal(data[n.start:n.end], &old); err != nil { + return nil, err + } + before, err := annotationLinkXML(AnnotationLink{Dest: old.Dest}) + if err != nil { + return nil, err + } + updated, err = editorXMLMerge(data, n, before, link) + if err != nil { + return nil, err + } + } else { + var check func(*editorXML) bool + check = func(node *editorXML) bool { + allowed := map[string]string{"URI": "URI Base", "Goto": "", "Dest": "Type PageID Left Right Top Bottom Zoom", "Bookmark": "Name"} + attrs, known := allowed[node.name.Local] + if !known || node.name.Space != root.name.Space || !editorXMLAttributes(node, attrs) { + return false + } + for _, attr := range node.attrs { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" { + return false + } + } + for _, child := range node.children { + if !check(child) { + return false + } + } + return true + } + if !check(n) { + return nil, fmt.Errorf("changing link type would discard extension data") + } + } + data = editorPatchXML(data, []editorXMLPatch{{n.start, n.end, updated}}) + } else { + if len(objects) != 1 { + return nil, fmt.Errorf("a new link requires one appearance object") + } + action, err := editorXMLContainer("Action", ofdAttrs{{Name: xml.Name{Local: "Event"}, Value: "CLICK"}}, link) + if err != nil { + return nil, err + } + action = bytes.TrimPrefix(action, []byte(xml.Header)) + if actions := objects[0].child("Actions"); actions != nil { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, actions, append(bytes.Clone(data[actions.open:actions.close]), action...))}) + } else { + actions, err := editorXMLContainer("Actions", nil, action) + if err != nil { + return nil, err + } + position := objects[0].open + if clips := objects[0].child("Clips"); clips != nil { + position = clips.end + } + data = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(actions, []byte(xml.Header))}}) + } + } + return editorAnnotationDate(data) + }) +} + +// annotationLinkXML 序列化标准跳转目标,不改写其他动作或命名空间 +// 入参: target 跳转目标 +// 返回: []byte 独立XML片段, error 错误信息 +func annotationLinkXML(target AnnotationLink) ([]byte, error) { + if (target.URI == nil) == (target.Dest == nil) { + return nil, fmt.Errorf("specify one URI or destination") + } + var data []byte + var err error + if target.URI != nil { + attrs := ofdAttrs{{Name: xml.Name{Local: "URI"}, Value: target.URI.URI}} + attrs.add("Base", target.URI.Base) + data, err = editorXMLContainer("URI", attrs, nil) + } else { + dest := target.Dest + if !slices.Contains([]string{"XYZ", "Fit", "FitH", "FitV", "FitR"}, dest.Type) { + return nil, fmt.Errorf("invalid destination type %q", dest.Type) + } + attrs := ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: dest.Type}, {Name: xml.Name{Local: "PageID"}, Value: dest.PageID}} + for _, value := range []struct { + name string + value float64 + }{{"Left", dest.Left}, {"Top", dest.Top}, {"Right", dest.Right}, {"Bottom", dest.Bottom}, {"Zoom", dest.Zoom}} { + if !finite(value.value) { + return nil, fmt.Errorf("destination coordinates must be finite") + } + if dest.Type == "XYZ" && slices.Contains([]string{"Left", "Top", "Zoom"}, value.name) || dest.Type == "FitH" && value.name == "Top" || dest.Type == "FitV" && value.name == "Left" || dest.Type == "FitR" && value.name != "Zoom" { + attrs.add(value.name, ofdNumber(value.value)) + } + } + data, err = editorXMLContainer("Dest", attrs, nil) + if err == nil { + data, err = editorXMLContainer("Goto", nil, bytes.TrimPrefix(data, []byte(xml.Header))) + } + } + return bytes.TrimPrefix(data, []byte(xml.Header)), err +} diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 5bdff3a..e7ef72f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -31,13 +31,15 @@ import ( // editorSource 保留输入包及按需加载的原始页面 type editorSource struct { - reader *Reader - document *Document - info DocInfo - directory string - pages map[string]*editorSourcePage - origins map[string]*editorObjectOrigin - idsReady bool + reader *Reader + document *Document + info DocInfo + directory string + pages map[string]*editorSourcePage + origins map[string]*editorObjectOrigin + idsReady bool + annotationStates map[string]map[string]editorCompositeState + annotationPages map[string]bool } // editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用 diff --git a/ofdgo_editor_pages.go b/ofdgo_editor_pages.go index b72a9f3..aeef547 100644 --- a/ofdgo_editor_pages.go +++ b/ofdgo_editor_pages.go @@ -28,6 +28,9 @@ func (e *Editor) prunePageReferences(parts map[string][]byte) error { for id := range e.source.pages { removed[id] = true } + for id := range e.source.annotationPages { + removed[id] = true + } for _, page := range e.pages { delete(removed, page.ID) } diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 881d7fd..13c650e 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -22,6 +22,22 @@ import ( "slices" ) +// TransformObjectsMatrix 对同页对象施加可逆页面变换,保留原文、资源与裁剪 +// 入参: page 页面索引, ids 对象标识, matrix 页面变换 +// 返回: error 错误信息 +func (e *Editor) TransformObjectsMatrix(page int, ids []string, matrix Matrix) error { + if _, ok := matrix.Invert(); !ok || !finite(matrix.a) || !finite(matrix.b) || !finite(matrix.c) || !finite(matrix.d) || !finite(matrix.e) || !finite(matrix.f) { + return fmt.Errorf("object transform must be finite and invertible") + } + objects, _, err := e.selectedObjects(page, ids) + if err != nil || len(objects) == 0 { + return err + } + return e.transformObjects(page, objects, func(object GraphicObject) (GraphicObject, error) { + return e.transformMatrix(object, matrix) + }) +} + // RotateObject 绕对象可见范围中心旋转 // 入参: page 页面索引, id 对象标识, degrees 顺时针角度 // 返回: error 错误信息 diff --git a/ofdgo_ink.go b/ofdgo_ink.go new file mode 100644 index 0000000..317df19 --- /dev/null +++ b/ofdgo_ink.go @@ -0,0 +1,101 @@ +// 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" + "math" + + "github.com/tdewolff/canvas" +) + +// InkPoint 手写采样点,X和Y为毫米,Pressure为0到1的压力 +type InkPoint struct { + X float64 `json:"x"` + Y float64 `json:"y"` + Pressure float64 `json:"pressure"` +} + +// NewInk 将采样点转为标准矢量路径,等宽笔保留中心线,压感笔使用一次填充的连续轮廓 +// 不推测笔压、不修改输入;单点生成圆点,页面外路径由页面边界裁切 +// 入参: points 采样点, width 等宽线宽或压感最大线宽,单位毫米, pressure 是否使用压力 +// 返回: PathObject 黑色笔迹,可修改颜色和透明度后插入页面或注解, error 错误信息 +func NewInk(points []InkPoint, width float64, pressure bool) (PathObject, error) { + if len(points) == 0 || !finite(width) || width <= 0 { + return PathObject{}, fmt.Errorf("ink requires points and a positive finite width") + } + path := &canvas.Path{} + stationary := true + for i, point := range points { + if !finite(point.X) || !finite(point.Y) || !finite(point.Pressure) || point.Pressure < 0 || point.Pressure > 1 { + return PathObject{}, fmt.Errorf("invalid ink point %d", i) + } + stationary = stationary && point.X == points[0].X && point.Y == points[0].Y + if !pressure { + if i == 0 { + path.MoveTo(point.X, point.Y) + } else { + path.LineTo(point.X, point.Y) + } + continue + } + radius := width * point.Pressure / 2 + if radius > 0 { + path = path.Append(canvas.Circle(radius).Translate(point.X, point.Y)) + } + if i == 0 { + continue + } + previous := points[i-1] + r := width * previous.Pressure / 2 + dx, dy := point.X-previous.X, point.Y-previous.Y + length := math.Hypot(dx, dy) + if radius == 0 && r == 0 || length <= math.Abs(radius-r) { + continue + } + x, y := dx/length, dy/length + a := (r - radius) / length + b := math.Sqrt(1 - a*a) + nx, ny, mx, my := a*x-b*y, a*y+b*x, a*x+b*y, a*y-b*x + segment := &canvas.Path{} + segment.MoveTo(previous.X+r*mx, previous.Y+r*my) + segment.LineTo(point.X+radius*mx, point.Y+radius*my) + segment.LineTo(point.X+radius*nx, point.Y+radius*ny) + segment.LineTo(previous.X+r*nx, previous.Y+r*ny) + segment.Close() + path = path.Append(segment) + } + filled := pressure || stationary + if !pressure && stationary { + path = canvas.Circle(width/2).Translate(points[0].X, points[0].Y) + } + if path.Empty() { + return PathObject{}, fmt.Errorf("ink has no visible stroke") + } + box := path.Bounds() + padding := width / 2 + boundary := Box{X: box.X0 - padding, Y: box.Y0 - padding, W: math.Max(box.W(), width) + width, H: math.Max(box.H(), width) + width} + object := editorClipPath(path.Translate(-boundary.X, -boundary.Y)) + object.Boundary = editorBoxString(boundary) + stroke := !filled + object.Fill, object.Stroke = &filled, &stroke + object.Cap, object.Join, object.LineWidth = "Round", "Round", width + if filled { + object.FillColor = &FillColor{Value: "0 0 0"} + } else { + object.StrokeColor = &StrokeColor{Value: "0 0 0"} + } + return object, nil +} diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 5554940..4ec69c2 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -45,6 +45,7 @@ type Reader struct { doc *Document Stamps map[string][]Stamp Annots map[string][]Annotation + annotationFiles map[string][]string fileIndex map[string]*zip.File fileIndexFold map[string]*zip.File files map[string][]byte diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index 2cb7c0c..dedff3f 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -37,6 +37,35 @@ type ObjectContour struct { EvenOdd bool `json:"evenOdd,omitempty"` } +// AnnotationGeometry 获取注解外观的实际内容轮廓,不将Appearance容器边界视为绘制内容 +// 链接保留透明对象定义的交互区域,图片按几何范围度量而不解码透明像素 +// 入参: annotation 注解 +// 返回: Box 页面范围, []ObjectContour 命中轮廓, error 错误信息 +func (r *Renderer) AnnotationGeometry(annotation Annotation) (Box, []ObjectContour, error) { + object := GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{Boundary: annotation.Appearance.Boundary, Objects: annotation.Appearance.Objects}} + box, contours, err := r.ObjectGeometry(object, "") + if err != nil { + return box, contours, err + } + for _, source := range r.annotationActionSources([]Annotation{annotation}) { + for _, action := range source.Actions { + if action.Event != "CLICK" { + continue + } + region, path := actionLinkRegion(source, action) + if region.W <= 0 || region.H <= 0 { + continue + } + if path == "" { + path = canvas.Rectangle(region.W, region.H).Translate(region.X, region.Y).ToSVG() + } + box = unionTextBox(box, region) + contours = append(contours, ObjectContour{Path: path}) + } + } + return box, contours, nil +} + // ObjectContours 获取路径、图片或复合对象的实际绘制区域,包含描边、虚线、变换及对象裁剪 // 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换 // 入参: object 路径、图片或复合对象, drawParam 图层绘制参数标识 diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index d767064..cd2267d 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -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 { @@ -176,7 +176,7 @@ func (s *svgResourceRenderer) beginAnnotation(id string) bool { if s.objects == nil { return false } - s.objectStack = append(s.objectStack, svgObjectGroup{}) + s.objectStack = append(s.objectStack, svgObjectGroup{path: "annotation:" + id, composite: true}) fmt.Fprintf(s.writer, ``, html.EscapeString(id)) return true } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index c3f6ee6..eb8fbf4 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -109,6 +109,7 @@ func (e *Editor) WritePagesTo(writer io.Writer, indexes []int) (int64, error) { if e.source != nil { source := *e.source source.pages = make(map[string]*editorSourcePage, len(indexes)) + source.annotationPages = nil for _, index := range indexes { id := e.pages[index].ID if page := e.source.pages[id]; page != nil { @@ -324,6 +325,30 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color used := make(map[string]bool) promoted := make(map[string]bool) files := make(map[string]bool) + if e.source != nil { + for _, name := range e.annotationFiles() { + data, changed := e.source.reader.files[name] + if !changed { + continue + } + refs := editorResourceRefs{ids: used, files: make(map[string]bool)} + if _, safe := refs.scan(bytes.NewReader(data), name); !safe { + for _, resource := range e.resources { + if resource.font != nil { + used[resource.font.ID] = true + } + if resource.image != nil { + used[resource.image.ID] = true + } + if resource.space != nil { + used[resource.space.ID] = true + } + used[resource.definition()] = true + } + } + } + maps.Copy(promoted, used) + } for _, page := range e.pages { var original map[string]GraphicObject if e.source != nil {