From f8eeaad9f2424391028c6ac418122a937bf20c01 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Thu, 17 Sep 2026 20:27:58 +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=86=85=E9=83=A8=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=96=87=E5=AD=97=E9=87=8D?= =?UTF-8?q?=E6=A1=86=E4=B8=8E=E5=A4=8D=E5=88=B6=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 2 +- assets/webui/ofdgo.js | 16 +++- assets/webui/ofdgo_edit.js | 4 +- internal/webui/wasm.go | 67 +++++++++++-- ofdgo_editor.go | 8 +- ofdgo_editor_composite.go | 32 +++++-- ofdgo_editor_composite_copy.go | 160 +++++++++++++++++++++++-------- ofdgo_editor_composite_image.go | 77 +++++++++++++-- ofdgo_editor_composite_insert.go | 110 ++++++++++++++++----- ofdgo_editor_composite_ops.go | 65 +++++++++++++ ofdgo_editor_copy.go | 1 + ofdgo_editor_erase.go | 76 +++++++++++++-- ofdgo_editor_open.go | 4 + ofdgo_editor_transform.go | 2 + ofdgo_page.go | 1 + ofdgo_shape.go | 30 +++++- 16 files changed, 546 insertions(+), 109 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 6bc01b6..e740134 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -111,7 +111,7 @@ - + 插入
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 0019065..46a02fd 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -379,7 +379,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { onErase: (items, box, points) => { const blocked = items.find(item => !canEditObject(item, box ? "arrange" : "delete")); if (blocked) { setStatus(objectEditReason(blocked) || "对象暂不可擦除"); return false; } - const item = { index: items[0].index, id: items.map(item => item.id) }; + const item = { index: items[0].index, id: items.map(item => item.id), scoped: items.every(item => item.scoped) }; if (points) return changeDocument("ofdgoEraseObjectsPath", item, points); return box ? changeDocument("ofdgoEraseObjects", item, box.x, box.y, box.width, box.height) : changeDocument("ofdgoDeleteObjects", item); @@ -570,6 +570,7 @@ el.resetCropButton.addEventListener("click", () => { } const item = canvasEditor.selected; if (item?.scoped) return changeDocument("ofdgoResetCompositeCrop", item); + if (canEditObject(item, "resetCrop")) return changeDocument("ofdgoResetImageCrop", item); if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds); }); editorClick(el.undoButton, () => changeDocument("ofdgoUndo")); @@ -661,6 +662,12 @@ el.arrowTool.addEventListener("change", async () => { const tool = el.arrowTool.value; el.arrowTool.value = ""; if (await canvasEditor.commitText() && await canvasEditor.commitCrop()) { + const item = canvasEditor.selected; + if (lineShape(item?.shape)) { + const box = item.geometry; + await changeDocument("ofdgoReshapeLine", item, box.x, box.y, box.width, box.height, tool); + return; + } setPan(false); state.selectObjects = true; canvasEditor.setTool(tool); @@ -1828,7 +1835,7 @@ function updateDrawingControls() { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); } - el.eraseButton.disabled = el.eraseMode.disabled = disabled || Boolean(state.composite); + el.eraseButton.disabled = el.eraseMode.disabled = disabled; 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")); @@ -1860,6 +1867,7 @@ async function changeDocument(name, item, ...args) { 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", ofdgoLayoutText: "layout", ofdgoFitImage: "fit", ofdgoResetCompositeCrop: "resetCrop", + ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", 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); @@ -1876,7 +1884,7 @@ async function changeDocument(name, item, ...args) { return true; } const view = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; - const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects"].includes(name) + const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name) || inserting || name === "ofdgoPasteObjects" && !!args[4]); const reindexed = restoring ? view?.reindex && view.before : reindex && before; const clipboard = state.objectClipboard; @@ -5616,7 +5624,7 @@ function updateObjectControls(item, reset = false) { 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.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9)); + el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !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")); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 38dc2d8..4ed08d2 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -289,7 +289,7 @@ export class CanvasEditor { svg.setAttribute("aria-hidden", "true"); svg.setAttribute("preserveAspectRatio", "none"); let contour; - if (object.type === "PathObject" && !object.scoped) { + if (object.type === "PathObject" && (!object.scoped || object.shape)) { 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); @@ -1025,6 +1025,7 @@ export class CanvasEditor { this.input = { input, item, face, fontChoice: item.fontChoice }; const editing = this.input; item.artwork?.classList.add("edit-text-source"); + item.node.classList.add("edit-text-source"); item.surface.append(input); input.addEventListener("input", () => this.options.onTextChange()); input.addEventListener("compositionstart", () => { editing.composing = true; }); @@ -1143,6 +1144,7 @@ export class CanvasEditor { if (editing) { editing.input.remove(); editing.item.artwork?.classList.remove("edit-text-source"); + editing.item.node.classList.remove("edit-text-source"); document.fonts.delete(editing.face); if (editing.item.draft && this.selected === editing.item) this.select(null); this.options.onTextChange(); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 9823fca..eb8adbf 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -136,6 +136,8 @@ func RunWASM() { registerCallback("ofdgoAlignObject", alignObject) registerCallback("ofdgoTransformObject", transformObject) registerCallback("ofdgoReshapeObject", reshapeObject) + registerCallback("ofdgoReshapeLine", reshapeLine) + registerCallback("ofdgoResetImageCrop", resetImageCrop) registerCallback("ofdgoLayoutText", layoutText) registerCallback("ofdgoTransformObjects", transformObjects) registerCallback("ofdgoCompositeObjects", compositeObjects) @@ -833,7 +835,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { // 返回: map[string]any 前端能力 func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), - "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} if missing := capability.MissingGlyphs; missing != nil { result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -923,6 +925,9 @@ func compositeObjects(args []js.Value) (any, error) { item := 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": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "scoped": true, "contours": member.Contours, "capabilities": editorCapabilities(member.Capabilities, member.Object.Type)} editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) + if kind, geometry := member.Shape(); kind != "" { + item["shape"], item["geometry"] = string(kind), editorBox(geometry) + } if member.Object.Type == "TextObject" { object := member.Object.TextObject value, layout := object.TextLayout() @@ -978,6 +983,19 @@ func changeCompositeObjects(args []js.Value) (any, error) { page := args[0].Int() operation := args[3].String() switch operation { + case "reshape", "line": + if len(indexes) != 1 { + return fmt.Errorf("line editing requires one member") + } + kind := ofdgo.ShapeKind("") + if operation == "line" { + kind = ofdgo.ShapeKind(args[8].String()) + } + return currentEditor.ReshapeCompositeLine(page, path, indexes[0], kind, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "erase": + return currentEditor.EraseCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "erasePath": + return currentEditor.EraseCompositeObjectsPath(page, path, indexes, pointsFromJS(args[4])) case "delete": return currentEditor.DeleteCompositeObjects(page, path, indexes) case "copy": @@ -1265,6 +1283,27 @@ func reshapeObject(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } +// reshapeLine 修改直线端点与箭头类型,复用库层样式保留逻辑 +// 入参: args 页码、对象标识、页面端点及箭头类型 +// 返回: any 文档信息, error 错误信息 +func reshapeLine(args []js.Value) (any, error) { + return changeObjects(func() error { + page, id := args[0].Int(), args[1].String() + object, err := currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "PathObject" { + return fmt.Errorf("object is not a path") + } + object.PathObject, err = object.PathObject.ReshapeLine(ofdgo.ShapeKind(args[6].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + if err != nil { + return err + } + return currentEditor.UpdateObject(page, id, object) + }) +} + // copyObjects 复制选区并返回新对象标识,复用字体和图片资源 // 入参: args 页码、对象标识列表和位移 // 返回: any 文档信息, error 错误信息 @@ -2164,6 +2203,13 @@ func cropImage(args []js.Value) (any, error) { }) } +// resetImageCrop 还原跨范围复制图片的会话裁剪,不移除原始及父级裁剪 +// 入参: args 页面索引及对象标识 +// 返回: any 文档信息, error 错误信息 +func resetImageCrop(args []js.Value) (any, error) { + return changeObjects(func() error { return currentEditor.ResetImageCrop(args[0].Int(), args[1].String()) }) +} + // fitImage 按原始比例适应或填充图片框 // 入参: args 页面索引、对象标识和适应方式 // 返回: any 文档信息, error 错误信息 @@ -2249,15 +2295,22 @@ func deleteObjects(args []js.Value) (any, error) { // 返回: any 文档信息, error 错误信息 func eraseObjectsPath(args []js.Value) (any, error) { return changeObjects(func() error { - points := make([]ofdgo.Point, args[2].Length()) - for i := range points { - point := args[2].Index(i) - points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} - } - return currentEditor.EraseObjectsPath(args[0].Int(), stringsFromJS(args[1]), points) + return currentEditor.EraseObjectsPath(args[0].Int(), stringsFromJS(args[1]), pointsFromJS(args[2])) }) } +// pointsFromJS 读取前端页面坐标点数组 +// 入参: value 坐标点数组 +// 返回: []ofdgo.Point 页面毫米坐标 +func pointsFromJS(value js.Value) []ofdgo.Point { + points := make([]ofdgo.Point, value.Length()) + for i := range points { + point := value.Index(i) + points[i] = ofdgo.Point{X: point.Get("x").Float(), Y: point.Get("y").Float()} + } + return points +} + // eraseObjects 按页面矩形范围擦除对象,保留局部裁剪之外的内容 // 入参: args 页面索引、对象标识数组和毫米坐标范围 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 8a52061..87d21d1 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -858,13 +858,13 @@ func (e *Editor) validateObjectClips(clips *Clips) error { func cloneEditorObject(object GraphicObject) (GraphicObject, error) { switch object.Type { case "TextObject": - return GraphicObject{Type: object.Type, TextObject: cloneEditorData(object.TextObject)}, nil + return GraphicObject{Type: object.Type, TextObject: cloneEditorData(object.TextObject), state: object.state}, nil case "PathObject": - return GraphicObject{Type: object.Type, PathObject: cloneEditorData(object.PathObject)}, nil + return GraphicObject{Type: object.Type, PathObject: cloneEditorData(object.PathObject), state: object.state}, nil case "ImageObject": - return GraphicObject{Type: object.Type, ImageObject: cloneEditorData(object.ImageObject)}, nil + return GraphicObject{Type: object.Type, ImageObject: cloneEditorData(object.ImageObject), state: object.state}, nil case "CompositeObject", "CompositeGraphicUnit": - return GraphicObject{Type: object.Type, CompositeGraphicUnit: cloneEditorData(object.CompositeGraphicUnit)}, nil + return GraphicObject{Type: object.Type, CompositeGraphicUnit: cloneEditorData(object.CompositeGraphicUnit), state: object.state}, nil } return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type) } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 89a92f5..582c5e5 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -72,17 +72,17 @@ type editorCompositeNode struct { states map[string]editorCompositeState } -// editorCompositeState 保存内部对象的会话排版及新增裁剪起点,不写入OFD +// editorCompositeState 保存内部对象的会话排版及可还原裁剪,不写入OFD type editorCompositeState struct { layout *textLayout crop *editorCompositeCrop origin [2]float64 } -// editorCompositeCrop 记录编辑前的裁剪数量及节点存在性 +// editorCompositeCrop 记录本次编辑新增的裁剪序号及原节点存在性 type editorCompositeCrop struct { - count int - exists bool + indexes []int + exists bool } // setStates 独立保存当前XML子树的会话信息,共享不可变记录 @@ -387,6 +387,9 @@ func (e *Editor) compositeMembers(n *editorCompositeNode, reader *Reader, render var collect func(*editorXML) error collect = func(container *editorXML) error { for _, child := range container.children { + if child.name.Space != "" && child.name.Space != ofdNamespace && child.name.Space != "http://www.ofdspec.org" { + continue + } switch child.name.Local { case "Content", "PageBlock": if err := collect(child); err != nil { @@ -492,6 +495,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp clips := node.object.ImageObject.Clips _, capability.CropImage = node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() capability.FitImage = capability.CropImage && axisAlignedMatrix(NewMatrix(node.object.ImageObject.CTM)) + capability.ResetCrop = node.states[editorObjectID(node.object)].crop != nil } _, ctm := editorGeometry(node.object) matrix := node.parent.Multiply(NewMatrix(ctm)) @@ -651,22 +655,19 @@ func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int // 返回: 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) + return edit(renderer, selectedNodes, e.measureCompositeMembers(renderer, selectedNodes)) }) } @@ -929,10 +930,21 @@ func (e *Editor) addCompositeResource(data []byte, states map[string]editorCompo // 入参: id 资源标识, data 资源XML // 返回: editorResource 资源, error 错误信息 func (e *Editor) compositeResource(id string, data []byte) (editorResource, error) { - root, err := parseEditorXML(data) + refs, err := editorResourceReferences(data) if err != nil { return editorResource{}, err } + return editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id, references: refs}, nil +} + +// editorResourceReferences 收集标准资源XML中的对象引用 +// 入参: data 资源XML +// 返回: []string 引用标识, error 错误信息 +func editorResourceReferences(data []byte) ([]string, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } refs := make(map[string]bool) var collect func(*editorXML) collect = func(node *editorXML) { @@ -948,7 +960,7 @@ func (e *Editor) compositeResource(id string, data []byte) (editorResource, erro } } collect(root) - return editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id, references: slices.Sorted(maps.Keys(refs))}, nil + return slices.Sorted(maps.Keys(refs)), nil } // collectCompositeReferences 收集内联复合对象引用的新资源 diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index ab611a9..3b5273d 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -37,8 +37,7 @@ type CompositeSelection struct { source *editorSourcePage } -// PasteCompositeSelection 将内部基本对象快照粘贴到页面顶层,保持源继承外观和裁剪 -// 复合成员需留在原范围操作,不将未知嵌套结构强制扁平化 +// PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪 // 入参: page 目标页, selection 当前编辑器内捕获的快照, dx、dy 页面位移 // 返回: []string 新对象标识, error 错误信息 func (e *Editor) PasteCompositeSelection(page int, selection *CompositeSelection, dx, dy float64) ([]string, error) { @@ -48,31 +47,35 @@ func (e *Editor) PasteCompositeSelection(page int, selection *CompositeSelection if _, err := e.page(page); err != nil { return nil, err } - objects, err := e.compositeSelectionObjects(selection) - if err != nil { - return nil, err - } - return e.CopyObjects(page, objects, dx, dy) + var result []string + err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { + var err error + result, err = e.CopyObjects(page, objects, dx, dy) + return err + }) + return result, err } -// compositeSelectionObjects 固定内部基本对象的页面几何和父裁剪,保留原XML扩展 -// 入参: selection 独立内部快照 -// 返回: []GraphicObject 可复制到其他范围的快照, error 错误信息 -func (e *Editor) compositeSelectionObjects(selection *CompositeSelection) ([]GraphicObject, error) { +// pasteCompositeSelection 固定内部快照的页面几何和外观,粘贴失败时回收新增绘制资源 +// 入参: selection 独立内部快照, paste 目标范围粘贴操作 +// 返回: error 错误信息 +func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste func([]GraphicObject) error) (err error) { if selection == nil || selection.editor != e { - return nil, fmt.Errorf("composite selection belongs to another editor") + return fmt.Errorf("composite selection belongs to another editor") } - for _, node := range selection.nodes { - if node.object.Type == "CompositeObject" || node.object.Type == "CompositeGraphicUnit" { - return nil, fmt.Errorf("composite groups require their original editing scope") + count, maximum, ready := len(e.resources), e.maxID, e.source.idsReady + defer func() { + if err != nil { + clear(e.resources[count:]) + e.resources, e.maxID, e.source.idsReady = e.resources[:count], maximum, ready } - } + }() if err := e.prepareSourceIDs(); err != nil { - return nil, err + return err } reader, err := e.Reader() if err != nil { - return nil, err + return err } defer reader.Close() renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) @@ -80,12 +83,13 @@ func (e *Editor) compositeSelectionObjects(selection *CompositeSelection) ([]Gra for _, source := range selection.nodes { copy, err := newEditorCompositeNode(source.data) if err != nil { - return nil, err + return err } copy.setStates(source.states) + copy.defaults = source.defaults object, err := e.compositeMemberStyle(source) if err != nil { - return nil, err + return err } object = mergeGraphicObjectAlpha(object, source.alpha) if !source.visible || source.clip != nil && source.clip.Empty() { @@ -97,48 +101,126 @@ func (e *Editor) compositeSelectionObjects(selection *CompositeSelection) ([]Gra object.PathObject.Visible = &visible case "ImageObject": object.ImageObject.Visible = &visible + case "CompositeObject", "CompositeGraphicUnit": + object.CompositeGraphicUnit.Visible = &visible } } - if !source.boundaryInCTM { - object = compositeBoundary(object, source.parent, true) - } if err := copy.update(object); err != nil { - return nil, err + return err + } + if !source.boundaryInCTM { + if err := copy.convertCoordinates(source.parent, true); err != nil { + return err + } } copy.parent, copy.boundaryInCTM = IdentityMatrix, true if err := e.transformCompositeMember(renderer, copy, source.parent); err != nil { - return nil, err + return err } if err := e.isolateCompositeStyle(copy); err != nil { - return nil, err + return err } if source.clip != nil && !source.clip.Empty() { shape := compositeClipPath(source.clip) clips := *editorObjectClips(©.object) matrix, ok := copy.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() if !ok { - return nil, fmt.Errorf("composite clip transform is not invertible") + return fmt.Errorf("composite clip transform is not invertible") } if err := appendCompositeClip(copy, shape, matrix, false); err != nil { - return nil, err + return err + } + } + if copy.object.Type == "CompositeObject" || copy.object.Type == "CompositeGraphicUnit" { + if err := copy.convertCoordinates(IdentityMatrix, false); err != nil { + return err } } copy.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}} object, err = e.resolveEditorStyle(copy.object, "") if err != nil { - return nil, err + return err + } + object.state = copy.states[editorObjectID(copy.object)] + object.TextObject.layout = object.state.layout + copy.object.TextObject.layout = object.TextObject.layout + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + object.CompositeGraphicUnit.states = maps.Clone(copy.states) + copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states } object.origin = &editorObjectOrigin{page: selection.source, data: copy.data, node: copy.node, object: copy.object} objects = append(objects, object) } - return objects, nil + return paste(objects) +} + +// convertCoordinates 转换内联基本成员的边界约定,引用资源保持标准局部坐标 +// 入参: parent 所在范围矩阵, inward 是否转换到随父变换的坐标 +// 返回: error 错误信息 +func (n *editorCompositeNode) convertCoordinates(parent Matrix, inward bool) error { + if n.object.Type != "CompositeObject" && n.object.Type != "CompositeGraphicUnit" { + return n.update(compositeBoundary(n.object, parent, inward)) + } + var patches []editorXMLPatch + var visit func(*editorXML, Matrix) error + visit = func(node *editorXML, matrix Matrix) error { + if node.name.Local == "CompositeObject" || node.name.Local == "CompositeGraphicUnit" { + box, _ := ParseBox(node.attr("Boundary")) + matrix = matrix.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(node.attr("CTM"))) + if _, ok := matrix.Invert(); !ok { + return fmt.Errorf("composite copy requires invertible transforms") + } + } + for _, child := range node.children { + if child.name.Space != "" && child.name.Space != ofdNamespace && child.name.Space != "http://www.ofdspec.org" { + continue + } + switch child.name.Local { + case "Content", "PageBlock", "CompositeObject", "CompositeGraphicUnit": + if err := visit(child, matrix); err != nil { + return err + } + case "TextObject", "PathObject", "ImageObject": + data, err := editorXMLStandalone(n.data[child.start:child.end], child) + if err != nil { + return err + } + member, err := newEditorCompositeNode(data) + if err != nil { + return err + } + if err := member.update(compositeBoundary(cloneEditorData(member.object), matrix, inward)); err != nil { + return err + } + patches = append(patches, editorXMLPatch{child.start, child.end, member.data}) + } + } + return nil + } + if err := visit(n.node, parent); err != nil { + return err + } + data := editorPatchXML(n.data, patches) + next, err := newEditorCompositeNode(data) + if err != nil { + return err + } + n.data, n.node, n.object = data, next.node, next.object + return nil } // compositeClipPath 将已解析的父裁剪转换为标准紧缩路径,使用页面毫米坐标 // 入参: path 画布裁剪 // 返回: PathObject 标准裁剪路径 func compositeClipPath(path *canvas.Path) PathObject { - path = path.Copy().Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ReplaceArcs() + return editorClipPath(path.Copy().Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}})) +} + +// editorClipPath 将页面坐标路径写为标准紧缩裁剪路径 +// 入参: path 页面毫米路径 +// 返回: PathObject 标准裁剪路径 +func editorClipPath(path *canvas.Path) PathObject { + path = path.ReplaceArcs() data := path.Data() var value strings.Builder for i := 0; i < len(data); { @@ -211,7 +293,7 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in } // PasteCompositeObjects 将快照粘贴至内部范围末尾,不依赖源成员当前的序号或存在性 -// 同范围沿用原容器和当前继承样式,跨范围固定基本对象的源外观,位移使用页面毫米 +// 同范围沿用原容器和当前继承样式,跨范围固定对象的源外观,位移使用页面毫米 // 目标范围的父透明度及裁剪仍作用于新成员,全部成员提交一次撤销记录 // 入参: page 页面索引, path 父复合路径, selection 捕获的快照, dx、dy 页面位移 // 返回: []int 新成员序号, error 错误信息 @@ -226,11 +308,13 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com return nil, err } if e.pages[page].ID != selection.page || path.ID != selection.path.ID || !slices.Equal(path.Children, selection.path.Children) { - objects, err := e.compositeSelectionObjects(selection) - if err != nil { - return nil, err - } - return e.CopyObjectsToComposite(page, path, objects, dx, dy) + var result []int + err := e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { + var err error + result, err = e.CopyObjectsToComposite(page, path, objects, dx, dy) + return err + }) + return result, err } var result []int err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error { @@ -321,7 +405,7 @@ func (n *editorCompositeNode) loadedScope(path []int) *editorCompositeNode { func compositeContainers(node *editorXML) []*editorXML { var result []*editorXML for _, child := range node.children { - if child.name.Local == "Content" || child.name.Local == "PageBlock" { + if (child.name.Local == "Content" || child.name.Local == "PageBlock") && (child.name.Space == "" || child.name.Space == ofdNamespace || child.name.Space == "http://www.ofdspec.org") { result = append(result, child) } } diff --git a/ofdgo_editor_composite_image.go b/ofdgo_editor_composite_image.go index 5a58d65..b4c7109 100644 --- a/ofdgo_editor_composite_image.go +++ b/ofdgo_editor_composite_image.go @@ -18,6 +18,8 @@ import ( "bytes" "encoding/xml" "fmt" + "reflect" + "slices" ) // matrix 获取对象局部坐标到页面的变换,兼容旧式内联边界 @@ -33,7 +35,7 @@ func (n *editorCompositeNode) matrix(transform bool) Matrix { m = Matrix{a: box.W, d: box.H} } } - if n.boundaryInCTM { + if n.boundaryInCTM || n.object.Type == "CompositeObject" || n.object.Type == "CompositeGraphicUnit" { return n.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(m) } if !transform { @@ -74,19 +76,29 @@ func (e *Editor) CropCompositeImage(page int, path ObjectPath, index int, box Bo }) } -// appendCompositeClip 追加独立交集裁剪并记录会话还原起点,保留原始裁剪XML -// 入参: node 对象节点, shape 裁剪路径, matrix 路径到裁剪坐标的变换, track 是否记录还原起点 +// appendCompositeClip 追加独立交集裁剪并记录可还原序号,保留原始裁剪XML +// 入参: node 对象节点, shape 裁剪路径, matrix 路径到裁剪坐标的变换, track 是否记录还原序号 // 返回: error 错误信息 func appendCompositeClip(node *editorCompositeNode, shape PathObject, matrix Matrix, track bool) error { + clip := Clip{Area: []ClipArea{{CTM: matrix.String(), Path: []PathObject{shape}}}} + if clips := *editorObjectClips(&node.object); clips != nil && slices.ContainsFunc(clips.Clip, func(previous Clip) bool { return reflect.DeepEqual(previous, clip) }) { + return nil + } state := node.states[editorObjectID(node.object)] - if track && state.crop == nil { - state.crop = &editorCompositeCrop{exists: node.node.child("Clips") != nil} - if clips := node.object.ImageObject.Clips; clips != nil { - state.crop.count = len(clips.Clip) + if track { + crop := editorCompositeCrop{exists: node.node.child("Clips") != nil} + if state.crop != nil { + crop = *state.crop } + count := 0 + if clips := node.object.ImageObject.Clips; clips != nil { + count = len(clips.Clip) + } + crop.indexes = append(slices.Clone(crop.indexes), count) + state.crop = &crop } data, err := encodeOFDXML(func(x *ofdXML) { - x.clips(&Clips{Clip: []Clip{{Area: []ClipArea{{CTM: matrix.String(), Path: []PathObject{shape}}}}}}) + x.clips(&Clips{Clip: []Clip{clip}}) }) if err != nil { return err @@ -130,6 +142,51 @@ func (e *Editor) ResetCompositeImageCrop(page int, path ObjectPath, index int) e }) } +// ResetImageCrop 还原从内部范围粘贴到页面的图片裁剪,保留原始裁剪与父范围限制 +// 仅处理当前会话记录的新增裁剪,无还原记录时不修改对象 +// 入参: page 页面索引, id 图片标识 +// 返回: error 错误信息 +func (e *Editor) ResetImageCrop(page int, id string) error { + object, err := e.Object(page, id) + if err != nil { + return err + } + if object.Type != "ImageObject" { + return fmt.Errorf("object %q is not an image", id) + } + if object.state.crop == nil { + return nil + } + origin := e.snapshotOrigin(object) + var data []byte + if origin == nil { + data, err = editorObjectXML(object) + } else { + data, err = editorXMLObject(origin.data, origin.node, origin.object, object) + if err == nil { + data, err = editorXMLStandalone(data, origin.node) + } + } + if err != nil { + return err + } + node, err := newEditorCompositeNode(data) + if err != nil { + return err + } + node.setState(object.state) + if err := resetCompositeImageCrop(node); err != nil { + return err + } + node.object.state = node.states[id] + if origin == nil { + return e.UpdateObject(page, id, node.object) + } + node.node.parent = origin.node.parent + next := &editorObjectOrigin{page: origin.page, data: node.data, node: node.node, object: node.object} + return e.updateObjectOrigins(page, []GraphicObject{node.object}, true, map[string]*editorObjectOrigin{id: next}) +} + // resetCompositeImageCrop 恢复图片原始裁剪节点,不还原后续变换和资源替换 // 入参: node 图片节点 // 返回: error 错误信息 @@ -143,13 +200,13 @@ func resetCompositeImageCrop(node *editorCompositeNode) error { } clips := node.node.child("Clips") var patches []editorXMLPatch - if !state.crop.exists { + if !state.crop.exists && len(node.object.ImageObject.Clips.Clip) == len(state.crop.indexes) { patches = append(patches, editorXMLPatch{clips.start, clips.end, nil}) } else { count := 0 for _, child := range clips.children { if child.name.Local == "Clip" { - if count >= state.crop.count { + if slices.Contains(state.crop.indexes, count) { patches = append(patches, editorXMLPatch{child.start, child.end, nil}) } count++ diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index 179aefa..4181052 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -19,11 +19,12 @@ import ( "encoding/xml" "fmt" "image/color" + "reflect" "strconv" ) -// AddCompositeObject 按页面坐标向内部范围末尾添加基本对象,复用已注册资源 -// 入参: page 页面索引, path 父路径, object 文字、图片或路径 +// AddCompositeObject 按页面坐标向内部范围末尾添加对象,复用已注册资源 +// 入参: page 页面索引, path 父路径, object 基本对象或复合快照 // 返回: int 新成员序号, error 错误信息 func (e *Editor) AddCompositeObject(page int, path ObjectPath, object GraphicObject) (int, error) { indexes, err := e.CopyObjectsToComposite(page, path, []GraphicObject{object}, 0, 0) @@ -64,9 +65,6 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra } var content []byte for _, source := range objects { - if source.Type == "CompositeObject" || source.Type == "CompositeGraphicUnit" { - return fmt.Errorf("composite groups require their original editing scope") - } id := e.nextID() object, err := e.prepareCopiedObject(id, source) if err != nil { @@ -95,6 +93,16 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return err } node.parent, node.boundaryInCTM = IdentityMatrix, true + node.setStates(object.CompositeGraphicUnit.states) + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + if err := node.convertCoordinates(IdentityMatrix, true); err != nil { + return err + } + node.defaults, err = e.editorDrawParam(e.copiedLayerStyle(source), make(map[string]bool)) + if err != nil { + return err + } + } if err := e.isolateCompositeStyle(node); err != nil { return err } @@ -102,14 +110,16 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return err } if !owner.boundaryInCTM { - if err := node.update(compositeBoundary(node.object, parent, false)); err != nil { + if err := node.convertCoordinates(parent, false); err != nil { return err } } - if origin == nil && object.TextObject.layout != nil { + state := object.state + if object.TextObject.layout != nil && !reflect.DeepEqual(state.layout, object.TextObject.layout) { y, _ := strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64) - node.setState(editorCompositeState{layout: object.TextObject.layout, origin: [2]float64{0, y}}) + state.layout, state.origin = object.TextObject.layout, [2]float64{0, y} } + node.setState(state) for id, state := range node.states { owner.states[id] = state } @@ -142,10 +152,39 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return result, nil } -// isolateCompositeStyle 固定基本对象的绘制参数,避免目标容器改变颜色、虚线和线帽 +// isolateCompositeStyle 固定对象的绘制参数,避免目标容器改变颜色、虚线和线帽 // 入参: node 页面坐标中的对象节点 // 返回: error 错误信息 func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { + if node.object.Type == "CompositeObject" || node.object.Type == "CompositeGraphicUnit" { + own, err := e.editorDrawParam(node.object.CompositeGraphicUnit.DrawParam, make(map[string]bool)) + if err != nil { + return err + } + if node.defaults != nil { + own = mergeDrawParam(*node.defaults, own) + } + for _, color := range []*FillColor{own.FillColor, (*FillColor)(own.StrokeColor)} { + if err := e.editorColor(color); err != nil { + return &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("composite inherited color cannot be isolated: %w", err)} + } + } + id, err := e.neutralDrawParam() + if err != nil { + return err + } + base, err := e.editorDrawParam(id, make(map[string]bool)) + if err != nil { + return err + } + id, err = e.addEditorDrawParam(*mergeDrawParam(*base, own)) + if err != nil { + return err + } + object := node.object + object.CompositeGraphicUnit.DrawParam = id + return node.update(object) + } object, err := e.resolveEditorStyleDefaults(node.object, &DrawParam{}) if err != nil { return err @@ -168,31 +207,51 @@ func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { // neutralDrawParam 注册可复用的完整默认绘制参数,不改变文档默认颜色空间 // 返回: string 绘制参数标识, error 错误信息 func (e *Editor) neutralDrawParam() (string, error) { - for _, resource := range e.resources { - if resource.draw != nil { - return resource.draw.ID, nil - } - } - if err := e.prepareSourceIDs(); err != nil { - return "", err - } fill, err := e.RGBColor(color.NRGBA{A: 255}) if err != nil { return "", err } zero := 0.0 - draw := &DrawParam{ID: e.nextID(), LineWidth: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit, DashOffset: &zero, dashPatternSet: true, FillColor: fill, StrokeColor: (*StrokeColor)(fill)} + return e.addEditorDrawParam(DrawParam{LineWidth: defaultPathLineWidth, Cap: "Butt", Join: "Miter", MiterLimit: defaultMiterLimit, DashOffset: &zero, dashPatternSet: true, FillColor: fill, StrokeColor: (*StrokeColor)(fill)}) +} + +// addEditorDrawParam 注册完整有效绘制参数,相同外观复用资源 +// 入参: draw 不含继承关系的有效参数 +// 返回: string 资源标识, error 错误信息 +func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) { + draw.ID, draw.Relative, draw.ResourceID, draw.BaseLoc, draw.Link = "", "", "", "", "" + for _, resource := range e.resources { + if resource.draw != nil { + previous := *resource.draw + previous.ID = "" + if reflect.DeepEqual(previous, draw) { + return resource.draw.ID, nil + } + } + } + if err := e.prepareSourceIDs(); err != nil { + return "", err + } + draw.ID = e.nextID() data, err := encodeOFDXML(func(x *ofdXML) { x.root("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}) x.start("DrawParams", nil) attrs := ofdAttrs{} - for _, pair := range [][2]string{{"ID", draw.ID}, {"LineWidth", ofdNumber(draw.LineWidth)}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"MiterLimit", ofdNumber(draw.MiterLimit)}, {"DashOffset", "0"}} { + for _, pair := range [][2]string{{"ID", draw.ID}, {"LineWidth", ofdNumber(draw.LineWidth)}, {"Cap", draw.Cap}, {"Join", draw.Join}, {"MiterLimit", ofdNumber(draw.MiterLimit)}, {"Font", draw.Font}} { attrs.add(pair[0], pair[1]) } - attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: ""}) + attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern}) + if draw.DashOffset != nil { + attrs.add("DashOffset", ofdNumber(*draw.DashOffset)) + } + attrs.number("Size", draw.Size) + attrs.number("Weight", float64(draw.Weight)) + if draw.Italic { + attrs.add("Italic", "true") + } x.start("DrawParam", attrs) - x.color("FillColor", fill) - x.color("StrokeColor", fill) + x.color("FillColor", draw.FillColor) + x.color("StrokeColor", (*FillColor)(draw.StrokeColor)) x.end("DrawParam") x.end("DrawParams") x.end("Res") @@ -200,10 +259,11 @@ func (e *Editor) neutralDrawParam() (string, error) { if err != nil { return "", err } - resource := editorResource{name: e.resourceDirectory() + "/DrawParam_" + draw.ID + ".xml", data: data, draw: draw} - if fill.ColorSpace != "" { - resource.references = []string{fill.ColorSpace} + refs, err := editorResourceReferences(data) + if err != nil { + return "", err } + resource := editorResource{name: e.resourceDirectory() + "/DrawParam_" + draw.ID + ".xml", data: data, draw: &draw, references: refs} e.resources = append(e.resources, resource) return draw.ID, nil } diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 0a3b1c9..5d96ce3 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -183,6 +183,71 @@ func (n *editorCompositeNode) update(object GraphicObject) error { return nil } +// Shape 获取内部直线的页面端点,其他成员返回空类型 +// 返回: ShapeKind 直线或箭头类型, Box 起点及有符号端点位移 +func (m CompositeMember) Shape() (ShapeKind, Box) { + if m.Object.Type != "PathObject" || !m.Capabilities.Transform { + return "", Box{} + } + kind, box := m.Object.PathObject.Shape() + if !lineShapeKind(kind) { + return "", Box{} + } + return kind, transformLineBox(box, m.lineParent()) +} + +// lineParent 获取直线所在坐标到页面的变换,兼容旧式内联边界 +// 返回: Matrix 直线父坐标变换 +func (m CompositeMember) lineParent() Matrix { + p := m.Object.PathObject + box, _ := ParseBox(p.Boundary) + inverse, _ := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(p.CTM)).Invert() + return m.Matrix.Multiply(inverse) +} + +// transformLineBox 变换起点及有符号端点位移 +// 入参: box 直线端点, matrix 坐标变换 +// 返回: Box 变换后的端点 +func transformLineBox(box Box, matrix Matrix) Box { + x, y := matrix.Transform(box.X, box.Y) + w, h := matrixVector(matrix, box.W, box.H) + return Box{X: x, Y: y, W: w, H: h} +} + +// ReshapeCompositeLine 修改内部直线的页面端点及箭头,保留原始属性和父变换 +// 入参: page 页面索引, path 父路径, index 成员序号, kind 直线类型, box 页面端点 +// 返回: error 错误信息 +func (e *Editor) ReshapeCompositeLine(page int, path ObjectPath, index int, kind ShapeKind, box Box) error { + return e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + before, geometry := members[0].Shape() + if before == "" { + return fmt.Errorf("composite member is not a supported line") + } + if kind == "" { + kind = before + } + if kind == before && geometry == box { + return nil + } + node := nodes[0] + inverse, _ := node.parent.Invert() + object := cloneEditorData(node.object) + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, true) + } + var err error + object.PathObject, err = object.PathObject.ReshapeLine(kind, transformLineBox(box, inverse)) + if err != nil { + return err + } + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, false) + } + object.PathObject.Clips = node.object.PathObject.Clips + return node.update(object) + }) +} + // collectCompositeIDs 为已校验副本中的标准标识分配新值,拒绝重复标识 // 入参: node 原节点, ids 原标识与新标识 // 返回: error 错误信息 diff --git a/ofdgo_editor_copy.go b/ofdgo_editor_copy.go index 62f2905..5fab778 100644 --- a/ofdgo_editor_copy.go +++ b/ofdgo_editor_copy.go @@ -111,6 +111,7 @@ func (e *Editor) copyObjectOrigin(before, after GraphicObject, maximum *int) (Gr return GraphicObject{}, nil, err } result.TextObject.layout = after.TextObject.layout + result.state = after.state result.CompositeGraphicUnit.states = remapCompositeStates(after.CompositeGraphicUnit.states, ids) root.parent = origin.node.parent return result, &editorObjectOrigin{page: origin.page, data: data, node: root, object: result}, nil diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go index edd8a31..8f7be4c 100644 --- a/ofdgo_editor_erase.go +++ b/ofdgo_editor_erase.go @@ -41,13 +41,25 @@ func (e *Editor) EraseObjects(page int, ids []string, box Box) error { // 入参: page 页面索引, ids 对象标识, points 页面毫米坐标,至少三个点 // 返回: error 错误信息 func (e *Editor) EraseObjectsPath(page int, ids []string, points []Point) error { + region, err := editorEraseRegion(points) + if err != nil || region.Empty() { + return err + } + bounds := region.FastBounds() + return e.eraseObjects(page, ids, Box{X: bounds.X0, Y: bounds.Y0, W: bounds.W(), H: bounds.H()}, points, region) +} + +// editorEraseRegion 校验闭合折线并按奇偶规则构建擦除区域 +// 入参: points 页面毫米坐标 +// 返回: *canvas.Path 擦除区域, error 错误信息 +func editorEraseRegion(points []Point) (*canvas.Path, error) { if len(points) < 3 { - return fmt.Errorf("erase path requires at least three points") + return nil, fmt.Errorf("erase path requires at least three points") } region := &canvas.Path{} for i, point := range points { if !finite(point.X) || !finite(point.Y) { - return fmt.Errorf("invalid erase path point %d", i) + return nil, fmt.Errorf("invalid erase path point %d", i) } if i == 0 { region.MoveTo(point.X, point.Y) @@ -56,12 +68,62 @@ func (e *Editor) EraseObjectsPath(page int, ids []string, points []Point) error } } region.Close() - region = region.Settle(canvas.EvenOdd) - if region.Empty() { - return nil + return region.Settle(canvas.EvenOdd), nil +} + +// EraseCompositeObjects 擦除内部成员与矩形相交的部分,保留原始属性及共享资源 +// 全部覆盖时删除成员,部分覆盖追加标准裁剪,不用于敏感信息脱敏 +// 入参: page 页面索引, path 父路径, indexes 成员序号, box 页面毫米范围 +// 返回: error 错误信息 +func (e *Editor) EraseCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err } - bounds := region.FastBounds() - return e.eraseObjects(page, ids, Box{X: bounds.X0, Y: bounds.Y0, W: bounds.W(), H: bounds.H()}, points, region) + return e.eraseCompositeObjects(page, path, indexes, canvas.Rectangle(box.W, box.H).Translate(box.X, box.Y)) +} + +// EraseCompositeObjectsPath 按闭合折线擦除内部成员,末点自动连接起点 +// 与矩形擦除共用原子提交和撤销逻辑,不用于敏感信息脱敏 +// 入参: page 页面索引, path 父路径, indexes 成员序号, points 页面毫米坐标 +// 返回: error 错误信息 +func (e *Editor) EraseCompositeObjectsPath(page int, path ObjectPath, indexes []int, points []Point) error { + region, err := editorEraseRegion(points) + if err != nil || region.Empty() { + return err + } + return e.eraseCompositeObjects(page, path, indexes, region) +} + +// eraseCompositeObjects 按页面区域裁剪内部成员,全部成功后提交一次历史 +// 入参: page 页面索引, path 父路径, indexes 成员序号, region 擦除区域 +// 返回: error 错误信息 +func (e *Editor) eraseCompositeObjects(page int, path ObjectPath, indexes []int, region *canvas.Path) error { + return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + for i, node := range nodes { + box := members[i].Bounds + bounds := canvas.Rectangle(box.W, box.H).Translate(box.X, box.Y) + if box.W <= 0 || box.H <= 0 || bounds.And(region).Empty() { + continue + } + if bounds.Not(region).Empty() { + node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil}) + continue + } + if !members[i].Capabilities.Arrange { + return fmt.Errorf("composite member cannot be erased") + } + clips := *editorObjectClips(&node.object) + inverse, ok := node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert() + if !ok { + return fmt.Errorf("composite clip transform is not invertible") + } + outer := canvas.Rectangle(box.W+2, box.H+2).Translate(box.X-1, box.Y-1) + if err := appendCompositeClip(node, editorClipPath(outer.Not(region)), inverse, false); err != nil { + return err + } + } + return nil + }) } // eraseObjects 按矩形或闭合路径裁剪对象,只在全部对象处理成功后提交修改 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 87a9956..7b52db2 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -62,6 +62,7 @@ type editorSourcePage struct { // Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块 // Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复 // FitImage表示可按原始图片比例适应或填充现有边界 +// ResetCrop表示有可单独还原的会话裁剪 // Paint表示可独立修改纯色填充和描边,不要求重新排版文字 // ReplaceImage表示可替换图片数据,CropImage表示可裁剪图片;内部裁剪与原裁剪取交集 // MissingGlyphs提供缺字导致操作受限时的结构化诊断 @@ -72,6 +73,7 @@ type ObjectCapabilities struct { ReplaceImage bool CropImage bool FitImage bool + ResetCrop bool Reflow bool LayoutKnown bool Transform bool @@ -358,6 +360,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er } object := layer.Objects[index] all := ObjectCapabilities{Update: true, Paint: object.Type == "TextObject" || object.Type == "PathObject", ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true} + all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil origin := e.objectOrigin(id) if origin == nil { all.ReplaceImage = object.Type == "ImageObject" @@ -426,6 +429,7 @@ func editorPreservedObject(before, after GraphicObject) bool { return false } before = cloneEditorData(before) + before.state = after.state switch before.Type { case "TextObject": a, b := &before.TextObject, after.TextObject diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index a63e703..4ad2694 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -313,6 +313,7 @@ func (e *Editor) CropImage(page int, id string, box Box) error { if err := cropImageObject(image, box); err != nil { return err } + object.state.crop = nil return e.UpdateObject(page, id, object) } @@ -370,6 +371,7 @@ func (e *Editor) FitImage(page int, id, mode string) error { if err := e.fitImage(&object.ImageObject, mode); err != nil { return err } + object.state.crop = nil return e.UpdateObject(page, id, object) } diff --git a/ofdgo_page.go b/ofdgo_page.go index a512b2c..d4c64de 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -59,6 +59,7 @@ type GraphicObject struct { ImageObject ImageObject CompositeGraphicUnit CompositeGraphicUnit origin *editorObjectOrigin + state editorCompositeState } // Clips 裁剪区域集合 diff --git a/ofdgo_shape.go b/ofdgo_shape.go index ee24702..bc4bd9f 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -211,11 +211,37 @@ func sameShapePath(a, b []string) bool { // 入参: box 新几何范围,直线使用起点和有符号的端点位移 // 返回: PathObject 调整后的对象, error 错误信息 func (p PathObject) Reshape(box Box) (PathObject, error) { - kind, previous := p.Shape() + kind, _ := p.Shape() + return p.reshape(kind, box) +} + +// ReshapeLine 调整直线端点及箭头类型,保留绘制样式、变换及裁剪 +// 入参: kind 为ShapeLine、ShapeArrow或ShapeDoubleArrow, box 起点及有符号端点位移 +// 返回: PathObject 调整后的直线, error 错误信息 +func (p PathObject) ReshapeLine(kind ShapeKind, box Box) (PathObject, error) { + before, _ := p.Shape() + if !lineShapeKind(before) || !lineShapeKind(kind) { + return PathObject{}, fmt.Errorf("path is not a supported line") + } + return p.reshape(kind, box) +} + +// lineShapeKind 判断基本图形是否使用直线端点 +// 入参: kind 图形类型 +// 返回: bool 是否为直线或箭头 +func lineShapeKind(kind ShapeKind) bool { + return kind == ShapeLine || kind == ShapeArrow || kind == ShapeDoubleArrow +} + +// reshape 在原坐标变换下重建已识别基本图形的几何,不改写样式 +// 入参: kind 目标类型, box 新几何范围 +// 返回: PathObject 调整后的对象, error 错误信息 +func (p PathObject) reshape(kind ShapeKind, box Box) (PathObject, error) { + before, previous := p.Shape() if kind == "" { return PathObject{}, fmt.Errorf("path is not a supported basic shape") } - if box == previous { + if box == previous && kind == before { return p, nil } if !finite(box.X) || !finite(box.Y) || !finite(box.X+box.W) || !finite(box.Y+box.H) {