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; gap: 12px;
padding: 6px 8px; padding: 6px 8px;
line-height: 1.4; line-height: 1.4;
overflow-wrap: anywhere;
cursor: pointer; cursor: pointer;
} }
.font-listbox [role="option"] > span:first-child { .font-listbox [role="option"] > span:first-child {
min-width: 0; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.font-option-source { .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"; import { FontManager, FontPicker } from "./ofdgo_font.js";
const MM_TO_PX = 96 / 25.4; const MM_TO_PX = 96 / 25.4;
@@ -1727,7 +1727,7 @@ async function loadWASM() {
if (data.ok) { if (data.ok) {
request.resolve(data.data); request.resolve(data.data);
} else { } else {
const err = new Error(data.error); const err = new Error(missingGlyphMessage(data.missingGlyphs) || data.error);
if (data.canceled) { if (data.canceled) {
err.name = "AbortError"; 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])); 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) { export function objectEditReason(item) {
const reasons = (item?.items || (item ? [item] : [])).map(object => { const reasons = (item?.items || (item ? [item] : [])).map(object => {
const reason = object.capabilities?.reason || ""; const reason = object.capabilities?.reason || "";
if (!reason) return ""; if (!reason) return "";
let message = "对象特性暂不支持"; let message = "对象特性暂不支持";
if (reason.includes("embedded font")) message = "缺少内嵌字体"; if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
else if (reason.includes("does not contain")) message = "字体缺字"; else if (reason.includes("embedded font")) 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 = "图层特性暂不支持";
+3 -3
View File
@@ -80,7 +80,7 @@ export class FontPicker {
label.textContent = source; label.textContent = source;
option.append(label); option.append(label);
} }
option.title = font.name || font.fullName; option.title = [...new Set([font.fullName, font.name].filter(Boolean))].join(" · ");
this.list.append(option); this.list.append(option);
} }
if (!this.matches.length) { if (!this.matches.length) {
@@ -100,9 +100,9 @@ export class FontPicker {
position() { position() {
if (!this.open) return; 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; } 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`, 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` }); 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" "context"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"image" "image"
"io" "io"
@@ -77,9 +78,10 @@ var currentSession *Session
// apiResult 浏览器接口返回结果 // apiResult 浏览器接口返回结果
type apiResult struct { type apiResult struct {
OK bool `json:"ok"` OK bool `json:"ok"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"` Data any `json:"data,omitempty"`
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
} }
// RunWASM 注册浏览器WASM接口并阻塞运行 // 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 { func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
data, err := safeCall(fn, args) data, err := safeCall(fn, args)
if err != nil { 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 { if result, ok := data.(js.Value); ok {
return result 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, 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}} "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 { if object.Type == "ImageObject" && capability.Update {
full, err := object.ImageObject.ImageBounds() full, err := object.ImageObject.ImageBounds()
if err != nil { 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) lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
} }
var codes []TextCode var codes []TextCode
var missing []rune
for _, paragraph := range strings.Split(value, "\n") { for _, paragraph := range strings.Split(value, "\n") {
runes := []rune(paragraph) runes := []rune(paragraph)
advances := make([]float64, len(runes)) advances := make([]float64, len(runes))
for i, char := range runes { for i, char := range runes {
glyph := sfnt.GlyphIndex(char) glyph := sfnt.GlyphIndex(char)
if glyph == 0 { 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 advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
} }
if len(missing) != 0 {
continue
}
spaceTextAdvances(runes, advances, options.LetterSpacing) spaceTextAdvances(runes, advances, options.LetterSpacing)
for _, advance := range advances { for _, advance := range advances {
if !finite(advance) { 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]]))}) 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.TextCode = codes
obj.layout = nil obj.layout = nil
if options != (TextLayout{}) || e.objectOrigin(obj.ID) != 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...) obj.TextCode = append([]TextCode(nil), obj.TextCode...)
var missing []rune
x, y := "", "" x, y := "", ""
for i := range obj.TextCode { for i := range obj.TextCode {
code := &obj.TextCode[i] code := &obj.TextCode[i]
@@ -939,7 +948,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
runes := textCodeRunes(code.Value) runes := textCodeRunes(code.Value)
for _, char := range runes { for _, char := range runes {
if sfnt.GlyphIndex(char) == 0 { 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} { 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") == "" { if strings.Trim(obj.Text(), "\n") == "" {
return fmt.Errorf("text codes are empty") return fmt.Errorf("text codes are empty")
} }
return nil return missingGlyphError(obj.Font, missing)
} }
// creationDeltas 校验文字位移数组及g重复编码 // creationDeltas 校验文字位移数组及g重复编码
+4 -4
View File
@@ -57,7 +57,7 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
return nil, err return nil, err
} }
if !capability.Copy { 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) objects[i], err = cloneEditorObject(object)
if err != nil { if err != nil {
@@ -362,7 +362,7 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool)
return err return err
} }
if !capability.Transform || !geometry && !e.sourceRGB() { 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) { if geometry && e.originalPage(page) {
@@ -498,7 +498,7 @@ func (e *Editor) DeleteObjects(page int, ids []string) error {
return err return err
} }
if !capability.Delete { 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 layers := copyEditorPage(e.pages[page]).Content.Layer
@@ -568,7 +568,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error)
return nil, err return nil, err
} }
if !capability.Arrange { 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() reader, err := e.Reader()
+24 -10
View File
@@ -17,6 +17,7 @@ package ofdgo
import ( import (
"bytes" "bytes"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"image" "image"
"io" "io"
@@ -59,17 +60,28 @@ type editorSourcePage struct {
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因 // ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块 // Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复 // Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
type ObjectCapabilities struct { type ObjectCapabilities struct {
Update bool Update bool
ReplaceFont bool ReplaceFont bool
Reflow bool Reflow bool
LayoutKnown bool LayoutKnown bool
Transform bool Transform bool
Arrange bool Arrange bool
Copy bool Copy bool
Delete bool Delete bool
Order bool Order bool
Reason string 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对象;新增空白页面始终可用 // PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用
@@ -351,10 +363,12 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
all.Order = editorContainerOrderable(node.parent) all.Order = editorContainerOrderable(node.parent)
if _, err := e.prepareObject(id, object); err != nil { if _, err := e.prepareObject(id, object); err != nil {
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error() all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
errors.As(err, &all.MissingGlyphs)
} }
if !e.sourceRGB() { if !e.sourceRGB() {
all.ReplaceFont = false all.ReplaceFont = false
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space" 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" { if object.Type == "TextObject" {
all.Arrange = all.Update all.Arrange = all.Update
+36
View File
@@ -15,11 +15,47 @@
package ofdgo package ofdgo
import ( import (
"fmt"
"strings" "strings"
"github.com/go-text/typesetting/segmenter" "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 // TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 // LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// 零值保持显式换行和左对齐 // 零值保持显式换行和左对齐