diff --git a/assets/webui/index.html b/assets/webui/index.html index 01230bc..bb021d2 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -67,6 +67,7 @@ 多选 修改 复制 + 返回 删除 层级 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index f631aad..008074a 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1287,6 +1287,7 @@ button.outline-link:hover { } .empty-state[hidden], +.editor-group > [hidden], .panel-row > [hidden], .meta-list > [hidden], .navigation-tabs[hidden], diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 1039328..758638b 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -25,6 +25,7 @@ let wasmRecoveryTimer = 0; let textMeasure = null; const state = { + composite: null, ready: false, wasmExited: false, wasmSeq: 0, @@ -107,6 +108,7 @@ const el = { selectObjectButton: document.querySelector("#selectObjectButton"), deleteObjectButton: document.querySelector("#deleteObjectButton"), editObjectButton: document.querySelector("#editObjectButton"), + compositeBackButton: document.querySelector("#compositeBackButton"), copyObjectButton: document.querySelector("#copyObjectButton"), objectOrder: document.querySelector("#objectOrder"), objectAlign: document.querySelector("#objectAlign"), @@ -382,6 +384,11 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { : changeDocument("ofdgoDeleteObjects", item); }, onEdit: editCanvasObject, + onExitScope: () => { + if (!state.composite) return false; + exitCompositeScope(); + return true; + }, drawStyle: shapeStyle, onTool: updateDrawingControls, onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, @@ -439,6 +446,7 @@ editorClick(el.multiSelectButton, () => { canvasEditor.multiple = !canvasEditor.multiple; el.multiSelectButton.setAttribute("aria-pressed", String(canvasEditor.multiple)); }); +editorClick(el.compositeBackButton, exitCompositeScope); editorClick(el.editObjectButton, () => { if (canvasEditor.selected) { editCanvasObject(canvasEditor.selected); @@ -981,7 +989,7 @@ function openObjectBounds() { 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 = Boolean(state.composite) || !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || 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 = ""; @@ -1189,6 +1197,7 @@ function setEditorInfo(doc) { } function pageCan(capability, index = state.pageIndex) { + if (state.composite && capability === "insert") return false; const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability]; return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit); } @@ -1198,6 +1207,7 @@ async function toggleEditor() { if (state.editorInfo) { if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; state.editing = !state.editing; + resetCompositeScope(); canvasEditor.clear(); canvasEditor.setTool(""); if (state.editing) { @@ -1220,6 +1230,7 @@ async function toggleEditor() { state.savedRevision = doc.revision; setEditorInfo(doc); state.editing = true; + state.composite = null; state.selectObjects = true; state.ofdBytes = null; state.objectClipboard = null; @@ -1270,6 +1281,7 @@ async function createDocument(event) { return; } state.editing = true; + state.composite = null; state.objectClipboard = null; state.styleClipboard = null; state.fontRenderPending = false; @@ -1298,6 +1310,7 @@ async function createDocument(event) { 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 === "TextObject") { if (!confirmTextReflow(item)) return; } else if (!canEditObject(item, "update")) return; @@ -1328,6 +1341,51 @@ async function editCanvasObject(item) { } } +function mountEditorObjects(index, page, surface) { + const scope = state.composite?.index === index ? state.composite : null; + canvasEditor.mount(index, scope ? { ...page, objects: scope.objects } : page, surface); +} + +function resetCompositeScope() { + const scope = state.composite; + if (!scope) return; + state.composite = null; + canvasEditor.clear(); + const page = state.pageCache.get(scope.index), surface = pageShell(scope.index)?.querySelector(".page-surface"); + if (page && surface) mountEditorObjects(scope.index, page, surface); +} + +async function enterCompositeScope(index, key) { + const openSeq = state.openSeq; + setBusy(true); + try { + const objects = await callWASM("ofdgoCompositeObjects", index, key); + if (openSeq !== state.openSeq) return; + canvasEditor.clear(); + canvasEditor.setTool(""); + state.composite = { index, key, objects }; + const page = state.pageCache.get(index), surface = pageShell(index)?.querySelector(".page-surface"); + if (page && surface) mountEditorObjects(index, page, surface); + updateControls(); + setStatus(pageStatus(index, state.doc.pageCount)); + el.viewerPanel.focus({ preventScroll: true }); + } catch (err) { + if (openSeq === state.openSeq) showError(err, false); + } finally { + if (openSeq === state.openSeq) setBusy(false); + } +} + +async function exitCompositeScope() { + const scope = state.composite; + if (!scope) return; + const split = scope.key.lastIndexOf("/"); + if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split)); + resetCompositeScope(); + updateControls(); + el.viewerPanel.focus({ preventScroll: true }); +} + async function startImageCrop(item) { const openSeq = state.openSeq; const urls = []; @@ -1738,7 +1796,7 @@ function updateDrawingControls() { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); } - el.eraseButton.disabled = el.eraseMode.disabled = disabled; + el.eraseButton.disabled = el.eraseMode.disabled = disabled || Boolean(state.composite); el.eraseButton.setAttribute("aria-pressed", String(tool.startsWith("erase-"))); el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool)); const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert")); @@ -1764,7 +1822,15 @@ async function changeDocument(name, item, ...args) { if (openSeq !== state.openSeq) { return; } - const doc = await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); + const scope = state.composite; + const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize" }[name]; + const members = item?.items || (item ? [item] : []); + const scoped = scope && members.length && members.every(member => member.scoped); + if (!item) resetCompositeScope(); + 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) + : await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); if (openSeq !== state.openSeq) { return; } @@ -2355,6 +2421,7 @@ async function openOFD(file) { state.ofdBytes = bytes; state.fileName = file.name || "ofdgo.ofd"; state.editing = false; + state.composite = null; state.pageSelection.clear(); state.pageSelectionAnchor = null; state.pageMultiSelect = false; @@ -2414,7 +2481,7 @@ async function openSelectedFonts(event) { if (changed) { await applyFontChange(); } - setStatus(saved ? "字体保存完成" : "字体仅限本次"); + setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用"); } catch (err) { showError(err, false); } finally { @@ -2435,7 +2502,7 @@ async function loadLocalFonts() { await nextFrame(); const available = await fontManager.queryLocal(); if (!state.doc) { - setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体"); + setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体"); return; } if (await loadDocumentLocalFonts(available)) { @@ -2459,7 +2526,7 @@ async function requestLocalFontsBeforeOpen() { setBusy(true, "正在请求授权", 12, "正在请求授权"); try { const available = await fontManager.queryLocal(); - setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体"); + setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体"); } catch (err) { if (err && err.name === "NotAllowedError") { setStatus("字体尚未授权"); @@ -2503,7 +2570,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { } if (docFonts.every((font) => font.embedded)) { fontManager.localFonts = []; - setStatus("字体均为内嵌"); + setStatus("字体均已内嵌"); updateFontSummary(); renderFontList(); return false; @@ -2524,7 +2591,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) { fonts.push(fontManager.record(fontManager.localName(item), data, "browser")); } fontManager.localFonts = fonts; - setStatus(fonts.length ? `字体加载完成 ${fonts.length} 个` : emptyStatus); + setStatus(fonts.length ? `已加载${fonts.length}种字体` : emptyStatus); updateFontSummary(); renderFontList(); return fonts.length > 0; @@ -2972,7 +3039,7 @@ async function downloadAttachment(attachment) { if (result.blob) { downloadBytes(result.blob, result.mime, attachment.fileName); } - setStatus(`附件下载完成 ${formatBytes(result.size)}`); + setStatus(`附件已下载(${formatBytes(result.size)})`); } catch (err) { if (openSeq === state.openSeq) { if (err.name === "AbortError") { @@ -3086,7 +3153,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value) state.savedRevision = state.editorInfo.revision; setDirty(false); } - setStatus(`${result.label} ${saving ? "保存" : "导出"}完成 ${formatBytes(result.size)}`); + setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`); return true; } catch (err) { if (openSeq === state.openSeq) { @@ -3321,6 +3388,11 @@ async function processPageRenderQueue() { continue; } const page = await callWASM("ofdgoRenderPage", task.index); + const scope = state.composite; + if (scope?.index === task.index) { + const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key); + if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects; + } await loadSVGFonts(page.fonts, task.openSeq); if (task.openSeq === state.openSeq) { for (const image of page.images) { @@ -3436,7 +3508,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) { surface.replaceChildren(svg); surface.append(createTextLayer(page.text)); if (state.editorInfo) { - canvasEditor.mount(index, page, surface); + mountEditorObjects(index, page, surface); } for (const link of page.links) { const anchor = document.createElement("a"); @@ -3670,7 +3742,7 @@ async function pasteEditorContent(event) { if (token) { const clipboard = state.objectClipboard; if (!clipboard || token !== clipboard.token) { - setStatus("对象剪贴板失效"); + setStatus("复制内容已失效,请重新复制"); return; } const openSeq = state.openSeq; @@ -3700,7 +3772,7 @@ async function pasteEditorContent(event) { return; } if (!/^image\/(png|jpeg)$/.test(files[0].type)) { - setStatus("图片仅支持 PNG、JPG"); + setStatus("仅支持PNG、JPG图片"); return; } await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width); @@ -3853,6 +3925,7 @@ function pageShellAtPoint(x, y) { } function setCurrentPage(index) { + if (state.composite && state.composite.index !== index) resetCompositeScope(); state.pageIndex = index; if (state.fitMode === "width") { state.scale = fitWidthScale(currentPageInfo()); @@ -4958,7 +5031,7 @@ async function focusPageRegion(region, label) { return; } highlightPageRegion(region); - setStatus(`${label}定位完成 第 ${region.page} 页`); + setStatus(`已定位${label}(第${region.page}页)`); } function highlightPageRegion(region) { @@ -5453,14 +5526,17 @@ function updateEditorTools() { } function updateObjectControls(item, reset = false) { + el.compositeBackButton.hidden = !state.composite; + el.compositeBackButton.disabled = !state.ready || state.exporting; + el.deleteObjectButton.hidden = Boolean(state.composite); const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting; el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete"); el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy"); - el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform"); + el.objectStyleButton.disabled = disabled || Boolean(state.composite) || !canEditObject(item, "transform"); const cropping = Boolean(canvasEditor.crop); el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform"); el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); - el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled; + el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange"); el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "update"); el.imageFit.disabled = el.cropImageButton.disabled || cropping; el.cropImageButton.textContent = cropping ? "完成" : "裁剪"; @@ -5470,12 +5546,12 @@ function updateObjectControls(item, reset = false) { el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片"); el.resetCropButton.disabled = el.cropImageButton.disabled || !cropping && (!item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9)); el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3; - el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject" - || !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"); + el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject" + || !canEditObject(item, item.type === "TextObject" ? "reflow" : "update")); el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const selection = selectedText(item); const text = selection ? currentTextStyle() : state.textDefaults; - const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection); + const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(state.composite) || Boolean(item?.items && !selection); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint"); @@ -5498,9 +5574,9 @@ function updateObjectControls(item, reset = false) { el.textSize.value = text.size === undefined ? "" : displayPoints(text.size); } el.textSize.placeholder = text.size === undefined ? "混合" : ""; - el.textSize.title = text.size === undefined ? "字号:混合" : `字号 ${displayPoints(text.size)} pt`; + el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`; el.textColor.classList.toggle("mixed-color", Boolean(selection?.items && !text.color)); - el.textColor.title = selection?.items && !text.color ? "文字颜色:混合" : "文字颜色"; + el.textColor.title = selection?.items && !text.color ? "文字颜色不同" : "文字颜色"; if (reset || el.textColor.disabled || document.activeElement !== el.textColor) { el.textColor.value = text.color || "#000000"; } @@ -5512,7 +5588,7 @@ function updateObjectControls(item, reset = false) { for (const [input, key, label] of [[el.shapeFillColor, "fillColor", "填充颜色"], [el.shapeStrokeColor, "strokeColor", "描边颜色"]]) { const mixed = Boolean(path?.items && path[key] === undefined); input.classList.toggle("mixed-color", mixed); - input.title = mixed ? `${label}:混合` : label; + input.title = mixed ? `${label}不同` : label; if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000"; } el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : ""; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 893a4ea..6e3b516 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -174,25 +174,24 @@ export function canEditObject(item, capability) { return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability])); } -export function missingGlyphMessage(diagnostic) { +export function missingGlyphMessage(diagnostic, action = "请更换字体") { if (!diagnostic) return ""; const characters = Array.from(diagnostic.characters); const sample = characters.slice(0, 8).map(char => /[\p{C}\p{Z}\p{M}]/u.test(char) - ? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join(" "); - return `字体缺字:${sample}${characters.length > 8 ? ` 等 ${characters.length} 字` : ""},改选字体`; + ? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join("、"); + return `字体缺字(${sample}${characters.length > 8 ? `等${characters.length}字` : ""}),${action}`; } export function objectEditReason(item) { - const labels = { fontUnavailable: "字体不可用", unsupportedColor: "颜色样式暂不支持", unsupportedStyle: "绘制参数暂不支持", - unsupportedContainer: "容器特性暂不支持", invalidObject: "对象数据异常" }; + const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式", + unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" }; const reasons = (item?.items || (item ? [item] : [])).map(object => { const reason = object.capabilities?.reason || ""; if (!reason) return ""; - let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持"; - if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs); - const available = object.capabilities.transform ? object.capabilities.paint ? "可移动、改色" : "可移动" - : object.capabilities.copy && object.capabilities.delete ? "可复制、删除" : "暂不可编辑"; - return `${available}:${message}`; + const message = labels[object.capabilities.reasonCode] || "暂不支持此对象特性"; + const available = object.capabilities.transform ? object.capabilities.paint ? "仍可移动、改色" : "仍可移动" + : object.capabilities.copy && object.capabilities.delete ? "仍可复制、删除" : "暂不可编辑"; + return object.capabilities.missingGlyphs ? missingGlyphMessage(object.capabilities.missingGlyphs, available) : `${message},${available}`; }); return [...new Set(reasons.filter(Boolean))].join(";"); } @@ -257,6 +256,7 @@ export class CanvasEditor { } mount(index, page, surface) { + surface.querySelector(".edit-layer")?.remove(); const crop = this.crop?.item.index === index ? this.crop : null; if (crop) this.closeCrop(true); const pending = this.pendingSelection?.index === index; @@ -266,7 +266,7 @@ export class CanvasEditor { this.pages.set(surface, { index, width: page.width, height: page.height }); const layer = document.createElement("div"); layer.className = "edit-layer"; - const artwork = new Map([...surface.querySelectorAll("[data-ofd-object]")].map((node, order) => [node.getAttribute("data-ofd-object"), { node, order }])); + const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }])); const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); for (const object of objects) { const node = document.createElement("div"); @@ -281,14 +281,14 @@ export class CanvasEditor { node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[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.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.contours?.length) { node.classList.add("edit-contour"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.classList.add("edit-outline"); svg.setAttribute("aria-hidden", "true"); svg.setAttribute("preserveAspectRatio", "none"); let contour; - if (object.type === "PathObject") { + if (object.type === "PathObject" && !object.scoped) { contour = document.createElementNS("http://www.w3.org/2000/svg", object.shape === "line" ? "line" : object.shape === "ellipse" ? "ellipse" : object.shape === "rectangle" ? "rect" : "path"); if (!object.shape) contour.setAttribute("d", object.outline); svg.append(contour); @@ -952,6 +952,11 @@ export class CanvasEditor { this.setTool(""); return; } + if (event.key === "Escape" && !this.input && this.enabled && !this.options.busy() && !this.nudge && this.options.onExitScope?.()) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { return; } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index a1f91fb..ff8e4e0 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -138,6 +138,8 @@ func RunWASM() { registerCallback("ofdgoReshapeObject", reshapeObject) registerCallback("ofdgoLayoutText", layoutText) registerCallback("ofdgoTransformObjects", transformObjects) + registerCallback("ofdgoCompositeObjects", compositeObjects) + registerCallback("ofdgoChangeCompositeObjects", changeCompositeObjects) registerCallback("ofdgoAlignObjects", alignObjects) registerCallback("ofdgoDeleteObjects", deleteObjects) registerCallback("ofdgoEraseObjects", eraseObjects) @@ -266,6 +268,12 @@ func configureDocument(args []js.Value) (any, error) { if err := currentSession.SetFonts(fonts); err != nil { return nil, err } + if currentEditor != nil { + currentEditor.SetFontFS() + if currentSession.fontFS != nil { + currentEditor.SetFontFS(currentSession.fontFS) + } + } } if currentSession.Renderer.RenderAnnotations != args[1].Bool() { currentSession.Renderer.RenderAnnotations = args[1].Bool() @@ -757,6 +765,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if missing := capability.MissingGlyphs; missing != nil { item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } + item["capabilities"].(map[string]any)["enter"] = capability.Transform && (object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit") var alpha *int switch object.Type { case "TextObject": @@ -851,6 +860,88 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return objects, nil } +// compositePath 解析画布复合对象路径 +// 入参: value 顶层标识与逐层序号 +// 返回: ofdgo.ObjectPath 库层路径, error 错误信息 +func compositePath(value string) (ofdgo.ObjectPath, error) { + parts := strings.Split(value, "/") + path := ofdgo.ObjectPath{ID: parts[0]} + for _, part := range parts[1:] { + index, err := strconv.Atoi(part) + if err != nil || index < 0 { + return path, fmt.Errorf("invalid composite path") + } + path.Children = append(path.Children, index) + } + return path, nil +} + +// compositeObjects 获取内部编辑范围,不重新渲染页面或传输图片 +// 入参: args 页面索引与复合路径 +// 返回: any 画布对象列表, error 错误信息 +func compositeObjects(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + key := args[1].String() + path, err := compositePath(key) + if err != nil { + return nil, err + } + members, err := currentEditor.CompositeObjects(args[0].Int(), path) + if err != nil { + return nil, err + } + objects := make([]any, 0, len(members)) + for i, member := range members { + box := member.Bounds + if box.W <= 0 || box.H <= 0 { + continue + } + objects = append(objects, map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": i, "count": len(members), "container": key, "scoped": true, "contours": member.Contours, + "capabilities": map[string]any{"transform": member.Transform, "enter": member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit"}}) + } + return objects, nil +} + +// changeCompositeObjects 复用库层内部选区变换与单次撤销 +// 入参: args 页面索引、父路径、成员路径列表、操作及参数 +// 返回: any 文档信息, error 错误信息 +func changeCompositeObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + key := args[1].String() + path, err := compositePath(key) + if err != nil { + return err + } + var indexes []int + for _, id := range stringsFromJS(args[2]) { + part, found := strings.CutPrefix(id, key+"/") + if !found { + return fmt.Errorf("selection is outside composite") + } + i, err := strconv.Atoi(part) + if err != nil { + return err + } + indexes = append(indexes, i) + } + page := args[0].Int() + switch args[3].String() { + case "transform": + return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float()) + case "rotate": + return currentEditor.RotateCompositeObjects(page, path, indexes, args[4].Int()) + case "flip": + return currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String()) + case "resize": + return currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + default: + return fmt.Errorf("unsupported composite operation") + } + }) +} + // editorFont 读取对象实际使用的内嵌字体,供画布输入使用 // 入参: args 字体资源标识 // 返回: any 字体数据, error 错误信息 @@ -1384,10 +1475,12 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { if err != nil { return editorInfo{}, err } + editor.SetFontFS() if currentSession != nil { if currentSession.fontFS != nil { session.fontFS = currentSession.fontFS session.Renderer.SetFontFS(session.fontFS) + editor.SetFontFS(session.fontFS) } _ = currentSession.Close() } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 6c1066d..ad21999 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -22,6 +22,7 @@ import ( "encoding/xml" "fmt" "image" + "io/fs" "math" "reflect" "slices" @@ -45,6 +46,7 @@ type Editor struct { pages []PageContent resources []editorResource fonts map[string]*font.SFNT + fontFS []fs.FS images map[string]image.Point resourceID map[editorResourceKey]string maxID int @@ -57,15 +59,22 @@ type Editor struct { outlines []byte } +// SetFontFS 设置几何度量使用的外部字体来源,与预览渲染器保持一致,不嵌入或替换文档字体 +// 入参: fsys 字体文件系统,空参数恢复默认来源 +func (e *Editor) SetFontFS(fsys ...fs.FS) { + e.fontFS = slices.Clone(fsys) +} + // editorResource 文档内嵌资源 type editorResource struct { - name string - data []byte - font *Font - image *MultiMedia - space *ColorSpace - composite string - subset *editorFontSubset + name string + data []byte + font *Font + image *MultiMedia + space *ColorSpace + composite string + references []string + subset *editorFontSubset } // editorResourceKey 资源内容和字体集合索引 diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 9ee7378..b053869 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -350,6 +350,13 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error { // 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容 // 返回: error 错误信息 func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveContent bool) error { + return e.updateObjectOrigins(page, objects, preserveContent, nil) +} + +// updateObjectOrigins 原子提交对象及原文来源,撤销时一并恢复 +// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容, origins 新原文来源 +// 返回: error 错误信息 +func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserveContent bool, origins map[string]*editorObjectOrigin) error { ids := make([]string, len(objects)) for i, object := range objects { ids[i] = editorObjectID(object) @@ -416,6 +423,13 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveConten } } } + if next := origins[ids[i]]; next != nil { + if beforeOrigins == nil { + beforeOrigins = make(map[string]*editorObjectOrigin) + afterOrigins = make(map[string]*editorObjectOrigin) + } + beforeOrigins[ids[i]], afterOrigins[ids[i]] = origin, next + } } if reflect.DeepEqual(before, after) { return nil @@ -612,7 +626,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) if _, err := reader.PageContentByIndex(page); err != nil { return nil, err } - renderer := NewRenderer(reader) + renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) boxes := make([]Box, len(objects)) for i, object := range objects { layer, _, findErr := e.findObject(page, editorObjectID(object)) diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go new file mode 100644 index 0000000..967506d --- /dev/null +++ b/ofdgo_editor_composite.go @@ -0,0 +1,645 @@ +// 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" + "math" + "slices" + + "github.com/tdewolff/canvas" +) + +// ObjectPath 定位页面顶层对象及复合对象内的成员,Children为从0开始的逐层绘制序号 +// 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举 +type ObjectPath struct { + ID string `json:"id"` + Children []int `json:"children,omitempty"` +} + +// CompositeMember 复合对象的直接成员快照,Bounds与Contours使用页面毫米坐标 +type CompositeMember struct { + Object GraphicObject + Bounds Box + Contours []ObjectContour + Transform bool +} + +// editorCompositeNode 保留原始XML及实例上下文,不修改共享资源 +type editorCompositeNode struct { + data []byte + node *editorXML + object GraphicObject + parent Matrix + boundaryInCTM bool + defaults *DrawParam + clip *canvas.Path + alpha *int + visible bool + ref *editorCompositeNode + children []*editorCompositeNode + expanded bool + changed bool + span *editorXML +} + +// CompositeObjects 枚举复合对象的直接可绘制成员,资源引用层不占用路径层级 +// 入参: page 页面索引, path 复合对象路径 +// 返回: []CompositeMember 按绘制顺序排列的成员, error 错误信息 +func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, error) { + reader, renderer, _, members, err := e.compositeScope(page, path) + if err != nil { + return nil, err + } + defer reader.Close() + return measureCompositeMembers(renderer, members), nil +} + +// TransformCompositeObjects 在页面坐标中等比缩放并平移内部选区,只隔离被修改实例的资源 +// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, dx、dy 位移, scale 正缩放比例 +// 返回: error 错误信息 +func (e *Editor) TransformCompositeObjects(page int, path ObjectPath, indexes []int, dx, dy, scale float64) error { + if !finite(dx) || !finite(dy) || !finite(scale) || scale <= 0 { + return fmt.Errorf("invalid composite transform") + } + return e.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return Matrix{a: scale, d: scale, e: dx, f: dy} }) +} + +// RotateCompositeObjects 绕内部选区的可见中心旋转,提交一次撤销记录 +// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, degrees 顺时针角度,为90度的整数倍 +// 返回: error 错误信息 +func (e *Editor) RotateCompositeObjects(page int, path ObjectPath, indexes []int, degrees int) error { + if degrees%90 != 0 { + return fmt.Errorf("rotation must be a multiple of 90 degrees") + } + m := []Matrix{IdentityMatrix, {b: 1, c: -1}, {a: -1, d: -1}, {b: -1, c: 1}}[(degrees%360+360)%360/90] + return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) }) +} + +// FlipCompositeObjects 绕内部选区的可见中心镜像 +// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, axis 为horizontal或vertical +// 返回: error 错误信息 +func (e *Editor) FlipCompositeObjects(page int, path ObjectPath, indexes []int, axis string) error { + m := IdentityMatrix + switch axis { + case "horizontal": + m.a = -1 + case "vertical": + m.d = -1 + default: + return fmt.Errorf("invalid flip axis %q", axis) + } + return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) }) +} + +// ResizeCompositeObjects 等比缩放内部选区到目标范围,不重排文字或重采样图片 +// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, box 页面毫米目标范围 +// 返回: error 错误信息 +func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + members, err := e.CompositeObjects(page, path) + if err != nil { + return err + } + var before Box + for _, i := range indexes { + if i < 0 || i >= len(members) { + return fmt.Errorf("invalid composite selection") + } + before = unionTextBox(before, members[i].Bounds) + } + if before.W <= 0 || before.H <= 0 { + return fmt.Errorf("selection has no visible bounds") + } + sx, sy := box.W/before.W, box.H/before.H + if math.Abs(sx-sy) > 1e-9*math.Max(sx, sy) { + return fmt.Errorf("composite members require proportional resizing") + } + return e.TransformCompositeObjects(page, path, indexes, box.X-before.X*sx, box.Y-before.Y*sx, sx) +} + +// compositeOrientation 将旋转或镜像中心移至选区中心 +// 入参: box 选区, matrix 方向矩阵 +// 返回: Matrix 页面变换 +func compositeOrientation(box Box, matrix Matrix) Matrix { + x, y := box.X+box.W/2, box.Y+box.H/2 + return TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y)) +} + +// compositeScope 加载所需的复合路径及直接成员,不展开无关子树 +// 入参: page 页面索引, path 复合对象路径 +// 返回: *Reader 预览读取器, *Renderer 度量器, *editorCompositeNode 顶层节点, []*editorCompositeNode 成员, error 错误信息 +func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) { + layer, index, err := e.findObject(page, path.ID) + if err != nil { + return nil, nil, nil, nil, err + } + object := layer.Objects[index] + origin := e.objectOrigin(path.ID) + if origin == nil { + return nil, nil, nil, nil, fmt.Errorf("composite requires original content") + } + data, err := editorXMLObject(origin.data, origin.node, origin.object, object) + if err == nil { + data, err = editorXMLStandalone(data, origin.node) + } + if err != nil { + return nil, nil, nil, nil, err + } + root, err := newEditorCompositeNode(data) + if err != nil { + return nil, nil, nil, nil, err + } + 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, root.defaults = IdentityMatrix, true, renderer.drawParamDefaults(layer.DrawParam, nil) + scope := root + for depth := 0; ; depth++ { + var members []*editorCompositeNode + members, err = scope.members(reader, renderer, make(map[string]bool)) + if err != nil { + break + } + if depth == len(path.Children) { + return reader, renderer, root, members, nil + } + i := path.Children[depth] + if i < 0 || i >= len(members) { + err = fmt.Errorf("composite member index %d out of range", i) + break + } + scope = members[i] + } + reader.Close() + return nil, nil, nil, nil, err +} + +// newEditorCompositeNode 解析独立图形片段,保留全部原始属性 +// 入参: data XML片段 +// 返回: *editorCompositeNode 节点, error 错误信息 +func newEditorCompositeNode(data []byte) (*editorCompositeNode, error) { + node, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var object GraphicObject + object.Type = node.name.Local + switch object.Type { + case "TextObject": + err = xml.Unmarshal(data, &object.TextObject) + case "PathObject": + err = xml.Unmarshal(data, &object.PathObject) + case "ImageObject": + err = xml.Unmarshal(data, &object.ImageObject) + case "CompositeObject", "CompositeGraphicUnit": + err = xml.Unmarshal(data, &object.CompositeGraphicUnit) + default: + err = fmt.Errorf("unsupported composite member %q", object.Type) + } + return &editorCompositeNode{data: data, node: node, object: object}, err +} + +// members 展开当前复合实例的资源引用与直接绘制成员 +// 入参: reader 资源读取器, renderer 渲染上下文, visiting 当前资源链 +// 返回: []*editorCompositeNode 成员, error 错误信息 +func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiting map[string]bool) ([]*editorCompositeNode, error) { + if n.object.Type != "CompositeObject" && n.object.Type != "CompositeGraphicUnit" { + return nil, fmt.Errorf("object is not composite") + } + if !n.expanded { + c := n.object.CompositeGraphicUnit + box, _ := ParseBox(c.Boundary) + boundary := n.parent.Multiply(TranslationMatrix(box.X, box.Y)) + matrix := boundary.Multiply(NewMatrix(c.CTM)) + clips, clipMatrix := c.Clips, matrix + if clips != nil && clips.TransFlag != nil && !*clips.TransFlag { + copy := *clips + copy.TransFlag = nil + clips, clipMatrix = ©, boundary + } + clip := intersectClipPath(n.clip, renderer.buildClipPath(clips, 0, 0, 0, clipMatrix)) + defaults := renderer.drawParamDefaults(c.DrawParam, n.defaults) + alpha := mergeAlpha(c.Alpha, n.alpha) + visible := n.visible && (c.Visible == nil || *c.Visible) + if c.ResourceID != "" { + data, err := reader.readFile(reader.resourceFiles[c.ResourceID]) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var resource *editorXML + if units := root.child("CompositeGraphicUnits"); units != nil { + for _, child := range units.children { + if child.name.Local == "CompositeGraphicUnit" && child.attr("ID") == c.ResourceID { + resource = child + break + } + } + } + if resource == nil { + return nil, fmt.Errorf("composite resource %q not found", c.ResourceID) + } + fragment, err := editorXMLStandalone(data[resource.start:resource.end], resource) + if err != nil { + return nil, err + } + n.ref, err = newEditorCompositeNode(fragment) + if err != nil { + return nil, err + } + n.ref.parent, n.ref.boundaryInCTM, n.ref.defaults, n.ref.clip, n.ref.alpha, n.ref.visible = matrix, true, defaults, clip, alpha, visible + } + var collect func(*editorXML) error + collect = func(container *editorXML) error { + for _, child := range container.children { + switch child.name.Local { + case "Content", "PageBlock": + if err := collect(child); err != nil { + return err + } + case "TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit": + data, err := editorXMLStandalone(n.data[child.start:child.end], child) + if err != nil { + return err + } + item, err := newEditorCompositeNode(data) + if err != nil { + return err + } + item.parent, item.boundaryInCTM, item.defaults, item.clip, item.alpha, item.visible, item.span = matrix, n.boundaryInCTM, defaults, clip, alpha, visible, child + n.children = append(n.children, item) + } + } + return nil + } + if err := collect(n.node); err != nil { + return nil, err + } + n.expanded = true + } + var result []*editorCompositeNode + if n.ref != nil { + id := n.object.CompositeGraphicUnit.ResourceID + if visiting[id] { + return nil, fmt.Errorf("cyclic composite resource %q", id) + } + visiting[id] = true + members, err := n.ref.members(reader, renderer, visiting) + delete(visiting, id) + if err != nil { + return nil, err + } + result = append(result, members...) + } + return append(result, n.children...), nil +} + +// measureCompositeMembers 按实际父变换、裁剪和继承样式度量成员 +// 入参: renderer 渲染器, nodes 成员 +// 返回: []CompositeMember 独立快照及操作能力 +func measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember { + result := make([]CompositeMember, len(nodes)) + for i, node := range nodes { + bounds := &boundsRenderer{collect: true} + object := mergeGraphicObjectAlpha(node.object, node.alpha) + if node.visible { + renderer.renderObject(canvas.NewContext(bounds), &object, 0, node.defaults, &node.parent, node.boundaryInCTM, node.clip) + } + _, invertible := node.parent.Invert() + borderActions := node.object.Type == "ImageObject" && node.object.ImageObject.Border != nil && len(node.object.ImageObject.Actions) != 0 + result[i] = CompositeMember{Object: cloneEditorData(node.object), Bounds: bounds.box, Contours: bounds.contours, Transform: invertible && editorXMLTransformable(node.node) && validateEditorGeometry(node.object) == nil && !borderActions} + } + return result +} + +// changeCompositeObjects 原子变换内部选区,沿改动路径复制共享资源 +// 入参: page 页面索引, path 父路径, indexes 成员序号, transform 页面变换生成器 +// 返回: error 错误信息 +func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int, transform func(Box) Matrix) (err error) { + reader, renderer, root, nodes, err := e.compositeScope(page, path) + if err != nil { + return err + } + defer reader.Close() + members := measureCompositeMembers(renderer, nodes) + selected := make(map[int]bool) + var box Box + for _, i := range indexes { + if i < 0 || i >= len(nodes) || selected[i] { + return fmt.Errorf("invalid composite selection") + } + if !members[i].Transform { + return fmt.Errorf("composite member %d cannot be transformed", i) + } + selected[i] = true + box = unionTextBox(box, members[i].Bounds) + } + if len(selected) == 0 { + return nil + } + matrix := transform(box) + if matrix == IdentityMatrix { + return nil + } + count, maximum := len(e.resources), e.maxID + ready := e.source.idsReady + defer func() { + if err != nil { + clear(e.resources[count:]) + e.resources, e.maxID = e.resources[:count], maximum + e.source.idsReady = ready + } + }() + if err = e.prepareSourceIDs(); err != nil { + return err + } + for i := range selected { + n := nodes[i] + object := cloneEditorData(n.object) + inverse, _ := n.parent.Invert() + basic := object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" + if basic && !n.boundaryInCTM { + object = compositeBoundary(object, n.parent, true) + } + local := inverse.Multiply(matrix).Multiply(n.parent) + if object.Type == "ImageObject" && object.ImageObject.Border != nil { + origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object} + object, err = e.transformBorderedImage(object, local, origin, renderer) + if err != nil { + return err + } + } else { + object = transformEditorMatrix(object, local) + } + if basic && object.Type == n.object.Type && !n.boundaryInCTM { + object = compositeBoundary(object, n.parent, false) + } + if err = validateEditorGeometry(object); err != nil { + return err + } + n.data, err = editorXMLObject(n.data, n.node, n.object, object) + if err != nil { + return err + } + if !basic && !n.boundaryInCTM { + n.data, err = transformCompositeOffsets(n.data, matrix) + if err != nil { + return err + } + } + updated, err := newEditorCompositeNode(n.data) + if err != nil { + return err + } + n.node, n.object, n.changed = updated.node, updated.object, true + } + data, _, err := e.writeCompositeNode(root) + if err != nil { + return err + } + next, err := newEditorCompositeNode(data) + if err != nil { + return err + } + origin := e.objectOrigin(path.ID) + next.node.parent = origin.node.parent + updated := &editorObjectOrigin{page: origin.page, data: data, node: next.node, object: next.object} + return e.updateObjectOrigins(page, []GraphicObject{next.object}, true, map[string]*editorObjectOrigin{path.ID: updated}) +} + +// transformCompositeOffsets 变换旧式内联复合成员的页面偏移,资源引用保持原坐标约定 +// 入参: data 修改后的复合片段, matrix 页面变换 +// 返回: []byte 同步内部边界及非随动裁剪后的片段, error 错误信息 +func transformCompositeOffsets(data []byte, matrix Matrix) ([]byte, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var patches []editorXMLPatch + var visit func(*editorXML) error + visit = func(node *editorXML) error { + for _, child := range node.children { + switch child.name.Local { + case "Content", "PageBlock", "CompositeObject", "CompositeGraphicUnit": + if err := visit(child); err != nil { + return err + } + case "TextObject", "PathObject", "ImageObject": + fragment, err := editorXMLStandalone(data[child.start:child.end], child) + if err != nil { + return err + } + n, err := newEditorCompositeNode(fragment) + if err != nil { + return err + } + object := cloneEditorData(n.object) + object = compositeBoundary(object, Matrix{a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d}, false) + if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { + *clips = transformObjectClips(*clips, TranslationMatrix(matrix.e, matrix.f)) + } + fragment, err = editorXMLObject(n.data, n.node, n.object, object) + if err != nil { + return err + } + patches = append(patches, editorXMLPatch{child.start, child.end, fragment}) + } + } + return nil + } + if err = visit(root); err != nil { + return nil, err + } + return editorPatchXML(data, patches), nil +} + +// compositeBoundary 在边界参与和不参与父变换的坐标约定之间转换 +// 入参: object 基本对象, parent 父矩阵, inward 是否转换到父坐标 +// 返回: GraphicObject 转换后的对象 +func compositeBoundary(object GraphicObject, parent Matrix, inward bool) GraphicObject { + matrix := parent + matrix.e, matrix.f = 0, 0 + clipMatrix := parent + if inward { + matrix, _ = matrix.Invert() + clipMatrix, _ = parent.Invert() + } + boundary, _ := editorGeometry(object) + box, _ := ParseBox(boundary) + box.X, box.Y = matrix.a*box.X+matrix.c*box.Y, matrix.b*box.X+matrix.d*box.Y + switch object.Type { + case "TextObject": + object.TextObject.Boundary = editorBoxString(box) + case "PathObject": + object.PathObject.Boundary = editorBoxString(box) + case "ImageObject": + object.ImageObject.Boundary = editorBoxString(box) + } + if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { + *clips = transformObjectClips(*clips, clipMatrix) + } + return object +} + +// writeCompositeNode 保留原文逐级合并内部修改,仅复制发生变化的资源分支 +// 入参: node 实例节点 +// 返回: []byte 新片段, bool 是否变化, error 错误信息 +func (e *Editor) writeCompositeNode(node *editorCompositeNode) ([]byte, bool, error) { + var patches []editorXMLPatch + for _, child := range node.children { + data, changed, err := e.writeCompositeNode(child) + if err != nil { + return nil, false, err + } + if changed { + patches = append(patches, editorXMLPatch{child.span.start, child.span.end, data}) + } + } + data := editorPatchXML(node.data, patches) + changed := node.changed || len(patches) != 0 + if node.ref != nil { + fragment, dirty, err := e.writeCompositeNode(node.ref) + if err != nil { + return nil, false, err + } + if dirty { + id, err := e.addCompositeResource(fragment) + if err != nil { + return nil, false, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, false, err + } + after := node.object + after.CompositeGraphicUnit.ResourceID = id + data, err = editorXMLObject(data, root, node.object, after) + if err != nil { + return nil, false, err + } + changed = true + } + } + return data, changed, nil +} + +// addCompositeResource 注册独立复合资源并重映射内部标识,保留未知XML +// 入参: data 复合资源片段 +// 返回: string 新资源标识, error 错误信息 +func (e *Editor) addCompositeResource(data []byte) (string, error) { + root, err := parseEditorXML(data) + if err != nil { + return "", err + } + if !editorXMLCopyable(root) { + return "", fmt.Errorf("composite contains unsupported identifiers") + } + ids := make(map[string]string) + var collect func(*editorXML) error + collect = func(node *editorXML) error { + if id := editorResourceID(node.attr("ID")); id != "" { + if ids[id] != "" { + return fmt.Errorf("duplicate object ID %q", id) + } + ids[id] = e.nextID() + } + for _, child := range node.children { + if err := collect(child); err != nil { + return err + } + } + return nil + } + if err := collect(root); err != nil { + return "", err + } + id := ids[editorResourceID(root.attr("ID"))] + data, err = editorXMLRemapIDs(data, root, ids) + if err != nil { + return "", err + } + data, err = editorXMLContainer("CompositeGraphicUnits", nil, bytes.TrimPrefix(data, []byte(xml.Header))) + if err != nil { + return "", err + } + data, err = editorXMLContainer("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}, bytes.TrimPrefix(data, []byte(xml.Header))) + if err != nil { + return "", err + } + resource, err := e.compositeResource(id, data) + if err != nil { + return "", err + } + e.resources = append(e.resources, resource) + return id, nil +} + +// compositeResource 记录生成复合资源的完整引用,用于保存时按依赖保留资源 +// 入参: id 资源标识, data 资源XML +// 返回: editorResource 资源, error 错误信息 +func (e *Editor) compositeResource(id string, data []byte) (editorResource, error) { + root, err := parseEditorXML(data) + if err != nil { + return editorResource{}, err + } + refs := make(map[string]bool) + var collect func(*editorXML) + collect = func(node *editorXML) { + if node.name.Space == "" || node.name.Space == ofdNamespace || node.name.Space == "http://www.ofdspec.org" { + for _, attr := range node.attrs { + if attr.Name.Space == "" && editorObjectReference(attr.Name.Local) { + refs[attr.Value] = true + } + } + } + for _, child := range node.children { + collect(child) + } + } + collect(root) + return editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id, references: slices.Sorted(maps.Keys(refs))}, nil +} + +// collectCompositeReferences 收集内联复合对象引用的新资源 +// 入参: composite 复合对象, used 引用集合 +func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]bool) { + used[composite.ResourceID] = true + for _, object := range composite.Objects { + switch object.Type { + case "TextObject": + used[object.TextObject.Font] = true + case "ImageObject": + used[object.ImageObject.ResourceID] = true + used[object.ImageObject.ImageMask] = true + case "CompositeObject", "CompositeGraphicUnit": + collectCompositeReferences(object.CompositeGraphicUnit, used) + } + } +} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 5f8b4f8..b3edc44 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -283,7 +283,8 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err encoded = data[origin.start:origin.end] } else { var err error - encoded, err = editorXMLObject(data, origin, before[nextID], next) + current := e.objectOrigin(nextID) + encoded, err = editorXMLObject(current.data, current.node, current.object, next) if err != nil { return nil, err } diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 253c42f..a63e703 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -179,6 +179,13 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb if origin == nil { return GraphicObject{}, fmt.Errorf("bordered image requires an original object") } + return e.transformBorderedImage(object, matrix, origin, NewRenderer(e.source.reader, WithFontFS(e.fontFS...))) +} + +// transformBorderedImage 将原文图片封装为复合资源,保留边框和裁剪 +// 入参: object 图片对象, matrix 变换, origin 原文, renderer 资源度量器 +// 返回: GraphicObject 变换结果, error 错误信息 +func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, origin *editorObjectOrigin, renderer *Renderer) (GraphicObject, error) { if err := e.prepareSourceIDs(); err != nil { return GraphicObject{}, err } @@ -186,7 +193,7 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb if err != nil { return GraphicObject{}, err } - visible, err := NewRenderer(e.source.reader).ObjectBounds(object, "") + visible, err := renderer.ObjectBounds(object, "") if err != nil { return GraphicObject{}, err } @@ -220,7 +227,11 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb if err != nil { return GraphicObject{}, err } - e.resources = append(e.resources, editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id}) + resource, err := e.compositeResource(id, data) + if err != nil { + return GraphicObject{}, err + } + 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_reader.go b/ofdgo_reader.go index 43034ec..5554940 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -241,10 +241,12 @@ func (r *Reader) loadRes(resPath string) { for i := range res.DrawParams.DrawParam { dp := &res.DrawParams.DrawParam[i] r.drawParamCache[dp.ID] = dp + r.resourceFiles[dp.ID] = fullPath } for i := range res.CompositeGraphicUnits.CompositeGraphicUnit { cgu := &res.CompositeGraphicUnits.CompositeGraphicUnit[i] r.compositeGraphicUnitCache[cgu.ID] = cgu + r.resourceFiles[cgu.ID] = fullPath } r.resourcesRead[fullPath] = true } diff --git a/ofdgo_renderer_layer.go b/ofdgo_renderer_layer.go index e54be95..19629a2 100644 --- a/ofdgo_renderer_layer.go +++ b/ofdgo_renderer_layer.go @@ -115,6 +115,18 @@ func (r *Renderer) renderLayer(ctx *canvas.Context, layer Layer, pageH float64, // 入参: ctx 画布上下文, cgu 复合图元对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径 func (r *Renderer) renderCompositeGraphicUnit(ctx *canvas.Context, cgu CompositeGraphicUnit, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { if cgu.Visible != nil && !*cgu.Visible { + if groups, ok := ctx.Renderer.(interface{ skipObjects(int) }); ok { + count, ref, seen := len(cgu.Objects), cgu.ResourceID, make(map[string]bool) + for ref != "" && !seen[ref] { + seen[ref] = true + unit := r.CompositeGraphicUnits[ref] + if unit == nil { + break + } + count, ref = count+len(unit.Objects), unit.ResourceID + } + groups.skipObjects(count) + } return } ctx.Push() diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index 24ffcbb..9ed5afa 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -54,15 +54,23 @@ type SVGResources struct { // svgResourceRenderer 分离外部资源的SVG渲染器 type svgResourceRenderer struct { *svg.SVG - renderer *Renderer - writer io.Writer - fonts []SVGFont - images []SVGImage - imageNames map[image.Image]string - seen map[string]bool - styles strings.Builder - err error - objects map[*GraphicObject]string + renderer *Renderer + writer io.Writer + fonts []SVGFont + images []SVGImage + imageNames map[image.Image]string + seen map[string]bool + styles strings.Builder + err error + objects map[*GraphicObject]string + objectStack []svgObjectGroup +} + +// svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员 +type svgObjectGroup struct { + path string + next int + composite bool } // RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体 @@ -135,23 +143,40 @@ 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 { id := s.objects[object] + attribute := "data-ofd-object" if id == "" { - return false + if len(s.objectStack) == 0 || !s.objectStack[len(s.objectStack)-1].composite { + return false + } + parent := &s.objectStack[len(s.objectStack)-1] + id = fmt.Sprintf("%s/%d", parent.path, parent.next) + parent.next++ + attribute = "data-ofd-child" } - fmt.Fprintf(s.writer, ``, html.EscapeString(id)) + s.objectStack = append(s.objectStack, svgObjectGroup{path: id, composite: object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit"}) + fmt.Fprintf(s.writer, ``, attribute, html.EscapeString(id)) return true } // endObject 结束对象分组 func (s *svgResourceRenderer) endObject() { + s.objectStack = s.objectStack[:len(s.objectStack)-1] fmt.Fprint(s.writer, ``) } +// skipObjects 为不可见资源保留内部成员序号 +// 入参: count 直接成员数量 +func (s *svgResourceRenderer) skipObjects(count int) { + if len(s.objectStack) > 0 { + s.objectStack[len(s.objectStack)-1].next += count + } +} + // RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 // 入参: img 图片对象, m 变换矩阵 func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 25e3f45..400af64 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -320,7 +320,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color for _, layer := range page.Content.Layer { for _, object := range layer.Objects { before, exists := original[editorObjectID(object)] - if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!exists || before.Type != object.Type) { + if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { for _, name := range origin.page.original.PageRes { files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true } @@ -335,7 +335,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color } switch object.Type { case "CompositeObject", "CompositeGraphicUnit": - used[object.CompositeGraphicUnit.ResourceID] = true + collectCompositeReferences(object.CompositeGraphicUnit, used) case "TextObject": used[object.TextObject.Font] = true if e.source != nil && (!exists || before.Type != object.Type || before.TextObject.Font != object.TextObject.Font) { @@ -356,6 +356,14 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color } } } + for i := len(e.resources) - 1; i >= 0; i-- { + resource := e.resources[i] + if resource.composite != "" && used[resource.composite] { + for _, id := range resource.references { + used[id] = true + } + } + } for _, resource := range e.resources { if resource.composite != "" && used[resource.composite] { files[resource.name] = true