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 @@
-
-
-
-
图片
+
+
+
+
+
+
+ 插入
-
-
-
-
-
-
- 插入
-
+
+
+
+
+ 图片
+
+
+
+
+ 变换
+
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)