mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
This commit is contained in:
1 parent
47562c4e7a
commit
2ddde73b65
16 files changed
+363
-64
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"))]
|
||||
|
||||
@@ -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, ©, 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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('"')
|
||||
|
||||
@@ -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 错误信息
|
||||
|
||||
@@ -172,6 +172,7 @@ type PathObject struct {
|
||||
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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,6 +88,7 @@ type DrawParam struct {
|
||||
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"`
|
||||
|
||||
@@ -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)
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user