feat(编辑能力): 完善缺字诊断,优化字体选择体验
build.yaml / build (push) Successful in 40s

This commit is contained in:
xiaoqidun committed 2026-09-16 20:36:11 +08:00
1 parent bf27afea13
commit f924f008a3
9 files changed
+92 -16

No files matched your search

+3 -1
View File
@@ -273,12 +273,14 @@ a:focus-visible {
gap: 12px;
padding: 6px 8px;
line-height: 1.4;
overflow-wrap: anywhere;
cursor: pointer;
}
.font-listbox [role="option"] > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.font-option-source {
+2 -2
View File
@@ -1,4 +1,4 @@
import { CanvasEditor, canEditObject, objectEditReason } from "./ofdgo_edit.js";
import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason } from "./ofdgo_edit.js";
import { FontManager, FontPicker } from "./ofdgo_font.js";
const MM_TO_PX = 96 / 25.4;
@@ -1727,7 +1727,7 @@ async function loadWASM() {
if (data.ok) {
request.resolve(data.data);
} else {
const err = new Error(data.error);
const err = new Error(missingGlyphMessage(data.missingGlyphs) || data.error);
if (data.canceled) {
err.name = "AbortError";
}
+10 -2
View File
@@ -122,13 +122,21 @@ export function canEditObject(item, capability) {
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability]));
}
export function missingGlyphMessage(diagnostic) {
if (!diagnostic) return "";
const characters = Array.from(diagnostic.characters);
const sample = characters.slice(0, 8).map(char => /[\p{C}\p{Z}\p{M}]/u.test(char)
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join(" ");
return `字体缺字:${sample}${characters.length > 8 ? ` 等 ${characters.length} 字` : ""},改选字体`;
}
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 = "字体缺字";
if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
else if (reason.includes("embedded font")) message = "缺少内嵌字体";
else if (reason.includes("color") || reason.includes("RGB")) message = "颜色样式暂不支持";
else if (reason.includes("draw parameter")) message = "绘制参数暂不支持";
else if (reason.includes("layer")) message = "图层特性暂不支持";
+3 -3
View File
@@ -80,7 +80,7 @@ export class FontPicker {
label.textContent = source;
option.append(label);
}
option.title = font.name || font.fullName;
option.title = [...new Set([font.fullName, font.name].filter(Boolean))].join(" · ");
this.list.append(option);
}
if (!this.matches.length) {
@@ -100,9 +100,9 @@ export class FontPicker {
position() {
if (!this.open) return;
const rect = this.input.getBoundingClientRect();
const rect = this.input.parentElement.getBoundingClientRect();
if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; }
const width = Math.min(Math.max(rect.width, 260), window.innerWidth - 16);
const width = Math.min(rect.width, window.innerWidth - 16);
Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`,
top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` });
}
+8 -1
View File
@@ -22,6 +22,7 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"io"
@@ -80,6 +81,7 @@ type apiResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"`
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
}
// RunWASM 注册浏览器WASM接口并阻塞运行
@@ -167,7 +169,9 @@ func registerExportCallback(name string, fn func([]js.Value) (any, error)) {
func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
data, err := safeCall(fn, args)
if err != nil {
return encodeResult(apiResult{OK: false, Error: err.Error()})
result := apiResult{Error: err.Error()}
errors.As(err, &result.MissingGlyphs)
return encodeResult(result)
}
if result, ok := data.(js.Value); ok {
return result
@@ -698,6 +702,9 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
}
item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container,
"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 missing := capability.MissingGlyphs; missing != nil {
item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
}
if object.Type == "ImageObject" && capability.Update {
full, err := object.ImageObject.ImageBounds()
if err != nil {
+12 -3
View File
@@ -858,16 +858,21 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
}
var codes []TextCode
var missing []rune
for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph)
advances := make([]float64, len(runes))
for i, char := range runes {
glyph := sfnt.GlyphIndex(char)
if glyph == 0 {
return fmt.Errorf("font does not contain U+%04X", char)
missing = append(missing, char)
continue
}
advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
}
if len(missing) != 0 {
continue
}
spaceTextAdvances(runes, advances, options.LetterSpacing)
for _, advance := range advances {
if !finite(advance) {
@@ -884,6 +889,9 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
codes = append(codes, TextCode{X: ofdNumber(x), Y: ofdNumber(y), DeltaX: deltas, Value: escapeOFDText(string(runes[line[0]:line[1]]))})
}
}
if err := missingGlyphError(obj.Font, missing); err != nil {
return err
}
obj.TextCode = codes
obj.layout = nil
if options != (TextLayout{}) || e.objectOrigin(obj.ID) != nil {
@@ -918,6 +926,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
}
}
obj.TextCode = append([]TextCode(nil), obj.TextCode...)
var missing []rune
x, y := "", ""
for i := range obj.TextCode {
code := &obj.TextCode[i]
@@ -939,7 +948,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
runes := textCodeRunes(code.Value)
for _, char := range runes {
if sfnt.GlyphIndex(char) == 0 {
return fmt.Errorf("font does not contain U+%04X", char)
missing = append(missing, char)
}
}
for _, delta := range []string{code.DeltaX, code.DeltaY} {
@@ -957,7 +966,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
if strings.Trim(obj.Text(), "\n") == "" {
return fmt.Errorf("text codes are empty")
}
return nil
return missingGlyphError(obj.Font, missing)
}
// creationDeltas 校验文字位移数组及g重复编码
+4 -4
View File
@@ -57,7 +57,7 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
return nil, err
}
if !capability.Copy {
return nil, fmt.Errorf("object %q cannot be copied: %s", editorObjectID(object), capability.Reason)
return nil, fmt.Errorf("object %q cannot be copied: %w", editorObjectID(object), capability.editError())
}
objects[i], err = cloneEditorObject(object)
if err != nil {
@@ -362,7 +362,7 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool)
return err
}
if !capability.Transform || !geometry && !e.sourceRGB() {
return fmt.Errorf("object %q is read-only for this operation: %s", ids[i], capability.Reason)
return fmt.Errorf("object %q is read-only for this operation: %w", ids[i], capability.editError())
}
}
if geometry && e.originalPage(page) {
@@ -498,7 +498,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error {
return err
}
if !capability.Delete {
return fmt.Errorf("object %q cannot be deleted: %s", id, capability.Reason)
return fmt.Errorf("object %q cannot be deleted: %w", id, capability.editError())
}
}
layers := copyEditorPage(e.pages[page]).Content.Layer
@@ -568,7 +568,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error)
return nil, err
}
if !capability.Arrange {
return nil, fmt.Errorf("object %q cannot be arranged: %s", editorObjectID(object), capability.Reason)
return nil, fmt.Errorf("object %q cannot be arranged: %w", editorObjectID(object), capability.editError())
}
}
reader, err := e.Reader()
+14
View File
@@ -17,6 +17,7 @@ package ofdgo
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"image"
"io"
@@ -59,6 +60,7 @@ type editorSourcePage struct {
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
type ObjectCapabilities struct {
Update bool
ReplaceFont bool
@@ -70,6 +72,16 @@ type ObjectCapabilities struct {
Delete bool
Order bool
Reason string
MissingGlyphs *MissingGlyphError
}
// editError 将操作受限原因转为错误,保留可供调用方识别的缺字诊断
// 返回: error 受限原因
func (c ObjectCapabilities) editError() error {
if c.MissingGlyphs != nil {
return c.MissingGlyphs
}
return errors.New(c.Reason)
}
// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用
@@ -351,10 +363,12 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
all.Order = editorContainerOrderable(node.parent)
if _, err := e.prepareObject(id, object); err != nil {
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
errors.As(err, &all.MissingGlyphs)
}
if !e.sourceRGB() {
all.ReplaceFont = false
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
all.MissingGlyphs = nil
}
if object.Type == "TextObject" {
all.Arrange = all.Update
+36
View File
@@ -15,11 +15,47 @@
package ofdgo
import (
"fmt"
"strings"
"github.com/go-text/typesetting/segmenter"
)
// MissingGlyphError 字体缺字错误,FontID为OFD字体资源标识,Characters按首次出现顺序保留不重复的缺失字符
// 可通过errors.As从编辑错误中取得,不表示字体缺失或文件损坏
type MissingGlyphError struct {
FontID string `json:"fontID"`
Characters string `json:"characters"`
}
// Error 返回缺失字符的Unicode码点
// 返回: string 错误描述
func (e *MissingGlyphError) Error() string {
var codes []string
for _, char := range e.Characters {
codes = append(codes, fmt.Sprintf("U+%04X", char))
}
return fmt.Sprintf("font %q does not contain %s", e.FontID, strings.Join(codes, ", "))
}
// missingGlyphError 汇总缺失字符,无缺字时返回nil
// 入参: id 字体资源标识, characters 缺失字符
// 返回: error 缺字诊断
func missingGlyphError(id string, characters []rune) error {
if len(characters) == 0 {
return nil
}
seen := make(map[rune]bool)
var value strings.Builder
for _, char := range characters {
if !seen[char] {
seen[char] = true
value.WriteRune(char)
}
}
return &MissingGlyphError{FontID: id, Characters: value.String()}
}
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// 零值保持显式换行和左对齐