diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 758638b..f93d8d2 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1005,7 +1005,7 @@ function openObjectStyle() { const alpha = shared("alpha", 255), dash = shared("dashPattern", ""); el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); el.objectOpacity.placeholder = alpha === null ? "混合" : ""; - el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject"); + el.objectStrokeFields.hidden = Boolean(state.composite) || !items.every(item => item.type === "PathObject"); el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom"; el.objectDashPattern.value = dash || ""; el.objectDashRow.hidden = el.objectDash.value !== "custom"; @@ -1704,6 +1704,13 @@ async function changeTextStyle(color) { updateObjectControls(canvasEditor.selected, true); return; } + if (state.composite) { + if (color && fill !== null && canEditObject(item, "paint")) { + await changeDocument("ofdgoCompositeTextColor", item, fill); + } + updateObjectControls(canvasEditor.selected, true); + return; + } if (item.items) { if (color ? !canEditObject(item, "paint") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; if (!color && !el.textSize.value) return; @@ -1823,7 +1830,8 @@ async function changeDocument(name, item, ...args) { return; } const scope = state.composite; - const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize" }[name]; + const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize", + ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor" }[name]; const members = item?.items || (item ? [item] : []); const scoped = scope && members.length && members.every(member => member.scoped); if (!item) resetCompositeScope(); @@ -5498,7 +5506,7 @@ function updateControls() { } function updateEditorTools() { - el.editButton.disabled = !state.doc || !state.ready || state.exporting; + el.editButton.disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy"); el.editButton.setAttribute("aria-pressed", String(state.editing)); el.editButton.title = state.editing ? "阅读" : "编辑"; el.editButton.setAttribute("aria-label", el.editButton.title); @@ -5532,7 +5540,7 @@ function updateObjectControls(item, reset = false) { const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting; el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete"); el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy"); - el.objectStyleButton.disabled = disabled || Boolean(state.composite) || !canEditObject(item, "transform"); + el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform"); const cropping = Boolean(canvasEditor.crop); el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform"); el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); @@ -5551,7 +5559,7 @@ function updateObjectControls(item, reset = false) { el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const selection = selectedText(item); const text = selection ? currentTextStyle() : state.textDefaults; - const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(state.composite) || Boolean(item?.items && !selection); + const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(state.composite && !selection) || Boolean(item?.items && !selection); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint"); @@ -5659,6 +5667,7 @@ function showError(err, empty = !state.doc) { function setBusy(busy, text = "", percent = 0, status = "") { document.body.toggleAttribute("aria-busy", busy); + el.editButton.disabled = busy || !state.doc || !state.ready || state.exporting; el.createForm.inert = busy; el.insertForm.inert = busy; el.pageForm.inert = busy; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index ff8e4e0..8b87b45 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -761,34 +761,10 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return nil, err } item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, - "capabilities": map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode)}} - if missing := capability.MissingGlyphs; missing != nil { - item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} - } - item["capabilities"].(map[string]any)["enter"] = capability.Transform && (object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit") - var alpha *int - switch object.Type { - case "TextObject": - alpha = object.TextObject.Alpha - case "ImageObject": - alpha = object.ImageObject.Alpha - item["imageBorder"] = object.ImageObject.Border != nil - case "PathObject": - alpha = object.PathObject.Alpha - case "CompositeObject", "CompositeGraphicUnit": - alpha = object.CompositeGraphicUnit.Alpha - } - item["alpha"] = 255 - if alpha != nil { - item["alpha"] = *alpha - } + "capabilities": editorCapabilities(capability, object.Type)} + editorAppearance(item, object, capability.Paint, editorPathScale(object.PathObject)) if object.Type == "PathObject" { path := object.PathObject - item["dashPattern"], item["cap"], item["join"] = path.DashPattern, path.Cap, path.Join - item["dashOffset"] = 0.0 - if path.DashOffset != nil { - item["dashOffset"] = *path.DashOffset - } kind, geometry := path.Shape() if kind != "" && capability.Update { item["shape"] = string(kind) @@ -796,12 +772,6 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } else if outline, err := path.Outline(); err == nil { item["outline"] = outline } - if capability.Paint { - item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke - item["fillColor"] = editorColorHex(path.FillColor) - item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) - item["lineWidth"] = path.LineWidth * editorPathScale(path) - } } if object.Type == "PathObject" || object.Type == "ImageObject" || object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { paths := make([]any, len(contours)) @@ -849,9 +819,6 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { second, _ := strconv.ParseFloat(codes[1].Y, 64) item["lineHeight"] = second - first } - if capability.Paint { - item["color"] = editorColorHex(object.TextObject.FillColor) - } } objects = append(objects, item) } @@ -860,6 +827,52 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return objects, nil } +// editorCapabilities 统一顶层与内部对象的操作能力和受限原因 +// 入参: capability 库层能力, kind 对象类型 +// 返回: map[string]any 前端能力 +func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any { + result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode), + "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")} + if missing := capability.MissingGlyphs; missing != nil { + result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} + } + return result +} + +// editorAppearance 提供对象自身透明度及有效绘制外观,不混入父对象透明度 +// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率 +func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) { + var alpha *int + switch object.Type { + case "TextObject": + alpha = object.TextObject.Alpha + if paint { + item["color"] = editorColorHex(object.TextObject.FillColor) + } + case "ImageObject": + alpha = object.ImageObject.Alpha + item["imageBorder"] = object.ImageObject.Border != nil + case "PathObject": + path := object.PathObject + alpha = path.Alpha + item["dashPattern"], item["cap"], item["join"], item["dashOffset"] = path.DashPattern, path.Cap, path.Join, 0.0 + if path.DashOffset != nil { + item["dashOffset"] = *path.DashOffset + } + if paint { + item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke + item["fillColor"], item["strokeColor"] = editorColorHex(path.FillColor), editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) + item["lineWidth"] = path.LineWidth * scale + } + case "CompositeObject", "CompositeGraphicUnit": + alpha = object.CompositeGraphicUnit.Alpha + } + item["alpha"] = 255 + if alpha != nil { + item["alpha"] = *alpha + } +} + // compositePath 解析画布复合对象路径 // 入参: value 顶层标识与逐层序号 // 返回: ofdgo.ObjectPath 库层路径, error 错误信息 @@ -892,14 +905,29 @@ func compositeObjects(args []js.Value) (any, error) { if err != nil { return nil, err } + fonts, err := currentSession.Reader.Fonts() + if err != nil { + return nil, err + } + fontNames := make(map[string]string, len(fonts)) + for _, font := range fonts { + fontNames[font.ID] = font.FontName + } objects := make([]any, 0, len(members)) for i, member := range members { box := member.Bounds if box.W <= 0 || box.H <= 0 { continue } - objects = append(objects, map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": i, "count": len(members), "container": key, "scoped": true, "contours": member.Contours, - "capabilities": map[string]any{"transform": member.Transform, "enter": member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit"}}) + item := map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": i, "count": len(members), "container": key, "scoped": true, "contours": member.Contours, + "capabilities": editorCapabilities(member.Capabilities, member.Object.Type)} + editorAppearance(item, member.Object, member.Capabilities.Paint, member.StrokeScale) + if member.Object.Type == "TextObject" { + object := member.Object.TextObject + item["text"], _ = object.TextLayout() + item["font"], item["fontName"], item["size"] = object.Font, fontNames[object.Font], object.Size + } + objects = append(objects, item) } return objects, nil } @@ -927,7 +955,8 @@ func changeCompositeObjects(args []js.Value) (any, error) { indexes = append(indexes, i) } page := args[0].Int() - switch args[3].String() { + operation := args[3].String() + switch operation { case "transform": return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float()) case "rotate": @@ -936,6 +965,44 @@ func changeCompositeObjects(args []js.Value) (any, error) { return currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String()) case "resize": return currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()}) + case "align": + return currentEditor.AlignCompositeObjects(page, path, indexes, args[4].String()) + case "distribute": + return currentEditor.DistributeCompositeObjects(page, path, indexes, args[4].String()) + case "style": + return currentEditor.StyleCompositeObjects(page, path, indexes, objectStyle(args[4])) + case "paint", "textColor": + style := ofdgo.ObjectStyle{} + if operation == "textColor" { + style.FillColor = &ofdgo.FillColor{} + if err := setEditorColor(style.FillColor, args[4].String()); err != nil { + return err + } + } else { + if !args[4].IsNull() { + value := args[4].Bool() + style.Fill = &value + } + if !args[6].IsNull() { + value := args[6].Bool() + style.Stroke = &value + } + var colors [2]*ofdgo.FillColor + for i := range colors { + if !args[5+i*2].IsNull() { + colors[i] = &ofdgo.FillColor{} + if err := setEditorColor(colors[i], args[5+i*2].String()); err != nil { + return err + } + } + } + style.FillColor, style.StrokeColor = colors[0], (*ofdgo.StrokeColor)(colors[1]) + if !args[8].IsNull() { + value := args[8].Float() + style.LineWidth = &value + } + } + return currentEditor.StyleCompositeObjects(page, path, indexes, style) default: return fmt.Errorf("unsupported composite operation") } @@ -1321,32 +1388,38 @@ func changeOutline(args []js.Value) (any, error) { // 返回: any 文档信息, error 错误信息 func styleObjects(args []js.Value) (any, error) { return changeObjects(func() error { - style := ofdgo.ObjectStyle{} - value := args[2] - if field := value.Get("alpha"); !field.IsUndefined() { - v := field.Int() - style.Alpha = &v - } - if field := value.Get("dashPattern"); !field.IsUndefined() { - v := field.String() - style.DashPattern = &v - } - if field := value.Get("dashOffset"); !field.IsUndefined() { - v := field.Float() - style.DashOffset = &v - } - if field := value.Get("cap"); !field.IsUndefined() { - v := field.String() - style.Cap = &v - } - if field := value.Get("join"); !field.IsUndefined() { - v := field.String() - style.Join = &v - } - return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), style) + return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), objectStyle(args[2])) }) } +// objectStyle 读取高级面板提交的外观字段,省略字段保持原值 +// 入参: value 前端样式 +// 返回: ofdgo.ObjectStyle 库层样式 +func objectStyle(value js.Value) ofdgo.ObjectStyle { + style := ofdgo.ObjectStyle{} + if field := value.Get("alpha"); !field.IsUndefined() { + v := field.Int() + style.Alpha = &v + } + if field := value.Get("dashPattern"); !field.IsUndefined() { + v := field.String() + style.DashPattern = &v + } + if field := value.Get("dashOffset"); !field.IsUndefined() { + v := field.Float() + style.DashOffset = &v + } + if field := value.Get("cap"); !field.IsUndefined() { + v := field.String() + style.Cap = &v + } + if field := value.Get("join"); !field.IsUndefined() { + v := field.String() + style.Join = &v + } + return style +} + // clearImport 释放待插页文档 func clearImport() { if pendingImport != nil { diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index b053869..cf714db 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -268,6 +268,39 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { if err != nil { return err } + matrices, err := editorDistribution(boxes, indexes, axis) + if err != nil { + return err + } + var updates []GraphicObject + for i, matrix := range matrices { + if matrix == IdentityMatrix { + continue + } + object, err := cloneEditorObject(objects[i]) + if err != nil { + return err + } + object, err = e.transformObject(object, matrix.e, matrix.f, 1) + if err != nil { + return err + } + updates = append(updates, object) + } + return e.updateObjects(page, updates, true) +} + +// editorDistribution 计算等间距平移,位置相同时按原绘制顺序排序 +// 入参: boxes 可见范围, indexes 绘制位置, axis 分布方向 +// 返回: []Matrix 页面平移, error 错误信息 +func editorDistribution(boxes []Box, indexes []editorObjectPosition, axis string) ([]Matrix, error) { + matrices := make([]Matrix, len(boxes)) + for i := range matrices { + matrices[i] = IdentityMatrix + } + if len(boxes) < 3 { + return matrices, nil + } positions, sizes := make([]float64, len(boxes)), make([]float64, len(boxes)) order := make([]int, len(boxes)) total := 0.0 @@ -293,11 +326,10 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { step = 0 } if step < 0 || step == 0 && compareEditorPosition(indexes[index], indexes[order[i+1]]) > 0 { - return fmt.Errorf("overlap prevents ordered equal-gap distribution") + return nil, fmt.Errorf("overlap prevents ordered equal-gap distribution") } } next := positions[first] + sizes[first] + gap - updates := make([]GraphicObject, 0, len(order)-2) for _, index := range order[1 : len(order)-1] { position := next next += sizes[index] + gap @@ -308,17 +340,9 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error { if axis == "vertical" { dx, dy = 0, dx } - object, err := cloneEditorObject(objects[index]) - if err != nil { - return err - } - object, err = e.transformObject(object, dx, dy, 1) - if err != nil { - return err - } - updates = append(updates, object) + matrices[index] = TranslationMatrix(dx, dy) } - return e.updateObjects(page, updates, true) + return matrices, nil } // replaceLayers 替换图层容器,隔离历史快照与后续的增删、排序操作 @@ -504,26 +528,12 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { } } for i, box := range boxes { - dx, dy := 0.0, 0.0 - switch alignment { - case "left": - dx = target.X - box.X - case "center": - dx = target.X + (target.W-box.W)/2 - box.X - case "right": - dx = target.X + target.W - box.W - box.X - case "top": - dy = target.Y - box.Y - case "middle": - dy = target.Y + (target.H-box.H)/2 - box.Y - case "bottom": - dy = target.Y + target.H - box.H - box.Y - } + matrix := editorAlignment(box, target, alignment) objects[i], err = cloneEditorObject(objects[i]) if err != nil { return err } - objects[i], err = e.transformObject(objects[i], dx, dy, 1) + objects[i], err = e.transformObject(objects[i], matrix.e, matrix.f, 1) if err != nil { return err } @@ -531,6 +541,34 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { return e.updateObjects(page, objects, true) } +// editorAlignment 计算对象范围到目标边缘或中心的平移 +// 入参: box 对象范围, target 目标范围, alignment 对齐方式 +// 返回: Matrix 页面平移 +func editorAlignment(box, target Box, alignment string) Matrix { + matrix := IdentityMatrix + switch alignment { + case "left": + matrix.e = target.X - box.X + case "center": + matrix.e = target.X + (target.W-box.W)/2 - box.X + case "right": + matrix.e = target.X + target.W - box.W - box.X + case "top": + matrix.f = target.Y - box.Y + case "middle": + matrix.f = target.Y + (target.H-box.H)/2 - box.Y + case "bottom": + matrix.f = target.Y + target.H - box.H - box.Y + } + if canvas.Equal(matrix.e, 0) { + matrix.e = 0 + } + if canvas.Equal(matrix.f, 0) { + matrix.f = 0 + } + return matrix +} + // DeleteObjects 原子删除同页对象,保留其余对象顺序,一次撤销恢复全部 // 入参: page 页面索引, ids 对象标识 // 返回: error 错误信息 diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 967506d..60e6270 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -32,12 +32,15 @@ type ObjectPath struct { Children []int `json:"children,omitempty"` } -// CompositeMember 复合对象的直接成员快照,Bounds与Contours使用页面毫米坐标 +// CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象 +// Bounds与Contours使用页面毫米坐标,StrokeScale为对象描边到页面的倍率 type CompositeMember struct { - Object GraphicObject - Bounds Box - Contours []ObjectContour - Transform bool + Object GraphicObject + Bounds Box + Contours []ObjectContour + StrokeScale float64 + Capabilities ObjectCapabilities + Transform bool } // editorCompositeNode 保留原始XML及实例上下文,不修改共享资源 @@ -48,8 +51,8 @@ type editorCompositeNode struct { parent Matrix boundaryInCTM bool defaults *DrawParam + drawParams []string clip *canvas.Path - alpha *int visible bool ref *editorCompositeNode children []*editorCompositeNode @@ -67,7 +70,7 @@ func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, return nil, err } defer reader.Close() - return measureCompositeMembers(renderer, members), nil + return e.measureCompositeMembers(renderer, members), nil } // TransformCompositeObjects 在页面坐标中等比缩放并平移内部选区,只隔离被修改实例的资源 @@ -177,6 +180,7 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, } renderer := NewRenderer(reader, WithFontFS(e.fontFS...)) root.parent, root.visible, root.defaults = IdentityMatrix, true, renderer.drawParamDefaults(layer.DrawParam, nil) + root.drawParams = []string{layer.DrawParam} scope := root for depth := 0; ; depth++ { var members []*editorCompositeNode @@ -243,7 +247,7 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti } clip := intersectClipPath(n.clip, renderer.buildClipPath(clips, 0, 0, 0, clipMatrix)) defaults := renderer.drawParamDefaults(c.DrawParam, n.defaults) - alpha := mergeAlpha(c.Alpha, n.alpha) + drawParams := append(slices.Clone(n.drawParams), c.DrawParam) visible := n.visible && (c.Visible == nil || *c.Visible) if c.ResourceID != "" { data, err := reader.readFile(reader.resourceFiles[c.ResourceID]) @@ -274,7 +278,8 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti if err != nil { return nil, err } - n.ref.parent, n.ref.boundaryInCTM, n.ref.defaults, n.ref.clip, n.ref.alpha, n.ref.visible = matrix, true, defaults, clip, alpha, visible + n.ref.parent, n.ref.boundaryInCTM, n.ref.defaults, n.ref.clip, n.ref.visible = matrix, true, defaults, clip, visible + n.ref.drawParams = drawParams } var collect func(*editorXML) error collect = func(container *editorXML) error { @@ -293,7 +298,8 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti if err != nil { return err } - item.parent, item.boundaryInCTM, item.defaults, item.clip, item.alpha, item.visible, item.span = matrix, n.boundaryInCTM, defaults, clip, alpha, visible, child + item.parent, item.boundaryInCTM, item.defaults, item.clip, item.visible, item.span = matrix, n.boundaryInCTM, defaults, clip, visible, child + item.drawParams = drawParams n.children = append(n.children, item) } } @@ -321,53 +327,223 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti return append(result, n.children...), nil } -// measureCompositeMembers 按实际父变换、裁剪和继承样式度量成员 +// measureCompositeMembers 按父变换、裁剪和继承样式度量成员,透明对象保留选区,未着色路径按轮廓度量 // 入参: renderer 渲染器, nodes 成员 // 返回: []CompositeMember 独立快照及操作能力 -func measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember { +func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember { result := make([]CompositeMember, len(nodes)) for i, node := range nodes { bounds := &boundsRenderer{collect: true} - object := mergeGraphicObjectAlpha(node.object, node.alpha) + object := node.object + object.TextObject.Alpha, object.PathObject.Alpha = nil, nil + object.ImageObject.Alpha, object.CompositeGraphicUnit.Alpha = nil, nil + if object.Type == "PathObject" && object.PathObject.Stroke != nil && !*object.PathObject.Stroke && (object.PathObject.Fill == nil || !*object.PathObject.Fill) { + object.PathObject.Stroke = nil + } 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 - result[i] = CompositeMember{Object: cloneEditorData(node.object), Bounds: bounds.box, Contours: bounds.contours, Transform: invertible && editorXMLTransformable(node.node) && validateEditorGeometry(node.object) == nil && !borderActions} + transform := invertible && editorXMLTransformable(node.node) && validateEditorGeometry(node.object) == nil && !borderActions + capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.box.W > 0 && bounds.box.H > 0} + style, err := e.compositeMemberStyle(node) + if !transform { + capability.ReasonCode, capability.Reason = EditUnsupportedContainer, "composite member cannot be transformed" + } else if err != nil { + capability.ReasonCode, capability.Reason = editReason(err), err.Error() + } else { + capability.Paint = e.editorPaintable(style) + } + if err != nil { + style = cloneEditorData(node.object) + } + _, ctm := editorGeometry(node.object) + matrix := node.parent.Multiply(NewMatrix(ctm)) + result[i] = CompositeMember{Object: style, Bounds: bounds.box, Contours: bounds.contours, StrokeScale: math.Sqrt(math.Abs(matrix.a*matrix.d - matrix.b*matrix.c)), Capabilities: capability, Transform: transform} } return result } +// compositeMemberStyle 解析成员的继承样式,缺失或循环参数不开放改色 +// 入参: node 成员节点 +// 返回: GraphicObject 有效外观, error 错误信息 +func (e *Editor) compositeMemberStyle(node *editorCompositeNode) (GraphicObject, error) { + for _, id := range node.drawParams { + if _, err := e.editorDrawParam(id, make(map[string]bool)); err != nil { + return GraphicObject{}, err + } + } + base := node.defaults + if base == nil { + base = &DrawParam{} + } + object, err := e.resolveEditorStyleDefaults(node.object, base) + if err == nil && object.Type == "PathObject" && object.PathObject.LineWidth == 0 { + object.PathObject.LineWidth = defaultPathLineWidth + } + return object, err +} + +// StyleCompositeObjects 原子修改成员透明度、文字颜色及路径填充、描边和线宽 +// 不重排文字,不改写继承参数;暂不支持虚线及端点样式 +// 入参: page 页面索引, path 父路径, indexes 成员序号, style 待修改属性,线宽使用页面毫米 +// 返回: error 错误信息 +func (e *Editor) StyleCompositeObjects(page int, path ObjectPath, indexes []int, style ObjectStyle) error { + if style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil { + return fmt.Errorf("composite stroke parameters are not supported") + } + return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + paint := style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil + for i, node := range nodes { + capability := members[i].Capabilities + if !capability.Transform || paint && !capability.Paint { + return fmt.Errorf("composite member %d cannot accept this style: %w", indexes[i], capability.editError()) + } + local := style + switch members[i].Object.Type { + case "TextObject": + local.FillColor = editorStyleColor(style.FillColor, members[i].Object.TextObject.FillColor) + case "PathObject": + p := members[i].Object.PathObject + local.FillColor = editorStyleColor(style.FillColor, p.FillColor) + local.StrokeColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.StrokeColor), (*FillColor)(p.StrokeColor))) + if style.LineWidth != nil { + width := *style.LineWidth / members[i].StrokeScale + local.LineWidth = &width + } + } + object, err := e.styleObject(node.object, local) + if err != nil { + return err + } + data, err := editorXMLObject(node.data, node.node, node.object, object) + if err != nil { + return err + } + if bytes.Equal(data, node.data) { + continue + } + updated, err := newEditorCompositeNode(data) + if err != nil { + return err + } + node.data, node.node, node.object, node.changed = data, updated.node, updated.object, true + } + return nil + }) +} + +// AlignCompositeObjects 将内部多选成员对齐至选区边界,单选时对齐页面 +// 入参: page 页面索引, path 父路径, indexes 成员序号, alignment 为left、center、right、top、middle或bottom +// 返回: error 错误信息 +func (e *Editor) AlignCompositeObjects(page int, path ObjectPath, indexes []int, alignment string) error { + if !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, alignment) { + return fmt.Errorf("invalid alignment %q", alignment) + } + return e.arrangeCompositeObjects(page, path, indexes, func(boxes []Box) ([]Matrix, error) { + target, _ := ParseBox(e.pages[page].Area.PhysicalBox) + if len(boxes) > 1 { + target = Box{} + for _, box := range boxes { + target = unionTextBox(target, box) + } + } + matrices := make([]Matrix, len(boxes)) + for i, box := range boxes { + matrices[i] = editorAlignment(box, target, alignment) + } + return matrices, nil + }) +} + +// DistributeCompositeObjects 按可见范围等距分布内部成员,固定两端并保留绘制顺序 +// 入参: page 页面索引, path 父路径, indexes 成员序号, axis 为horizontal或vertical +// 返回: error 错误信息 +func (e *Editor) DistributeCompositeObjects(page int, path ObjectPath, indexes []int, axis string) error { + if axis != "horizontal" && axis != "vertical" { + return fmt.Errorf("invalid distribution axis %q", axis) + } + return e.arrangeCompositeObjects(page, path, indexes, func(boxes []Box) ([]Matrix, error) { + positions := make([]editorObjectPosition, len(indexes)) + for i, index := range indexes { + positions[i].index = index + } + return editorDistribution(boxes, positions, axis) + }) +} + +// arrangeCompositeObjects 按成员实际轮廓计算独立页面变换并提交一次历史记录 +// 入参: page 页面索引, path 父路径, indexes 成员序号, arrange 排列计算 +// 返回: error 错误信息 +func (e *Editor) arrangeCompositeObjects(page int, path ObjectPath, indexes []int, arrange func([]Box) ([]Matrix, error)) error { + return e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + boxes := make([]Box, len(members)) + for i, member := range members { + if !member.Capabilities.Arrange { + return fmt.Errorf("composite member has no editable bounds") + } + boxes[i] = member.Bounds + } + matrices, err := arrange(boxes) + if err != nil { + return err + } + for i, matrix := range matrices { + if err := e.transformCompositeMember(renderer, nodes[i], matrix); err != nil { + return err + } + } + return nil + }) +} + // changeCompositeObjects 原子变换内部选区,沿改动路径复制共享资源 // 入参: page 页面索引, path 父路径, indexes 成员序号, transform 页面变换生成器 // 返回: error 错误信息 -func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int, transform func(Box) Matrix) (err error) { +func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int, transform func(Box) Matrix) error { + return e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + var box Box + for i := range nodes { + if !members[i].Capabilities.Transform { + return members[i].Capabilities.editError() + } + box = unionTextBox(box, members[i].Bounds) + } + matrix := transform(box) + for _, node := range nodes { + if err := e.transformCompositeMember(renderer, node, matrix); err != nil { + return err + } + } + return nil + }) +} + +// editCompositeObjects 校验选区并原子提交成员改动,失败时回收本次分配的资源 +// 入参: page 页面索引, path 父路径, indexes 成员序号, edit 修改回调 +// 返回: error 错误信息 +func (e *Editor) editCompositeObjects(page int, path ObjectPath, indexes []int, edit func(*Renderer, []*editorCompositeNode, []CompositeMember) error) (err error) { reader, renderer, root, nodes, err := e.compositeScope(page, path) if err != nil { return err } defer reader.Close() - members := measureCompositeMembers(renderer, nodes) + members := e.measureCompositeMembers(renderer, nodes) selected := make(map[int]bool) - var box Box + var selectedNodes []*editorCompositeNode + var selectedMembers []CompositeMember for _, i := range indexes { if i < 0 || i >= len(nodes) || selected[i] { return fmt.Errorf("invalid composite selection") } - if !members[i].Transform { - return fmt.Errorf("composite member %d cannot be transformed", i) - } selected[i] = true - box = unionTextBox(box, members[i].Bounds) + selectedNodes = append(selectedNodes, nodes[i]) + selectedMembers = append(selectedMembers, members[i]) } if len(selected) == 0 { return nil } - matrix := transform(box) - if matrix == IdentityMatrix { - return nil - } count, maximum := len(e.resources), e.maxID ready := e.source.idsReady defer func() { @@ -380,48 +556,11 @@ func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int if err = e.prepareSourceIDs(); err != nil { return err } - for i := range selected { - n := nodes[i] - object := cloneEditorData(n.object) - inverse, _ := n.parent.Invert() - basic := object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" - if basic && !n.boundaryInCTM { - object = compositeBoundary(object, n.parent, true) - } - local := inverse.Multiply(matrix).Multiply(n.parent) - if object.Type == "ImageObject" && object.ImageObject.Border != nil { - origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object} - object, err = e.transformBorderedImage(object, local, origin, renderer) - if err != nil { - return err - } - } else { - object = transformEditorMatrix(object, local) - } - if basic && object.Type == n.object.Type && !n.boundaryInCTM { - object = compositeBoundary(object, n.parent, false) - } - if err = validateEditorGeometry(object); err != nil { - return err - } - n.data, err = editorXMLObject(n.data, n.node, n.object, object) - if err != nil { - return err - } - if !basic && !n.boundaryInCTM { - n.data, err = transformCompositeOffsets(n.data, matrix) - if err != nil { - return err - } - } - updated, err := newEditorCompositeNode(n.data) - if err != nil { - return err - } - n.node, n.object, n.changed = updated.node, updated.object, true + if err = edit(renderer, selectedNodes, selectedMembers); err != nil { + return err } - data, _, err := e.writeCompositeNode(root) - if err != nil { + data, changed, err := e.writeCompositeNode(root) + if err != nil || !changed { return err } next, err := newEditorCompositeNode(data) @@ -434,6 +573,53 @@ func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int return e.updateObjectOrigins(page, []GraphicObject{next.object}, true, map[string]*editorObjectOrigin{path.ID: updated}) } +// transformCompositeMember 将页面变换换算至成员坐标,保留边框及旧式边界约定 +// 入参: renderer 渲染器, n 成员, matrix 页面变换 +// 返回: error 错误信息 +func (e *Editor) transformCompositeMember(renderer *Renderer, n *editorCompositeNode, matrix Matrix) (err error) { + if matrix == IdentityMatrix { + return nil + } + object := cloneEditorData(n.object) + inverse, _ := n.parent.Invert() + basic := object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" + if basic && !n.boundaryInCTM { + object = compositeBoundary(object, n.parent, true) + } + local := inverse.Multiply(matrix).Multiply(n.parent) + if object.Type == "ImageObject" && object.ImageObject.Border != nil { + origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object} + object, err = e.transformBorderedImage(object, local, origin, renderer) + if err != nil { + return err + } + } else { + object = transformEditorMatrix(object, local) + } + if basic && object.Type == n.object.Type && !n.boundaryInCTM { + object = compositeBoundary(object, n.parent, false) + } + if err = validateEditorGeometry(object); err != nil { + return err + } + n.data, err = editorXMLObject(n.data, n.node, n.object, object) + if err != nil { + return err + } + if !basic && !n.boundaryInCTM { + n.data, err = transformCompositeOffsets(n.data, matrix) + if err != nil { + return err + } + } + updated, err := newEditorCompositeNode(n.data) + if err != nil { + return err + } + n.node, n.object, n.changed = updated.node, updated.object, true + return nil +} + // transformCompositeOffsets 变换旧式内联复合成员的页面偏移,资源引用保持原坐标约定 // 入参: data 修改后的复合片段, matrix 页面变换 // 返回: []byte 同步内部边界及非随动裁剪后的片段, error 错误信息 @@ -632,14 +818,23 @@ func (e *Editor) compositeResource(id string, data []byte) (editorResource, erro func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]bool) { used[composite.ResourceID] = true for _, object := range composite.Objects { + var fill, stroke *FillColor switch object.Type { case "TextObject": used[object.TextObject.Font] = true + fill, stroke = object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor) + case "PathObject": + fill, stroke = object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor) case "ImageObject": used[object.ImageObject.ResourceID] = true used[object.ImageObject.ImageMask] = true case "CompositeObject", "CompositeGraphicUnit": collectCompositeReferences(object.CompositeGraphicUnit, used) } + for _, color := range []*FillColor{fill, stroke} { + if color != nil { + used[color.ColorSpace] = true + } + } } } diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 976ea9d..30d9f2f 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -66,6 +66,22 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic if err != nil { return GraphicObject{}, err } + return e.resolveEditorStyleDefaults(object, base) +} + +// resolveEditorStyleDefaults 合并对象参数与继承外观,仅生成编辑快照 +// 入参: object 原对象, base 继承参数 +// 返回: GraphicObject 有效样式快照, error 错误信息 +func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawParam) (GraphicObject, error) { + var id string + switch object.Type { + case "TextObject": + id = object.TextObject.DrawParam + case "PathObject": + id = object.PathObject.DrawParam + default: + return cloneEditorData(object), nil + } dp, err := e.editorDrawParam(id, make(map[string]bool)) if err != nil { return GraphicObject{}, err @@ -101,75 +117,142 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic return cloneEditorObject(object) } -// ObjectStyle 对象外观,nil字段保持原值,长度单位为对象坐标系中的毫米 +// ObjectStyle 对象外观,nil字段保持原值,颜色的Alpha为nil时保留原颜色透明度 +// StyleObjects使用对象坐标毫米,StyleCompositeObjects的LineWidth使用页面毫米 type ObjectStyle struct { Alpha *int + Fill *bool + Stroke *bool + FillColor *FillColor + StrokeColor *StrokeColor + LineWidth *float64 DashPattern *string DashOffset *float64 Cap *string Join *string } -// StyleObjects 原子更新文字、图片、路径与复合对象透明度,路径另支持描边样式 +// StyleObjects 原子更新对象透明度、文字颜色及路径填充和描边样式 // 入参: page 页面索引, ids 对象标识, style 待修改属性 // 返回: error 错误信息 func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { - if style.Alpha != nil && (*style.Alpha < 0 || *style.Alpha > 255) { - return fmt.Errorf("alpha must be between 0 and 255") - } objects, _, err := e.selectedObjects(page, ids) if err != nil { return err } - stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil for i := range objects { - object := cloneEditorData(objects[i]) - if stroke && object.Type != "PathObject" { - return fmt.Errorf("stroke style requires path objects") + if style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil { + capability, err := e.ObjectCapabilities(page, ids[i]) + if err != nil { + return err + } + if !capability.Paint { + return fmt.Errorf("object %q cannot be painted: %w", ids[i], capability.editError()) + } } - switch object.Type { - case "TextObject": - if style.Alpha != nil { - object.TextObject.Alpha = style.Alpha - } - case "ImageObject": - if style.Alpha != nil { - object.ImageObject.Alpha = style.Alpha - } - case "CompositeObject", "CompositeGraphicUnit": - if style.Alpha != nil { - object.CompositeGraphicUnit.Alpha = style.Alpha - } - case "PathObject": - path := &object.PathObject - if style.Alpha != nil { - path.Alpha = style.Alpha - } - if style.DashPattern != nil { - path.DashPattern = *style.DashPattern - } - if style.DashOffset != nil { - path.DashOffset = style.DashOffset - } - if style.Cap != nil { - path.Cap = *style.Cap - } - if style.Join != nil { - path.Join = *style.Join - } - if stroke { - if err := validateEditorStroke(*path); err != nil { - return err - } - } - default: - return fmt.Errorf("unsupported object type %q", object.Type) + objects[i], err = e.styleObject(objects[i], style) + if err != nil { + return err } - objects[i] = object } return e.updateObjects(page, objects, true) } +// styleObject 仅替换请求的外观字段,保留内容、定位及未修改样式 +// 入参: object 原对象, style 待修改属性 +// 返回: GraphicObject 修改后的独立对象, error 错误信息 +func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicObject, error) { + if style.Alpha != nil && (*style.Alpha < 0 || *style.Alpha > 255) { + return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255") + } + stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil + paint := style.Fill != nil || style.Stroke != nil || style.StrokeColor != nil || style.LineWidth != nil + if (stroke || paint) && object.Type != "PathObject" { + return GraphicObject{}, fmt.Errorf("stroke style requires path objects") + } + if style.FillColor != nil && object.Type != "TextObject" && object.Type != "PathObject" { + return GraphicObject{}, fmt.Errorf("fill color requires text or path objects") + } + for _, value := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { + if value != nil { + if err := e.editorColor(value); err != nil { + return GraphicObject{}, err + } + } + } + switch object.Type { + case "TextObject": + if style.Alpha != nil { + object.TextObject.Alpha = style.Alpha + } + if style.FillColor != nil { + object.TextObject.FillColor = editorStyleColor(style.FillColor, object.TextObject.FillColor) + } + case "ImageObject": + if style.Alpha != nil { + object.ImageObject.Alpha = style.Alpha + } + case "CompositeObject", "CompositeGraphicUnit": + if style.Alpha != nil { + object.CompositeGraphicUnit.Alpha = style.Alpha + } + case "PathObject": + path := &object.PathObject + if style.Alpha != nil { + path.Alpha = style.Alpha + } + if style.Fill != nil { + path.Fill = style.Fill + } + if style.Stroke != nil { + path.Stroke = style.Stroke + } + if style.FillColor != nil { + path.FillColor = editorStyleColor(style.FillColor, path.FillColor) + } + if style.StrokeColor != nil { + path.StrokeColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.StrokeColor), (*FillColor)(path.StrokeColor))) + } + if style.LineWidth != nil { + if !finite(*style.LineWidth) || *style.LineWidth <= 0 { + return GraphicObject{}, fmt.Errorf("line width must be positive") + } + path.LineWidth = *style.LineWidth + } + if style.DashPattern != nil { + path.DashPattern = *style.DashPattern + } + if style.DashOffset != nil { + path.DashOffset = style.DashOffset + } + if style.Cap != nil { + path.Cap = *style.Cap + } + if style.Join != nil { + path.Join = *style.Join + } + if stroke { + if err := validateEditorStroke(*path); err != nil { + return GraphicObject{}, err + } + } + default: + return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type) + } + return cloneEditorData(object), nil +} + +// editorStyleColor 替换色值时保留未指定的颜色透明度 +// 入参: color 新颜色, before 原有效颜色 +// 返回: *FillColor 独立颜色 +func editorStyleColor(color, before *FillColor) *FillColor { + result := cloneEditorData(color) + if result != nil && result.Alpha == nil && before != nil { + result.Alpha = cloneEditorData(before.Alpha) + } + return result +} + // CopyStyle 将同类型对象的外观复制到选区,不复制内容、位置、资源数据或动作 // 文字复制字体、字号、颜色及透明度,沿用目标段落设置;字体标识须属于当前文档 // 路径按页面中的实际线宽和虚线长度复制,保持目标变换