diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 1780582..df21b11 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -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 { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index edf7e27..3dfbfc4 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -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"; } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index fcb29f4..f0c5548 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -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 = "图层特性暂不支持"; diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 9a57853..246e4fc 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -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` }); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index a6b31f6..f6fbdd0 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -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 { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 845946d..8060cf3 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -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重复编码 diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 4dcdb60..566045d 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -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() diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index cccc039..8bc268d 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -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 diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 7aa4499..9803526 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -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为字素间的附加毫米间距,可为负 // 零值保持显式换行和左对齐