feat(编辑能力): 完善对象选区与变换,精简界面文案
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-16 14:37:27 +08:00
1 parent f312350f73
commit 47f7628959
7 files changed
+167 -36

No files matched your search

+3 -3
View File
@@ -17,8 +17,8 @@
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple> <input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
<div class="tool-group"> <div class="tool-group">
<button id="ofdButton" class="icon-button" type="button" title="打开" aria-label="打开" aria-keyshortcuts="Control+O Meta+O"><span class="open-icon" aria-hidden="true"></span></button> <button id="ofdButton" class="icon-button" type="button" title="打开" aria-label="打开" aria-keyshortcuts="Control+O Meta+O"><span class="open-icon" aria-hidden="true"></span></button>
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建文档"><span class="new-icon" aria-hidden="true"></span></button> <button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建"><span class="new-icon" aria-hidden="true"></span></button>
<button id="editButton" class="icon-button" type="button" title="编辑文档" aria-label="编辑文档" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button> <button id="editButton" class="icon-button" type="button" title="编辑" aria-label="编辑" aria-pressed="false" disabled><span class="edit-icon" aria-hidden="true"></span></button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button> <button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button> <button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
</div> </div>
@@ -40,7 +40,7 @@
<button id="fitHeightButton" class="button" type="button" data-short="高" aria-label="适高" disabled>适高</button> <button id="fitHeightButton" class="button" type="button" data-short="高" aria-label="适高" disabled>适高</button>
<button id="rotateButton" class="button" type="button" data-short="转" title="顺时针旋转90°" aria-label="旋转" disabled>旋转</button> <button id="rotateButton" class="button" type="button" data-short="转" title="顺时针旋转90°" aria-label="旋转" disabled>旋转</button>
<button id="panButton" class="button" type="button" data-short="拖" title="拖动页面" aria-label="拖动" aria-pressed="false" disabled>拖动</button> <button id="panButton" class="button" type="button" data-short="拖" title="拖动页面" aria-label="拖动" aria-pressed="false" disabled>拖动</button>
<button id="continuousButton" class="button" type="button" data-short="连" title="无距连续显示" aria-label="无距连续显示" aria-pressed="false" disabled>连续</button> <button id="continuousButton" class="button" type="button" data-short="连" title="连续显示" aria-label="连续显示" aria-pressed="false" disabled>连续</button>
<button id="annotationButton" class="button" type="button" data-short="注" title="关闭注解" aria-label="关闭注解" aria-pressed="true">注解</button> <button id="annotationButton" class="button" type="button" data-short="注" title="关闭注解" aria-label="关闭注解" aria-pressed="true">注解</button>
</div> </div>
<div class="tool-group"> <div class="tool-group">
+3 -3
View File
@@ -2939,7 +2939,7 @@ function copyEditorSelection(event) {
const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order); const items = canvasEditor.items().slice().sort((a, b) => a.order - b.order);
if (!canEditObject(canvasEditor.selected, "copy")) { if (!canEditObject(canvasEditor.selected, "copy")) {
event.preventDefault(); event.preventDefault();
setStatus("当前对象不可复制"); setStatus("对象不可复制");
return true; return true;
} }
const clipboard = { token: crypto.randomUUID(), page: state.doc.pages[items[0].index].id, offset: 0 }; 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) { if (token) {
const clipboard = state.objectClipboard; const clipboard = state.objectClipboard;
if (!clipboard || token !== clipboard.token) { if (!clipboard || token !== clipboard.token) {
setStatus("对象剪贴板已失效"); setStatus("对象剪贴板失效");
return; return;
} }
const openSeq = state.openSeq; const openSeq = state.openSeq;
@@ -4418,7 +4418,7 @@ function updateControls() {
function updateEditorTools() { function updateEditorTools() {
el.editButton.disabled = !state.doc || !state.ready || state.exporting; el.editButton.disabled = !state.doc || !state.ready || state.exporting;
el.editButton.setAttribute("aria-pressed", String(state.editing)); 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.editButton.setAttribute("aria-label", el.editButton.title);
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || ""; el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
el.editNotice.hidden = !state.editing || !el.editNotice.textContent; el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
+1 -1
View File
@@ -128,7 +128,7 @@ export function objectEditReason(item) {
if (!reason) return ""; if (!reason) return "";
let message = "对象特性暂不支持"; let message = "对象特性暂不支持";
if (reason.includes("embedded font")) 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("color") || reason.includes("RGB")) message = "颜色样式暂不支持";
else if (reason.includes("draw parameter")) message = "绘制参数暂不支持"; else if (reason.includes("draw parameter")) message = "绘制参数暂不支持";
else if (reason.includes("layer")) message = "图层特性暂不支持"; else if (reason.includes("layer")) message = "图层特性暂不支持";
+2 -4
View File
@@ -655,10 +655,8 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
switch object.Type { switch object.Type {
case "TextObject": case "TextObject":
box = textBoxes[id] box = textBoxes[id]
case "ImageObject": default:
box, err = ofdgo.ParseBox(object.ImageObject.Boundary) box, err = currentSession.Renderer.ObjectBounds(object, layer.DrawParam)
case "PathObject":
box, err = ofdgo.ParseBox(object.PathObject.Boundary)
} }
if err != nil { if err != nil {
continue continue
+6 -21
View File
@@ -499,9 +499,9 @@ func editorObjectID(object GraphicObject) string {
return "" return ""
} }
// objectBounds 批量获取对齐范围,每页只度量一次文字。 // objectBounds 批量获取对齐范围,复用同一渲染器的资源和字形缓存。
// 入参: page 页面索引, objects 待度量的对象 // 入参: page 页面索引, objects 待度量的对象
// 返回: []Box 对象范围,文字采用字形范围,其余采用Boundary, error 错误信息 // 返回: []Box 对象范围, error 错误信息
func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) { func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) {
for _, object := range objects { for _, object := range objects {
capability, err := e.ObjectCapabilities(page, editorObjectID(object)) 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) 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() reader, err := e.Reader()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer reader.Close() defer reader.Close()
text, err := NewRenderer(reader).PageText(&e.pages[page]) renderer := NewRenderer(reader)
if err != nil {
return nil, err
}
for _, run := range text.Runs {
for _, box := range run.Boxes {
textBoxes[run.ID] = unionTextBox(textBoxes[run.ID], box)
}
}
}
boxes := make([]Box, len(objects)) boxes := make([]Box, len(objects))
for i, object := range objects { for i, object := range objects {
switch object.Type { boxes[i], err = renderer.ObjectBounds(object, "")
case "TextObject": if err != nil {
boxes[i] = textBoxes[object.TextObject.ID] return nil, err
case "PathObject":
boxes[i], _ = ParseBox(object.PathObject.Boundary)
case "ImageObject":
boxes[i], _ = ParseBox(object.ImageObject.Boundary)
} }
if boxes[i].W <= 0 || boxes[i].H <= 0 { if boxes[i].W <= 0 || boxes[i].H <= 0 {
return nil, fmt.Errorf("object %q has no visible bounds", editorObjectID(object)) return nil, fmt.Errorf("object %q has no visible bounds", editorObjectID(object))
+4 -4
View File
@@ -20,14 +20,14 @@ import (
"slices" "slices"
) )
// RotateObject 绕对象中心旋转,文字采用字形范围,其余对象采用Boundary。 // RotateObject 绕对象可见范围中心旋转。
// 入参: page 页面索引, id 对象标识, degrees 顺时针角度 // 入参: page 页面索引, id 对象标识, degrees 顺时针角度
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) RotateObject(page int, id string, degrees int) error { func (e *Editor) RotateObject(page int, id string, degrees int) error {
return e.RotateObjects(page, []string{id}, degrees) return e.RotateObjects(page, []string{id}, degrees)
} }
// RotateObjects 绕同页选区中心旋转,文字采用字形范围,其余对象采用Boundary。 // RotateObjects 绕同页选区的可见范围中心旋转。
// 保留相对位置及资源,一次撤销恢复全部。 // 保留相对位置及资源,一次撤销恢复全部。
// 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍 // 入参: page 页面索引, ids 对象标识, degrees 顺时针角度,仅支持90度的整数倍
// 返回: error 错误信息 // 返回: error 错误信息
@@ -40,7 +40,7 @@ func (e *Editor) RotateObjects(page int, ids []string, degrees int) error {
return e.orientObjects(page, ids, m) return e.orientObjects(page, ids, m)
} }
// FlipObject 绕对象中心镜像,文字采用字形范围,其余对象采用Boundary。 // FlipObject 绕对象可见范围中心镜像。
// 入参: page 页面索引, id 对象标识, axis 为horizontal或vertical // 入参: page 页面索引, id 对象标识, axis 为horizontal或vertical
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) FlipObject(page int, id, axis string) 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 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部。 // FlipObjects 以同页选区中心镜像,保留资源及绘制顺序,一次撤销恢复全部。
// 文字采用字形范围,其余对象采用Boundary。 // 以对象可见范围计算选区。
// 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical // 入参: page 页面索引, ids 对象标识, axis 为horizontal或vertical
// 返回: error 错误信息 // 返回: error 错误信息
func (e *Editor) FlipObjects(page int, ids []string, axis string) error { func (e *Editor) FlipObjects(page int, ids []string, axis string) error {
+148
View File
@@ -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) {}