feat(编辑能力): 修复样式复制继承与直线编辑校验问题
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-17 21:26:54 +08:00
1 parent f8eeaad9f2
commit 4826496e98
13 files changed
+519 -178

No files matched your search

+6 -6
View File
@@ -106,12 +106,12 @@
<button id="objectBoundsButton" class="editor-group-action" type="button" title="位置尺寸" aria-label="位置尺寸" aria-haspopup="dialog" aria-controls="objectBoundsPanel" disabled>变换</button>
</div>
<div class="editor-group">
<button id="insertTextButton" class="button" type="button" aria-label="添加文字" aria-pressed="false">文字</button>
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
<button id="drawLineButton" class="button" type="button" aria-label="绘制直线" aria-pressed="false">直线</button>
<button id="drawRectangleButton" class="button" type="button" aria-label="绘制矩形" aria-pressed="false">矩形</button>
<button id="drawEllipseButton" class="button" type="button" aria-label="绘制椭圆" aria-pressed="false">椭圆</button>
<select id="arrowTool" aria-label="箭头" title="箭头"><option value="" hidden>箭头</option><option value="line">无</option><option value="arrow">单向</option><option value="double-arrow">双向</option></select>
<button id="insertTextButton" class="button" type="button" title="文字" aria-label="文字" aria-pressed="false">文字</button>
<button id="insertImageButton" class="button" type="button" title="图片" aria-label="图片">图片</button>
<button id="drawLineButton" class="button" type="button" title="直线" aria-label="直线" aria-pressed="false">直线</button>
<button id="drawRectangleButton" class="button" type="button" title="矩形" aria-label="矩形" aria-pressed="false">矩形</button>
<button id="drawEllipseButton" class="button" type="button" title="椭圆" aria-label="椭圆" aria-pressed="false">椭圆</button>
<select id="arrowTool" aria-label="箭头" title="箭头"><option value="" hidden>箭头</option><option value="line">直线</option><option value="arrow">单向</option><option value="double-arrow">双向</option></select>
<span>插入</span>
</div>
<div class="editor-group">
+11 -4
View File
@@ -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);
+31 -5
View File
@@ -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
+1
View File
@@ -77,6 +77,7 @@ type editorResource struct {
subset *editorFontSubset
states map[string]editorCompositeState
draw *DrawParam
drawSource string
}
// definition 获取独立定义资源的标识
+9 -3
View File
@@ -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 错误信息
+6 -3
View File
@@ -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 {
+7 -20
View File
@@ -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
}
+46 -12
View File
@@ -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
}
+80
View File
@@ -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
})
}
+81 -72
View File
@@ -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 错误信息
+130
View File
@@ -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
}
+101 -47
View File
@@ -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 描边倍率
+10 -6
View File
@@ -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)
}