From fa8fec9830bcfdbc2a848435043940a4e9801c49 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 17 Sep 2026 18:05:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=A4=8D=E5=90=88=E5=AF=B9=E8=B1=A1=E7=BC=96?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=92=A4=E9=94=80=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E7=B2=98=E8=B4=B4=E8=B7=AF=E5=BE=84=E5=92=8C=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 84 ++++++++++++--- internal/webui/wasm.go | 159 +++++++++++++++++++++++---- ofdgo_editor_composite.go | 61 +++++++---- ofdgo_editor_composite_copy.go | 186 ++++++++++++++++++++++++++++++++ ofdgo_editor_composite_image.go | 107 ++++++++++++++++++ ofdgo_editor_composite_ops.go | 45 +++++--- ofdgo_editor_composite_text.go | 115 ++++++++++++++++++++ ofdgo_editor_font.go | 46 ++++++++ ofdgo_editor_open.go | 5 +- 9 files changed, 737 insertions(+), 71 deletions(-) create mode 100644 ofdgo_editor_composite_copy.go create mode 100644 ofdgo_editor_composite_image.go create mode 100644 ofdgo_editor_composite_text.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 97319f6..a409e51 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -37,6 +37,7 @@ const state = { selectObjects: true, dirty: false, editorInfo: null, + editorViews: new Map(), savedRevision: null, insertObject: null, importPageCount: 0, @@ -1196,6 +1197,20 @@ function setEditorInfo(doc) { setDirty(doc.revision !== state.savedRevision); } +function editorLocation() { + const pending = canvasEditor.pendingSelection; + const index = pending?.index ?? canvasEditor.selected?.index ?? state.pageIndex; + return { page: state.doc.pages[index].id, scope: state.composite?.key || "", ids: pending?.ids || canvasEditor.items().map(item => item.id) }; +} + +function rememberEditorView(before, revision, reindex = false) { + for (const key of state.editorViews.keys()) { + if (key > revision) state.editorViews.delete(key); + } + state.editorViews.set(state.editorInfo.revision, { before, after: editorLocation(), reindex }); + if (state.editorViews.size > 100) state.editorViews.delete(state.editorViews.keys().next().value); +} + 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]; @@ -1228,6 +1243,7 @@ async function toggleEditor() { const doc = await callWASM("ofdgoEditDocument"); if (openSeq !== state.openSeq) return; state.savedRevision = doc.revision; + state.editorViews.clear(); setEditorInfo(doc); state.editing = true; state.composite = null; @@ -1283,6 +1299,7 @@ async function createDocument(event) { state.editing = true; state.composite = null; state.objectClipboard = null; + state.editorViews.clear(); state.styleClipboard = null; state.fontRenderPending = false; canvasEditor.clear(); @@ -1387,6 +1404,10 @@ async function exitCompositeScope() { } async function startImageCrop(item) { + if (item.scoped) { + canvasEditor.startCrop(item, { svg: item.surface.querySelector(".ofd-svg").cloneNode(true), urls: [] }); + return; + } const openSeq = state.openSeq; const urls = []; let mounted = false; @@ -1603,6 +1624,7 @@ async function insertObject(event) { } let openSeq = state.openSeq; const item = state.insertObject; + const before = editorLocation(), revision = state.editorInfo.revision; const index = item?.index ?? state.pageIndex; const page = state.doc.pages[index]; const x = Math.min(20, page.width / 10); @@ -1633,6 +1655,7 @@ async function insertObject(event) { openSeq = ++state.openSeq; await refreshEditorPage(doc, index, openSeq); if (openSeq === state.openSeq) { + rememberEditorView(before, revision); el.viewerPanel.focus({ preventScroll: true }); } } catch (err) { @@ -1705,7 +1728,7 @@ async function changeTextStyle(color) { updateObjectControls(canvasEditor.selected, true); return; } - if (state.composite) { + if (state.composite && color) { if (color && fill !== null && canEditObject(item, "paint")) { await changeDocument("ofdgoCompositeTextColor", item, fill); } @@ -1821,6 +1844,8 @@ async function changeDocument(name, item, ...args) { const active = document.activeElement; const toolbarFocus = el.editorTools.contains(active) ? active : null; const previous = currentPageInfo(); + const before = editorLocation(), revision = state.editorInfo.revision; + const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; const { scrollLeft, scrollTop } = el.viewerPanel; const importing = name === "ofdgoImportPages"; if (importing) state.importing = true; @@ -1833,10 +1858,10 @@ async function changeDocument(name, item, ...args) { const scope = state.composite; const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize", ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor", + ofdgoUpdateText: "text", ofdgoStyleText: "textStyle", ofdgoCropImage: "crop", ofdgoDeleteObject: "delete", ofdgoDeleteObjects: "delete", ofdgoCopyObjects: "copy", ofdgoOrderObjects: "order" }[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) @@ -1847,7 +1872,20 @@ async function changeDocument(name, item, ...args) { if (doc.revision === state.editorInfo?.revision) { return true; } - if ((name === "ofdgoUndo" || name === "ofdgoRedo") && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) { + const view = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; + const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects"].includes(name) + || name === "ofdgoPasteObjects" && !!args[4]); + const reindexed = restoring ? view?.reindex && view.before : reindex && before; + const clipboard = state.objectClipboard; + if (reindexed && clipboard?.scope?.startsWith(reindexed.scope + "/") && clipboard.page === reindexed.page) state.objectClipboard = null; + const location = name === "ofdgoUndo" ? view?.before : view?.after; + const restoredIndex = location ? doc.pages.findIndex(page => page.id === location.page) : -1; + if (restoredIndex >= 0) { + state.composite = location.scope ? { index: restoredIndex, key: location.scope, objects: [] } : null; + } else if (!item && !(name === "ofdgoPasteObjects" && args[4])) { + resetCompositeScope(); + } + if (restoring && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) { state.outlineSelection = null; state.outlineExpanded.clear(); } @@ -1856,6 +1894,7 @@ async function changeDocument(name, item, ...args) { Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject }); renderMeta(); updateControls(); + rememberEditorView(before, revision); return true; } if (name === "ofdgoChangeOutline" || name === "ofdgoMoveOutline") { @@ -1865,6 +1904,7 @@ async function changeDocument(name, item, ...args) { renderOutlines(false); showNavigation(el.outlinesTab); updateControls(); + rememberEditorView(before, revision); return true; } const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); @@ -1880,14 +1920,16 @@ async function changeDocument(name, item, ...args) { await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { const samePage = doc.pages.findIndex((page) => page.id === previous.id); - const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); + const pageIndex = restoredIndex >= 0 ? restoredIndex : doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); const page = doc.pages[pageIndex]; const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex && page.width === previous.width && page.height === previous.height; await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale, - keepPreview: true, clearSelection, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null }); + keepPreview: true, clearSelection, selection: restoredIndex >= 0 ? { index: restoredIndex, ids: location.ids } : null, + previewScroll: keepScroll ? { scrollLeft, scrollTop } : null }); } if (openSeq === state.openSeq) { + if (!restoring) rememberEditorView(before, revision, reindex); if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true }); return true; } @@ -2439,6 +2481,7 @@ async function openOFD(file) { state.objectClipboard = null; state.styleClipboard = null; state.editorInfo = null; + state.editorViews.clear(); state.fontRenderPending = false; state.savedRevision = null; canvasEditor.clear(); @@ -2910,6 +2953,7 @@ async function openDocument(options = {}) { } } if (options.clearSelection) canvasEditor.clear(); + if (options.selection) canvasEditor.pendingSelection = options.selection; resetPageFlow(options.keepPreview); renderPageList(); if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) { @@ -3697,9 +3741,9 @@ function copyEditorSelection(event) { || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false; const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order); const cut = event.type === "cut"; - if (state.composite) { + if (state.composite && items.some(item => item.container !== items[0].container)) { event.preventDefault(); - setStatus("内部对象请用复制按钮"); + setStatus("请选择同一组内的对象"); return true; } if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) { @@ -3708,7 +3752,7 @@ function copyEditorSelection(event) { return true; } const index = items[0].index, ids = items.map(item => item.id); - const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut }; + const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[index].id, scope: state.composite?.key || "", bounds: selectionBounds(items), x: 0, y: 0, cut }; if (canvasEditor.nudge) { clipboard.bounds.x += canvasEditor.nudge.x; clipboard.bounds.y += canvasEditor.nudge.y; @@ -3719,10 +3763,10 @@ function copyEditorSelection(event) { state.objectClipboard = clipboard; const capture = () => { const openSeq = state.openSeq, revision = state.editorInfo.revision; - return callWASM("ofdgoCaptureObjects", index, ids, clipboard.token).then(async () => { + return callWASM("ofdgoCaptureObjects", index, ids, clipboard.token, clipboard.scope).then(async () => { clipboard.cut = false; - if (cut && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard) { - clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids })); + if (cut && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex && state.objectClipboard === clipboard && clipboard.scope === (state.composite?.key || "")) { + clipboard.cut = Boolean(await changeDocument("ofdgoDeleteObjects", { index, id: ids, items })); } return true; }, err => { @@ -3753,7 +3797,7 @@ async function pasteEditorContent(event) { const files = Array.from(event.clipboardData.files); const value = event.clipboardData.getData("text/plain").replace(/\t/g, " "); if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return; - if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || !pageCan("insert")) return; + if (document.body.hasAttribute("aria-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag) return; const index = state.pageIndex, page = state.doc.pages[index]; if (token) { const clipboard = state.objectClipboard; @@ -3763,6 +3807,11 @@ async function pasteEditorContent(event) { } const openSeq = state.openSeq; if (!await clipboard.ready || openSeq !== state.openSeq || index !== state.pageIndex || state.objectClipboard !== clipboard) return; + if ((clipboard.scope || "") !== (state.composite?.key || "") || clipboard.scope && clipboard.page !== page.id) { + setStatus("请回到原编辑范围粘贴"); + return; + } + if (!clipboard.scope && !pageCan("insert")) return; const visible = visiblePageBounds(index), bounds = clipboard.bounds; const offset = clipboard.page === page.id ? { x: clipboard.x + (clipboard.cut ? 0 : 3), y: clipboard.y + (clipboard.cut ? 0 : 3) } : { x: 0, y: 0 }; for (const [axis, size] of [["x", "width"], ["y", "height"]]) { @@ -3772,7 +3821,7 @@ async function pasteEditorContent(event) { offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis]; } } - if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y)) { + if (await changeDocument("ofdgoPasteObjects", null, index, token, offset.x, offset.y, clipboard.scope || "")) { clipboard.page = page.id; clipboard.x = offset.x; clipboard.y = offset.y; @@ -3780,6 +3829,7 @@ async function pasteEditorContent(event) { } return; } + if (!pageCan("insert")) return; const visible = visiblePageBounds(index), margin = Math.min(20, visible.width / 10); const x = visible.x + margin, y = visible.y + Math.min(20, visible.height / 10), width = visible.width - margin * 2; if (files.length) { @@ -5554,14 +5604,14 @@ function updateObjectControls(item, reset = false) { el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform"); el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); 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.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "cropImage"); + el.imageFit.disabled = el.cropImageButton.disabled || cropping || Boolean(item.scoped); el.cropImageButton.textContent = cropping ? "完成" : "裁剪"; el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片"); el.cropImageButton.setAttribute("aria-pressed", String(cropping)); el.resetCropButton.textContent = cropping ? "取消" : "还原"; 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.resetCropButton.disabled = el.cropImageButton.disabled || !cropping && (item.scoped || !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) || !canEditObject(item, "enter") && (item.type === "PathObject" || !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update")); @@ -5573,7 +5623,7 @@ function updateObjectControls(item, reset = false) { 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"); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; - el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); + el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input || state.composite); el.paragraphButton.disabled = el.textAlign.disabled; const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); el.textAlign.value = layoutKnown ? text.align || "left" : ""; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index cd27c34..c5f4a49 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -613,8 +613,9 @@ var currentEditor *ofdgo.Editor // editorClipboard 当前编辑文档中的对象快照与剪贴板标识 type editorClipboard struct { - token string - objects []ofdgo.GraphicObject + token string + objects []ofdgo.GraphicObject + composite *ofdgo.CompositeSelection } // copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据 @@ -832,7 +833,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, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} if missing := capability.MissingGlyphs; missing != nil { result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -926,6 +927,15 @@ func compositeObjects(args []js.Value) (any, error) { object := member.Object.TextObject item["text"], _ = object.TextLayout() item["font"], item["fontName"], item["size"] = object.Font, fontNames[object.Font], object.Size + item["bounds"] = editorBox(box) + if member.Capabilities.Reflow { + inverse, _ := member.Matrix.Invert() + local := inverse.TransformBox(box) + item["textFrame"] = map[string]any{"width": math.Max(object.Size, local.X+local.W), "height": math.Max(object.Size, local.Y+local.H), "matrix": editorMatrix(member.Matrix)} + } + } + if member.Capabilities.CropImage { + item["imageBounds"] = editorBox(box) } objects = append(objects, item) } @@ -943,17 +953,9 @@ func changeCompositeObjects(args []js.Value) (any, error) { 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) + indexes, err := compositeIndexes(key, stringsFromJS(args[2])) + if err != nil { + return err } page := args[0].Int() operation := args[3].String() @@ -966,6 +968,13 @@ func changeCompositeObjects(args []js.Value) (any, error) { case "order": selected, err = currentEditor.OrderCompositeObjects(page, path, indexes, args[4].String()) return err + case "text", "textStyle": + return changeCompositeText(page, path, indexes, operation, args[4:]) + case "crop": + if len(indexes) != 1 { + return fmt.Errorf("image crop requires one member") + } + return currentEditor.CropCompositeImage(page, path, indexes[0], ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) case "image": if len(indexes) != 1 { return fmt.Errorf("image replacement requires one member") @@ -1038,6 +1047,82 @@ func changeCompositeObjects(args []js.Value) (any, error) { return doc, nil } +// compositeIndexes 将画布成员路径转换为同一范围的序号 +// 入参: key 父路径, ids 成员路径 +// 返回: []int 成员序号, error 错误信息 +func compositeIndexes(key string, ids []string) ([]int, error) { + indexes := make([]int, 0, len(ids)) + for _, id := range ids { + part, found := strings.CutPrefix(id, key+"/") + if !found { + return nil, fmt.Errorf("selection is outside composite") + } + i, err := strconv.Atoi(part) + if err != nil || i < 0 { + return nil, fmt.Errorf("invalid composite member") + } + indexes = append(indexes, i) + } + return indexes, nil +} + +// changeCompositeText 修改内部文字,嵌入字体前校验选区用字 +// 入参: page 页面索引, path 父路径, indexes 成员序号, operation 操作, args 内容及样式 +// 返回: error 错误信息 +func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operation string, args []js.Value) error { + var value *string + if operation == "text" { + if len(indexes) != 1 { + return fmt.Errorf("text editing requires one member") + } + if !args[0].IsNull() { + text := args[0].String() + value = &text + } + args = args[1:] + } + style := ofdgo.TextStyle{Size: args[1].Float()} + if !args[0].IsNull() { + data, err := bytesFromJS(args[0]) + if err != nil { + return err + } + members, err := currentEditor.CompositeObjects(page, path) + if err != nil { + return err + } + var text strings.Builder + for _, index := range indexes { + if index >= len(members) || !members[index].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()) + } + } + if err := checkFontGlyphs(data, text.String()); err != nil { + return err + } + style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[2].IsNull() { + var fill ofdgo.FillColor + if err := setEditorColor(&fill, args[2].String()); err != nil { + return err + } + style.Color = fill.Value + } + if value != nil { + return currentEditor.UpdateCompositeText(page, path, indexes[0], *value, style) + } + return currentEditor.StyleCompositeText(page, path, indexes, style) +} + // editorFont 读取对象实际使用的内嵌字体,供画布输入使用 // 入参: args 字体资源标识 // 返回: any 字体数据, error 错误信息 @@ -1152,27 +1237,65 @@ func copyObjects(args []js.Value) (any, error) { } // captureObjects 保存独立选区快照,不修改文档或历史 -// 入参: args 页面索引、对象标识数组和剪贴板标识 +// 入参: args 页面索引、对象标识数组、剪贴板标识和可选父路径 // 返回: any 空结果, error 错误信息 func captureObjects(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } - objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) + clipboard := &editorClipboard{token: args[2].String()} + var err error + if len(args) > 3 && args[3].String() != "" { + key := args[3].String() + path, pathErr := compositePath(key) + if pathErr != nil { + return nil, pathErr + } + indexes, indexErr := compositeIndexes(key, stringsFromJS(args[1])) + if indexErr != nil { + return nil, indexErr + } + clipboard.composite, err = currentEditor.CaptureCompositeObjects(args[0].Int(), path, indexes) + } else { + clipboard.objects, err = currentEditor.Objects(args[0].Int(), stringsFromJS(args[1])) + } if err != nil { return nil, err } - copiedObjects = &editorClipboard{args[2].String(), objects} + copiedObjects = clipboard return nil, nil } // pasteObjects 将当前文档的对象快照粘贴到目标页 -// 入参: args 目标页、剪贴板标识和横纵位移 +// 入参: args 目标页、剪贴板标识、横纵位移和可选父路径 // 返回: any 文档信息及新选区, error 错误信息 func pasteObjects(args []js.Value) (any, error) { if currentEditor == nil || copiedObjects == nil || copiedObjects.token != args[1].String() { return nil, fmt.Errorf("object clipboard is no longer available") } + if copiedObjects.composite != nil { + if len(args) < 5 || args[4].String() == "" { + return nil, fmt.Errorf("paste requires the original composite scope") + } + key := args[4].String() + path, err := compositePath(key) + if err != nil { + return nil, err + } + indexes, err := currentEditor.PasteCompositeObjects(args[0].Int(), path, copiedObjects.composite, args[2].Float(), args[3].Float()) + if err != nil { + return nil, err + } + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + result := editorSelectionInfo{editorInfo: info} + for _, index := range indexes { + result.SelectedIDs = append(result.SelectedIDs, fmt.Sprintf("%s/%d", key, index)) + } + return result, err + } + if len(args) > 4 && args[4].String() != "" { + return nil, fmt.Errorf("top-level objects cannot be pasted into a composite") + } return copyEditorObjects(args[0].Int(), copiedObjects.objects, args[2].Float(), args[3].Float()) } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 90aaa8e..fd30cb5 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -17,6 +17,7 @@ package ofdgo import ( "bytes" "encoding/xml" + "errors" "fmt" "maps" "math" @@ -33,13 +34,14 @@ type ObjectPath struct { } // CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象 -// Bounds与Contours使用页面毫米坐标,StrokeScale为对象描边到页面的倍率 +// Bounds与Contours使用页面毫米坐标,Matrix将对象局部坐标映射至页面,StrokeScale为描边倍率 // Position表示当前范围内的直接容器位置,结构操作后需重新枚举 type CompositeMember struct { Object GraphicObject Bounds Box Contours []ObjectContour StrokeScale float64 + Matrix Matrix Capabilities ObjectCapabilities Position ObjectPosition Transform bool @@ -380,10 +382,24 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp if err != nil { style = cloneEditorData(node.object) } + if transform && err == nil && node.object.Type == "TextObject" && editorXMLSupported(node.node) { + capability.ReplaceFont = true + text := cloneEditorData(style.TextObject) + if err := e.prepareText(&text); err != nil { + capability.ReasonCode, capability.Reason = editReason(err), err.Error() + errors.As(err, &capability.MissingGlyphs) + } else if _, err := text.TextFrame(); err == nil { + capability.Reflow = text.ReadDirection == 0 && text.CharDirection == 0 + } + } + if transform && node.object.Type == "ImageObject" && bounds.box.W > 0 && bounds.box.H > 0 { + clips := node.object.ImageObject.Clips + _, capability.CropImage = node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() + } _, ctm := editorGeometry(node.object) matrix := node.parent.Multiply(NewMatrix(ctm)) position := positions[node.span.parent] - result[i] = CompositeMember{Object: style, Bounds: bounds.box, Contours: bounds.contours, StrokeScale: math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c)), Capabilities: capability, Position: position, Transform: transform} + result[i] = CompositeMember{Object: style, Bounds: bounds.box, Contours: bounds.contours, Matrix: node.matrix(true), StrokeScale: math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c)), Capabilities: capability, Position: position, Transform: transform} position.Index++ positions[node.span.parent] = position } @@ -537,26 +553,35 @@ func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int // 入参: page 页面索引, path 父路径, indexes 成员序号, edit 修改回调 // 返回: error 错误信息 func (e *Editor) editCompositeObjects(page int, path ObjectPath, indexes []int, edit func(*Renderer, []*editorCompositeNode, []CompositeMember) error) (err error) { + return e.editCompositeScope(page, path, func(renderer *Renderer, _ *editorCompositeNode, nodes []*editorCompositeNode) error { + members := e.measureCompositeMembers(renderer, nodes) + selected := make(map[int]bool) + var selectedNodes []*editorCompositeNode + var selectedMembers []CompositeMember + for _, i := range indexes { + if i < 0 || i >= len(nodes) || selected[i] { + return fmt.Errorf("invalid composite selection") + } + selected[i] = true + selectedNodes = append(selectedNodes, nodes[i]) + selectedMembers = append(selectedMembers, members[i]) + } + if len(selected) == 0 { + return nil + } + return edit(renderer, selectedNodes, selectedMembers) + }) +} + +// editCompositeScope 原子提交内部范围修改,支持向空容器粘贴并在失败时回收新增资源 +// 入参: page 页面索引, path 父路径, edit 修改回调 +// 返回: error 错误信息 +func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Renderer, *editorCompositeNode, []*editorCompositeNode) error) (err error) { reader, renderer, root, nodes, err := e.compositeScope(page, path) if err != nil { return err } defer reader.Close() - members := e.measureCompositeMembers(renderer, nodes) - selected := make(map[int]bool) - var selectedNodes []*editorCompositeNode - var selectedMembers []CompositeMember - for _, i := range indexes { - if i < 0 || i >= len(nodes) || selected[i] { - return fmt.Errorf("invalid composite selection") - } - selected[i] = true - selectedNodes = append(selectedNodes, nodes[i]) - selectedMembers = append(selectedMembers, members[i]) - } - if len(selected) == 0 { - return nil - } count, maximum := len(e.resources), e.maxID ready := e.source.idsReady defer func() { @@ -577,7 +602,7 @@ func (e *Editor) editCompositeObjects(page int, path ObjectPath, indexes []int, if err = e.prepareSourceIDs(); err != nil { return err } - if err = edit(renderer, selectedNodes, selectedMembers); err != nil { + if err = edit(renderer, root, nodes); err != nil { return err } data, changed, err := e.writeCompositeNode(root) diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go new file mode 100644 index 0000000..e9fa397 --- /dev/null +++ b/ofdgo_editor_composite_copy.go @@ -0,0 +1,186 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "slices" +) + +// CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源 +// 仅可粘贴回当前Editor的原范围,源对象修改或删除不改变快照,不用于跨文档交换 +// 父路径的祖先成员增删或排序后需重新捕获,避免使用已失效的序号路径 +type CompositeSelection struct { + editor *Editor + page string + path ObjectPath + references int + containers []int + nodes []*editorCompositeNode +} + +// CaptureCompositeObjects 捕获内部选区,不修改文档或分配资源 +// 入参: page 页面索引, path 父复合路径, indexes 同一容器的成员序号 +// 返回: *CompositeSelection 独立快照, error 错误信息 +func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []int) (*CompositeSelection, error) { + reader, renderer, root, nodes, err := e.compositeScope(page, path) + if err != nil { + return nil, err + } + defer reader.Close() + if len(indexes) == 0 { + return nil, fmt.Errorf("composite selection is empty") + } + indexes = slices.Clone(indexes) + slices.Sort(indexes) + members := e.measureCompositeMembers(renderer, nodes) + var owner *editorCompositeNode + var container *editorXML + selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Children: slices.Clone(path.Children)}} + for j, i := range indexes { + if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i || !members[i].Capabilities.Copy { + return nil, fmt.Errorf("invalid composite copy selection") + } + node := nodes[i] + if j == 0 { + owner, container = node.owner, node.span.parent + } else if node.span.parent != container { + return nil, fmt.Errorf("copy requires members in the same container") + } + copy := *node + copy.owner, copy.span = nil, nil + selection.nodes = append(selection.nodes, ©) + } + scope := root.loadedScope(path.Children) + for scope != owner { + scope = scope.ref + selection.references++ + } + for node := container; node != owner.node; node = node.parent { + containers := compositeContainers(node.parent) + selection.containers = append(selection.containers, slices.Index(containers, node)) + } + slices.Reverse(selection.containers) + return selection, nil +} + +// PasteCompositeObjects 将快照粘贴至原内部容器末尾,不依赖源成员当前的序号或存在性 +// 位移使用当前父变换下的页面毫米,父容器和继承样式沿用当前范围,提交一次撤销记录 +// 入参: page 页面索引, path 父复合路径, selection 捕获的快照, dx、dy 页面位移 +// 返回: []int 新成员序号, error 错误信息 +func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *CompositeSelection, dx, dy float64) ([]int, error) { + if selection == nil || selection.editor != e || path.ID != selection.path.ID || !slices.Equal(path.Children, selection.path.Children) { + return nil, fmt.Errorf("paste requires the original composite scope") + } + if !finite(dx) || !finite(dy) { + return nil, fmt.Errorf("paste requires finite offsets") + } + var result []int + err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error { + if e.pages[page].ID != selection.page { + return fmt.Errorf("paste requires the original page") + } + scope := root.loadedScope(path.Children) + owner := scope + for i := 0; i < selection.references; i++ { + owner = owner.ref + if owner == nil { + return fmt.Errorf("composite resource container no longer exists") + } + } + container := owner.node + for _, index := range selection.containers { + containers := compositeContainers(container) + if index >= len(containers) { + return fmt.Errorf("composite page block no longer exists") + } + container = containers[index] + } + box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary) + parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM)) + nodes := make([]*editorCompositeNode, len(selection.nodes)) + for i, node := range selection.nodes { + copy := *node + copy.parent, copy.boundaryInCTM = parent, owner.boundaryInCTM + nodes[i] = © + } + data, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + if err != nil { + return err + } + next, reached := 0, false + var count func(*editorCompositeNode) + count = func(node *editorCompositeNode) { + if node.ref != nil { + count(node.ref) + } + if reached { + return + } + for _, child := range node.children { + if node != owner || child.span.start < container.close { + next++ + } + } + reached = node == owner + } + count(scope) + patch := editorXMLPatch{container.close, container.close, data} + if container.open == container.end { + patch = editorXMLContent(owner.data, container, data) + } + owner.patches = append(owner.patches, patch) + for i := range nodes { + result = append(result, next+i) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// loadedScope 获取已由compositeScope展开并校验的范围 +// 入参: path 逐层成员序号 +// 返回: *editorCompositeNode 范围节点 +func (n *editorCompositeNode) loadedScope(path []int) *editorCompositeNode { + for _, index := range path { + var nodes []*editorCompositeNode + var collect func(*editorCompositeNode) + collect = func(node *editorCompositeNode) { + if node.ref != nil { + collect(node.ref) + } + nodes = append(nodes, node.children...) + } + collect(n) + n = nodes[index] + } + return n +} + +// compositeContainers 获取直接内容容器,序号不随绘制成员增删变化 +// 入参: node 父节点 +// 返回: []*editorXML 内容容器与页块 +func compositeContainers(node *editorXML) []*editorXML { + var result []*editorXML + for _, child := range node.children { + if child.name.Local == "Content" || child.name.Local == "PageBlock" { + result = append(result, child) + } + } + return result +} diff --git a/ofdgo_editor_composite_image.go b/ofdgo_editor_composite_image.go new file mode 100644 index 0000000..d4bea2d --- /dev/null +++ b/ofdgo_editor_composite_image.go @@ -0,0 +1,107 @@ +// 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" +) + +// matrix 获取对象局部坐标到页面的变换,兼容旧式内联边界 +// 入参: transform 是否包含对象CTM +// 返回: Matrix 坐标变换 +func (n *editorCompositeNode) matrix(transform bool) Matrix { + boundary, ctm := editorGeometry(n.object) + box, _ := ParseBox(boundary) + m := IdentityMatrix + if transform { + m = NewMatrix(ctm) + if n.object.Type == "ImageObject" && ctm == "" { + m = Matrix{a: box.W, d: box.H} + } + } + if n.boundaryInCTM { + return n.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(m) + } + if !transform { + return TranslationMatrix(box.X, box.Y) + } + return TranslationMatrix(box.X, box.Y).Multiply(n.parent).Multiply(m) +} + +// CropCompositeImage 将内部图片与页面矩形取交集,保留原裁剪、蒙版、变换和边框 +// 只收窄可见区域,撤销恢复上次裁剪,不重采样图片 +// 入参: page 页面索引, path 父复合路径, index 图片序号, box 页面毫米范围 +// 返回: error 错误信息 +func (e *Editor) CropCompositeImage(page int, path ObjectPath, index int, box Box) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + return e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + node, member := nodes[0], members[0] + if !member.Capabilities.CropImage { + return fmt.Errorf("composite image cannot be cropped") + } + before := member.Bounds + if box.X <= before.X && box.Y <= before.Y && box.X+box.W >= before.X+before.W && box.Y+box.H >= before.Y+before.H { + return nil + } + if box.X+box.W <= before.X || box.Y+box.H <= before.Y || box.X >= before.X+before.W || box.Y >= before.Y+before.H { + return fmt.Errorf("crop does not intersect the image") + } + clips := node.object.ImageObject.Clips + inverse, _ := node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() + shape, err := NewShape(ShapeRectangle, box) + if err != nil { + return err + } + fill, stroke := true, false + shape.Fill, shape.Stroke = &fill, &stroke + data, err := encodeOFDXML(func(x *ofdXML) { + x.clips(&Clips{Clip: []Clip{{Area: []ClipArea{{CTM: inverse.String(), Path: []PathObject{shape}}}}}}) + }) + if err != nil { + return err + } + data = bytes.TrimPrefix(data, []byte(xml.Header)) + root, err := parseEditorXML(data) + if err != nil { + return err + } + var patch editorXMLPatch + if original := node.node.child("Clips"); original != nil { + clip, err := editorXMLStandalone(data[root.children[0].start:root.children[0].end], root.children[0]) + if err != nil { + return err + } + content := append(bytes.Clone(node.data[original.open:original.close]), clip...) + patch = editorXMLContent(node.data, original, content) + } else { + data, err = editorXMLStandalone(data, root) + if err != nil { + return err + } + content := append(data, node.data[node.node.open:node.node.close]...) + patch = editorXMLContent(node.data, node.node, content) + } + updated, err := newEditorCompositeNode(editorPatchXML(node.data, []editorXMLPatch{patch})) + if err != nil { + return err + } + node.data, node.node, node.object, node.changed = updated.data, updated.node, updated.object, true + return nil + }) +} diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 9d42169..331dc44 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -60,23 +60,9 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int, } } slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index }) - ids := make(map[string]string) - for _, node := range nodes { - if err := e.collectCompositeIDs(node.node, ids); err != nil { - return err - } - } - var data []byte - for _, node := range nodes { - copy := *node - if err := e.transformCompositeMember(renderer, ©, TranslationMatrix(dx, dy)); err != nil { - return err - } - fragment, err := editorXMLRemapIDs(copy.data, copy.node, ids) - if err != nil { - return err - } - data = append(data, fragment...) + data, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + if err != nil { + return err } owner := nodes[0].owner owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data}) @@ -97,6 +83,31 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int, return result, nil } +// copyCompositeNodes 复制成员原文并统一重映射标识,保持副本之间的引用 +// 入参: renderer 渲染器, nodes 快照, dx、dy 页面位移 +// 返回: []byte 副本XML, error 错误信息 +func (e *Editor) copyCompositeNodes(renderer *Renderer, nodes []*editorCompositeNode, dx, dy float64) ([]byte, error) { + ids := make(map[string]string) + for _, node := range nodes { + if err := e.collectCompositeIDs(node.node, ids); err != nil { + return nil, err + } + } + var data []byte + for _, node := range nodes { + copy := *node + if err := e.transformCompositeMember(renderer, ©, TranslationMatrix(dx, dy)); err != nil { + return nil, err + } + fragment, err := editorXMLRemapIDs(copy.data, copy.node, ids) + if err != nil { + return nil, err + } + data = append(data, fragment...) + } + return data, nil +} + // OrderCompositeObjects 调整同一直接容器内的绘制顺序,不改变页块和其他实例 // 入参: page 页面索引, path 父复合路径, indexes 成员序号, order 为up、down、top或bottom // 返回: []int 调整后的选区序号, error 错误信息 diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go new file mode 100644 index 0000000..2fccd31 --- /dev/null +++ b/ofdgo_editor_composite_text.go @@ -0,0 +1,115 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "fmt" + "image/color" + "strconv" +) + +// UpdateCompositeText 修改内部普通横向文字,保留首行起点、变换、裁剪及未修改属性 +// 复杂字形定位不自动重排,空样式保留原字体、字号和颜色 +// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量 +// 返回: error 错误信息 +func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value string, style TextStyle) error { + return e.changeCompositeText(page, path, []int{index}, &value, style) +} + +// StyleCompositeText 批量修改内部文字字体、字号或颜色,换字体保留原文字定位 +// 字体缺字或复杂字形映射使整批操作失败,不使用回退字体代替原文 +// 入参: page 页面索引, path 父复合路径, indexes 成员序号, style 文字样式增量 +// 返回: error 错误信息 +func (e *Editor) StyleCompositeText(page int, path ObjectPath, indexes []int, style TextStyle) error { + return e.changeCompositeText(page, path, indexes, nil, style) +} + +// changeCompositeText 生成独立文字副本,全部校验通过后合并原文 +// 入参: page 页面索引, path 父复合路径, indexes 成员序号, value 新内容,nil保留定位, style 样式增量 +// 返回: error 错误信息 +func (e *Editor) changeCompositeText(page int, path ObjectPath, indexes []int, value *string, style TextStyle) error { + return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + for i, node := range nodes { + member := members[i] + if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont { + return fmt.Errorf("composite text style is not supported") + } + object := cloneEditorData(node.object) + text := cloneEditorData(member.Object.TextObject) + if style.Font != "" { + text.Font = style.Font + } + content := text.Text() + if value != nil { + content = *value + } + if content != text.Text() || style.Size != 0 && style.Size != text.Size { + if style.Size != 0 { + text.Size = style.Size + } + if err := e.layoutCompositeText(&text, content); err != nil { + return err + } + object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode + } + if style.Font != "" && style.Font != object.TextObject.Font { + if err := e.prepareText(&text); err != nil { + return err + } + object.TextObject.Font = style.Font + } + if style.Color != "" { + if err := creationColor(&FillColor{Value: style.Color}); err != nil { + return err + } + var r, g, b float64 + fmt.Sscan(style.Color, &r, &g, &b) + fill, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) + if err != nil { + return err + } + object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor) + } + if err := node.update(object); err != nil { + return err + } + } + return nil + }) +} + +// layoutCompositeText 重排普通文字并保持原首行基线起点 +// 入参: text 有效文字样式, value 新内容 +// 返回: error 错误信息 +func (e *Editor) layoutCompositeText(text *TextObject, value string) error { + if len(text.TextCode) == 0 { + return fmt.Errorf("text codes are empty") + } + origin, err := creationNumbers(text.TextCode[0].X+" "+text.TextCode[0].Y, 2) + if err != nil { + return err + } + if err := e.LayoutText(text, value, TextLayout{}); err != nil { + return err + } + baseline, _ := strconv.ParseFloat(text.TextCode[0].Y, 64) + for i := range text.TextCode { + code := &text.TextCode[i] + cx, _ := strconv.ParseFloat(code.X, 64) + cy, _ := strconv.ParseFloat(code.Y, 64) + code.X, code.Y = ofdNumber(cx+origin[0]), ofdNumber(cy+origin[1]-baseline) + } + return e.prepareText(text) +} diff --git a/ofdgo_editor_font.go b/ofdgo_editor_font.go index 8eb7fad..1243502 100644 --- a/ofdgo_editor_font.go +++ b/ofdgo_editor_font.go @@ -196,6 +196,11 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) used[resource.font.ID] = make(map[uint16]bool) } } + if len(used) == 0 { + return nil, progress.report("fonts", 0, 0) + } + refs := editorResourceRefs{ids: make(map[string]bool), files: make(map[string]bool)} + composite := false total := len(e.pages) + len(e.resources) for i, page := range e.pages { if err := progress.report("fonts", i, total); err != nil { @@ -203,6 +208,23 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } for _, layer := range page.Content.Layer { for _, object := range layer.Objects { + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + composite = true + origin := e.objectOrigin(editorObjectID(object)) + if origin == nil { + return nil, nil + } + data, err := editorXMLObject(origin.data, origin.node, origin.object, object) + if err == nil { + data, err = editorXMLStandalone(data, origin.node) + } + if err != nil { + return nil, err + } + if _, safe := refs.scan(bytes.NewReader(data), "Page.xml"); !safe { + return nil, nil + } + } if object.Type != "TextObject" { continue } @@ -219,6 +241,30 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) } } } + if composite { + _, _, _, files := e.usedResources() + for i, resource := range e.resources { + if resource.composite == "" || !slices.Contains(files, resource.name) { + continue + } + if err := progress.report("fonts", len(e.pages)+i, total); err != nil { + return nil, err + } + if _, safe := refs.scan(bytes.NewReader(resource.data), resource.name); !safe { + return nil, nil + } + } + for id, usage := range refs.fonts { + glyphs := used[id] + if usage.unsafe || len(usage.glyphs) != 0 { + delete(used, id) + } else if glyphs != nil { + for char := range usage.chars { + glyphs[e.fonts[id].GlyphIndex(char)] = true + } + } + } + } result := make(map[string][]byte) for i := range e.resources { if err := progress.report("fonts", len(e.pages)+i, total); err != nil { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 67d4056..61aae8d 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -62,13 +62,14 @@ type editorSourcePage struct { // Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块 // Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复 // Paint表示可独立修改纯色填充和描边,不要求重新排版文字 -// ReplaceImage表示可替换图片数据,内部成员保留原布局,不开放裁剪或重新适应 +// ReplaceImage表示可替换图片数据,CropImage表示可裁剪图片;内部裁剪与原裁剪取交集 // MissingGlyphs提供缺字导致操作受限时的结构化诊断 type ObjectCapabilities struct { Update bool Paint bool ReplaceFont bool ReplaceImage bool + CropImage bool Reflow bool LayoutKnown bool Transform bool @@ -358,6 +359,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er origin := e.objectOrigin(id) if origin == nil { all.ReplaceImage = object.Type == "ImageObject" + all.CropImage = all.ReplaceImage return all, nil } node := origin.node @@ -388,6 +390,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er } all.Paint = all.Paint && e.editorPaintable(object) all.ReplaceImage = all.Update && object.Type == "ImageObject" + all.CropImage = all.ReplaceImage if object.Type == "ImageObject" && object.ImageObject.Border != nil && len(object.ImageObject.Actions) != 0 { all.Transform, all.Arrange = false, false all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject