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)
}