From 0034e0edbdd51e678feb50eed5c38bb7bf67b2b7 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 13:27:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=BB=A7=E6=89=BF=E6=A0=B7=E5=BC=8F=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8E=E5=8F=97=E9=99=90=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BC=96=E8=BE=91=E5=85=A5=E5=8F=A3=E5=92=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=A0=8F=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 62 +++++++++++++------------- assets/webui/ofdgo.css | 32 ++++++++++++++ assets/webui/ofdgo.js | 11 ++++- assets/webui/ofdgo_edit.js | 21 ++++++++- internal/webui/wasm.go | 14 +++--- ofdgo_editor.go | 3 +- ofdgo_editor_batch.go | 6 +++ ofdgo_editor_open.go | 24 +++++++--- ofdgo_editor_style.go | 91 ++++++++++++++++++++++++++++++++++++++ ofdgo_editor_xml.go | 20 +++++++-- ofdgo_writer.go | 2 + 11 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 ofdgo_editor_style.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 5b6def8..bec6915 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -18,6 +18,7 @@
+
@@ -43,7 +44,6 @@
- 对象
-
- - - 变换 -
-
- - - - 图片 +
+ + + + + + 插入
@@ -133,20 +118,35 @@ 段落
-
- - - - - - 插入 -
图形
+
+ + + + 图片 +
+
+ + + 变换 +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 46ca2fa..b3b9b67 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -374,6 +374,34 @@ button:not(:disabled):hover { transform: translate(-50%, -50%) rotate(90deg); } +.edit-icon { + position: relative; + width: 15px; + height: 15px; + transform: rotate(-45deg); +} + +.edit-icon::before { + position: absolute; + top: 5px; + left: 4px; + width: 9px; + height: 3px; + border: 1px solid currentColor; + content: ""; +} + +.edit-icon::after { + position: absolute; + top: 5px; + left: 0; + width: 4px; + height: 5px; + background: currentColor; + clip-path: polygon(0 50%, 100% 0, 100% 100%); + content: ""; +} + .toolbar-space { flex: 1 1 auto; } @@ -1305,6 +1333,10 @@ button.outline-link:hover { outline: calc(1px / var(--surface-scale)) solid var(--accent); } +.edit-object.read-only { + cursor: default; +} + .edit-line::before { position: absolute; inset: calc(-4px / var(--surface-scale)); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 833e921..4ba8eba 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, canEditObject } from "./ofdgo_edit.js"; +import { CanvasEditor, canEditObject, objectEditReason } from "./ofdgo_edit.js"; import { FontManager } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -250,7 +250,14 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { busy: () => document.body.hasAttribute("aria-busy"), rotation: () => state.rotation, canInsert: (index) => pageCan("insert", index), - onSelect: updateObjectControls, + onSelect: (item, previous) => { + updateObjectControls(item); + const reason = objectEditReason(item), previousReason = objectEditReason(previous); + if (state.editing && state.doc && !document.body.hasAttribute("aria-busy") + && (reason || previousReason && el.statusText.textContent === previousReason)) { + setStatus(reason || pageStatus(state.pageIndex, state.doc.pageCount)); + } + }, onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height), diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 99ef5dd..6931e05 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -122,6 +122,22 @@ export function canEditObject(item, capability) { return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability])); } +export function objectEditReason(item) { + const reasons = (item?.items || (item ? [item] : [])).map(object => { + const reason = object.capabilities?.reason || ""; + if (!reason) return ""; + let message = "对象特性暂不支持"; + if (reason.includes("embedded font")) message = "缺少内嵌字体"; + else if (reason.includes("does not contain")) message = "字体缺少所需文字"; + else if (reason.includes("color") || reason.includes("RGB")) message = "颜色样式暂不支持"; + else if (reason.includes("draw parameter")) message = "绘制参数暂不支持"; + else if (reason.includes("layer")) message = "图层特性暂不支持"; + else if (reason.includes("invalid")) message = "对象数据异常"; + return `${object.capabilities.transform ? "部分操作受限" : "暂不可编辑"}:${message}`; + }); + return [...new Set(reasons.filter(Boolean))].join(";"); +} + export class CanvasEditor { constructor(viewer, options) { this.viewer = viewer; @@ -182,6 +198,8 @@ export class CanvasEditor { for (const object of page.objects) { const node = document.createElement("div"); node.className = "edit-object"; + node.classList.toggle("read-only", !canEditObject(object, "transform")); + node.title = objectEditReason(object); if (object.shape === "line") { node.classList.add("edit-line"); } @@ -269,6 +287,7 @@ export class CanvasEditor { } setSelection(items) { + const previous = this.selected; if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop(); if (this.selected?.items) this.selected.node.remove(); for (const item of this.items()) { @@ -296,7 +315,7 @@ export class CanvasEditor { item.node.classList.toggle("multi-selected", items.length > 1); item.node.setAttribute("aria-pressed", "true"); } - this.options.onSelect(this.selected); + this.options.onSelect(this.selected, previous); } select(item, add = false) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 5e7beaf..5e52277 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -607,7 +607,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if currentEditor == nil { return objects, nil } - page, err := currentSession.pageContent(index) + page, err := currentEditor.Page(index) if err != nil { return nil, err } @@ -651,7 +651,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if err != nil { return nil, err } - if !capability.Transform && !capability.Update { + if capability.Reason == "nested objects are read-only" { continue } var box ofdgo.Box @@ -664,12 +664,12 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { box, err = ofdgo.ParseBox(object.PathObject.Boundary) } if err != nil { - return nil, err + continue } if box.W > 0 && box.H > 0 { item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects), "layer": layer.ID, "capabilities": map[string]any{"update": capability.Update, "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}} - if object.Type == "ImageObject" { + if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() if err != nil { return nil, err @@ -689,11 +689,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { item["lineWidth"] = path.LineWidth * editorPathScale(path) } if object.Type == "TextObject" { - source, err := currentEditor.Object(index, id) - if err != nil { - return nil, err - } - value, layout := source.TextObject.TextLayout() + value, layout := object.TextObject.TextLayout() item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight item["letterSpacing"] = layout.LetterSpacing diff --git a/ofdgo_editor.go b/ofdgo_editor.go index b3d4fdd..dcd2e95 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -392,7 +392,8 @@ func (e *Editor) AddObject(page int, object GraphicObject) (string, error) { return ids[0], nil } -// Object 获取对象的独立副本,修改副本不影响文档 +// Object 获取对象的独立副本,修改副本不影响文档。 +// 可编辑的继承样式解析为直接属性;仅在对象修改或复制时写入独立样式。 // 入参: page 页面索引, id 对象标识 // 返回: GraphicObject 对象内容, error 错误信息 func (e *Editor) Object(page int, id string) (GraphicObject, error) { diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 3869a23..f3d4593 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -316,6 +316,12 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) if err != nil { return err } + if e.originalPage(page) { + after[i], err = e.resolveEditorStyle(after[i], e.pages[page].Content.Layer[indexes[i].layer].DrawParam) + if err != nil { + return err + } + } } } if reflect.DeepEqual(before, after) { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index ecf4783..3ea9c1b 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -44,6 +44,7 @@ type editorObjectOrigin struct { page *editorSourcePage node *editorXML object GraphicObject + reason string } // editorSourcePage 保存页面原文、解析快照及可定位的直接对象。 @@ -292,15 +293,25 @@ func (e *Editor) loadSourcePage(index int) error { } } source.data, source.root, source.nodes = data, root, nodes - source.original = page - for _, layer := range page.Content.Layer { - for _, object := range layer.Objects { + for i := range page.Content.Layer { + layer := &page.Content.Layer[i] + for j, object := range layer.Objects { id := editorObjectID(object) if node := nodes[id]; node != nil { - e.source.origins[id] = &editorObjectOrigin{page: source, node: node, object: object} + origin := &editorObjectOrigin{page: source, node: node, object: object} + e.source.origins[id] = origin + if editorXMLSupported(node) { + resolved, err := e.resolveEditorStyle(object, layer.DrawParam) + if err != nil { + origin.reason = err.Error() + } else { + layer.Objects[j] = resolved + } + } } } } + source.original = page e.pages[index] = copyEditorPage(*page) return nil } @@ -332,9 +343,12 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er } return all, nil } - if layer.DrawParam != "" || !editorXMLSupported(node) { + if !editorXMLSupported(node) { return ObjectCapabilities{Reason: "object uses unsupported editing features"}, nil } + if origin := e.objectOrigin(id); origin.reason != "" { + return ObjectCapabilities{Reason: origin.reason}, nil + } for _, attr := range node.parent.attrs { if attr.Name.Space == "xmlns" || attr.Name.Space == "" && attr.Name.Local == "xmlns" { continue diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go new file mode 100644 index 0000000..771e390 --- /dev/null +++ b/ofdgo_editor_style.go @@ -0,0 +1,91 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import "fmt" + +// editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照。 +// 入参: id 绘制参数标识, visited 已访问标识 +// 返回: *DrawParam 合并参数, error 错误信息 +func (e *Editor) editorDrawParam(id string, visited map[string]bool) (*DrawParam, error) { + if id == "" { + return &DrawParam{}, nil + } + dp := e.source.reader.drawParamCache[id] + if dp == nil || visited[id] { + return nil, fmt.Errorf("invalid draw parameter reference %q", id) + } + visited[id] = true + base, err := e.editorDrawParam(dp.Relative, visited) + if err != nil { + return nil, err + } + return mergeDrawParam(*base, dp), nil +} + +// resolveEditorStyle 为直接对象生成独立的有效样式,不改写共享资源或原XML。 +// 入参: object 原对象, layer 图层绘制参数标识 +// 返回: GraphicObject 编辑快照, error 错误信息 +func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (GraphicObject, error) { + var id string + switch object.Type { + case "TextObject": + id = object.TextObject.DrawParam + case "PathObject": + id = object.PathObject.DrawParam + default: + return object, nil + } + if layer == "" && id == "" { + return object, nil + } + base, err := e.editorDrawParam(layer, make(map[string]bool)) + if err != nil { + return GraphicObject{}, err + } + dp, err := e.editorDrawParam(id, make(map[string]bool)) + if err != nil { + return GraphicObject{}, err + } + style := mergeDrawParam(*base, dp) + 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, + 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.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor + } else { + obj := &object.TextObject + if style.Cap != "" && style.Cap != "Butt" || style.DashPattern != "" || style.DashOffset != nil && *style.DashOffset != 0 { + return GraphicObject{}, fmt.Errorf("text draw parameters require unsupported stroke styles") + } + style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join, + MiterLimit: obj.MiterLimit, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) + text := mergeDrawParam(*dp, &DrawParam{Font: obj.Font, Size: obj.Size, Weight: obj.Weight, Italic: obj.Italic}) + obj.DrawParam = "" + obj.Font, obj.Size, obj.Weight, obj.Italic = text.Font, text.Size, text.Weight, text.Italic + obj.LineWidth, obj.Join, obj.MiterLimit = style.LineWidth, style.Join, style.MiterLimit + obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor + } + for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { + if err := creationColor(color); err != nil { + return GraphicObject{}, fmt.Errorf("unsupported draw parameter color: %w", err) + } + } + return cloneEditorObject(object) +} diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index ea54685..8fa0245 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -191,17 +191,17 @@ func editorXMLSetText(data []byte, node *editorXML, values [][2]string) []byte { // 入参: node 对象或子节点 // 返回: bool 是否支持完整编辑 func editorXMLSupported(node *editorXML) bool { - if node.name.Space != "" && node.name.Space != ofdNamespace { + if node.name.Space != "" && node.name.Space != ofdNamespace && node.name.Space != "http://www.ofdspec.org" { return false } var allowed, children string switch node.name.Local { case "TextObject": - allowed, children = "ID Boundary CTM Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "FillColor StrokeColor TextCode" + allowed, children = "ID Boundary CTM DrawParam Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "FillColor StrokeColor TextCode" case "PathObject", "Path": allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData" if node.name.Local == "PathObject" { - allowed += " ID" + allowed += " ID DrawParam" } case "ImageObject": allowed, children = "ID Boundary CTM ResourceID ImageMask Visible Alpha", "Clips" @@ -236,12 +236,26 @@ func editorXMLSupported(node *editorXML) bool { } // editorXMLObject 修改对象发生变化的属性与内容,保留原文中的显式默认值。 +// 有效样式快照在修改后展开原绘制参数,避免已清除的属性重新继承。 // 入参: data 页面原文, node 原对象节点, before 原对象, after 新对象 // 返回: []byte 对象XML, error 错误信息 func editorXMLObject(data []byte, node *editorXML, before, after GraphicObject) ([]byte, error) { if before.Type != after.Type { return editorObjectXML(after) } + if node.attr("DrawParam") != "" && before.TextObject.DrawParam == "" && before.PathObject.DrawParam == "" { + before = GraphicObject{Type: before.Type} + var err error + switch before.Type { + case "TextObject": + err = xml.Unmarshal(data[node.start:node.end], &before.TextObject) + case "PathObject": + err = xml.Unmarshal(data[node.start:node.end], &before.PathObject) + } + if err != nil { + return nil, err + } + } oldXML, err := editorObjectXML(before) if err != nil { return nil, err diff --git a/ofdgo_writer.go b/ofdgo_writer.go index e857656..abc21fd 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -467,6 +467,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) { case "TextObject": obj := object.TextObject attrs.add("ID", obj.ID) + attrs.add("DrawParam", obj.DrawParam) attrs.add("Boundary", obj.Boundary) attrs.add("CTM", obj.CTM) attrs.add("Font", obj.Font) @@ -489,6 +490,7 @@ func (x *ofdXML) object(object GraphicObject, root bool) { case "PathObject", "Path": obj := object.PathObject attrs.add("ID", obj.ID) + attrs.add("DrawParam", obj.DrawParam) attrs.add("Boundary", obj.Boundary) attrs.add("CTM", obj.CTM) attrs.number("LineWidth", obj.LineWidth)