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
+105 -29

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` });
}
+11 -4
View File
@@ -22,6 +22,7 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"io"
@@ -77,9 +78,10 @@ var currentSession *Session
// apiResult 浏览器接口返回结果
type apiResult struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"`
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()
+24 -10
View File
@@ -17,6 +17,7 @@ package ofdgo
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"image"
"io"
@@ -59,17 +60,28 @@ type editorSourcePage struct {
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
type ObjectCapabilities struct {
Update bool
ReplaceFont bool
Reflow bool
LayoutKnown bool
Transform bool
Arrange bool
Copy bool
Delete bool
Order bool
Reason string
Update bool
ReplaceFont bool
Reflow bool
LayoutKnown bool
Transform bool
Arrange bool
Copy bool
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为字素间的附加毫米间距,可为负
// 零值保持显式换行和左对齐