From 2ffbf573ba2d1de76088691d318d3578ff036362 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 17 Sep 2026 23:45:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=98=85=E8=AF=BB=E7=BC=96=E8=BE=91):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BB=84=E5=90=88=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=B3=A8=E8=A7=A3=E4=BA=A4=E4=BA=92=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A1=B5=E9=9D=A2=E5=AE=9A=E4=BD=8D=E5=92=8C?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E9=97=AA=E7=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 10 +- assets/webui/ofdgo.css | 43 +++--- assets/webui/ofdgo.js | 151 ++++++++++++-------- assets/webui/ofdgo_edit.js | 8 +- internal/webui/wasm.go | 22 ++- ofdgo_editor_composite.go | 55 +++++--- ofdgo_editor_composite_group.go | 236 ++++++++++++++++++++++++++++++++ ofdgo_editor_group.go | 27 ++-- ofdgo_editor_open.go | 7 +- ofdgo_editor_transform.go | 58 +++++++- ofdgo_renderer_bounds.go | 8 +- 11 files changed, 497 insertions(+), 128 deletions(-) create mode 100644 ofdgo_editor_composite_group.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 6cd8256..3699970 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -264,6 +264,12 @@
+ +
+
+
+
+
@@ -444,10 +450,6 @@
-
OFD 字体
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index bc3eb58..47ce601 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1777,6 +1777,32 @@ button.outline-link:hover { background: rgba(15, 123, 104, 0.08); } +.annotation-note { + position: absolute; + z-index: 2; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--line-strong); + background: var(--panel); + color: var(--accent-strong); + font-size: 16px; + line-height: 20px; + text-align: center; + transform: translateX(-100%); +} + +.annotation-detail { + display: grid; + gap: 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.editing .annotation-note { + display: none; +} + .page-placeholder { position: absolute; inset: 0; @@ -1845,7 +1871,6 @@ button.outline-link:hover { .font-panel, .doc-font-panel, .attachment-panel, -.annotation-panel, .signature-panel { margin-top: 18px; padding-top: 12px; @@ -1898,14 +1923,12 @@ button.outline-link:hover { } .doc-font-list, -.annotation-list, .signature-list { display: grid; gap: 8px; } .doc-font-row, -.annotation-row, .signature-row { display: grid; gap: 6px; @@ -1918,7 +1941,6 @@ button.outline-link:hover { .page-list-item:not(:disabled):hover, .doc-font-row:hover, .signature-row:hover, -.annotation-row:hover, .font-row:hover { border-color: var(--line-strong); background: #ffffff; @@ -1945,7 +1967,6 @@ button.outline-link:hover { } .doc-font-head, -.annotation-head, .signature-head { display: flow-root; } @@ -1976,11 +1997,6 @@ button.outline-link:hover { text-decoration: underline; } -.annotation-head .info-name-button { - color: var(--accent-strong); - font-size: 13px; -} - .signature-stamp-group { color: var(--text-muted); font-weight: 400; @@ -2015,16 +2031,11 @@ button.outline-link:hover { } .doc-font-badges, -.signature-badges, -.annotation-head > .font-badge { +.signature-badges { float: right; margin-left: 8px; } -.annotation-row .info-value { - white-space: pre-wrap; -} - .font-badge { display: inline-flex; align-items: center; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index b034e3f..027d270 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -17,6 +17,7 @@ const STATUS = { pageExporting: "正在导出单页", }; const wasmRequests = new Map(); +const metaContents = new WeakMap(); let wasmPromise = null; let wasmWorker = null; @@ -332,8 +333,8 @@ const el = { signaturePanel: document.querySelector("#signaturePanel"), signatureSummary: document.querySelector("#signatureSummary"), signatureList: document.querySelector("#signatureList"), - annotationPanel: document.querySelector("#annotationPanel"), - annotationList: document.querySelector("#annotationList"), + annotationNote: document.querySelector("#annotationNote"), + annotationDetail: document.querySelector("#annotationDetail"), docFontList: document.querySelector("#docFontList"), docFontSummary: document.querySelector("#docFontSummary"), availableFontSummary: document.querySelector("#availableFontSummary"), @@ -711,7 +712,11 @@ el.objectBoundsForm.addEventListener("submit", async event => { const item = canvasEditor.selected; const values = [el.objectX, el.objectY, el.objectWidth, el.objectHeight].map(objectDimension); let saved; - if (item.shape) { + if (item.oriented) { + const [a, b, c, d, e, f] = item.oriented.matrix, determinant = a * d - b * c; + const x = values[0] - e, y = values[1] - f; + saved = await changeDocument("ofdgoReshapeObject", item, (d * x - c * y) / determinant, (a * y - b * x) / determinant, values[2] / Math.hypot(a, b), values[3] / Math.hypot(c, d), true); + } else if (item.shape) { if (lineShape(item.shape)) { values[2] *= Math.sign(item.geometry.width); values[3] *= Math.sign(item.geometry.height); } saved = await changeDocument("ofdgoReshapeObject", item, ...values); } else saved = await changeDocument("ofdgoResizeObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ...values); @@ -989,7 +994,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.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open; } function setObjectDimension(input, value) { @@ -1004,11 +1009,16 @@ function objectDimension(input) { function openObjectBounds() { const item = canvasEditor.selected; - const box = item.geometry || selectionBounds((item.items || [item]).map(member => member.bounds || member)); + let box = item.geometry || selectionBounds((item.items || [item]).map(member => member.bounds || member)); + if (item.oriented) { + const frame = item.oriented, [a, b, c, d, e, f] = frame.matrix; + box = { x: a * frame.box.x + c * frame.box.y + e, y: b * frame.box.x + d * frame.box.y + f, + width: frame.box.width * Math.hypot(a, b), height: frame.box.height * Math.hypot(c, d) }; + } state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) }; for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]); el.objectAspect.checked = true; - el.objectAspect.disabled = !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height); + el.objectAspect.disabled = !item.shape && !item.oriented && !canEditObject(item, "stretch") || lineShape(item.shape) && (!box.width || !box.height); el.objectWidth.disabled = lineShape(item.shape) && !box.width; el.objectHeight.disabled = lineShape(item.shape) && !box.height; el.objectBoundsStatus.textContent = ""; @@ -1026,8 +1036,8 @@ function openObjectStyle() { const items = canvasEditor.items(); if (!items.length) return; const ordered = [...items].sort((a, b) => a.position - b.position); - el.groupObjectsButton.disabled = Boolean(state.composite) || items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index); - el.ungroupObjectButton.disabled = Boolean(state.composite) || items.length !== 1 || !canEditObject(items[0], "ungroup"); + el.groupObjectsButton.disabled = items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index); + el.ungroupObjectButton.disabled = items.length !== 1 || !canEditObject(items[0], "ungroup"); 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; @@ -1248,6 +1258,7 @@ async function toggleEditor() { if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy") || formDialogOpen()) return; if (state.editorInfo) { if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; + const anchor = scaleAnchor(0); state.editing = !state.editing; resetCompositeScope(); canvasEditor.clear(); @@ -1260,10 +1271,11 @@ async function toggleEditor() { renderOutlines(false); renderPageList(); applyFit(false); + restoreScaleAnchor(anchor); return; } const { pageIndex, fitMode, scale } = state; - const { scrollLeft, scrollTop } = el.viewerPanel; + const anchor = scaleAnchor(0); const openSeq = ++state.openSeq; setBusy(true, "正在准备编辑", null, "正在准备编辑"); try { @@ -1281,7 +1293,7 @@ async function toggleEditor() { canvasEditor.clear(); setPan(false); await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale, - keepPreview: true, previewScroll: { scrollLeft, scrollTop } }); + keepPreview: true, previewAnchor: anchor }); } catch (err) { if (openSeq === state.openSeq) showError(err, false); } finally { @@ -1887,7 +1899,7 @@ async function changeDocument(name, item, ...args) { ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor", ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoCropImage: "crop", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop", ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", ofdgoPasteStyle: "pasteStyle", - ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order" }[name]; + 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); @@ -1903,7 +1915,7 @@ async function changeDocument(name, item, ...args) { return true; } const view = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; - const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name) + const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name) || inserting || name === "ofdgoPasteObjects" && !!args[4]); const reindexed = restoring ? view?.reindex && view.before : reindex && before; const clipboard = state.objectClipboard; @@ -2031,7 +2043,7 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { } finally { if (openSeq === state.openSeq) { releasePageResources(); - renderMeta(); + renderMeta(true); updateControls(); loadDocumentDetails(openSeq); for (const page of doc.pages) { @@ -2521,6 +2533,7 @@ async function openOFD(file) { el.pagePanel.close(); el.paragraphPanel.close(); el.infoPanel.close(); + el.annotationNote.close(); el.importPanel.close(); el.batchPagesPanel.close(); el.objectStylePanel.close(); @@ -2989,7 +3002,7 @@ async function openDocument(options = {}) { if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) { renderOutlines(!options.keepPreview); } - renderMeta(); + renderMeta(options.keepPreview); renderPageFlow(); if (options.keepPreview) { for (const [index, page] of state.pageCache) { @@ -3007,7 +3020,9 @@ async function openDocument(options = {}) { } applyFit(false); if (options.keepPreview) { - if (options.previewScroll) { + if (options.previewAnchor) { + restoreScaleAnchor(options.previewAnchor); + } else if (options.previewScroll) { Object.assign(el.viewerPanel, options.previewScroll); } else { scrollToPage(pageIndex); @@ -3045,11 +3060,13 @@ async function loadDocumentDetails(openSeq) { try { const info = await callWASM("ofdgoDocumentInfo"); if (openSeq === state.openSeq) { - Object.assign(state.doc, info, { detailsPending: false }); + Object.assign(state.doc, info, { detailsPending: false, detailsError: "" }); renderMeta(); } } catch (err) { if (openSeq === state.openSeq) { + Object.assign(state.doc, { detailsPending: false, detailsError: err.message }); + renderMeta(); showError(err, false); } } @@ -3595,14 +3612,14 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { if (state.editorInfo) { mountEditorObjects(index, page, surface); } + mountAnnotationNotes(index, surface); for (const link of page.links) { const anchor = document.createElement("a"); anchor.className = "page-link"; if (link.dest) { const target = state.doc.pages.findIndex(page => page.id === link.dest.pageID); - if (target < 0 || !["XYZ", "Fit", "FitH", "FitV", "FitR"].includes(link.dest.type)) continue; - anchor.href = `#page-${target + 1}`; - anchor.title = `第${target + 1}页`; + anchor.href = target < 0 ? "#" : `#page-${target + 1}`; + anchor.title = target < 0 ? "文档链接" : `第${target + 1}页`; anchor.addEventListener("click", event => { event.preventDefault(); navigateDestination(link.dest); }); } else { let url; @@ -3637,7 +3654,14 @@ function destinationPoint(page, x, y) { async function navigateDestination(dest) { if (document.body.hasAttribute("aria-busy")) return; const index = state.doc.pages.findIndex(page => page.id === dest.pageID); - if (index < 0) return; + if (index < 0) { + setStatus("目标页面不存在"); + return; + } + if (!["XYZ", "Fit", "FitH", "FitV", "FitR"].includes(dest.type)) { + setStatus("暂不支持此跳转方式"); + return; + } const seq = state.openSeq, scale = state.scale; await renderPage(index, { fit: false, scroll: false }); if (seq !== state.openSeq || state.pageIndex !== index) return; @@ -4873,8 +4897,9 @@ function markThumbnailError(index) { } } -function renderMeta() { +function renderMeta(keepDetails = false) { const doc = state.doc || {}; + el.metaPanel.setAttribute("aria-busy", String(!!doc.detailsPending)); document.title = `OFDGo WebUI - ${state.fileName}`; el.metaFile.textContent = state.fileName; el.metaTitle.textContent = doc.title || "-"; @@ -4889,17 +4914,25 @@ function renderMeta() { } el.metaType.textContent = doc.docType || "-"; el.metaVersion.textContent = doc.version || "-"; - el.metaSignatures.textContent = doc.detailsPending ? "正在检查" : String(doc.signatureCount || 0); el.metaFonts.textContent = String(doc.fontCount || 0); el.pageTotal.textContent = String(doc.pageCount || 0); - renderAttachments(); - renderSignatures(); + if (keepDetails && doc.detailsPending) return; + el.metaSignatures.textContent = doc.detailsPending ? "正在检查" : doc.detailsError ? "读取失败" : String(doc.signatureCount || 0); + renderMetaContent(el.attachmentList, [doc.attachments, doc.attachmentError], renderAttachments); + renderMetaContent(el.signatureList, [doc.signatures, doc.signatureError], renderSignatures); renderAnnotations(); - renderDocumentFonts(); - renderFontList(); + renderMetaContent(el.docFontList, doc.fonts || [], renderDocumentFonts); + updateTextFonts(canvasEditor.selected, true); updateLocalFontButton(); } +function renderMetaContent(node, value, render) { + const key = JSON.stringify(value); + if (metaContents.get(node) === key) return; + render(); + metaContents.set(node, key); +} + function renderAttachments() { const attachments = state.doc?.attachments || []; el.attachmentPanel.hidden = !attachments.length && !state.doc?.attachmentError; @@ -5157,32 +5190,38 @@ function clearRegionHighlights() { } function renderAnnotations() { - const annotations = (state.doc?.annotations || []).filter((annotation) => annotation.visible); - el.annotationPanel.hidden = !annotations.length; - el.annotationList.replaceChildren(); - const types = { Link: "链接", Path: "路径", Highlight: "高亮", Stamp: "印章", Watermark: "水印" }; - const fragment = document.createDocumentFragment(); - for (const annotation of annotations) { - const row = document.createElement("div"); - row.className = "annotation-row"; - const head = document.createElement("div"); - head.className = "annotation-head"; - const name = document.createElement("button"); - name.type = "button"; - name.className = "info-name-button"; - name.textContent = `第 ${annotation.page} 页`; - name.disabled = !state.renderAnnotations || annotation.width <= 0 || annotation.height <= 0; - name.title = state.renderAnnotations ? "定位注解" : "注解已隐藏"; - name.addEventListener("click", () => focusPageRegion(annotation, "注解")); - const badge = fontBadge(types[annotation.type] || "注解", ""); - head.append(badge, name); - row.append(head); - appendInfoLine(row, "内容", annotation.remark); - appendInfoLine(row, "作者", annotation.creator); - appendInfoLine(row, "时间", formatDocumentTime(annotation.lastModDate)); - fragment.append(row); + for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) { + mountAnnotationNotes(Number(shell.dataset.pageIndex), shell.querySelector(".page-surface")); + } +} + +function mountAnnotationNotes(index, surface) { + for (const note of surface.querySelectorAll(".annotation-note")) note.remove(); + if (!state.renderAnnotations) return; + const page = state.doc.pages[index]; + for (const annotation of state.doc.annotations || []) { + if (!annotation.visible || annotation.page !== index + 1 || !annotation.remark?.trim()) continue; + const note = document.createElement("button"); + note.type = "button"; + note.className = "annotation-note"; + note.textContent = "\u24d8"; + note.title = "查看注解"; + note.setAttribute("aria-label", "查看注解"); + note.setAttribute("aria-haspopup", "dialog"); + note.style.left = `${Math.max(22 / MM_TO_PX, Math.min(page.width, annotation.x + annotation.width)) * MM_TO_PX}px`; + note.style.top = `${Math.max(0, Math.min(page.height - 22 / MM_TO_PX, annotation.y)) * MM_TO_PX}px`; + note.addEventListener("click", () => { + if (state.editing || document.body.hasAttribute("aria-busy")) return; + el.annotationDetail.replaceChildren(); + const content = document.createElement("div"); + content.textContent = annotation.remark; + el.annotationDetail.append(content); + appendInfoLine(el.annotationDetail, "作者", annotation.creator); + appendInfoLine(el.annotationDetail, "时间", formatDocumentTime(annotation.lastModDate)); + el.annotationNote.showModal(); + }); + surface.append(note); } - el.annotationList.append(fragment); } function renderDocumentFonts() { @@ -5358,7 +5397,7 @@ function setPan(enabled) { } function startPan(event) { - if (!state.panMode || !state.doc || event.pointerType !== "mouse" || event.button !== 0 || document.body.hasAttribute("aria-busy") || event.target.closest("a")) { + if (!state.panMode || !state.doc || event.pointerType !== "mouse" || event.button !== 0 || document.body.hasAttribute("aria-busy") || event.target.closest("a, button")) { return; } const rect = el.viewerPanel.getBoundingClientRect(); @@ -5494,7 +5533,7 @@ function setScale(nextScale, updateStatus = true, fitMode = "free") { updateControls(); } -function scaleAnchor() { +function scaleAnchor(viewY = 0.45) { const shell = pageShellFromView() || pageShell(state.pageIndex); if (!shell) { return null; @@ -5503,8 +5542,9 @@ function scaleAnchor() { const shellRect = shell.getBoundingClientRect(); return { index: Number.parseInt(shell.dataset.pageIndex, 10), + viewY, x: (viewerRect.left + viewerRect.width / 2 - shellRect.left) / Math.max(1, shellRect.width), - y: (viewerRect.top + viewerRect.height * 0.45 - shellRect.top) / Math.max(1, shellRect.height), + y: (viewerRect.top + viewerRect.height * viewY - shellRect.top) / Math.max(1, shellRect.height), }; } @@ -5519,7 +5559,7 @@ function restoreScaleAnchor(anchor) { const viewerRect = el.viewerPanel.getBoundingClientRect(); const shellRect = shell.getBoundingClientRect(); el.viewerPanel.scrollLeft += shellRect.left + shellRect.width * anchor.x - viewerRect.left - viewerRect.width / 2; - el.viewerPanel.scrollTop += shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * 0.45; + el.viewerPanel.scrollTop += shellRect.top + shellRect.height * anchor.y - viewerRect.top - viewerRect.height * anchor.viewY; } function currentPageInfo() { @@ -5596,6 +5636,7 @@ function updateControls() { } function updateEditorTools() { + el.viewerPanel.classList.toggle("editing", state.editing); el.editButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); el.editButton.setAttribute("aria-pressed", String(state.editing)); el.editButton.title = state.editing ? "阅读" : "编辑"; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 24f56c0..33b7f2f 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -294,7 +294,7 @@ export class CanvasEditor { svg.setAttribute("aria-hidden", "true"); svg.setAttribute("preserveAspectRatio", "none"); let contour; - if (object.type === "PathObject" && (!object.scoped || object.shape || object.oriented)) { + if (object.type === "PathObject" && (object.shape || object.oriented || !object.contours?.length)) { const shape = object.shape || object.oriented?.kind; contour = document.createElementNS("http://www.w3.org/2000/svg", shape === "line" ? "line" : shape === "ellipse" ? "ellipse" : shape === "rectangle" ? "rect" : "path"); if (!shape) contour.setAttribute("d", object.outline); @@ -312,7 +312,7 @@ export class CanvasEditor { this.nodes.set(node, item); node.addEventListener("focus", () => this.select(item)); const handles = !canEditObject(object, "transform") ? [] : lineShape(object.shape) ? ["start", "end"] - : object.shape || object.oriented || object.type === "ImageObject" && !object.imageBorder ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] + : object.shape || object.oriented || canEditObject(object, "stretch") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : object.type === "TextObject" && canEditObject(object, "reflow") && canEditObject(object, "layoutKnown") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"]; for (const corner of handles) { @@ -629,7 +629,7 @@ export class CanvasEditor { Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` }); return; } - if (drag.item.type === "ImageObject" && !drag.item.imageBorder && drag.corner.length === 1) { + if (canEditObject(drag.item, "stretch") && drag.corner.length === 1) { drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, to.y - from.y, drag.corner, false); const sx = drag.box.width / drag.item.width, sy = drag.box.height / drag.item.height; Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, top: `${drag.box.y * PX_PER_MM}px`, @@ -763,7 +763,7 @@ export class CanvasEditor { this.viewer.releasePointerCapture(drag.pointerID); const geometry = drag.item.oriented?.box || drag.item.geometry || (drag.item.textFrame ? { x: drag.item.x, width: drag.item.textFrame.width } : drag.item); const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]); - const apply = drag.item.shape || drag.item.oriented ? this.options.onReshape : drag.item.type === "ImageObject" ? this.options.onResize : this.options.onTextWidth; + const apply = drag.item.shape || drag.item.oriented ? this.options.onReshape : canEditObject(drag.item, "stretch") ? this.options.onResize : this.options.onTextWidth; Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => { drag.preview?.remove(); this.place(drag.item); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index f6f1c8b..f0f7ffd 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -841,7 +841,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { // 返回: map[string]any 前端能力 func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), - "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} if missing := capability.MissingGlyphs; missing != nil { result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -991,6 +991,18 @@ func changeCompositeObjects(args []js.Value) (any, error) { page := args[0].Int() operation := args[3].String() switch operation { + case "group": + if args[4].Bool() { + if len(indexes) != 1 { + return fmt.Errorf("ungrouping requires one member") + } + selected, err = currentEditor.UngroupCompositeObject(page, path, indexes[0]) + } else { + var index int + index, err = currentEditor.GroupCompositeObjects(page, path, indexes) + selected = []int{index} + } + return err case "pasteStyle": if copiedStyle == nil { return fmt.Errorf("style clipboard is empty") @@ -1161,19 +1173,19 @@ func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operati if err != nil { return err } - members, err := currentEditor.CompositeObjects(page, path) + members, err := currentEditor.CompositeObjectsAt(page, path, indexes) if err != nil { return err } var text strings.Builder - for _, index := range indexes { - if index >= len(members) || !members[index].Capabilities.ReplaceFont { + for _, member := range members { + if !member.Capabilities.ReplaceFont { return fmt.Errorf("composite font replacement is not supported") } if value != nil { text.WriteString(*value) } else { - text.WriteString(members[index].Object.TextObject.Text()) + text.WriteString(member.Object.TextObject.Text()) } } if err := checkFontGlyphs(data, text.String()); err != nil { diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index c1e992b..728f3a3 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -162,30 +162,48 @@ func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, // 入参: page 页面索引, path 父复合路径, index 成员序号 // 返回: CompositeMember 独立成员快照, error 错误信息 func (e *Editor) CompositeObject(page int, path ObjectPath, index int) (CompositeMember, error) { - reader, renderer, _, nodes, err := e.compositeScope(page, path) + members, err := e.CompositeObjectsAt(page, path, []int{index}) if err != nil { return CompositeMember{}, err } - defer reader.Close() - if index < 0 || index >= len(nodes) { - return CompositeMember{}, fmt.Errorf("composite member index %d out of range", index) + return members[0], nil +} + +// CompositeObjectsAt 按输入顺序读取指定成员,一次解析范围且仅度量选区 +// 入参: page 页面索引, path 父复合路径, indexes 不重复的成员序号 +// 返回: []CompositeMember 独立成员快照, error 错误信息 +func (e *Editor) CompositeObjectsAt(page int, path ObjectPath, indexes []int) ([]CompositeMember, error) { + reader, renderer, _, nodes, err := e.compositeScope(page, path) + if err != nil { + return nil, err } - member := e.measureCompositeMembers(renderer, nodes[index:index+1])[0] - position := ObjectPosition{} - for i, node := range nodes { - if node.span.parent != nodes[index].span.parent { - continue + defer reader.Close() + selected := make([]*editorCompositeNode, len(indexes)) + seen := make(map[int]bool, len(indexes)) + for i, index := range indexes { + if index < 0 || index >= len(nodes) || seen[index] { + return nil, fmt.Errorf("invalid composite member index %d", index) } + seen[index] = true + selected[i] = nodes[index] + } + positions := make([]ObjectPosition, len(nodes)) + containers := make(map[*editorXML]ObjectPosition) + for i, node := range nodes { + position := containers[node.span.parent] if position.Count == 0 { position.Container = fmt.Sprint(i) } - if i == index { - position.Index = position.Count - } + positions[i] = ObjectPosition{Container: position.Container, Index: position.Count} position.Count++ + containers[node.span.parent] = position } - member.Position = position - return member, nil + members := e.measureCompositeMembers(renderer, selected) + for i, index := range indexes { + members[i].Position = positions[index] + members[i].Position.Count = containers[nodes[index].span.parent].Count + } + return members, nil } // TransformCompositeObjects 在页面坐标中等比缩放并平移内部选区,只隔离被修改实例的资源 @@ -225,7 +243,7 @@ func (e *Editor) FlipCompositeObjects(page int, path ObjectPath, indexes []int, return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) }) } -// ResizeCompositeObjects 缩放内部选区到目标范围,无边框图片支持独立宽高,其他对象保持比例 +// ResizeCompositeObjects 按内部选区范围计算变换,图片及纯图片资源支持独立宽高,描边遵循OFD变换规则 // 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, box 页面毫米目标范围 // 返回: error 错误信息 func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { @@ -247,8 +265,8 @@ func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int uniform := math.Abs(sx-sy) <= 1e-9*math.Max(sx, sy) if !uniform { for _, node := range nodes { - if node.object.Type != "ImageObject" || node.object.ImageObject.Border != nil { - return fmt.Errorf("nonuniform resizing requires images without borders") + if !e.objectStretchable(node.object, make(map[string]bool)) { + return fmt.Errorf("nonuniform resizing requires images or image-only groups") } } } else { @@ -504,6 +522,8 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp transform := node.transformable() capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.box.W > 0 && bounds.box.H > 0, Delete: true, Copy: transform && editorXMLCopyable(node.node), Order: orderable[node.span.parent], ReplaceImage: node.object.Type == "ImageObject"} + capability.Ungroup = transform && capability.Order && e.compositeUngroupable(node.node) && (node.object.CompositeGraphicUnit.ResourceID != "" || len(node.object.CompositeGraphicUnit.Objects) != 0) + capability.Stretch = transform && e.objectStretchable(node.object, make(map[string]bool)) style, err := e.compositeMemberStyle(node) if !transform { capability.ReasonCode, capability.Reason = EditUnsupportedContainer, "composite member cannot be transformed" @@ -779,6 +799,7 @@ func (e *Editor) transformCompositeMember(renderer *Renderer, n *editorComposite } local := inverse.Multiply(matrix).Multiply(n.parent) if object.Type == "ImageObject" && object.ImageObject.Border != nil { + object.state = n.states[editorObjectID(object)] origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object} object, err = e.transformBorderedImage(object, local, origin, renderer) if err != nil { diff --git a/ofdgo_editor_composite_group.go b/ofdgo_editor_composite_group.go new file mode 100644 index 0000000..13cb06f --- /dev/null +++ b/ofdgo_editor_composite_group.go @@ -0,0 +1,236 @@ +// 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" +) + +// GroupCompositeObjects 组合内部同一容器的连续成员,保留标识、绘制顺序及共享资源 +// 入参: page 页面索引, path 父路径, indexes 至少两个成员序号 +// 返回: int 新组合序号, error 错误信息 +func (e *Editor) GroupCompositeObjects(page int, path ObjectPath, indexes []int) (int, error) { + if len(indexes) < 2 { + return 0, fmt.Errorf("grouping requires at least two objects") + } + var result int + err := e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + for _, member := range members { + if !member.Capabilities.Copy || !member.Capabilities.Order { + return fmt.Errorf("composite member cannot be grouped") + } + } + slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index }) + siblings := nodes[0].siblings() + first := slices.Index(siblings, nodes[0]) + var content []byte + for i, node := range nodes { + if first+i >= len(siblings) || siblings[first+i] != node { + return fmt.Errorf("grouping requires consecutive members in the same container") + } + content = append(content, bytes.TrimPrefix(node.data, []byte(xml.Header))...) + } + content, err := editorXMLContainer("Content", nil, content) + if err != nil { + return err + } + content, err = editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: e.nextID()}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) + if err != nil { + return err + } + for i, node := range nodes { + var replacement []byte + if i == 0 { + replacement = bytes.TrimPrefix(content, []byte(xml.Header)) + } + node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, replacement}) + } + result = nodes[0].index + return nil + }) + return result, err +} + +// UngroupCompositeObject 展开内部组合,隔离引用资源并保留父容器的样式和裁剪 +// 入参: page 页面索引, path 父路径, index 组合序号 +// 返回: []int 展开后的成员序号, error 错误信息 +func (e *Editor) UngroupCompositeObject(page int, path ObjectPath, index int) ([]int, error) { + var result []int + err := e.editCompositeObjects(page, path, []int{index}, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + node := nodes[0] + if !members[0].Capabilities.Order { + return fmt.Errorf("composite container cannot be reordered") + } + return e.ungroupCompositeNode(renderer, node, func(objects []GraphicObject) error { + var content []byte + for i, object := range objects { + content = append(content, object.origin.data...) + maps.Copy(node.owner.states, object.CompositeGraphicUnit.states) + node.owner.states[editorObjectID(object)] = object.state + result = append(result, index+i) + } + delete(node.owner.states, editorObjectID(node.object)) + node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, content}) + return nil + }) + }) + return result, err +} + +// ungroupCompositeNode 将组合展开至所在坐标系,祖先透明度和裁剪仅由原父级应用一次 +// 入参: renderer 当前读取器, node 待展开组合, replace 原子替换回调 +// 返回: error 错误信息 +func (e *Editor) ungroupCompositeNode(renderer *Renderer, node *editorCompositeNode, replace func([]GraphicObject) error) error { + if !e.compositeUngroupable(node.node) { + return fmt.Errorf("group container cannot be removed without losing attributes") + } + copy, err := newEditorCompositeNode(node.data) + if err != nil { + return err + } + copy.setStates(node.states) + if !node.boundaryInCTM { + if err := copy.convertCoordinates(node.parent, true); err != nil { + return err + } + } + copy.parent, copy.boundaryInCTM, copy.visible = IdentityMatrix, true, true + copy.defaults, copy.drawParams = node.defaults, node.drawParams + members, err := e.compositeMembers(copy, renderer.Reader, renderer, make(map[string]bool)) + if err != nil { + return err + } + if len(members) == 0 { + return fmt.Errorf("group has no objects") + } + for _, member := range members { + if !member.transformable() || !editorXMLCopyable(member.node) { + return fmt.Errorf("group member cannot be expanded") + } + } + return e.pasteCompositeSelection(&CompositeSelection{editor: e, nodes: members}, func(objects []GraphicObject) error { + ids := make(map[string]string) + if copy.ref != nil { + for _, object := range objects { + if err := e.collectCompositeIDs(object.origin.node, ids); err != nil { + return err + } + } + } + for i, object := range objects { + data := object.origin.data + if len(ids) != 0 { + data, err = editorXMLRemapIDs(data, object.origin.node, ids) + if err != nil { + return err + } + } + member, err := newEditorCompositeNode(data) + if err != nil { + return err + } + states := maps.Clone(object.CompositeGraphicUnit.states) + if states == nil { + states = make(map[string]editorCompositeState) + } + states[editorObjectID(object)] = object.state + if len(ids) != 0 { + states = remapCompositeStates(states, ids) + } + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + if err := member.convertCoordinates(IdentityMatrix, true); err != nil { + return err + } + } + if !node.boundaryInCTM { + if err := member.convertCoordinates(node.parent, false); err != nil { + return err + } + } + member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}} + member.object.state = states[editorObjectID(member.object)] + member.object.TextObject.layout = member.object.state.layout + member.object.CompositeGraphicUnit.states = states + member.object.origin = &editorObjectOrigin{editor: e, data: member.data, node: member.node, object: member.object} + objects[i] = member.object + } + return replace(objects) + }) +} + +// compositeUngroupable 检查待移除的容器及引用链,子对象原文不展开重写 +// 入参: node 组合节点 +// 返回: bool 是否可解组 +func (e *Editor) compositeUngroupable(node *editorXML) bool { + seen := make(map[string]bool) + for { + if !editorUngroupable(node) { + return false + } + id := node.attr("ResourceID") + if id == "" { + return true + } + if seen[id] { + return false + } + seen[id] = true + resource, err := e.compositeDefinition(id) + if err != nil { + return false + } + node = resource.node + } +} + +// compositeDefinition 读取复合资源原文,不加载图片或字体数据 +// 入参: id 资源标识 +// 返回: *editorCompositeNode 定义节点, error 错误信息 +func (e *Editor) compositeDefinition(id string) (*editorCompositeNode, error) { + var data []byte + for _, resource := range e.resources { + if resource.composite == id { + data = resource.data + break + } + } + if data == nil && e.source != nil { + var err error + data, err = e.source.reader.readFile(e.source.reader.resourceFiles[id]) + if err != nil { + return nil, err + } + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if units := root.child("CompositeGraphicUnits"); units != nil { + for _, unit := range units.children { + if unit.name.Local == "CompositeGraphicUnit" && unit.attr("ID") == id { + fragment, err := editorXMLStandalone(data[unit.start:unit.end], unit) + if err != nil { + return nil, err + } + return newEditorCompositeNode(fragment) + } + } + } + return nil, fmt.Errorf("composite resource %q not found", id) +} diff --git a/ofdgo_editor_group.go b/ofdgo_editor_group.go index 12a5644..59af34f 100644 --- a/ofdgo_editor_group.go +++ b/ofdgo_editor_group.go @@ -103,36 +103,25 @@ func (e *Editor) GroupObjects(page int, ids []string) (string, error) { return id, nil } -// UngroupObject 展开内联组合,保留页面外观与裁剪,含容器行为或扩展的组合不拆解 +// UngroupObject 展开组合,保留页面外观与裁剪,资源引用分配独立标识,含容器行为或扩展的组合不拆解 // 入参: page 页面索引, id 组合标识 // 返回: []string 展开后的对象标识, error 错误信息 func (e *Editor) UngroupObject(page int, id string) ([]string, error) { - objects, indexes, err := e.selectedObjects(page, []string{id}) + _, indexes, err := e.selectedObjects(page, []string{id}) if err != nil { return nil, err } origin := e.objectOrigin(id) - if origin == nil || !editorUngroupable(origin.node) || !editorContainerOrderable(origin.node.parent) || objects[0].CompositeGraphicUnit.ResourceID != "" { + if origin == nil || !editorContainerOrderable(origin.node.parent) { return nil, fmt.Errorf("group container cannot be removed without losing attributes") } - reader, _, _, nodes, err := e.compositeScope(page, ObjectPath{ID: id}) - if err != nil { - return nil, err - } - reader.Close() - if len(nodes) == 0 { - return nil, fmt.Errorf("group has no objects") - } - members := make([]int, len(nodes)) - for i := range members { - members[i] = i - } - selection, err := e.CaptureCompositeObjects(page, ObjectPath{ID: id}, members) + reader, renderer, root, _, err := e.compositeScope(page, ObjectPath{ID: id}) if err != nil { return nil, err } + defer reader.Close() var result []string - err = e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { + err = e.ungroupCompositeNode(renderer, root, func(objects []GraphicObject) error { origins := make(map[string]*editorObjectOrigin, len(objects)) for i := range objects { object := &objects[i] @@ -149,14 +138,14 @@ func (e *Editor) UngroupObject(page int, id string) ([]string, error) { return result, err } -// editorUngroupable 判断移除内联容器是否会丢失对象行为或未知属性 +// editorUngroupable 判断移除容器是否会丢失对象行为或未知属性 // 入参: node 容器节点 // 返回: bool 是否可解组 func editorUngroupable(node *editorXML) bool { if node.name.Local != "CompositeObject" && node.name.Local != "CompositeGraphicUnit" { return false } - if !editorXMLAttributes(node, "ID Boundary CTM DrawParam Alpha Visible") { + if !editorXMLAttributes(node, "ID Boundary CTM DrawParam Alpha Visible ResourceID Width Height") { return false } for _, child := range node.children { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index f4a717f..5bdff3a 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -67,7 +67,7 @@ type editorSourcePage struct { // Paint表示可独立修改纯色填充和描边,不要求重新排版文字 // ReplaceImage表示可替换图片数据,CropImage表示可裁剪图片;内部裁剪与原裁剪取交集 // MissingGlyphs提供缺字导致操作受限时的结构化诊断 -// Ungroup表示可移除内联组合容器并保留内部内容 +// Ungroup表示可移除组合容器并保留内部内容,Stretch表示图片及纯图片组合可独立调整宽高 type ObjectCapabilities struct { Update bool Paint bool @@ -84,6 +84,7 @@ type ObjectCapabilities struct { Delete bool Order bool Ungroup bool + Stretch bool Reason string ReasonCode EditReason MissingGlyphs *MissingGlyphError @@ -366,6 +367,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil origin := e.objectOrigin(id) if origin == nil { + all.Stretch = object.Type == "ImageObject" all.ReplaceImage = object.Type == "ImageObject" all.CropImage = all.ReplaceImage all.FitImage = all.ReplaceImage && axisAlignedMatrix(NewMatrix(object.ImageObject.CTM)) @@ -385,7 +387,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}, nil } all.Order = editorContainerOrderable(node.parent) - all.Ungroup = all.Order && editorUngroupable(node) && len(object.CompositeGraphicUnit.Objects) != 0 + all.Ungroup = all.Order && e.compositeUngroupable(node) && (object.CompositeGraphicUnit.ResourceID != "" || len(object.CompositeGraphicUnit.Objects) != 0) all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor) if origin.reason != nil { all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false @@ -412,6 +414,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er all.Reflow = false } } + all.Stretch = all.Transform && e.objectStretchable(object, make(map[string]bool)) return all, nil } diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 4ad2694..881d7fd 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -67,7 +67,7 @@ func (e *Editor) FlipObjects(page int, ids []string, axis string) error { return e.orientObjects(page, ids, m) } -// ResizeObjects 将选区可见范围移动并缩放到指定矩形,文字、路径和带边框图片保持比例,无边框图片支持独立宽高 +// ResizeObjects 按选区可见范围计算目标矩形的变换,图片及纯图片资源支持独立宽高,描边遵循OFD变换规则 // 不重排文字或重采样图片,一次撤销恢复全部;基本图形独立调整宽高可使用PathObject.Reshape // 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的目标范围 // 返回: error 错误信息 @@ -96,10 +96,10 @@ func (e *Editor) ResizeObjects(page int, ids []string, box Box) error { return e.transformObjects(page, objects, func(object GraphicObject) (GraphicObject, error) { if uniform { return e.transformObject(object, matrix.e, matrix.f, sx) - } else if object.Type == "ImageObject" && object.ImageObject.Border == nil { + } else if e.objectStretchable(object, make(map[string]bool)) { return e.transformMatrix(object, matrix) } - return GraphicObject{}, fmt.Errorf("nonuniform resizing requires images without borders") + return GraphicObject{}, fmt.Errorf("nonuniform resizing requires images or image-only groups") }) } @@ -177,9 +177,56 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb } origin := e.objectOrigin(object.ImageObject.ID) if origin == nil { - return GraphicObject{}, fmt.Errorf("bordered image requires an original object") + return GraphicObject{}, fmt.Errorf("image source is unavailable") } - return e.transformBorderedImage(object, matrix, origin, NewRenderer(e.source.reader, WithFontFS(e.fontFS...))) + reader, err := e.Reader() + if err != nil { + return GraphicObject{}, err + } + defer reader.Close() + return e.transformBorderedImage(object, matrix, origin, NewRenderer(reader, WithFontFS(e.fontFS...))) +} + +// objectStretchable 允许图片及纯图片资源独立调整宽高,内联组合仍保持比例 +// 入参: object 对象, visiting 当前资源链 +// 返回: bool 是否支持独立宽高 +func (e *Editor) objectStretchable(object GraphicObject, visiting map[string]bool) bool { + if object.Type != "ImageObject" && (object.CompositeGraphicUnit.ResourceID == "" || len(object.CompositeGraphicUnit.Objects) != 0) { + return false + } + return e.objectImagesOnly(object, visiting) +} + +// objectImagesOnly 检查资源链中是否只有图片,保留原边框与裁剪语义 +// 入参: object 对象, visiting 当前资源链 +// 返回: bool 是否只包含图片 +func (e *Editor) objectImagesOnly(object GraphicObject, visiting map[string]bool) bool { + if object.Type == "ImageObject" { + return object.ImageObject.Border == nil || len(object.ImageObject.Actions) == 0 + } + if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { + return false + } + group := object.CompositeGraphicUnit + found := len(group.Objects) != 0 + if id := group.ResourceID; id != "" { + if visiting[id] { + return false + } + visiting[id] = true + resource, err := e.compositeDefinition(id) + if err != nil || !e.objectImagesOnly(resource.object, visiting) { + return false + } + delete(visiting, id) + found = true + } + for _, child := range group.Objects { + if !e.objectImagesOnly(child, visiting) { + return false + } + } + return found } // transformBorderedImage 将原文图片封装为复合资源,保留边框和裁剪 @@ -231,6 +278,7 @@ func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, ori if err != nil { return GraphicObject{}, err } + resource.states = map[string]editorCompositeState{child.ImageObject.ID: object.state} e.resources = append(e.resources, resource) object = GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ID: object.ImageObject.ID, ResourceID: id, Boundary: editorBoxString(extent)}} return transformEditorMatrix(object, matrix), nil diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index 76a3034..2cb7c0c 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -180,7 +180,13 @@ func (r *boundsRenderer) addContour(path *canvas.Path, evenOdd bool) { rect := path.Bounds() r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) if r.collect { - r.contours = append(r.contours, ObjectContour{Path: path.Copy().Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), EvenOdd: evenOdd}) + outline := &canvas.Path{} + for _, subpath := range path.Split() { + subpath = subpath.Copy() + subpath.Close() + outline = outline.Append(subpath) + } + r.contours = append(r.contours, ObjectContour{Path: outline.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), EvenOdd: evenOdd}) } }