From 47f7628959030b74d6b81942b6a59a0273b3db53 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 14:37:27 +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=E5=AF=B9=E8=B1=A1=E9=80=89=E5=8C=BA=E4=B8=8E?= =?UTF-8?q?=E5=8F=98=E6=8D=A2=EF=BC=8C=E7=B2=BE=E7=AE=80=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 6 +- assets/webui/ofdgo.js | 6 +- assets/webui/ofdgo_edit.js | 2 +- internal/webui/wasm.go | 6 +- ofdgo_editor_batch.go | 35 +++------ ofdgo_editor_transform.go | 8 +- ofdgo_renderer_bounds.go | 148 +++++++++++++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 40 deletions(-) create mode 100644 ofdgo_renderer_bounds.go diff --git a/assets/webui/index.html b/assets/webui/index.html index bec6915..0947ab8 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -17,8 +17,8 @@
- - + +
@@ -40,7 +40,7 @@ - +
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 4ba8eba..f57839a 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -2939,7 +2939,7 @@ function copyEditorSelection(event) { const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order); if (!canEditObject(canvasEditor.selected, "copy")) { event.preventDefault(); - setStatus("当前对象不可复制"); + setStatus("对象不可复制"); return true; } const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[items[0].index].id, offset: 0 }; @@ -2964,7 +2964,7 @@ async function pasteEditorContent(event) { if (token) { const clipboard = state.objectClipboard; if (!clipboard || token !== clipboard.token) { - setStatus("对象剪贴板已失效"); + setStatus("对象剪贴板失效"); return; } const openSeq = state.openSeq; @@ -4418,7 +4418,7 @@ function updateControls() { function updateEditorTools() { el.editButton.disabled = !state.doc || !state.ready || state.exporting; el.editButton.setAttribute("aria-pressed", String(state.editing)); - el.editButton.title = state.editing ? "返回阅读" : "编辑文档"; + el.editButton.title = state.editing ? "阅读" : "编辑"; el.editButton.setAttribute("aria-label", el.editButton.title); el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || ""; el.editNotice.hidden = !state.editing || !el.editNotice.textContent; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index c276cfe..6c71c2e 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -128,7 +128,7 @@ export function objectEditReason(item) { if (!reason) return ""; let message = "对象特性暂不支持"; if (reason.includes("embedded font")) message = "缺少内嵌字体"; - else if (reason.includes("does not contain")) 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 = "图层特性暂不支持"; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 73d2ba6..0a991a0 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -655,10 +655,8 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { switch object.Type { case "TextObject": box = textBoxes[id] - case "ImageObject": - box, err = ofdgo.ParseBox(object.ImageObject.Boundary) - case "PathObject": - box, err = ofdgo.ParseBox(object.PathObject.Boundary) + default: + box, err = currentSession.Renderer.ObjectBounds(object, layer.DrawParam) } if err != nil { continue diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index f3d4593..f07c16b 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -499,9 +499,9 @@ func editorObjectID(object GraphicObject) string { return "" } -// objectBounds 批量获取对齐范围,每页只度量一次文字。 +// objectBounds 批量获取对齐范围,复用同一渲染器的资源和字形缓存。 // 入参: page 页面索引, objects 待度量的对象 -// 返回: []Box 对象范围,文字采用字形范围,其余采用Boundary, error 错误信息 +// 返回: []Box 对象范围, error 错误信息 func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) { for _, object := range objects { capability, err := e.ObjectCapabilities(page, editorObjectID(object)) @@ -512,32 +512,17 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) return nil, fmt.Errorf("object %q cannot be arranged: %s", editorObjectID(object), capability.Reason) } } - textBoxes := make(map[string]Box) - if slices.ContainsFunc(objects, func(o GraphicObject) bool { return o.Type == "TextObject" }) { - reader, err := e.Reader() - if err != nil { - return nil, err - } - defer reader.Close() - text, err := NewRenderer(reader).PageText(&e.pages[page]) - if err != nil { - return nil, err - } - for _, run := range text.Runs { - for _, box := range run.Boxes { - textBoxes[run.ID] = unionTextBox(textBoxes[run.ID], box) - } - } + reader, err := e.Reader() + if err != nil { + return nil, err } + defer reader.Close() + renderer := NewRenderer(reader) boxes := make([]Box, len(objects)) for i, object := range objects { - switch object.Type { - case "TextObject": - boxes[i] = textBoxes[object.TextObject.ID] - case "PathObject": - boxes[i], _ = ParseBox(object.PathObject.Boundary) - case "ImageObject": - boxes[i], _ = ParseBox(object.ImageObject.Boundary) + boxes[i], err = renderer.ObjectBounds(object, "") + if err != nil { + return nil, err } if boxes[i].W <= 0 || boxes[i].H <= 0 { return nil, fmt.Errorf("object %q has no visible bounds", editorObjectID(object)) diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 9fe2a6c..6ce0698 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -20,14 +20,14 @@ import ( "slices" ) -// RotateObject 绕对象中心旋转,文字采用字形范围,其余对象采用Boundary。 +// RotateObject 绕对象可见范围中心旋转。 // 入参: page 页面索引, id 对象标识, degrees 顺时针角度 // 返回: error 错误信息 func (e *Editor) RotateObject(page int, id string, degrees int) error { return e.RotateObjects(page, []string{id}, degrees) } -// RotateObjects 绕同页选区中心旋转,文字采用字形范围,其余对象采用Boundary。 +// RotateObjects 绕同页选区的可见范围中心旋转。 // 保留相对位置及资源,一次撤销恢复全部。 // 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍 // 返回: error 错误信息 @@ -40,7 +40,7 @@ func (e *Editor) RotateObjects(page int, ids []string, degrees int) error { return e.orientObjects(page, ids, m) } -// FlipObject 绕对象中心镜像,文字采用字形范围,其余对象采用Boundary。 +// FlipObject 绕对象可见范围中心镜像。 // 入参: page 页面索引, id 对象标识, axis 为horizontal或vertical // 返回: error 错误信息 func (e *Editor) FlipObject(page int, id, axis string) error { @@ -48,7 +48,7 @@ func (e *Editor) FlipObject(page int, id, axis string) error { } // FlipObjects 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部。 -// 文字采用字形范围,其余对象采用Boundary。 +// 以对象可见范围计算选区。 // 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical // 返回: error 错误信息 func (e *Editor) FlipObjects(page int, ids []string, axis string) error { diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go new file mode 100644 index 0000000..c6985f6 --- /dev/null +++ b/ofdgo_renderer_bounds.go @@ -0,0 +1,148 @@ +// 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" + "image" + + "github.com/tdewolff/canvas" +) + +// ObjectBounds 获取文字、路径或图片在页面坐标中的轴对齐范围,不修改对象。 +// 文字采用字形范围,路径包含描边与裁剪,图片采用裁剪后的几何范围,不解码像素或排除透明像素。 +// 底纹按填充或描边轮廓度量,不展开图案单元。 +// 不应用页面边界或父级变换;无可见范围时返回零值,不支持的对象类型返回错误。 +// 入参: object 图形对象, drawParam 图层绘制参数标识,无继承时为空 +// 返回: Box 毫米坐标范围, error 错误信息 +func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, error) { + if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { + return Box{}, fmt.Errorf("unsupported object type %q", object.Type) + } + boundary, ctm := editorGeometry(object) + if _, err := creationBox(boundary); err != nil { + return Box{}, err + } + if ctm != "" { + if _, err := creationNumbers(ctm, 6); err != nil { + return Box{}, err + } + } + renderer := *r + renderer.pageText = nil + renderer.textOnly = false + defaults := renderer.drawParamDefaults(drawParam, nil) + bounds := &boundsRenderer{} + ctx := canvas.NewContext(bounds) + switch object.Type { + case "TextObject": + renderer.pageText = &PageText{} + renderer.textOnly = true + renderer.renderText(ctx, object.TextObject, 0, defaults, nil, false, nil) + var box Box + for _, run := range renderer.pageText.Runs { + for _, glyph := range run.Boxes { + box = unionTextBox(box, glyph) + } + } + return box, nil + case "PathObject": + obj := object.PathObject + defaults = renderer.drawParamDefaults(obj.DrawParam, defaults) + obj.DrawParam = "" + if defaults != nil { + style := *defaults + style.FillColor = boundsColor(style.FillColor) + style.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(style.StrokeColor))) + defaults = &style + } + obj.FillColor = boundsColor(obj.FillColor) + obj.StrokeColor = (*StrokeColor)(boundsColor((*FillColor)(obj.StrokeColor))) + renderer.renderPath(ctx, obj, 0, defaults, nil, false, nil) + case "ImageObject": + obj := object.ImageObject + if obj.Visible != nil && !*obj.Visible || obj.Alpha != nil && *obj.Alpha == 0 { + return Box{}, nil + } + box, _ := ParseBox(obj.Boundary) + ctm := NewMatrix(obj.CTM) + if obj.CTM == "" { + ctm = Matrix{a: box.W, d: box.H} + } + p := canvas.Rectangle(1, 1).Transform(canvas.Matrix{{ctm.a, ctm.c, box.X + ctm.e}, {-ctm.b, -ctm.d, -box.Y - ctm.f}}) + clip := renderer.buildObjectClipPath(obj.Clips, 0, box.X, box.Y, ctm, nil, false) + bounds.add(applyClipPath(p, clip)) + if obj.Border != nil { + border := *obj.Border + border.BorderColor = (*StrokeColor)(boundsColor((*FillColor)(border.BorderColor))) + obj.Border = &border + renderer.renderImageBorder(ctx, obj, box, 0, nil, false, clip) + } + } + return bounds.box, nil +} + +// boundsColor 以底纹的基础颜色和透明度度量轮廓,不修改原画刷。 +// 入参: color 填充或描边颜色 +// 返回: *FillColor 用于度量的颜色 +func boundsColor(color *FillColor) *FillColor { + if color == nil || color.Pattern == nil { + return color + } + return &FillColor{Value: color.Value, Index: color.Index, ColorSpace: color.ColorSpace, Alpha: color.Alpha} +} + +// boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓。 +type boundsRenderer struct { + box Box +} + +// Size 返回不限定边界的度量画布尺寸。 +// 返回: float64 宽度, float64 高度 +func (r *boundsRenderer) Size() (float64, float64) { + return 0, 0 +} + +// add 合并路径的精确曲线范围并转换为向下的纵轴。 +// 入参: path 绘制路径 +func (r *boundsRenderer) add(path *canvas.Path) { + if path.Empty() { + return + } + rect := path.Bounds() + r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) +} + +// RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义。 +// 入参: path 路径, style 绘制样式, m 变换矩阵 +func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { + if style.HasFill() { + r.add(path.Copy().Transform(m)) + } + if style.HasStroke() { + p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) + r.add(p.Transform(m)) + } +} + +// RenderText 收集绘制文字范围。 +// 入参: text 文字, m 变换矩阵 +func (r *boundsRenderer) RenderText(text *canvas.Text, m canvas.Matrix) { + text.RenderTo(r, m, 0) +} + +// RenderImage 度量不展开底纹图片。 +// 入参: img 图片, m 变换矩阵 +func (r *boundsRenderer) RenderImage(img image.Image, m canvas.Matrix) {}