diff --git a/assets/webui/index.html b/assets/webui/index.html index 181ff4e..6cd8256 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -175,6 +175,7 @@
+
组合
格式
%
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 8f95699..b034e3f 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -203,6 +203,8 @@ const el = { objectAspect: document.querySelector("#objectAspect"), objectStylePanel: document.querySelector("#objectStylePanel"), objectStyleForm: document.querySelector("#objectStyleForm"), + groupObjectsButton: document.querySelector("#groupObjectsButton"), + ungroupObjectButton: document.querySelector("#ungroupObjectButton"), objectOpacity: document.querySelector("#objectOpacity"), objectStrokeFields: document.querySelector("#objectStrokeFields"), objectDash: document.querySelector("#objectDash"), @@ -366,7 +368,8 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { updatePendingChanges(); el.undoButton.disabled = !state.ready || state.exporting || !state.editorInfo?.canUndo && !canvasEditor.nudgeChanged(); }, - onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height), + onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height, Boolean(item.oriented)), + onResize: (item, box) => changeDocument("ofdgoResizeObjects", { ...item, id: [item.id] }, box.x, box.y, box.width, box.height), onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)), onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height), onCropChange: () => { @@ -658,6 +661,12 @@ el.batchPagesForm.addEventListener("submit", async event => { }); editorClick(el.objectStyleButton, openObjectStyle); editorClick(el.objectBoundsButton, openObjectBounds); +for (const [button, ungroup] of [[el.groupObjectsButton, false], [el.ungroupObjectButton, true]]) { + button.addEventListener("click", async () => { + const item = canvasEditor.selected; + if (await changeDocument("ofdgoGroupObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ungroup)) el.objectStylePanel.close(); + }); +} el.arrowTool.addEventListener("change", async () => { const tool = el.arrowTool.value; el.arrowTool.value = ""; @@ -999,7 +1008,7 @@ function openObjectBounds() { state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) }; for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]); el.objectAspect.checked = true; - el.objectAspect.disabled = Boolean(state.composite) || !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height); + el.objectAspect.disabled = !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height); el.objectWidth.disabled = lineShape(item.shape) && !box.width; el.objectHeight.disabled = lineShape(item.shape) && !box.height; el.objectBoundsStatus.textContent = ""; @@ -1016,6 +1025,9 @@ function canCopyStyle(item) { function openObjectStyle() { const items = canvasEditor.items(); if (!items.length) return; + const ordered = [...items].sort((a, b) => a.position - b.position); + el.groupObjectsButton.disabled = Boolean(state.composite) || items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index); + el.ungroupObjectButton.disabled = Boolean(state.composite) || items.length !== 1 || !canEditObject(items[0], "ungroup"); el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]); el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item)); const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; @@ -1926,7 +1938,7 @@ async function changeDocument(name, item, ...args) { return true; } const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); - if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { + if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoGroupObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { state.selectObjects = true; canvasEditor.setTool(""); setPan(false); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 4ed08d2..24f56c0 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -159,6 +159,11 @@ export function reshapeBox(item, dx, dy, handle, shift) { return { x, y, width: right - x, height: bottom - y }; } +export function reshapeFrame(frame, dx, dy, handle, shift) { + const [a, b, c, d] = frame.matrix, determinant = a * d - b * c; + return reshapeBox({ geometry: frame.box }, (d * dx - c * dy) / determinant, (a * dy - b * dx) / determinant, handle, shift); +} + function paintShape(node, shape, box) { const { x, y, width, height } = box; const attributes = lineShape(shape) && shape !== "line" ? { d: arrowPath(shape, box) } @@ -289,9 +294,10 @@ export class CanvasEditor { svg.setAttribute("aria-hidden", "true"); svg.setAttribute("preserveAspectRatio", "none"); let contour; - 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); + if (object.type === "PathObject" && (!object.scoped || object.shape || object.oriented)) { + const shape = object.shape || object.oriented?.kind; + contour = document.createElementNS("http://www.w3.org/2000/svg", shape === "line" ? "line" : shape === "ellipse" ? "ellipse" : shape === "rectangle" ? "rect" : "path"); + if (!shape) contour.setAttribute("d", object.outline); svg.append(contour); } else { for (const path of object.contours) { @@ -306,7 +312,7 @@ export class CanvasEditor { this.nodes.set(node, item); node.addEventListener("focus", () => this.select(item)); const handles = !canEditObject(object, "transform") ? [] : lineShape(object.shape) ? ["start", "end"] - : object.shape ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] + : object.shape || object.oriented || object.type === "ImageObject" && !object.imageBorder ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : object.type === "TextObject" && canEditObject(object, "reflow") && canEditObject(object, "layoutKnown") ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"]; for (const corner of handles) { @@ -346,6 +352,10 @@ export class CanvasEditor { if (x || y || scale !== 1) item.artwork.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`); else item.artwork.removeAttribute("transform"); } + if (item.oriented) { + this.placeOriented(item, item.oriented.box, change); + return; + } if (item.shape) { this.placeShape(item, { x: item.geometry.x + change.x + (item.geometry.x - item.x) * (change.scale - 1), y: item.geometry.y + change.y + (item.geometry.y - item.y) * (change.scale - 1), @@ -369,6 +379,28 @@ export class CanvasEditor { } } + placeOriented(item, box, change = { x: 0, y: 0, scale: 1 }) { + const frame = item.oriented, bounds = transformedBox(box, frame.matrix), scale = change.scale; + const [a, b, c, d, e, f] = frame.matrix; + Object.assign(item.node.style, { + left: `${(item.x + change.x + (bounds.x - item.x) * scale) * PX_PER_MM}px`, + top: `${(item.y + change.y + (bounds.y - item.y) * scale) * PX_PER_MM}px`, + width: `${bounds.width * scale * PX_PER_MM}px`, height: `${bounds.height * scale * PX_PER_MM}px`, + }); + for (const handle of item.node.children) { + const corner = handle.dataset.corner; + if (!corner) continue; + const x = box.x + box.width * (corner.includes("w") ? 0 : corner.includes("e") ? 1 : .5); + const y = box.y + box.height * (corner.includes("n") ? 0 : corner.includes("s") ? 1 : .5); + handle.style.left = `${(a * x + c * y + e - bounds.x) * scale * PX_PER_MM}px`; + handle.style.top = `${(b * x + d * y + f - bounds.y) * scale * PX_PER_MM}px`; + } + const { svg, path } = item.contour; + svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + path.setAttribute("transform", `matrix(${frame.matrix.join(" ")})`); + paintShape(path, frame.kind, box); + } + placeShape(item, box) { const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height); Object.assign(item.node.style, { @@ -597,6 +629,23 @@ export class CanvasEditor { Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, width: `${drag.box.width * PX_PER_MM}px` }); return; } + if (drag.item.type === "ImageObject" && !drag.item.imageBorder && drag.corner.length === 1) { + drag.box = reshapeBox({ geometry: drag.item }, to.x - from.x, to.y - from.y, drag.corner, false); + const sx = drag.box.width / drag.item.width, sy = drag.box.height / drag.item.height; + Object.assign(drag.item.node.style, { left: `${drag.box.x * PX_PER_MM}px`, top: `${drag.box.y * PX_PER_MM}px`, + width: `${drag.box.width * PX_PER_MM}px`, height: `${drag.box.height * PX_PER_MM}px` }); + drag.item.artwork?.setAttribute("transform", `translate(${drag.box.x - drag.item.x * sx} ${drag.box.y - drag.item.y * sy}) scale(${sx} ${sy})`); + return; + } + if (drag.item.oriented && drag.corner) { + const frame = drag.item.oriented; + drag.box = reshapeFrame(frame, to.x - from.x, to.y - from.y, drag.corner, event.shiftKey); + this.placeOriented(drag.item, drag.box); + const matrix = new DOMMatrix(frame.matrix), sx = drag.box.width / frame.box.width, sy = drag.box.height / frame.box.height; + const transform = matrix.translate(drag.box.x - frame.box.x * sx, drag.box.y - frame.box.y * sy).scale(sx, sy).multiply(matrix.inverse()); + drag.item.artwork?.setAttribute("transform", `matrix(${[transform.a, transform.b, transform.c, transform.d, transform.e, transform.f].join(" ")})`); + return; + } if (drag.item.shape && drag.corner) { drag.box = reshapeBox(drag.item, to.x - from.x, to.y - from.y, drag.corner, event.shiftKey); this.placeShape(drag.item, drag.box || drag.item.geometry); @@ -709,12 +758,12 @@ export class CanvasEditor { .finally(() => drag.preview.remove()); return; } - if ((drag.item.shape || drag.item.type === "TextObject") && drag.corner && drag.box) { + if (drag.corner && drag.box) { this.drag = null; this.viewer.releasePointerCapture(drag.pointerID); - const geometry = drag.item.geometry || (drag.item.textFrame ? { x: drag.item.x, width: drag.item.textFrame.width } : drag.item); + const geometry = drag.item.oriented?.box || drag.item.geometry || (drag.item.textFrame ? { x: drag.item.x, width: drag.item.textFrame.width } : drag.item); const changed = Object.keys(drag.box).some((key) => drag.box[key] !== geometry[key]); - const apply = drag.item.shape ? this.options.onReshape : this.options.onTextWidth; + const apply = drag.item.shape || drag.item.oriented ? this.options.onReshape : drag.item.type === "ImageObject" ? this.options.onResize : this.options.onTextWidth; Promise.resolve(changed && apply(drag.item, drag.box)).finally(() => { drag.preview?.remove(); this.place(drag.item); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 608969e..f6f1c8b 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -120,6 +120,7 @@ func RunWASM() { registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoMoveOutline", moveOutline) registerCallback("ofdgoCaptureStyle", captureStyle) + registerCallback("ofdgoGroupObjects", groupObjects) registerCallback("ofdgoPasteStyle", pasteStyle) registerCallback("ofdgoResizeObjects", resizeObjects) registerCallback("ofdgoLoadImport", loadImport) @@ -775,6 +776,11 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } else if outline, err := path.Outline(); err == nil { item["outline"] = outline } + if kind == "" && capability.Update { + if kind, box, matrix := path.ShapeFrame(); kind != "" { + item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} + } + } } if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { paths := make([]any, len(contours)) @@ -835,7 +841,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { // 返回: map[string]any 前端能力 func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), - "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + "replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} if missing := capability.MissingGlyphs; missing != nil { result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -927,6 +933,8 @@ func compositeObjects(args []js.Value) (any, error) { editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) if kind, geometry := member.Shape(); kind != "" { item["shape"], item["geometry"] = string(kind), editorBox(geometry) + } else if kind, box, matrix := member.ShapeFrame(); kind != "" { + item["oriented"] = map[string]any{"kind": string(kind), "box": editorBox(box), "matrix": editorMatrix(matrix)} } if member.Object.Type == "TextObject" { object := member.Object.TextObject @@ -996,6 +1004,9 @@ func changeCompositeObjects(args []js.Value) (any, error) { if operation == "line" { return currentEditor.ReshapeCompositeLine(page, path, indexes[0], ofdgo.ShapeKind(args[8].String()), box) } + if len(args) > 8 && args[8].Bool() { + return currentEditor.ReshapeCompositeFrame(page, path, indexes[0], box) + } return currentEditor.ReshapeCompositeObject(page, path, indexes[0], box) 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()}) @@ -1019,14 +1030,11 @@ func changeCompositeObjects(args []js.Value) (any, error) { if !args[4].IsNull() { return currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout) } - members, err := currentEditor.CompositeObjects(page, path) + member, err := currentEditor.CompositeObject(page, path, indexes[0]) if err != nil { return err } - if indexes[0] >= len(members) { - return fmt.Errorf("text member is unavailable") - } - value, _ := members[indexes[0]].Object.TextObject.TextLayout() + value, _ := member.Object.TextObject.TextLayout() return currentEditor.LayoutCompositeText(page, path, indexes[0], value, layout) case "fit", "resetCrop": if len(indexes) != 1 { @@ -1274,7 +1282,12 @@ func reshapeObject(args []js.Value) (any, error) { if object.Type != "PathObject" { return nil, fmt.Errorf("object is not a path") } - object.PathObject, err = object.PathObject.Reshape(ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()} + if len(args) > 6 && args[6].Bool() { + object.PathObject, err = object.PathObject.ReshapeFrame(box) + } else { + object.PathObject, err = object.PathObject.Reshape(box) + } if err != nil { return nil, err } @@ -1412,6 +1425,32 @@ func copyEditorObjects(page int, objects []ofdgo.GraphicObject, dx, dy float64) return editorSelectionInfo{info, ids}, err } +// groupObjects 组合或解组并返回新的选区,复用单次历史提交 +// 入参: args 页面索引、对象标识列表和是否解组 +// 返回: any 文档及选区, error 错误信息 +func groupObjects(args []js.Value) (any, error) { + var ids []string + info, err := changeObjects(func() error { + page, selected := args[0].Int(), stringsFromJS(args[1]) + var err error + if args[2].Bool() { + if len(selected) != 1 { + return fmt.Errorf("ungrouping requires one object") + } + ids, err = currentEditor.UngroupObject(page, selected[0]) + } else { + var id string + id, err = currentEditor.GroupObjects(page, selected) + ids = []string{id} + } + return err + }) + if err != nil { + return nil, err + } + return editorSelectionInfo{info.(editorInfo), ids}, nil +} + // orderObjects 调整选区的绘制顺序 // 入参: args 页码、对象标识列表和层级动作 // 返回: any 文档信息, error 错误信息 @@ -1587,14 +1626,11 @@ func captureStyle(args []js.Value) (any, error) { if err != nil { return nil, err } - members, err := currentEditor.CompositeObjects(args[0].Int(), path) + member, err := currentEditor.CompositeObject(args[0].Int(), path, indexes[0]) if err != nil { return nil, err } - if indexes[0] >= len(members) { - return nil, fmt.Errorf("style source is unavailable") - } - object := members[indexes[0]].Style() + object := member.Style() copiedStyle = &object return nil, nil } diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 892d36c..1e6593e 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -56,6 +56,7 @@ type Editor struct { revision uint64 serial uint64 source *editorSource + origins map[string]*editorObjectOrigin outlines []byte } @@ -252,15 +253,21 @@ func (e *Editor) CopyPages(indexes []int) ([]int, error) { } } result := make([]int, len(pages)) + maximum := e.maxID + origins := make(map[string]*editorObjectOrigin) for n := range pages { page := &pages[n] - page.ID = e.nextID() + maximum++ + page.ID = strconv.Itoa(maximum) for i := range page.Content.Layer { layer := &page.Content.Layer[i] - layer.ID = e.nextID() + maximum++ + layer.ID = strconv.Itoa(maximum) for j := range layer.Objects { object := &layer.Objects[j] - id := e.nextID() + before := *object + maximum++ + id := strconv.Itoa(maximum) switch object.Type { case "TextObject": object.TextObject.ID = id @@ -268,11 +275,25 @@ func (e *Editor) CopyPages(indexes []int) ([]int, error) { object.PathObject.ID = id case "ImageObject": object.ImageObject.ID = id + case "CompositeObject", "CompositeGraphicUnit": + object.CompositeGraphicUnit.ID = id + } + copied, origin, err := e.copyObjectOrigin(before, *object, &maximum) + if err != nil { + return nil, err + } + *object = copied + if origin != nil { + origins[id] = origin } } } result[n] = at + n } + e.maxID = maximum + for id, origin := range origins { + e.setObjectOrigin(id, origin) + } e.pages = append(e.pages, pages...) if change := e.recordChange(); change != nil { for i := range pages { diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 0f56776..677e949 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -113,7 +113,7 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) e.maxID = maximum for i, origin := range origins { if origin != nil { - e.source.origins[result[i]] = origin + e.setObjectOrigin(result[i], origin) } } layers := copyEditorPage(e.pages[page]).Content.Layer @@ -173,14 +173,26 @@ func (e *Editor) objectOrderIndexes(page int, layer *Layer, id string) (*editorX var nodes map[string]*editorXML var parent *editorXML if e.originalPage(page) { - nodes = e.source.pages[e.pages[page].ID].nodes + source := e.source.pages[e.pages[page].ID] + if slices.ContainsFunc(source.original.Content.Layer, func(original Layer) bool { return original.ID == layer.ID }) { + nodes = source.nodes + } + } + if nodes != nil { if node := nodes[id]; node != nil { parent = node.parent + } else if origin := e.objectOrigin(id); origin != nil { + parent = origin.node.parent } } var indexes []int for i, object := range layer.Objects { node := nodes[editorObjectID(object)] + if node == nil && nodes != nil { + if origin := e.objectOrigin(editorObjectID(object)); origin != nil { + node = origin.node + } + } if node == nil && parent == nil || node != nil && node.parent == parent { indexes = append(indexes, i) } @@ -469,7 +481,7 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i] } for id, origin := range origins { - e.source.origins[id] = origin + e.setObjectOrigin(id, origin) } } apply(e, after, afterOrigins) diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 9a93798..c1e992b 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -158,6 +158,36 @@ func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, return e.measureCompositeMembers(renderer, members), nil } +// CompositeObject 读取指定内部成员,仅度量该成员并保留完整容器位置 +// 入参: page 页面索引, path 父复合路径, index 成员序号 +// 返回: CompositeMember 独立成员快照, error 错误信息 +func (e *Editor) CompositeObject(page int, path ObjectPath, index int) (CompositeMember, error) { + reader, renderer, _, nodes, err := e.compositeScope(page, path) + if err != nil { + return CompositeMember{}, err + } + defer reader.Close() + if index < 0 || index >= len(nodes) { + return CompositeMember{}, fmt.Errorf("composite member index %d out of range", index) + } + member := e.measureCompositeMembers(renderer, nodes[index:index+1])[0] + position := ObjectPosition{} + for i, node := range nodes { + if node.span.parent != nodes[index].span.parent { + continue + } + if position.Count == 0 { + position.Container = fmt.Sprint(i) + } + if i == index { + position.Index = position.Count + } + position.Count++ + } + member.Position = position + return member, nil +} + // TransformCompositeObjects 在页面坐标中等比缩放并平移内部选区,只隔离被修改实例的资源 // 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, dx、dy 位移, scale 正缩放比例 // 返回: error 错误信息 @@ -195,7 +225,7 @@ func (e *Editor) FlipCompositeObjects(page int, path ObjectPath, indexes []int, return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) }) } -// ResizeCompositeObjects 等比缩放内部选区到目标范围,不重排文字或重采样图片 +// ResizeCompositeObjects 缩放内部选区到目标范围,无边框图片支持独立宽高,其他对象保持比例 // 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, box 页面毫米目标范围 // 返回: error 错误信息 func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { @@ -214,10 +244,17 @@ func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int return fmt.Errorf("selection has no visible bounds") } sx, sy := box.W/before.W, box.H/before.H - if math.Abs(sx-sy) > 1e-9*math.Max(sx, sy) { - return fmt.Errorf("composite members require proportional resizing") + uniform := math.Abs(sx-sy) <= 1e-9*math.Max(sx, sy) + if !uniform { + for _, node := range nodes { + if node.object.Type != "ImageObject" || node.object.ImageObject.Border != nil { + return fmt.Errorf("nonuniform resizing requires images without borders") + } + } + } else { + sy = sx } - matrix := Matrix{a: sx, d: sx, e: box.X - before.X*sx, f: box.Y - before.Y*sx} + matrix := Matrix{a: sx, d: sy, e: box.X - before.X*sx, f: box.Y - before.Y*sy} for _, node := range nodes { if err := e.transformCompositeMember(renderer, node, matrix); err != nil { return err @@ -687,7 +724,7 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render } defer reader.Close() count, maximum := len(e.resources), e.maxID - ready := e.source.idsReady + ready := e.source != nil && e.source.idsReady defer func() { if err != nil { removed := make(map[string]bool) @@ -700,7 +737,9 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render maps.DeleteFunc(e.resourceID, func(_ editorResourceKey, id string) bool { return removed[id] }) clear(e.resources[count:]) e.resources, e.maxID = e.resources[:count], maximum - e.source.idsReady = ready + if e.source != nil { + e.source.idsReady = ready + } } }() if err = e.prepareSourceIDs(); err != nil { diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index a7fcf6c..67bd50a 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -63,11 +63,15 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu if selection == nil || selection.editor != e { return fmt.Errorf("composite selection belongs to another editor") } - count, maximum, ready := len(e.resources), e.maxID, e.source.idsReady + count, maximum := len(e.resources), e.maxID + ready := e.source != nil && e.source.idsReady defer func() { if err != nil { clear(e.resources[count:]) - e.resources, e.maxID, e.source.idsReady = e.resources[:count], maximum, ready + e.resources, e.maxID = e.resources[:count], maximum + if e.source != nil { + e.source.idsReady = ready + } } }() if err := e.prepareSourceIDs(); err != nil { @@ -85,12 +89,22 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu if err != nil { return err } + style, styleErr := e.compositeMemberStyle(source) + if styleErr != nil { + if source.object.Type != "PathObject" || editReason(styleErr) != EditUnsupportedColor { + return styleErr + } + copy, err = e.wrapCompositePath(copy) + if err != nil { + return err + } + } copy.setStates(source.states) copy.defaults = source.defaults copy.drawParams = source.drawParams - object, err := e.compositeMemberStyle(source) - if err != nil { - return err + object := cloneEditorData(copy.object) + if styleErr == nil && object.Type != "PathObject" { + object = style } object = mergeGraphicObjectAlpha(object, source.alpha) if !source.visible || source.clip != nil && source.clip.Empty() { @@ -138,7 +152,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu } } copy.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}} - object, err = e.resolveEditorStyle(copy.object, "") + object, err = e.copiedObjectStyle(copy.object, "") if err != nil { return err } @@ -149,7 +163,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu 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} + object.origin = &editorObjectOrigin{editor: e, page: selection.source, data: copy.data, node: copy.node, object: copy.object} objects = append(objects, object) } return paste(objects) diff --git a/ofdgo_editor_composite_image.go b/ofdgo_editor_composite_image.go index b4c7109..f6b8317 100644 --- a/ofdgo_editor_composite_image.go +++ b/ofdgo_editor_composite_image.go @@ -110,17 +110,11 @@ func appendCompositeClip(node *editorCompositeNode, shape PathObject, matrix Mat } 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 - } + clip := editorXMLEncodedFragment(data, root.children[0]) 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 - } + data = editorXMLEncodedFragment(data, root) content := append(data, node.data[node.node.open:node.node.close]...) patch = editorXMLContent(node.data, node.node, content) } diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index 8e5a72e..be8b71e 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -92,13 +92,22 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra if err != nil { return err } + if node.object.Type == "PathObject" { + _, styleErr := e.resolveEditorStyle(node.object, e.copiedLayerStyle(source)) + if styleErr != nil && editReason(styleErr) == EditUnsupportedColor || !e.editorPaintable(node.object) { + node, err = e.wrapCompositePath(node) + if err != nil { + return err + } + } + } node.parent, node.boundaryInCTM = IdentityMatrix, true node.setStates(object.CompositeGraphicUnit.states) + node.drawParams = []string{e.copiedLayerStyle(source)} if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { if err := node.convertCoordinates(IdentityMatrix, true); err != nil { return err } - node.drawParams = []string{e.copiedLayerStyle(source)} } if err := e.isolateCompositeStyle(node); err != nil { return err @@ -149,11 +158,26 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return result, nil } +// wrapCompositePath 通过独立容器变换复杂路径,保持渐变坐标与原始颜色节点 +// 入参: node 复杂路径 +// 返回: *editorCompositeNode 包装节点, error 错误信息 +func (e *Editor) wrapCompositePath(node *editorCompositeNode) (*editorCompositeNode, error) { + content, err := editorXMLContainer("Content", nil, bytes.TrimPrefix(node.data, []byte(xml.Header))) + if err != nil { + return nil, err + } + data, err := editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: e.nextID()}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) + if err != nil { + return nil, err + } + return newEditorCompositeNode(data) +} + // isolateCompositeStyle 固定对象的绘制参数,避免目标容器改变颜色、虚线和线帽 // 入参: node 页面坐标中的对象节点 // 返回: error 错误信息 func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { - if node.object.Type == "CompositeObject" || node.object.Type == "CompositeGraphicUnit" { + if node.object.Type == "CompositeObject" || node.object.Type == "CompositeGraphicUnit" || node.object.Type == "PathObject" { id, err := e.neutralDrawParam() if err != nil { return err @@ -164,29 +188,32 @@ func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { return err } } - id, err = e.copyDrawParam(node.object.CompositeGraphicUnit.DrawParam, id, make(map[string]bool)) + parameter := node.object.CompositeGraphicUnit.DrawParam + if node.object.Type == "PathObject" { + parameter = node.object.PathObject.DrawParam + } + id, err = e.copyDrawParam(parameter, id, make(map[string]bool)) if err != nil { return err } object := node.object - object.CompositeGraphicUnit.DrawParam = id + if object.Type == "PathObject" { + object.PathObject.DrawParam = id + } else { + object.CompositeGraphicUnit.DrawParam = id + } return node.update(object) } object, err := e.resolveEditorStyleDefaults(node.object, &DrawParam{}) if err != nil { return err } - if object.Type == "TextObject" || object.Type == "PathObject" { + if object.Type == "TextObject" { id, err := e.neutralDrawParam() if err != nil { return err } - if object.Type == "TextObject" { - object.TextObject.DrawParam = id - } else { - object.PathObject.DrawParam = id - object.PathObject.dashPatternSet = true - } + object.TextObject.DrawParam = id } return node.update(object) } diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 49fa628..86bb598 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -206,6 +206,49 @@ func (m CompositeMember) Shape() (ShapeKind, Box) { return kind, parent.TransformBox(box) } +// ShapeFrame 获取内部矩形或椭圆的局部范围及页面变换 +// 返回: ShapeKind 图形类型, Box 局部范围, Matrix 页面变换 +func (m CompositeMember) ShapeFrame() (ShapeKind, Box, Matrix) { + if m.Object.Type != "PathObject" || !m.Capabilities.Transform { + return "", Box{}, Matrix{} + } + kind, box, _ := m.Object.PathObject.ShapeFrame() + if _, ok := m.Matrix.Invert(); !ok { + return "", Box{}, Matrix{} + } + return kind, box, m.Matrix +} + +// ReshapeCompositeFrame 沿内部矩形或椭圆自身方向拉伸,不修改共享实例 +// 入参: page 页面索引, path 父路径, index 成员序号, box ShapeFrame局部目标范围 +// 返回: error 错误信息 +func (e *Editor) ReshapeCompositeFrame(page int, path ObjectPath, index int, box Box) error { + return e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + kind, previous, _ := members[0].ShapeFrame() + if kind == "" { + return fmt.Errorf("composite member is not a framed shape") + } + if box == previous { + return nil + } + node := nodes[0] + object := cloneEditorData(node.object) + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, true) + } + var err error + object.PathObject, err = object.PathObject.ReshapeFrame(box) + 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) + }) +} + // shapeParent 获取路径所在坐标到页面的变换,兼容旧式内联边界 // 返回: Matrix 路径父坐标变换, bool 是否可逆 func (m CompositeMember) shapeParent() (Matrix, bool) { diff --git a/ofdgo_editor_copy.go b/ofdgo_editor_copy.go index 5fab778..82e045c 100644 --- a/ofdgo_editor_copy.go +++ b/ofdgo_editor_copy.go @@ -21,12 +21,23 @@ import ( "strconv" ) +// copiedObjectStyle 保留复杂路径的原始颜色引用,普通对象沿用有效样式快照 +// 入参: object 原对象, layer 图层绘制参数 +// 返回: GraphicObject 复制快照, error 错误信息 +func (e *Editor) copiedObjectStyle(object GraphicObject, layer string) (GraphicObject, error) { + result, err := e.resolveEditorStyle(object, layer) + if err != nil && object.Type == "PathObject" && editReason(err) == EditUnsupportedColor { + return cloneEditorData(object), nil + } + return result, err +} + // snapshotOrigin 获取当前文档内捕获时的原文来源,外部快照按普通对象校验 // 入参: object 对象快照 // 返回: *editorObjectOrigin 原文来源 func (e *Editor) snapshotOrigin(object GraphicObject) *editorObjectOrigin { if object.origin != nil { - if e.source != nil && e.source.pages[object.origin.page.ref.ID] == object.origin.page { + if object.origin.editor == e || object.origin.page != nil && e.source != nil && e.source.pages[object.origin.page.ref.ID] == object.origin.page { return object.origin } return nil @@ -199,15 +210,21 @@ func editorObjectReference(name string) bool { return false } -// copiedLayerStyle 获取复合对象需保留的图层绘制参数,不向基本对象重新施加继承样式 +// copiedLayerStyle 保留复合对象和复杂路径的图层参数,普通对象使用已解析样式 // 入参: object 对象 // 返回: string 绘制参数标识 func (e *Editor) copiedLayerStyle(object GraphicObject) string { - if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { + if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" || object.Type == "PathObject" { if origin := e.snapshotOrigin(object); origin != nil { for parent := origin.node.parent; parent != nil; parent = parent.parent { if parent.name.Local == "Layer" { - return parent.attr("DrawParam") + id := parent.attr("DrawParam") + if object.Type == "PathObject" { + if _, err := e.resolveEditorStyle(object, id); err == nil || editReason(err) != EditUnsupportedColor { + return "" + } + } + return id } } } diff --git a/ofdgo_editor_group.go b/ofdgo_editor_group.go new file mode 100644 index 0000000..12a5644 --- /dev/null +++ b/ofdgo_editor_group.go @@ -0,0 +1,206 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "encoding/xml" + "fmt" + "maps" + "slices" + "strconv" +) + +// GroupObjects 将同一容器内连续的对象组合,保留标识、绘制顺序及原始内容 +// 入参: page 页面索引, ids 至少两个对象标识 +// 返回: string 组合标识, error 错误信息 +func (e *Editor) GroupObjects(page int, ids []string) (string, error) { + _, indexes, err := e.selectedObjects(page, ids) + if err != nil { + return "", err + } + if len(indexes) < 2 { + return "", fmt.Errorf("grouping requires at least two objects") + } + slices.SortFunc(indexes, compareEditorPosition) + layer := &e.pages[page].Content.Layer[indexes[0].layer] + parent, siblings := e.objectOrderIndexes(page, layer, editorObjectID(layer.Objects[indexes[0].index])) + first := slices.Index(siblings, indexes[0].index) + for i, index := range indexes { + id := editorObjectID(e.pages[page].Content.Layer[index.layer].Objects[index.index]) + capability, err := e.ObjectCapabilities(page, id) + if err != nil { + return "", err + } + if index.layer != indexes[0].layer || first+i >= len(siblings) || siblings[first+i] != index.index || !capability.Copy || !capability.Order { + return "", fmt.Errorf("grouping requires consecutive objects in the same container") + } + } + var content []byte + var source *editorSourcePage + states := make(map[string]editorCompositeState) + for _, index := range indexes { + object := layer.Objects[index.index] + var data []byte + if origin := e.objectOrigin(editorObjectID(object)); origin != nil { + source = origin.page + data, err = editorXMLObject(origin.data, origin.node, origin.object, object) + if err == nil { + data, err = editorXMLStandalone(data, origin.node) + } + } else { + data, err = editorObjectXML(object) + } + if err != nil { + return "", err + } + content = append(content, bytes.TrimPrefix(data, []byte(xml.Header))...) + maps.Copy(states, object.CompositeGraphicUnit.states) + state := object.state + if object.Type == "TextObject" && object.TextObject.layout != nil { + state.layout = object.TextObject.layout + state.origin[0], _ = strconv.ParseFloat(object.TextObject.TextCode[0].X, 64) + state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64) + } + states[editorObjectID(object)] = state + } + if err := e.prepareSourceIDs(); err != nil { + return "", err + } + id := strconv.Itoa(e.maxID + 1) + content, err = editorXMLContainer("Content", nil, content) + if err != nil { + return "", err + } + data, err := editorXMLContainer("CompositeObject", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Boundary"}, Value: "0 0 1 1"}}, bytes.TrimPrefix(content, []byte(xml.Header))) + if err != nil { + return "", err + } + group, err := newEditorCompositeNode(data) + if err != nil { + return "", err + } + if parent == nil { + parent = &editorXML{name: xml.Name{Local: "Layer"}, attrs: []xml.Attr{{Name: xml.Name{Local: "ID"}, Value: layer.ID}}, parent: &editorXML{name: xml.Name{Local: "Content"}}} + } + group.node.parent = parent + group.object.CompositeGraphicUnit.states = states + origin := &editorObjectOrigin{page: source, data: data, node: group.node, object: group.object} + e.maxID++ + e.replaceGroup(page, indexes, []GraphicObject{group.object}, map[string]*editorObjectOrigin{id: origin}) + return id, nil +} + +// UngroupObject 展开内联组合,保留页面外观与裁剪,含容器行为或扩展的组合不拆解 +// 入参: page 页面索引, id 组合标识 +// 返回: []string 展开后的对象标识, error 错误信息 +func (e *Editor) UngroupObject(page int, id string) ([]string, error) { + objects, indexes, err := e.selectedObjects(page, []string{id}) + if err != nil { + return nil, err + } + origin := e.objectOrigin(id) + if origin == nil || !editorUngroupable(origin.node) || !editorContainerOrderable(origin.node.parent) || objects[0].CompositeGraphicUnit.ResourceID != "" { + return nil, fmt.Errorf("group container cannot be removed without losing attributes") + } + reader, _, _, nodes, err := e.compositeScope(page, ObjectPath{ID: id}) + if err != nil { + return nil, err + } + reader.Close() + if len(nodes) == 0 { + return nil, fmt.Errorf("group has no objects") + } + members := make([]int, len(nodes)) + for i := range members { + members[i] = i + } + selection, err := e.CaptureCompositeObjects(page, ObjectPath{ID: id}, members) + if err != nil { + return nil, err + } + var result []string + err = e.pasteCompositeSelection(selection, func(objects []GraphicObject) error { + origins := make(map[string]*editorObjectOrigin, len(objects)) + for i := range objects { + object := &objects[i] + copy := *object.origin + copy.node.parent = origin.node.parent + object.origin = nil + id := editorObjectID(*object) + origins[id] = © + result = append(result, id) + } + e.replaceGroup(page, indexes, objects, origins) + return nil + }) + return result, err +} + +// editorUngroupable 判断移除内联容器是否会丢失对象行为或未知属性 +// 入参: node 容器节点 +// 返回: bool 是否可解组 +func editorUngroupable(node *editorXML) bool { + if node.name.Local != "CompositeObject" && node.name.Local != "CompositeGraphicUnit" { + return false + } + if !editorXMLAttributes(node, "ID Boundary CTM DrawParam Alpha Visible") { + return false + } + for _, child := range node.children { + switch child.name.Local { + case "Content": + if !editorXMLAttributes(child, "") { + return false + } + for _, member := range child.children { + if member.name.Space != "" && member.name.Space != ofdNamespace && member.name.Space != "http://www.ofdspec.org" || !slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit"}, member.name.Local) { + return false + } + } + case "Clips": + if !editorXMLSupported(child) { + return false + } + default: + return false + } + } + return true +} + +// replaceGroup 原子替换同容器连续成员及原文来源,提交一次撤销记录 +// 入参: page 页面索引, indexes 原对象位置, objects 新成员, origins 新成员原文 +func (e *Editor) replaceGroup(page int, indexes []editorObjectPosition, objects []GraphicObject, origins map[string]*editorObjectOrigin) { + before := copyEditorPage(e.pages[page]).Content.Layer + after := copyEditorPage(e.pages[page]).Content.Layer + layer := &after[indexes[0].layer] + layer.Objects = slices.Replace(layer.Objects, indexes[0].index, indexes[len(indexes)-1].index+1, objects...) + previous := make(map[string]*editorObjectOrigin, len(origins)) + for id := range origins { + previous[id] = e.objectOrigin(id) + } + apply := func(e *Editor, layers []Layer, origins map[string]*editorObjectOrigin) { + e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer + for id, origin := range origins { + e.setObjectOrigin(id, origin) + } + } + apply(e, after, origins) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { apply(e, before, previous) } + change.redo = func(e *Editor) { apply(e, after, origins) } + } +} diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index a0beee2..54c7f13 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -219,13 +219,17 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo preview := *e preview.source, preview.pages = next, added for i, index := range indexes { - layouts := make(map[string]*textLayout) + states := make(map[string]editorCompositeState) + groups := make(map[string]map[string]editorCompositeState) for _, layer := range e.pages[index].Content.Layer { for _, object := range layer.Objects { + id := m.ids[editorObjectID(object)] + state := object.state if layout := object.TextObject.layout; object.Type == "TextObject" && layout != nil { - value := *layout - layouts[m.ids[object.TextObject.ID]] = &value + state.layout = layout } + states[id] = state + groups[id] = remapCompositeStates(object.CompositeGraphicUnit.states, m.ids) } } if err := preview.loadSourcePage(i); err != nil { @@ -234,9 +238,12 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo for j := range added[i].Content.Layer { for k := range added[i].Content.Layer[j].Objects { object := &added[i].Content.Layer[j].Objects[k] + id := editorObjectID(*object) + object.state = states[id] if object.Type == "TextObject" { - object.TextObject.layout = layouts[object.TextObject.ID] + object.TextObject.layout = object.state.layout } + object.CompositeGraphicUnit.states = groups[id] } } } diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 7b52db2..f4a717f 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -42,6 +42,7 @@ type editorSource struct { // editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用 type editorObjectOrigin struct { + editor *Editor page *editorSourcePage data []byte node *editorXML @@ -66,6 +67,7 @@ type editorSourcePage struct { // Paint表示可独立修改纯色填充和描边,不要求重新排版文字 // ReplaceImage表示可替换图片数据,CropImage表示可裁剪图片;内部裁剪与原裁剪取交集 // MissingGlyphs提供缺字导致操作受限时的结构化诊断 +// Ungroup表示可移除内联组合容器并保留内部内容 type ObjectCapabilities struct { Update bool Paint bool @@ -81,6 +83,7 @@ type ObjectCapabilities struct { Copy bool Delete bool Order bool + Ungroup bool Reason string ReasonCode EditReason MissingGlyphs *MissingGlyphError @@ -382,7 +385,8 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}, nil } all.Order = editorContainerOrderable(node.parent) - all.Copy = editorXMLCopyable(node) && origin.reason == nil + all.Ungroup = all.Order && editorUngroupable(node) && len(object.CompositeGraphicUnit.Objects) != 0 + all.Copy = editorXMLCopyable(node) && (origin.reason == nil || object.Type == "PathObject" && editReason(origin.reason) == EditUnsupportedColor) if origin.reason != nil { all.Update, all.Reflow, all.ReplaceFont, all.Paint = false, false, false, false all.Reason, all.ReasonCode = origin.reason.Error(), editReason(origin.reason) @@ -415,12 +419,29 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er // 入参: id 对象标识 // 返回: *editorObjectOrigin 原始对象,无来源时为nil func (e *Editor) objectOrigin(id string) *editorObjectOrigin { + if origin := e.origins[id]; origin != nil { + return origin + } if e.source == nil { return nil } return e.source.origins[id] } +// setObjectOrigin 保存当前编辑器的对象原文,支持新建文档中的组合对象 +// 入参: id 对象标识, origin 原文来源,nil恢复原始来源 +func (e *Editor) setObjectOrigin(id string, origin *editorObjectOrigin) { + if e.origins == nil { + e.origins = make(map[string]*editorObjectOrigin) + } + if origin != nil && origin.page == nil && origin.editor == nil { + copy := *origin + copy.editor = e + origin = © + } + e.origins[id] = origin +} + // editorPreservedObject 判断对象是否仅改变标识、几何和可独立写回的外观字段 // 入参: before 原始快照, after 新快照 // 返回: bool 是否保留原内容 @@ -475,7 +496,7 @@ func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicOb if origin == nil { return e.prepareObject(id, object) } - if !editorXMLTransformable(origin.node) || !editorXMLContainersSupported(origin.node) || !editorXMLCopyable(origin.node) || origin.reason != nil { + if !editorXMLTransformable(origin.node) || !editorXMLContainersSupported(origin.node) || !editorXMLCopyable(origin.node) || origin.reason != nil && (object.Type != "PathObject" || editReason(origin.reason) != EditUnsupportedColor) { return GraphicObject{}, fmt.Errorf("object cannot be copied without changing its original structure") } var drawParam string @@ -485,7 +506,7 @@ func (e *Editor) prepareCopiedObject(id string, object GraphicObject) (GraphicOb break } } - before, err := e.resolveEditorStyle(origin.object, drawParam) + before, err := e.copiedObjectStyle(origin.object, drawParam) if err != nil { return GraphicObject{}, err } diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index d59ff05..164d02c 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -250,7 +250,13 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err for _, object := range current.Objects { id := editorObjectID(object) after[id] = object - if node := source.nodes[id]; node != nil { + node := source.nodes[id] + if node == nil { + if origin := e.objectOrigin(id); origin != nil { + node = origin.node + } + } + if node != nil { ordered[node.parent] = append(ordered[node.parent], object) } } @@ -279,7 +285,7 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err if exists { nextID := editorObjectID(next) origin := source.nodes[nextID] - if reflect.DeepEqual(before[nextID], next) { + if origin != nil && reflect.DeepEqual(before[nextID], next) { encoded = data[origin.start:origin.end] } else { var err error @@ -294,6 +300,21 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err patches = append(patches, editorXMLPatch{node.start, node.end, encoded}) } } + for parent, members := range ordered { + if !orderable[parent] || positions[parent] >= len(members) { + continue + } + var added []byte + for _, object := range members[positions[parent]:] { + origin := e.objectOrigin(editorObjectID(object)) + encoded, err := editorXMLObject(origin.data, origin.node, origin.object, object) + if err != nil { + return nil, err + } + added = append(added, encoded...) + } + patches = append(patches, editorXMLPatch{parent.close, parent.close, added}) + } } var added []byte for _, layer := range page.Content.Layer[len(source.original.Content.Layer):] { diff --git a/ofdgo_shape.go b/ofdgo_shape.go index bafc389..afe0c01 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -187,6 +187,56 @@ func (p PathObject) Shape() (ShapeKind, Box) { return kind, box } +// ShapeFrame 获取矩形或椭圆的局部几何及到所在坐标系的变换 +// 返回: ShapeKind 图形类型, Box 局部范围, Matrix 坐标变换,非基本图形返回空类型 +func (p PathObject) ShapeFrame() (ShapeKind, Box, Matrix) { + if p.CTM != "" { + if _, err := creationNumbers(p.CTM, 6); err != nil { + return "", Box{}, Matrix{} + } + } + boundary, err := ParseBox(p.Boundary) + matrix := TranslationMatrix(boundary.X, boundary.Y).Multiply(NewMatrix(p.CTM)) + if _, ok := matrix.Invert(); err != nil || !ok { + return "", Box{}, Matrix{} + } + p.Boundary, p.CTM = "0 0 1 1", "" + kind, box := p.Shape() + if kind != ShapeRectangle && kind != ShapeEllipse { + return "", Box{}, Matrix{} + } + return kind, box, matrix +} + +// ReshapeFrame 沿矩形或椭圆自身方向调整局部范围,保留样式、变换方向和裁剪 +// 入参: box ShapeFrame坐标系中的目标范围 +// 返回: PathObject 调整后的路径, error 错误信息 +func (p PathObject) ReshapeFrame(box Box) (PathObject, error) { + kind, previous, matrix := p.ShapeFrame() + if kind == "" { + return PathObject{}, fmt.Errorf("path is not a supported framed shape") + } + if box == previous { + return p, nil + } + if _, err := creationBox(editorBoxString(box)); err != nil { + return PathObject{}, err + } + shape, err := NewShape(kind, Box{W: box.W, H: box.H}) + if err != nil { + return PathObject{}, err + } + matrix = matrix.Multiply(TranslationMatrix(box.X, box.Y)) + bounds := matrix.TransformBox(Box{W: box.W, H: box.H}) + if _, err := creationBox(editorBoxString(bounds)); err != nil { + return PathObject{}, err + } + p.Boundary = editorBoxString(bounds) + p.CTM = TranslationMatrix(-bounds.X, -bounds.Y).Multiply(matrix).String() + p.AbbreviatedData = shape.AbbreviatedData + return p, nil +} + // sameShapePath 比较基本路径的命令与数值,忽略数值格式差异 // 入参: a、b 路径词元 // 返回: bool 是否相同 diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 7a0a8e2..c3f6ee6 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -202,7 +202,7 @@ func (e *Editor) validate() error { // 入参: write 条目写入方法, progress 保存进度回调,预览时为nil // 返回: error 错误信息 func (e *Editor) writeParts(write func(string, []byte, bool) error, progress editorProgress) error { - fonts, images, spaces, _ := e.usedResources() + fonts, images, spaces, definitions := e.usedResources() writeXML := func(name string, encode func(*ofdXML)) error { data, err := encodeOFDXML(encode) if err != nil { @@ -257,6 +257,9 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi if len(fonts)+len(images)+len(spaces) != 0 { x.text("DocumentRes", "DocumentRes.xml") } + for _, name := range definitions { + x.text("DocumentRes", "/"+name) + } x.end("CommonData") x.start("Pages", nil) for i, page := range e.pages { @@ -280,10 +283,19 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi if err := progress.report("pages", i, len(e.pages)); err != nil { return err } - if err := writeXML(fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1), func(x *ofdXML) { - x.page(page) - }); err != nil { - return err + name := fmt.Sprintf("Doc_0/Pages/%d/Content.xml", i+1) + if len(e.origins) == 0 { + if err := writeXML(name, func(x *ofdXML) { x.page(page) }); err != nil { + return err + } + } else { + data, err := e.sourceNewPageXML(page) + if err != nil { + return err + } + if err := write(name, data, false); err != nil { + return err + } } } if err := progress.report("pages", len(e.pages), len(e.pages)); err != nil { @@ -296,6 +308,13 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error, progress edi } } } + for _, resource := range e.resources { + if resource.definition() != "" && slices.Contains(definitions, resource.name) { + if err := write(resource.name, resource.data, false); err != nil { + return err + } + } + } return nil } @@ -320,7 +339,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color for _, layer := range page.Content.Layer { for _, object := range layer.Objects { before, exists := original[editorObjectID(object)] - if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { + if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.page != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { for _, name := range origin.page.original.PageRes { files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true } @@ -336,6 +355,9 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color switch object.Type { case "CompositeObject", "CompositeGraphicUnit": collectCompositeReferences(object.CompositeGraphicUnit, used) + if e.source != nil && (!exists || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) { + collectCompositeReferences(object.CompositeGraphicUnit, promoted) + } case "TextObject": used[object.TextObject.DrawParam] = true used[object.TextObject.Font] = true @@ -364,6 +386,9 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color if resource.definition() != "" && used[resource.definition()] { for _, id := range resource.references { used[id] = true + if e.source != nil { + promoted[id] = true + } } } }