diff --git a/assets/webui/index.html b/assets/webui/index.html index e740134..181ff4e 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -106,12 +106,12 @@
- - - - - - + + + + + + 插入
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 46a02fd..8f95699 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -678,7 +678,7 @@ el.copyStyleButton.addEventListener("click", async () => { const item = canvasEditor.selected, seq = state.openSeq; setBusy(true); try { - await callWASM("ofdgoCaptureStyle", item.index, item.id); + await callWASM("ofdgoCaptureStyle", item.index, item.id, state.composite?.key || ""); if (seq === state.openSeq) { state.styleClipboard = item.type; el.objectStylePanel.close(); } } catch (err) { if (seq === state.openSeq) el.objectStyleStatus.textContent = err.message; } finally { if (seq === state.openSeq) setBusy(false); } @@ -1006,11 +1006,18 @@ function openObjectBounds() { el.objectBoundsPanel.showModal(); } +function canCopyStyle(item) { + if (!item?.scoped) return canEditObject(item, "update"); + if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint"); + if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint"); + return item.type === "ImageObject" && canEditObject(item, "transform"); +} + function openObjectStyle() { const items = canvasEditor.items(); if (!items.length) return; - el.copyStyleButton.disabled = items.length !== 1 || !canEditObject(items[0], "update"); - el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canEditObject(item, "update")); + 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; const alpha = shared("alpha", 255), dash = shared("dashPattern", ""); el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); @@ -1867,7 +1874,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", + ofdgoReshapeObject: "reshape", ofdgoReshapeLine: "line", ofdgoEraseObjects: "erase", ofdgoEraseObjectsPath: "erasePath", ofdgoPasteStyle: "pasteStyle", 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); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index eb8adbf..608969e 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -983,15 +983,20 @@ func changeCompositeObjects(args []js.Value) (any, error) { page := args[0].Int() operation := args[3].String() switch operation { + case "pasteStyle": + if copiedStyle == nil { + return fmt.Errorf("style clipboard is empty") + } + return currentEditor.CopyCompositeStyle(page, path, indexes, *copiedStyle) case "reshape", "line": if len(indexes) != 1 { - return fmt.Errorf("line editing requires one member") + return fmt.Errorf("shape editing requires one member") } - kind := ofdgo.ShapeKind("") + box := ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()} if operation == "line" { - kind = ofdgo.ShapeKind(args[8].String()) + return currentEditor.ReshapeCompositeLine(page, path, indexes[0], ofdgo.ShapeKind(args[8].String()), box) } - 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()}) + 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()}) case "erasePath": @@ -1566,12 +1571,33 @@ func moveOutline(args []js.Value) (any, error) { } // captureStyle 保存单个对象样式来源,不改写文档 -// 入参: args 页面索引和对象标识 +// 入参: args 页面索引、对象标识及可选内部范围 // 返回: any 空结果, error 错误信息 func captureStyle(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } + if len(args) > 2 && args[2].String() != "" { + key := args[2].String() + path, err := compositePath(key) + if err != nil { + return nil, err + } + indexes, err := compositeIndexes(key, []string{args[1].String()}) + if err != nil { + return nil, err + } + members, err := currentEditor.CompositeObjects(args[0].Int(), path) + if err != nil { + return nil, err + } + if indexes[0] >= len(members) { + return nil, fmt.Errorf("style source is unavailable") + } + object := members[indexes[0]].Style() + copiedStyle = &object + return nil, nil + } object, err := currentEditor.Object(args[0].Int(), args[1].String()) if err != nil { return nil, err diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 87d21d1..892d36c 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -77,6 +77,7 @@ type editorResource struct { subset *editorFontSubset states map[string]editorCompositeState draw *DrawParam + drawSource string } // definition 获取独立定义资源的标识 diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 582c5e5..9a93798 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -464,9 +464,7 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp if node.visible { renderer.renderObject(canvas.NewContext(bounds), &object, 0, node.defaults, &node.parent, node.boundaryInCTM, node.clip) } - _, invertible := node.parent.Invert() - borderActions := node.object.Type == "ImageObject" && node.object.ImageObject.Border != nil && len(node.object.ImageObject.Actions) != 0 - transform := invertible && editorXMLTransformable(node.node) && validateEditorGeometry(node.object) == nil && !borderActions + transform := node.transformable() capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.box.W > 0 && bounds.box.H > 0, Delete: true, Copy: transform && editorXMLCopyable(node.node), Order: orderable[node.span.parent], ReplaceImage: node.object.Type == "ImageObject"} style, err := e.compositeMemberStyle(node) @@ -507,6 +505,14 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp return result } +// transformable 校验内部对象能否保留原文进行几何操作,不执行绘制测量 +// 返回: bool 是否支持 +func (n *editorCompositeNode) transformable() bool { + _, invertible := n.parent.Invert() + borderActions := n.object.Type == "ImageObject" && n.object.ImageObject.Border != nil && len(n.object.ImageObject.Actions) != 0 + return invertible && editorXMLTransformable(n.node) && validateEditorGeometry(n.object) == nil && !borderActions +} + // compositeMemberStyle 解析成员的继承样式,缺失或循环参数不开放改色 // 入参: node 成员节点 // 返回: GraphicObject 有效外观, error 错误信息 diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index 3b5273d..a7fcf6c 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -87,6 +87,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu } copy.setStates(source.states) copy.defaults = source.defaults + copy.drawParams = source.drawParams object, err := e.compositeMemberStyle(source) if err != nil { return err @@ -251,7 +252,7 @@ func editorClipPath(path *canvas.Path) PathObject { // 入参: page 页面索引, path 父复合路径, indexes 同一容器的成员序号 // 返回: *CompositeSelection 独立快照, error 错误信息 func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []int) (*CompositeSelection, error) { - reader, renderer, root, nodes, err := e.compositeScope(page, path) + reader, _, root, nodes, err := e.compositeScope(page, path) if err != nil { return nil, err } @@ -261,15 +262,17 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in } indexes = slices.Clone(indexes) slices.Sort(indexes) - members := e.measureCompositeMembers(renderer, nodes) var owner *editorCompositeNode var container *editorXML selection := &CompositeSelection{editor: e, page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Children: slices.Clone(path.Children)}, source: e.objectOrigin(path.ID).page} for j, i := range indexes { - if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i || !members[i].Capabilities.Copy { + if i < 0 || i >= len(nodes) || j > 0 && indexes[j-1] == i { return nil, fmt.Errorf("invalid composite copy selection") } node := nodes[i] + if !node.transformable() || !editorXMLCopyable(node.node) { + return nil, fmt.Errorf("composite member cannot be copied") + } if j == 0 { owner, container = node.owner, node.span.parent } else if node.span.parent != container { diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index 4181052..8e5a72e 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -98,10 +98,7 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra 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 - } + node.drawParams = []string{e.copiedLayerStyle(source)} } if err := e.isolateCompositeStyle(node); err != nil { return err @@ -157,27 +154,17 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra // 返回: 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 + for _, source := range node.drawParams { + id, err = e.copyDrawParam(source, id, make(map[string]bool)) + if err != nil { + return err + } } - id, err = e.addEditorDrawParam(*mergeDrawParam(*base, own)) + id, err = e.copyDrawParam(node.object.CompositeGraphicUnit.DrawParam, id, make(map[string]bool)) if err != nil { return err } diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 5d96ce3..49fa628 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -183,26 +183,36 @@ func (n *editorCompositeNode) update(object GraphicObject) error { return nil } -// Shape 获取内部直线的页面端点,其他成员返回空类型 -// 返回: ShapeKind 直线或箭头类型, Box 起点及有符号端点位移 +// 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) { + if kind == "" { return "", Box{} } - return kind, transformLineBox(box, m.lineParent()) + parent, ok := m.shapeParent() + if !ok { + return "", Box{} + } + if lineShapeKind(kind) { + return kind, transformLineBox(box, parent) + } + if !axisAlignedMatrix(parent) { + return "", Box{} + } + return kind, parent.TransformBox(box) } -// lineParent 获取直线所在坐标到页面的变换,兼容旧式内联边界 -// 返回: Matrix 直线父坐标变换 -func (m CompositeMember) lineParent() Matrix { +// shapeParent 获取路径所在坐标到页面的变换,兼容旧式内联边界 +// 返回: Matrix 路径父坐标变换, bool 是否可逆 +func (m CompositeMember) shapeParent() (Matrix, bool) { p := m.Object.PathObject box, _ := ParseBox(p.Boundary) - inverse, _ := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(p.CTM)).Invert() - return m.Matrix.Multiply(inverse) + inverse, ok := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(p.CTM)).Invert() + return m.Matrix.Multiply(inverse), ok } // transformLineBox 变换起点及有符号端点位移 @@ -218,10 +228,27 @@ func transformLineBox(box Box, matrix Matrix) Box { // 入参: page 页面索引, path 父路径, index 成员序号, kind 直线类型, box 页面端点 // 返回: error 错误信息 func (e *Editor) ReshapeCompositeLine(page int, path ObjectPath, index int, kind ShapeKind, box Box) error { + if kind != "" && !lineShapeKind(kind) { + return fmt.Errorf("unsupported line kind %q", kind) + } + return e.reshapeCompositeObject(page, path, index, kind, box, true) +} + +// ReshapeCompositeObject 调整内部基本路径的页面几何,保留绘制样式、变换与裁剪 +// 入参: page 页面索引, path 父路径, index 成员序号, box 页面范围,直线使用有符号端点位移 +// 返回: error 错误信息 +func (e *Editor) ReshapeCompositeObject(page int, path ObjectPath, index int, box Box) error { + return e.reshapeCompositeObject(page, path, index, "", box, false) +} + +// reshapeCompositeObject 按页面几何修改单个内部路径,可选修改箭头类型 +// 入参: page 页面索引, path 父路径, index 成员序号, kind 目标类型,空值保持原类型, box 页面几何, lineOnly 是否仅接受直线 +// 返回: error 错误信息 +func (e *Editor) reshapeCompositeObject(page int, path ObjectPath, index int, kind ShapeKind, box Box, lineOnly bool) 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 before == "" || lineOnly && !lineShapeKind(before) { + return fmt.Errorf("composite member is not a supported shape") } if kind == "" { kind = before @@ -236,7 +263,14 @@ func (e *Editor) ReshapeCompositeLine(page int, path ObjectPath, index int, kind object = compositeBoundary(object, node.parent, true) } var err error - object.PathObject, err = object.PathObject.ReshapeLine(kind, transformLineBox(box, inverse)) + if lineShapeKind(kind) { + object.PathObject, err = object.PathObject.ReshapeLine(kind, transformLineBox(box, inverse)) + } else { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + object.PathObject, err = object.PathObject.Reshape(inverse.TransformBox(box)) + } if err != nil { return err } diff --git a/ofdgo_editor_composite_style.go b/ofdgo_editor_composite_style.go new file mode 100644 index 0000000..25c12de --- /dev/null +++ b/ofdgo_editor_composite_style.go @@ -0,0 +1,80 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import "fmt" + +// Style 获取内部成员的独立样式快照,路径CTM只表达页面描边倍率 +// 可传入CopyStyle或CopyCompositeStyle,不作为几何复制快照 +// 返回: GraphicObject 有效样式来源 +func (m CompositeMember) Style() GraphicObject { + object := cloneEditorData(m.Object) + if object.Type == "PathObject" { + object.PathObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() + } + return object +} + +// CopyCompositeStyle 将同类型样式复制到内部选区,保留内容、位置、裁剪及其他实例 +// 路径复制页面实际线宽和虚线,文字沿用目标段落和基线,全部成功后提交一次撤销 +// 入参: page 页面索引, path 父路径, indexes 成员序号, source Object或CompositeMember.Style取得的样式快照 +// 返回: error 错误信息 +func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, source GraphicObject) error { + if err := e.validateCopyStyle(source); err != nil { + return err + } + return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + for i, node := range nodes { + member := members[i] + if node.object.Type != source.Type || !member.Capabilities.Transform || source.Type != "ImageObject" && !member.Capabilities.Paint { + return fmt.Errorf("style requires editable members of the same type") + } + object := cloneEditorData(member.Object) + switch source.Type { + case "TextObject": + if err := e.updateCompositeText(node, member, nil, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}, nil, nil); err != nil { + return err + } + var err error + object, err = e.compositeMemberStyle(node) + if err != nil { + return err + } + object.TextObject.FillColor = cloneEditorData(source.TextObject.FillColor) + object.TextObject.Alpha = cloneEditorData(source.TextObject.Alpha) + object.TextObject.DrawParam, err = e.neutralDrawParam() + if err != nil { + return err + } + case "PathObject": + object.PathObject = copyEditorPathStyle(object.PathObject, source.PathObject, editorStrokeScale(source.PathObject.CTM)/member.StrokeScale) + if err := validateEditorStroke(object.PathObject); err != nil { + return err + } + var err error + object.PathObject.DrawParam, err = e.neutralDrawParam() + if err != nil { + return err + } + case "ImageObject": + object.ImageObject.Alpha = cloneEditorData(source.ImageObject.Alpha) + } + if err := node.update(object); err != nil { + return err + } + } + return nil + }) +} diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 51bf804..4c4da78 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -57,85 +57,94 @@ func (e *Editor) ResizeCompositeTextFrame(page int, path ObjectPath, index int, func (e *Editor) changeCompositeText(page int, path ObjectPath, indexes []int, value *string, style TextStyle, options *TextLayout, frame *Box) error { return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { for i, node := range nodes { - member := members[i] - if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont { - return fmt.Errorf("composite text style is not supported") - } - object := cloneEditorData(node.object) - text := cloneEditorData(member.Object.TextObject) - state := node.states[editorObjectID(node.object)] - content, layout := text.TextLayout() - original := content - if style.Font != "" { - text.Font = style.Font - } - if value != nil { - content = *value - } - if options != nil { - layout = *options - } - if frame != nil { - var err error - if !node.boundaryInCTM { - text = compositeBoundary(GraphicObject{Type: "TextObject", TextObject: text}, node.parent, true).TextObject - } - text, err = text.ResizeTextFrame(frame.X, frame.W) - if err != nil { - return err - } - if !node.boundaryInCTM { - text = compositeBoundary(GraphicObject{Type: "TextObject", TextObject: text}, node.parent, false).TextObject - } - object.TextObject.Boundary, object.TextObject.CTM = text.Boundary, text.CTM - } - if options != nil || content != original || style.Size != 0 && style.Size != text.Size { - if style.Size != 0 { - text.Size = style.Size - } - if state.layout == nil { - if len(text.TextCode) == 0 { - return fmt.Errorf("text codes are empty") - } - origin, err := creationNumbers(text.TextCode[0].X+" "+text.TextCode[0].Y, 2) - if err != nil { - return err - } - copy(state.origin[:], origin) - } - if err := e.layoutCompositeText(&text, content, layout, state.origin); err != nil { - return err - } - object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode - state.layout = &textLayout{value: content, options: layout} - } - if style.Font != "" && style.Font != object.TextObject.Font { - if err := e.prepareText(&text); err != nil { - return err - } - object.TextObject.Font = style.Font - } - if style.Color != "" { - if err := creationColor(&FillColor{Value: style.Color}); err != nil { - return err - } - var r, g, b float64 - fmt.Sscan(style.Color, &r, &g, &b) - fill, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) - if err != nil { - return err - } - object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor) - } - if err := node.update(object); err != nil { + if err := e.updateCompositeText(node, members[i], value, style, options, frame); err != nil { return err } - node.setState(state) } return nil }) } +// updateCompositeText 按有效样式更新单个内部文字节点并保留段落基线 +// 入参: node 原文节点, member 有效快照, value 新内容, style 样式增量, options 段落选项, frame 文字框调整 +// 返回: error 错误信息 +func (e *Editor) updateCompositeText(node *editorCompositeNode, member CompositeMember, value *string, style TextStyle, options *TextLayout, frame *Box) error { + if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont { + return fmt.Errorf("composite text style is not supported") + } + object := cloneEditorData(node.object) + text := cloneEditorData(member.Object.TextObject) + state := node.states[editorObjectID(node.object)] + content, layout := text.TextLayout() + original := content + if style.Font != "" { + text.Font = style.Font + } + if value != nil { + content = *value + } + if options != nil { + layout = *options + } + if frame != nil { + var err error + if !node.boundaryInCTM { + text = compositeBoundary(GraphicObject{Type: "TextObject", TextObject: text}, node.parent, true).TextObject + } + text, err = text.ResizeTextFrame(frame.X, frame.W) + if err != nil { + return err + } + if !node.boundaryInCTM { + text = compositeBoundary(GraphicObject{Type: "TextObject", TextObject: text}, node.parent, false).TextObject + } + object.TextObject.Boundary, object.TextObject.CTM = text.Boundary, text.CTM + } + if options != nil || content != original || style.Size != 0 && style.Size != text.Size { + if style.Size != 0 { + text.Size = style.Size + } + if state.layout == nil { + if len(text.TextCode) == 0 { + return fmt.Errorf("text codes are empty") + } + origin, err := creationNumbers(text.TextCode[0].X+" "+text.TextCode[0].Y, 2) + if err != nil { + return err + } + copy(state.origin[:], origin) + } + if err := e.layoutCompositeText(&text, content, layout, state.origin); err != nil { + return err + } + object.TextObject.Size, object.TextObject.TextCode = text.Size, text.TextCode + state.layout = &textLayout{value: content, options: layout} + } + if style.Font != "" && style.Font != object.TextObject.Font { + if err := e.prepareText(&text); err != nil { + return err + } + object.TextObject.Font = style.Font + } + if style.Color != "" { + if err := creationColor(&FillColor{Value: style.Color}); err != nil { + return err + } + var r, g, b float64 + fmt.Sscan(style.Color, &r, &g, &b) + fill, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) + if err != nil { + return err + } + object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor) + } + if err := node.update(object); err != nil { + return err + } + node.setState(state) + return nil +} + // layoutCompositeText 重排普通文字并保持原首行基线起点 // 入参: text 有效文字样式, value 新内容, options 段落选项, origin 原始基线起点 // 返回: error 错误信息 diff --git a/ofdgo_editor_draw.go b/ofdgo_editor_draw.go new file mode 100644 index 0000000..d9a91e5 --- /dev/null +++ b/ofdgo_editor_draw.go @@ -0,0 +1,130 @@ +// 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 ( + "encoding/xml" + "fmt" +) + +// drawParamXML 读取绘制参数原文,保留复杂颜色、扩展属性及命名空间 +// 入参: id 绘制参数标识 +// 返回: []byte 独立参数片段, error 错误信息 +func (e *Editor) drawParamXML(id string) ([]byte, error) { + var data []byte + for _, resource := range e.resources { + if resource.draw != nil && resource.draw.ID == id { + data = resource.data + break + } + } + if data == nil && e.source != nil { + var err error + data, err = e.source.reader.readFile(e.source.reader.resourceFiles[id]) + if err != nil { + return nil, err + } + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if params := root.child("DrawParams"); params != nil { + for _, node := range params.children { + if node.name.Local == "DrawParam" && node.attr("ID") == id && (node.name.Space == "" || node.name.Space == ofdNamespace || node.name.Space == "http://www.ofdspec.org") { + return editorXMLStandalone(data[node.start:node.end], node) + } + } + } + return nil, fmt.Errorf("draw parameter %q not found", id) +} + +// copyDrawParam 将原绘制参数继承链接到独立基准,复用相同副本且不改写原资源 +// 入参: id 源参数标识, base 独立基准标识, visited 当前继承链 +// 返回: string 新继承链末端标识, error 错误信息 +func (e *Editor) copyDrawParam(id, base string, visited map[string]bool) (string, error) { + if id == "" || id == base { + return base, nil + } + if visited[id] { + return "", fmt.Errorf("cyclic draw parameter %q", id) + } + visited[id] = true + data, err := e.drawParamXML(id) + if err != nil { + return "", err + } + root, err := parseEditorXML(data) + if err != nil { + return "", err + } + if root.attr("Relative") == base { + return id, nil + } + base, err = e.copyDrawParam(root.attr("Relative"), base, visited) + if err != nil { + return "", err + } + for _, resource := range e.resources { + if resource.drawSource == id && resource.draw.Relative == base { + return resource.draw.ID, nil + } + } + if !editorXMLCopyable(root) { + return "", fmt.Errorf("draw parameter contains unsupported identifiers") + } + before, err := editorXMLContainer("DrawParam", ofdAttrs{{Name: xml.Name{Local: "Relative"}, Value: root.attr("Relative")}}, nil) + if err != nil { + return "", err + } + after, err := editorXMLContainer("DrawParam", ofdAttrs{{Name: xml.Name{Local: "Relative"}, Value: base}}, nil) + if err != nil { + return "", err + } + data, err = editorXMLMerge(data, root, before, after) + if err != nil { + return "", err + } + root, err = parseEditorXML(data) + if err != nil { + return "", err + } + ids := make(map[string]string) + if err := e.collectCompositeIDs(root, ids); err != nil { + return "", err + } + data, err = editorXMLRemapIDs(data, root, ids) + if err != nil { + return "", err + } + var draw DrawParam + if err := xml.Unmarshal(data, &draw); err != nil { + return "", err + } + data, err = editorXMLContainer("DrawParams", nil, data) + if err != nil { + return "", err + } + data, err = editorXMLContainer("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}, data) + if err != nil { + return "", err + } + refs, err := editorResourceReferences(data) + if err != nil { + return "", err + } + e.resources = append(e.resources, editorResource{name: e.resourceDirectory() + "/DrawParam_" + draw.ID + ".xml", data: data, draw: &draw, drawSource: id, references: refs}) + return draw.ID, nil +} diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index b2eca8d..37907a4 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "image/color" "math" "strings" ) @@ -269,23 +270,12 @@ func editorStyleColor(color, before *FillColor) *FillColor { // 路径按页面中的实际线宽和虚线长度复制,保持目标变换 // 入参: page 目标页面索引, ids 目标对象标识, source 样式来源快照 // 返回: error 错误信息 -func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { - var alpha *int - switch source.Type { - case "TextObject": - alpha = source.TextObject.Alpha - case "PathObject": - alpha = source.PathObject.Alpha - case "ImageObject": - alpha = source.ImageObject.Alpha - default: - return fmt.Errorf("unsupported style source %q", source.Type) - } - if alpha != nil && (*alpha < 0 || *alpha > 255) { - return fmt.Errorf("alpha must be between 0 and 255") +func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) (err error) { + if err := e.validateCopyStyle(source); err != nil { + return err } objects, _, err := e.selectedObjects(page, ids) - if err != nil { + if err != nil || len(objects) == 0 { return err } for _, object := range objects { @@ -293,6 +283,33 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { return fmt.Errorf("style requires objects of the same type") } } + 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.resources[:count], maximum + if e.source != nil { + e.source.idsReady = ready + } + } + }() + if source.Type == "TextObject" && source.TextObject.FillColor == nil || source.Type == "PathObject" && (source.PathObject.FillColor == nil || source.PathObject.StrokeColor == nil) { + black, err := e.RGBColor(color.NRGBA{A: 255}) + if err != nil { + return err + } + if source.Type == "TextObject" { + source.TextObject.FillColor = black + } else { + if source.PathObject.FillColor == nil { + source.PathObject.FillColor = black + } + if source.PathObject.StrokeColor == nil { + source.PathObject.StrokeColor = (*StrokeColor)(black) + } + } + } if source.Type == "TextObject" { objects, err = e.styleTextObjects(objects, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}) if err != nil { @@ -304,42 +321,11 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { } return e.UpdateObjects(page, objects) } - if source.Type == "PathObject" { - from := source.PathObject - if from.DrawParam != "" { - return fmt.Errorf("style source must have resolved draw parameters") - } - if err := validateEditorStroke(from); err != nil { - return err - } - for _, color := range []*FillColor{from.FillColor, (*FillColor)(from.StrokeColor)} { - if err := e.editorColor(color); err != nil { - return err - } - } - } for i, object := range objects { object = cloneEditorData(object) switch source.Type { case "PathObject": - from, to := source.PathObject, &object.PathObject - to.Fill, to.Stroke, to.Alpha = from.Fill, from.Stroke, from.Alpha - to.FillColor, to.StrokeColor = from.FillColor, from.StrokeColor - to.LineWidth, to.Cap, to.Join, to.MiterLimit = from.LineWidth, from.Cap, from.Join, from.MiterLimit - to.DashPattern, to.DashOffset = from.DashPattern, from.DashOffset - to.dashPatternSet = from.dashPatternSet - scale := editorStrokeScale(from.CTM) / editorStrokeScale(to.CTM) - if scale != 1 { - if to.LineWidth == 0 { - to.LineWidth = defaultPathLineWidth - } - to.LineWidth *= scale - to.DashPattern = scaleTextNumbers(to.DashPattern, scale) - if to.DashOffset != nil { - value := *to.DashOffset * scale - to.DashOffset = &value - } - } + object.PathObject = copyEditorPathStyle(object.PathObject, source.PathObject, editorStrokeScale(source.PathObject.CTM)/editorStrokeScale(object.PathObject.CTM)) case "ImageObject": object.ImageObject.Alpha = source.ImageObject.Alpha } @@ -348,6 +334,74 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { return e.updateObjects(page, objects, true) } +// validateCopyStyle 校验样式来源,不分配资源或改写对象 +// 入参: source 有效外观快照 +// 返回: error 错误信息 +func (e *Editor) validateCopyStyle(source GraphicObject) error { + var alpha *int + var colors []*FillColor + switch source.Type { + case "TextObject": + alpha, colors = source.TextObject.Alpha, []*FillColor{source.TextObject.FillColor} + case "PathObject": + from := source.PathObject + alpha, colors = from.Alpha, []*FillColor{from.FillColor, (*FillColor)(from.StrokeColor)} + if from.DrawParam != "" { + return fmt.Errorf("style source must have resolved draw parameters") + } + if err := validateEditorStroke(from); err != nil { + return err + } + case "ImageObject": + alpha = source.ImageObject.Alpha + default: + return fmt.Errorf("unsupported style source %q", source.Type) + } + if alpha != nil && (*alpha < 0 || *alpha > 255) { + return fmt.Errorf("alpha must be between 0 and 255") + } + for _, color := range colors { + if err := e.editorColor(color); err != nil { + return err + } + } + return nil +} + +// copyEditorPathStyle 按页面描边倍率复制路径外观,保留目标几何和行为 +// 入参: to 目标路径, from 来源路径, scale 来源与目标的描边倍率比 +// 返回: PathObject 独立路径副本 +func copyEditorPathStyle(to, from PathObject, scale float64) PathObject { + from = cloneEditorData(from) + to.Fill, to.Stroke, to.Alpha = from.Fill, from.Stroke, from.Alpha + to.FillColor, to.StrokeColor = from.FillColor, from.StrokeColor + to.LineWidth, to.Cap, to.Join, to.MiterLimit = from.LineWidth, from.Cap, from.Join, from.MiterLimit + to.DashPattern, to.DashOffset, to.dashPatternSet = from.DashPattern, from.DashOffset, true + if to.LineWidth == 0 { + to.LineWidth = defaultPathLineWidth + } + if to.Cap == "" { + to.Cap = "Butt" + } + if to.Join == "" { + to.Join = "Miter" + } + if to.MiterLimit == 0 { + to.MiterLimit = defaultMiterLimit + } + if to.DashOffset == nil { + zero := 0.0 + to.DashOffset = &zero + } + if scale != 1 { + to.LineWidth *= scale + to.DashPattern = scaleTextNumbers(to.DashPattern, scale) + value := *to.DashOffset * scale + to.DashOffset = &value + } + return to +} + // editorStrokeScale 获取路径在页面坐标中的描边倍率,与渲染器保持一致 // 入参: ctm 对象变换 // 返回: float64 描边倍率 diff --git a/ofdgo_shape.go b/ofdgo_shape.go index bc4bd9f..bafc389 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -114,7 +114,7 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) { return object, nil } -// Shape 识别 NewShape 生成的基本路径及其轴对齐缩放、直角旋转、镜像和平移 +// Shape 识别NewShape生成的基本路径,直线支持可逆仿射变换,矩形和椭圆要求轴对齐 // 直线范围以起点和有符号的端点位移表示;非基本路径或不支持的变换返回空类型 // 返回: ShapeKind 图形类型, Box 几何范围(不含描边) func (p PathObject) Shape() (ShapeKind, Box) { @@ -170,7 +170,8 @@ func (p PathObject) Shape() (ShapeKind, Box) { } boundary, err := ParseBox(p.Boundary) m := NewMatrix(p.CTM) - if err != nil || !axisAlignedMatrix(m) { + _, invertible := m.Invert() + if err != nil || !axisAlignedMatrix(m) && (!line || !invertible) { return "", Box{} } if line { @@ -207,7 +208,7 @@ func sameShapePath(a, b []string) bool { } // Reshape 调整基本路径在所在坐标系中的几何范围,保留对象标识、方向、缩放和绘制属性,不缩放线宽 -// 返回对象可通过 Editor.UpdateObject 写入;仅支持 Shape 可识别的路径 +// 返回对象可通过Editor.UpdateObject写入;仅支持Shape可识别的路径 // 入参: box 新几何范围,直线使用起点和有符号的端点位移 // 返回: PathObject 调整后的对象, error 错误信息 func (p PathObject) Reshape(box Box) (PathObject, error) { @@ -249,12 +250,15 @@ func (p PathObject) reshape(kind ShapeKind, box Box) (PathObject, error) { } m := NewMatrix(p.CTM) var w, h float64 - if m.b == 0 { + line := lineShapeKind(kind) + if m.b == 0 && m.c == 0 { w, h = box.W/m.a, box.H/m.d - } else { + } else if m.a == 0 && m.d == 0 { w, h = box.H/m.b, box.W/m.c + } else { + inverse, _ := m.Invert() + w, h = matrixVector(inverse, box.W, box.H) } - line := kind == ShapeLine || kind == ShapeArrow || kind == ShapeDoubleArrow if !line { w, h = math.Abs(w), math.Abs(h) }