feat(编辑能力): 完善复合对象编辑,修复复制层级
build.yaml / build (push) Successful in 38s

This commit is contained in:
xiaoqidun committed 2026-09-17 16:47:13 +08:00
1 parent 47562c4e7a
commit 2ddde73b65
16 files changed
+389 -90

No files matched your search

+1 -1
View File
@@ -63,11 +63,11 @@
<span>文件</span>
</div>
<div class="editor-group editor-objects">
<button id="compositeBackButton" class="button" type="button" title="返回上层" hidden>返回</button>
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
<button id="multiSelectButton" class="button" type="button" aria-pressed="false" title="多选对象">多选</button>
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
<button id="compositeBackButton" class="button" type="button" title="返回上层" hidden>返回</button>
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
<option value="" hidden>层级</option>
+18 -10
View File
@@ -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 = Boolean(state.composite) || !items.every(item => item.type === "PathObject");
el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject" && (!item.scoped || canEditObject(item, "paint")));
el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom";
el.objectDashPattern.value = dash || "";
el.objectDashRow.hidden = el.objectDash.value !== "custom";
@@ -1313,7 +1313,7 @@ async function editCanvasObject(item) {
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "TextObject") {
if (!confirmTextReflow(item)) return;
} else if (!canEditObject(item, "update")) return;
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
if (item.type === "PathObject") {
el.shapeWidth.focus();
return;
@@ -1425,7 +1425,7 @@ function openInsertPanel(item = null) {
}
el.insertForm.reset();
state.insertObject = item;
el.insertFitRow.hidden = !item;
el.insertFitRow.hidden = !item || Boolean(item.scoped);
el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片");
el.insertSubmit.textContent = item ? "替换" : "添加";
el.insertStatus.textContent = "";
@@ -1614,8 +1614,9 @@ async function insertObject(event) {
if (openSeq !== state.openSeq) {
return;
}
const doc = item
? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
const doc = item?.scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, state.composite.key, [item.id], "image", data)
: item ? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value)
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
if (openSeq !== state.openSeq) {
return;
@@ -1831,7 +1832,8 @@ async function changeDocument(name, item, ...args) {
}
const scope = state.composite;
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];
ofdgoAlignObject: "align", ofdgoAlignObjects: "align", ofdgoDistributeObjects: "distribute", ofdgoStyleObjects: "style", ofdgoUpdatePathStyle: "paint", ofdgoCompositeTextColor: "textColor",
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);
if (!item) resetCompositeScope();
@@ -1872,6 +1874,7 @@ async function changeDocument(name, item, ...args) {
setPan(false);
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: doc.selectedIDs };
}
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
openSeq = ++state.openSeq;
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
@@ -3694,6 +3697,11 @@ function copyEditorSelection(event) {
|| canvasEditor.input || canvasEditor.crop || canvasEditor.drag || document.body.hasAttribute("aria-busy")) return false;
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
const cut = event.type === "cut";
if (state.composite) {
event.preventDefault();
setStatus("内部对象请用复制按钮");
return true;
}
if (!canEditObject(canvasEditor.selected, "copy") || cut && !canEditObject(canvasEditor.selected, "delete")) {
event.preventDefault();
setStatus(cut ? "对象不可剪切" : "对象不可复制");
@@ -5536,10 +5544,11 @@ function updateEditorTools() {
function updateObjectControls(item, reset = false) {
el.compositeBackButton.hidden = !state.composite;
el.compositeBackButton.disabled = !state.ready || state.exporting;
el.deleteObjectButton.hidden = Boolean(state.composite);
el.selectObjectButton.hidden = Boolean(state.composite);
const members = item?.items || (item ? [item] : []);
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.copyObjectButton.disabled = disabled || !canEditObject(item, "copy") || Boolean(state.composite && members.some(member => member.container !== members[0].container));
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
const cropping = Boolean(canvasEditor.crop);
el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform");
@@ -5555,7 +5564,7 @@ function updateObjectControls(item, reset = false) {
el.resetCropButton.disabled = el.cropImageButton.disabled || !cropping && (!item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"));
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update"));
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item);
const text = selection ? currentTextStyle() : state.textDefaults;
@@ -5608,7 +5617,6 @@ function updateObjectControls(item, reset = false) {
el.shapeWidth.value = "1";
}
updateDrawingControls();
const members = item?.items || (item ? [item] : []);
const orders = members.map(member => member.position).sort((a, b) => a - b);
const count = members[0]?.count || 0;
const canRaise = orders.some((order, i) => order !== count - orders.length + i);
+2 -1
View File
@@ -190,7 +190,8 @@ export function objectEditReason(item) {
if (!reason) return "";
const message = labels[object.capabilities.reasonCode] || "暂不支持此对象特性";
const available = object.capabilities.transform ? object.capabilities.paint ? "仍可移动、改色" : "仍可移动"
: object.capabilities.copy && object.capabilities.delete ? "仍可复制、删除" : "暂不可编辑";
: object.capabilities.replaceImage ? "仍可替换图片" : object.capabilities.copy && object.capabilities.delete ? "仍可复制、删除"
: object.capabilities.delete ? "仍可删除" : "暂不可编辑";
return object.capabilities.missingGlyphs ? missingGlyphMessage(object.capabilities.missingGlyphs, available) : `${message},${available}`;
});
return [...new Set(reasons.filter(Boolean))].join(";");
+33 -4
View File
@@ -832,7 +832,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
// 返回: map[string]any 前端能力
func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any {
result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode),
"enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")}
"replaceImage": capability.ReplaceImage, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")}
if missing := capability.MissingGlyphs; missing != nil {
result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
}
@@ -919,7 +919,7 @@ func compositeObjects(args []js.Value) (any, error) {
if box.W <= 0 || box.H <= 0 {
continue
}
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,
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": member.Position.Index, "count": member.Position.Count, "container": key + ":" + member.Position.Container, "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" {
@@ -932,11 +932,12 @@ func compositeObjects(args []js.Value) (any, error) {
return objects, nil
}
// changeCompositeObjects 复用库层内部选区变换与单次撤销
// changeCompositeObjects 提交内部选区操作,结构变化后返回新的选区路径
// 入参: args 页面索引、父路径、成员路径列表、操作及参数
// 返回: any 文档信息, error 错误信息
func changeCompositeObjects(args []js.Value) (any, error) {
return changeObjects(func() error {
var selected []int
result, err := changeObjects(func() error {
key := args[1].String()
path, err := compositePath(key)
if err != nil {
@@ -957,6 +958,23 @@ func changeCompositeObjects(args []js.Value) (any, error) {
page := args[0].Int()
operation := args[3].String()
switch operation {
case "delete":
return currentEditor.DeleteCompositeObjects(page, path, indexes)
case "copy":
selected, err = currentEditor.CopyCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float())
return err
case "order":
selected, err = currentEditor.OrderCompositeObjects(page, path, indexes, args[4].String())
return err
case "image":
if len(indexes) != 1 {
return fmt.Errorf("image replacement requires one member")
}
data, err := bytesFromJS(args[4])
if err != nil {
return err
}
return currentEditor.ReplaceCompositeImage(page, path, indexes[0], data)
case "transform":
return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float())
case "rotate":
@@ -1007,6 +1025,17 @@ func changeCompositeObjects(args []js.Value) (any, error) {
return fmt.Errorf("unsupported composite operation")
}
})
if err != nil {
return nil, err
}
if selected == nil {
return result, nil
}
doc := editorSelectionInfo{editorInfo: result.(editorInfo)}
for _, index := range selected {
doc.SelectedIDs = append(doc.SelectedIDs, fmt.Sprintf("%s/%d", args[1].String(), index))
}
return doc, nil
}
// editorFont 读取对象实际使用的内嵌字体,供画布输入使用
+13 -7
View File
@@ -219,6 +219,17 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error {
for _, index := range indexes {
selected[slices.Index(siblings, index.index)] = true
}
orderEditorObjects(objects, selected, order)
for i, index := range siblings {
layers[indexes[0].layer].Objects[index] = objects[i]
}
e.replaceLayers(page, layers)
return nil
}
// orderEditorObjects 调整选区层级,保持选中及未选中对象各自的相对顺序
// 入参: objects 同容器对象, selected 选中状态, order 排序方向
func orderEditorObjects[T any](objects []T, selected []bool, order string) {
switch order {
case "up":
for i := len(objects) - 2; i >= 0; i-- {
@@ -235,7 +246,7 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error {
}
}
default:
ordered := make([]GraphicObject, 0, len(objects))
ordered := make([]T, 0, len(objects))
for _, takeSelected := range []bool{order == "bottom", order != "bottom"} {
for i, object := range objects {
if selected[i] == takeSelected {
@@ -243,13 +254,8 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error {
}
}
}
objects = ordered
copy(objects, ordered)
}
for i, index := range siblings {
layers[indexes[0].layer].Objects[index] = objects[i]
}
e.replaceLayers(page, layers)
return nil
}
// DistributeObjects 按可见范围等距分布同页对象,固定两端对象,保留绘制顺序
+40 -34
View File
@@ -34,12 +34,14 @@ type ObjectPath struct {
// CompositeMember 复合对象的直接成员快照,Object包含有效样式,不用于整体替换原对象
// Bounds与Contours使用页面毫米坐标,StrokeScale为对象描边到页面的倍率
// Position表示当前范围内的直接容器位置,结构操作后需重新枚举
type CompositeMember struct {
Object GraphicObject
Bounds Box
Contours []ObjectContour
StrokeScale float64
Capabilities ObjectCapabilities
Position ObjectPosition
Transform bool
}
@@ -59,6 +61,9 @@ type editorCompositeNode struct {
expanded bool
changed bool
span *editorXML
owner *editorCompositeNode
patches []editorXMLPatch
index int
}
// CompositeObjects 枚举复合对象的直接可绘制成员,资源引用层不占用路径层级
@@ -189,6 +194,9 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer,
break
}
if depth == len(path.Children) {
for i, member := range members {
member.index = i
}
return reader, renderer, root, members, nil
}
i := path.Children[depth]
@@ -300,6 +308,7 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti
}
item.parent, item.boundaryInCTM, item.defaults, item.clip, item.visible, item.span = matrix, n.boundaryInCTM, defaults, clip, visible, child
item.drawParams = drawParams
item.owner = n
n.children = append(n.children, item)
}
}
@@ -332,6 +341,18 @@ func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiti
// 返回: []CompositeMember 独立快照及操作能力
func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember {
result := make([]CompositeMember, len(nodes))
positions := make(map[*editorXML]ObjectPosition)
orderable := make(map[*editorXML]bool)
for _, node := range nodes {
parent := node.span.parent
position := positions[parent]
if position.Count == 0 {
position.Container = fmt.Sprint(node.index)
orderable[parent] = editorContainerOrderable(parent)
}
position.Count++
positions[parent] = position
}
for i, node := range nodes {
bounds := &boundsRenderer{collect: true}
object := node.object
@@ -346,7 +367,8 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp
_, 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
capability := ObjectCapabilities{Transform: transform, Arrange: transform && bounds.box.W > 0 && bounds.box.H > 0}
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)
if !transform {
capability.ReasonCode, capability.Reason = EditUnsupportedContainer, "composite member cannot be transformed"
@@ -360,7 +382,10 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp
}
_, 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}
position := positions[node.span.parent]
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, Position: position, Transform: transform}
position.Index++
positions[node.span.parent] = position
}
return result
}
@@ -386,15 +411,12 @@ func (e *Editor) compositeMemberStyle(node *editorCompositeNode) (GraphicObject,
}
// 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
paint := style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil || style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil
for i, node := range nodes {
capability := members[i].Capabilities
if !capability.Transform || paint && !capability.Paint {
@@ -417,18 +439,9 @@ func (e *Editor) StyleCompositeObjects(page int, path ObjectPath, indexes []int,
if err != nil {
return err
}
data, err := editorXMLObject(node.data, node.node, node.object, object)
if err != nil {
if err := node.update(object); 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
})
@@ -548,6 +561,14 @@ func (e *Editor) editCompositeObjects(page int, path ObjectPath, indexes []int,
ready := e.source.idsReady
defer func() {
if err != nil {
removed := make(map[string]bool)
for _, resource := range e.resources[count:] {
if resource.image != nil {
removed[resource.image.ID] = true
delete(e.images, resource.image.ID)
}
}
maps.DeleteFunc(e.resourceID, func(_ editorResourceKey, id string) bool { return removed[id] })
clear(e.resources[count:])
e.resources, e.maxID = e.resources[:count], maximum
e.source.idsReady = ready
@@ -698,7 +719,7 @@ func compositeBoundary(object GraphicObject, parent Matrix, inward bool) Graphic
// 入参: node 实例节点
// 返回: []byte 新片段, bool 是否变化, error 错误信息
func (e *Editor) writeCompositeNode(node *editorCompositeNode) ([]byte, bool, error) {
var patches []editorXMLPatch
patches := slices.Clone(node.patches)
for _, child := range node.children {
data, changed, err := e.writeCompositeNode(child)
if err != nil {
@@ -748,22 +769,7 @@ func (e *Editor) addCompositeResource(data []byte) (string, error) {
return "", fmt.Errorf("composite contains unsupported identifiers")
}
ids := make(map[string]string)
var collect func(*editorXML) error
collect = func(node *editorXML) error {
if id := editorResourceID(node.attr("ID")); id != "" {
if ids[id] != "" {
return fmt.Errorf("duplicate object ID %q", id)
}
ids[id] = e.nextID()
}
for _, child := range node.children {
if err := collect(child); err != nil {
return err
}
}
return nil
}
if err := collect(root); err != nil {
if err := e.collectCompositeIDs(root, ids); err != nil {
return "", err
}
id := ids[editorResourceID(root.attr("ID"))]
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ofdgo
import (
"bytes"
"fmt"
"slices"
)
// siblings 获取同一原始容器内的直接绘制成员,不跨越资源和页块
// 返回: []*editorCompositeNode 按绘制顺序排列的成员
func (n *editorCompositeNode) siblings() []*editorCompositeNode {
var result []*editorCompositeNode
for _, member := range n.owner.children {
if member.span.parent == n.span.parent {
result = append(result, member)
}
}
return result
}
// DeleteCompositeObjects 删除内部成员,保留容器、未选对象及其他实例,一次撤销恢复全部
// 入参: page 页面索引, path 父复合路径, indexes 成员序号
// 返回: error 错误信息
func (e *Editor) DeleteCompositeObjects(page int, path ObjectPath, indexes []int) error {
return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
for _, node := range nodes {
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
}
return nil
})
}
// CopyCompositeObjects 在原容器末尾复制选区并按页面毫米平移,保留原文并重映射副本内部标识
// 入参: page 页面索引, path 父复合路径, indexes 同一容器的成员序号, dx、dy 页面位移
// 返回: []int 按绘制顺序排列的副本序号, error 错误信息
func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int, dx, dy float64) ([]int, error) {
if !finite(dx) || !finite(dy) {
return nil, fmt.Errorf("copy requires finite offsets")
}
var result []int
err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
container := nodes[0].span.parent
for i, node := range nodes {
if !members[i].Capabilities.Copy || node.span.parent != container {
return fmt.Errorf("copy requires editable members in the same container")
}
}
slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index })
ids := make(map[string]string)
for _, node := range nodes {
if err := e.collectCompositeIDs(node.node, ids); err != nil {
return err
}
}
var data []byte
for _, node := range nodes {
copy := *node
if err := e.transformCompositeMember(renderer, &copy, TranslationMatrix(dx, dy)); err != nil {
return err
}
fragment, err := editorXMLRemapIDs(copy.data, copy.node, ids)
if err != nil {
return err
}
data = append(data, fragment...)
}
owner := nodes[0].owner
owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data})
next := 0
for _, member := range owner.children {
if container.start < member.span.start && member.span.end <= container.close {
next = member.index + 1
}
}
for i := range nodes {
result = append(result, next+i)
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// OrderCompositeObjects 调整同一直接容器内的绘制顺序,不改变页块和其他实例
// 入参: page 页面索引, path 父复合路径, indexes 成员序号, order 为up、down、top或bottom
// 返回: []int 调整后的选区序号, error 错误信息
func (e *Editor) OrderCompositeObjects(page int, path ObjectPath, indexes []int, order string) ([]int, error) {
if !slices.Contains([]string{"up", "down", "top", "bottom"}, order) {
return nil, fmt.Errorf("invalid object order %q", order)
}
var result []int
err := e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
siblings := nodes[0].siblings()
selected := make([]bool, len(siblings))
for j, node := range nodes {
i := slices.Index(siblings, node)
if i < 0 || !members[j].Capabilities.Order {
return fmt.Errorf("ordering requires members in the same container")
}
selected[i] = true
}
ordered := slices.Clone(siblings)
orderEditorObjects(ordered, selected, order)
for i, node := range siblings {
if node != ordered[i] {
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, ordered[i].data})
}
if slices.Contains(nodes, ordered[i]) {
result = append(result, node.index)
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
// ReplaceCompositeImage 替换内部图片数据,保留变换、裁剪、蒙版、边框和透明度
// 入参: page 页面索引, path 父复合路径, index 图片序号, data PNG或JPEG数据
// 返回: error 错误信息
func (e *Editor) ReplaceCompositeImage(page int, path ObjectPath, index int, data []byte) error {
return e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
node := nodes[0]
if node.object.Type != "ImageObject" {
return fmt.Errorf("composite member is not an image")
}
object := node.object
id, err := e.AddImage(data)
if err != nil {
return err
}
object.ImageObject.ResourceID = id
return node.update(object)
})
}
// update 合并成员属性变化,不重写原文中未修改的字段
// 入参: object 修改后的对象
// 返回: error 错误信息
func (n *editorCompositeNode) update(object GraphicObject) error {
data, err := editorXMLObject(n.data, n.node, n.object, object)
if err != nil || bytes.Equal(data, n.data) {
return err
}
next, err := newEditorCompositeNode(data)
if err != nil {
return err
}
n.data, n.node, n.object, n.changed = data, next.node, next.object, true
return nil
}
// collectCompositeIDs 为已校验副本中的标准标识分配新值,拒绝重复标识
// 入参: node 原节点, ids 原标识与新标识
// 返回: error 错误信息
func (e *Editor) collectCompositeIDs(node *editorXML, ids map[string]string) error {
if id := editorResourceID(node.attr("ID")); id != "" {
if ids[id] != "" {
return fmt.Errorf("duplicate object ID %q", id)
}
ids[id] = e.nextID()
}
for _, child := range node.children {
if err := e.collectCompositeIDs(child, ids); err != nil {
return err
}
}
return nil
}
+5
View File
@@ -62,11 +62,13 @@ type editorSourcePage struct {
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
// Paint表示可独立修改纯色填充和描边,不要求重新排版文字
// ReplaceImage表示可替换图片数据,内部成员保留原布局,不开放裁剪或重新适应
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
type ObjectCapabilities struct {
Update bool
Paint bool
ReplaceFont bool
ReplaceImage bool
Reflow bool
LayoutKnown bool
Transform bool
@@ -355,6 +357,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
all := ObjectCapabilities{Update: true, Paint: object.Type == "TextObject" || object.Type == "PathObject", ReplaceFont: object.Type == "TextObject", Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true}
origin := e.objectOrigin(id)
if origin == nil {
all.ReplaceImage = object.Type == "ImageObject"
return all, nil
}
node := origin.node
@@ -384,6 +387,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
errors.As(err, &all.MissingGlyphs)
}
all.Paint = all.Paint && e.editorPaintable(object)
all.ReplaceImage = all.Update && object.Type == "ImageObject"
if object.Type == "ImageObject" && object.ImageObject.Border != nil && len(object.ImageObject.Actions) != 0 {
all.Transform, all.Arrange = false, false
all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject
@@ -425,6 +429,7 @@ func editorPreservedObject(before, after GraphicObject) bool {
a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha
a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth
a.DashOffset, a.DashPattern, a.Cap, a.Join = b.DashOffset, b.DashPattern, b.Cap, b.Join
a.dashPatternSet = b.dashPatternSet
case "ImageObject":
a, b := &before.ImageObject, after.ImageObject
a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha
+4 -1
View File
@@ -90,11 +90,12 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara
if object.Type == "PathObject" {
obj := &object.PathObject
style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join, Cap: obj.Cap,
MiterLimit: obj.MiterLimit, DashPattern: obj.DashPattern, DashOffset: obj.DashOffset,
MiterLimit: obj.MiterLimit, DashPattern: obj.DashPattern, dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset,
FillColor: obj.FillColor, StrokeColor: obj.StrokeColor})
obj.DrawParam = ""
obj.LineWidth, obj.Join, obj.Cap = style.LineWidth, style.Join, style.Cap
obj.MiterLimit, obj.DashPattern, obj.DashOffset = style.MiterLimit, style.DashPattern, style.DashOffset
obj.dashPatternSet = style.dashPatternSet
obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor
} else {
obj := &object.TextObject
@@ -221,6 +222,7 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb
}
if style.DashPattern != nil {
path.DashPattern = *style.DashPattern
path.dashPatternSet = path.DashPattern == ""
}
if style.DashOffset != nil {
path.DashOffset = style.DashOffset
@@ -316,6 +318,7 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error {
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 {
+10 -3
View File
@@ -104,6 +104,13 @@ func (n *editorXML) attr(name string) string {
return ""
}
// hasAttr 判断属性是否存在,区分空值与省略
// 入参: name 属性名称
// 返回: bool 是否存在
func (n *editorXML) hasAttr(name string) bool {
return slices.ContainsFunc(n.attrs, func(attr xml.Attr) bool { return attr.Name.Space == "" && attr.Name.Local == name })
}
// editorPatchXML 按位置应用互不重叠的替换
// 入参: data 原文, patches 修改区间
// 返回: []byte 修改后的XML
@@ -363,7 +370,7 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte
}
changes := make(map[string]string)
for _, attr := range append(oldNode.attrs, newNode.attrs...) {
if attr.Name.Space == "" && oldNode.attr(attr.Name.Local) != newNode.attr(attr.Name.Local) {
if attr.Name.Space == "" && (oldNode.attr(attr.Name.Local) != newNode.attr(attr.Name.Local) || oldNode.hasAttr(attr.Name.Local) != newNode.hasAttr(attr.Name.Local)) {
changes[attr.Name.Local] = newNode.attr(attr.Name.Local)
}
}
@@ -388,7 +395,7 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte
if value, ok := changes[attr.Name.Local]; ok {
attr.Value = value
delete(changes, attr.Name.Local)
if value == "" {
if !newNode.hasAttr(attr.Name.Local) {
continue
}
}
@@ -402,7 +409,7 @@ func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte
result.WriteByte('"')
}
for _, attr := range newNode.attrs {
if value, ok := changes[attr.Name.Local]; ok && value != "" {
if value, ok := changes[attr.Name.Local]; ok {
result.WriteString(" " + attr.Name.Local + "=\"")
_ = xml.EscapeText(&result, []byte(value))
result.WriteByte('"')
+40
View File
@@ -19,6 +19,46 @@ import (
"strconv"
)
// UnmarshalXML 解析路径并区分省略的虚线样式与显式实线
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type plain PathObject
var value struct {
plain
DashPattern *string `xml:"DashPattern,attr"`
}
if err := d.DecodeElement(&value, &start); err != nil {
return err
}
*p = PathObject(value.plain)
if value.DashPattern != nil {
p.DashPattern = *value.DashPattern
p.dashPatternSet = p.DashPattern == ""
}
return nil
}
// UnmarshalXML 解析绘制参数并保留显式实线对基础参数的覆盖
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
func (p *DrawParam) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type plain DrawParam
var value struct {
plain
DashPattern *string `xml:"DashPattern,attr"`
}
if err := d.DecodeElement(&value, &start); err != nil {
return err
}
*p = DrawParam(value.plain)
if value.DashPattern != nil {
p.DashPattern = *value.DashPattern
p.dashPatternSet = p.DashPattern == ""
}
return nil
}
// UnmarshalXML 解析填充颜色并区分未支持的复杂颜色
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
+9 -8
View File
@@ -164,14 +164,15 @@ type CGTransform struct {
// PathObject 路径对象
type PathObject struct {
ID string `xml:"ID,attr"`
Boundary string `xml:"Boundary,attr"`
DrawParam string `xml:"DrawParam,attr"`
LineWidth float64 `xml:"LineWidth,attr"`
Join string `xml:"Join,attr"`
Cap string `xml:"Cap,attr"`
DashOffset *float64 `xml:"DashOffset,attr"`
DashPattern string `xml:"DashPattern,attr"`
ID string `xml:"ID,attr"`
Boundary string `xml:"Boundary,attr"`
DrawParam string `xml:"DrawParam,attr"`
LineWidth float64 `xml:"LineWidth,attr"`
Join string `xml:"Join,attr"`
Cap string `xml:"Cap,attr"`
DashOffset *float64 `xml:"DashOffset,attr"`
DashPattern string `xml:"DashPattern,attr"`
dashPatternSet bool
MiterLimit float64 `xml:"MiterLimit,attr"`
CTM string `xml:"CTM,attr"`
Alpha *int `xml:"Alpha,attr"`
+2 -1
View File
@@ -249,8 +249,9 @@ func mergeDrawParam(base DrawParam, dp *DrawParam) *DrawParam {
if dp.Cap != "" {
base.Cap = dp.Cap
}
if dp.DashPattern != "" {
if dp.DashPattern != "" || dp.dashPatternSet {
base.DashPattern = dp.DashPattern
base.dashPatternSet = dp.dashPatternSet
}
if dp.DashOffset != nil {
base.DashOffset = dp.DashOffset
+2 -2
View File
@@ -126,7 +126,7 @@ func (s *pathStyle) applyDrawParam(r *Renderer, dp *DrawParam, bx, by, pageH flo
s.lineCap = pathLineCap(dp.Cap, s.lineCap)
}
s.applyLineJoin(dp.Join, dp.MiterLimit)
if dp.DashPattern != "" {
if dp.DashPattern != "" || dp.dashPatternSet {
s.dashPattern = parseFloats(dp.DashPattern)
}
if dp.DashOffset != nil {
@@ -150,7 +150,7 @@ func (s *pathStyle) applyPathObject(r *Renderer, obj PathObject, bx, by, pageH f
s.lineCap = pathLineCap(obj.Cap, canvas.ButtCap)
}
s.applyLineJoin(obj.Join, obj.MiterLimit)
if obj.DashPattern != "" {
if obj.DashPattern != "" || obj.dashPatternSet {
s.dashPattern = parseFloats(obj.DashPattern)
}
if obj.DashOffset != nil {
+18 -17
View File
@@ -78,23 +78,24 @@ type DrawParams struct {
// DrawParam 绘制参数
type DrawParam struct {
ID string `xml:"ID,attr"`
Relative string `xml:"Relative,attr"`
ResourceID string `xml:"ResourceID,attr"`
BaseLoc string `xml:"BaseLoc,attr"`
Link string `xml:"Link,attr"`
LineWidth float64 `xml:"LineWidth,attr"`
Join string `xml:"Join,attr"`
Cap string `xml:"Cap,attr"`
DashOffset *float64 `xml:"DashOffset,attr"`
DashPattern string `xml:"DashPattern,attr"`
MiterLimit float64 `xml:"MiterLimit,attr"`
Font string `xml:"Font,attr"`
Size float64 `xml:"Size,attr"`
Weight int `xml:"Weight,attr"`
Italic bool `xml:"Italic,attr"`
FillColor *FillColor `xml:"FillColor"`
StrokeColor *StrokeColor `xml:"StrokeColor"`
ID string `xml:"ID,attr"`
Relative string `xml:"Relative,attr"`
ResourceID string `xml:"ResourceID,attr"`
BaseLoc string `xml:"BaseLoc,attr"`
Link string `xml:"Link,attr"`
LineWidth float64 `xml:"LineWidth,attr"`
Join string `xml:"Join,attr"`
Cap string `xml:"Cap,attr"`
DashOffset *float64 `xml:"DashOffset,attr"`
DashPattern string `xml:"DashPattern,attr"`
dashPatternSet bool
MiterLimit float64 `xml:"MiterLimit,attr"`
Font string `xml:"Font,attr"`
Size float64 `xml:"Size,attr"`
Weight int `xml:"Weight,attr"`
Italic bool `xml:"Italic,attr"`
FillColor *FillColor `xml:"FillColor"`
StrokeColor *StrokeColor `xml:"StrokeColor"`
}
// CompositeGraphicUnits 复合图元集合
+5 -1
View File
@@ -637,7 +637,11 @@ func (x *ofdXML) object(object GraphicObject, root bool) {
attrs.add("Join", obj.Join)
attrs.add("Cap", obj.Cap)
attrs.add("Rule", obj.Rule)
attrs.add("DashPattern", obj.DashPattern)
if obj.dashPatternSet {
attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: obj.DashPattern})
} else {
attrs.add("DashPattern", obj.DashPattern)
}
if obj.DashOffset != nil {
attrs.add("DashOffset", ofdNumber(*obj.DashOffset))
}