From f9646c70e92f2c950860e3c2f4fadaaac4534019 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 19:11:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AD=97=E4=BD=93=E7=BC=96=E8=BE=91=E9=94=99?= =?UTF-8?q?=E9=85=8D=EF=BC=8C=E4=BC=98=E5=8C=96=E6=9D=A5=E6=BA=90=E6=A0=87?= =?UTF-8?q?=E8=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.css | 14 ++++++++ assets/webui/ofdgo_font.js | 24 ++++++++++++- internal/webui/wasm.go | 15 ++------ ofdgo_editor_open.go | 25 +++++--------- ofdgo_font_fs.go | 27 +++++++++++---- ofdgo_font_info.go | 25 ++++++++++++++ ofdgo_font_loader.go | 31 ++++++++++------- ofdgo_font_name.go | 70 ++++++++++++++++++++++++++------------ 8 files changed, 163 insertions(+), 68 deletions(-) diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index c2f7e1a..cd71828 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -265,12 +265,26 @@ a:focus-visible { } .font-listbox [role="option"] { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; padding: 6px 8px; line-height: 1.4; overflow-wrap: anywhere; cursor: pointer; } +.font-listbox [role="option"] > span:first-child { + min-width: 0; +} + +.font-option-source { + flex: none; + color: var(--text-muted); + font-size: 12px; +} + .font-listbox [role="option"]:hover, .font-listbox [aria-selected="true"] { background: var(--accent-soft); diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index c40325d..8d5fb22 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -26,6 +26,11 @@ export class FontPicker { setFonts(fonts, selected) { this.close(); this.fonts = fonts; + this.names = new Map(); + for (const font of fonts) { + const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); + this.names.set(name, (this.names.get(name) || 0) + 1); + } const key = font => font?.id || font?.postscriptName; this.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : ""; this.restore(); @@ -39,10 +44,17 @@ export class FontPicker { restore() { const font = this.fonts[Number(this.value)]; this.input.value = font?.fullName || font?.name || ""; + this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : ""; this.input.setAttribute("aria-expanded", String(this.open)); this.input.removeAttribute("aria-activedescendant"); } + sourceLabel(font) { + const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); + if (this.names.get(name) < 2) return ""; + return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统"; + } + show(query = "") { if (this.input.disabled) return; const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); @@ -58,7 +70,17 @@ export class FontPicker { option.dataset.index = String(index); option.setAttribute("role", "option"); option.setAttribute("aria-disabled", String(Boolean(font.disabled))); - option.textContent = font.fullName || font.name; + const name = document.createElement("span"); + name.textContent = font.fullName || font.name; + option.append(name); + const source = this.sourceLabel(font); + if (source) { + const label = document.createElement("span"); + label.className = "font-option-source"; + label.textContent = source; + option.append(label); + } + option.title = font.name || font.fullName; this.list.append(option); } if (!this.matches.length) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 2c3f2a5..a6b31f6 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -758,27 +758,18 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return objects, nil } -// editorFont 读取创作对象的完整内嵌字体,供画布输入使用 +// editorFont 读取对象实际使用的内嵌字体,供画布输入使用 // 入参: args 字体资源标识 // 返回: any 字体数据, error 错误信息 func editorFont(args []js.Value) (any, error) { if currentEditor == nil { return nil, fmt.Errorf("no document is being edited") } - fonts, err := currentSession.Reader.Fonts() + data, err := currentSession.Reader.FontData(args[0].String()) if err != nil { return nil, err } - for _, font := range fonts { - if font.ID == args[0].String() { - data, err := currentSession.Reader.ResData(font.FontFile) - if err != nil { - return nil, err - } - return successResult(map[string]any{"bytes": bytesToJS(data)}), nil - } - } - return nil, fmt.Errorf("font resource %q not found", args[0].String()) + return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } // replaceImage 替换图片资源并更新预览 diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 797962d..cccc039 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -410,23 +410,16 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) { return sfnt, nil } if e.source != nil { - reader := e.source.reader - if f := reader.fontCache[id]; f != nil && f.FontFile != "" { - data, err := reader.ResData(f.FontFile) - if err != nil { - return nil, err - } - data, err = font.ToSFNT(data) - if err != nil { - return nil, err - } - sfnt, err := font.ParseSFNT(data, 0) - if err != nil { - return nil, err - } - e.fonts[id] = sfnt - return sfnt, nil + data, err := e.source.reader.FontData(id) + if err != nil { + return nil, err } + sfnt, err := font.ParseSFNT(data, 0) + if err != nil { + return nil, err + } + e.fonts[id] = sfnt + return sfnt, nil } return nil, fmt.Errorf("embedded font %q not found", id) } diff --git a/ofdgo_font_fs.go b/ofdgo_font_fs.go index 568fa82..0480c39 100644 --- a/ofdgo_font_fs.go +++ b/ofdgo_font_fs.go @@ -189,7 +189,7 @@ func (fsys *FontFS) matchStyle(names []string, bold, italic bool) []fontFileMatc // 返回: []fontFileMatch 按优先级排列的字体匹配 func fontFileMatches(candidates []fontFileCandidate, names []string, bold, italic bool) []fontFileMatch { matches := make([]fontFileMatch, 0, len(candidates)) - seen := make(map[string]int, len(candidates)) + seen := make(map[fontFileKey]int, len(candidates)) if len(names) == 0 { for _, name := range fontDefaultSystemNames() { matches = append(matches, fontFileMatches(candidates, []string{name}, bold, italic)...) @@ -199,7 +199,7 @@ func fontFileMatches(candidates []fontFileCandidate, names []string, bold, itali }) unique := matches[:0] for _, match := range matches { - key := strings.ToLower(match.name) + key := fontFileKey{strings.ToLower(match.name), match.face} if _, ok := seen[key]; !ok { seen[key] = len(unique) unique = append(unique, match) @@ -278,6 +278,7 @@ func cleanFontName(name string) string { // fontFileCandidate 字体文件候选 type fontFileCandidate struct { name string + face int base string lowerBase string normalized string @@ -421,15 +422,22 @@ func (m fontPatternMatcher) styleSuffixNormalized(name string) string { // fontFileMatch 字体文件匹配结果 type fontFileMatch struct { name string + face int priority int rank int styleRank int sortName string } +// fontFileKey 字体文件与集合索引的去重键 +type fontFileKey struct { + name string + face int +} + // appendFontFileMatch 追加字体文件匹配结果 // 入参: matches 匹配结果, seen 已匹配文件, matcher 匹配器, file 字体文件候选, rank 匹配等级, bold 是否粗体, italic 是否斜体 -func appendFontFileMatch(matches *[]fontFileMatch, seen map[string]int, matcher fontPatternMatcher, file fontFileCandidate, rank int, bold, italic bool) { +func appendFontFileMatch(matches *[]fontFileMatch, seen map[fontFileKey]int, matcher fontPatternMatcher, file fontFileCandidate, rank int, bold, italic bool) { if rank == fontMatchNone { return } @@ -439,18 +447,20 @@ func appendFontFileMatch(matches *[]fontFileMatch, seen map[string]int, matcher } next := fontFileMatch{ name: file.name, + face: file.face, priority: matcher.priority, rank: rank, styleRank: fontFileStyleRank(suffix, bold, italic), sortName: file.lowerBase, } - if index, ok := seen[file.fold]; ok { + key := fontFileKey{file.fold, file.face} + if index, ok := seen[key]; ok { if fontFileMatchLess(next, (*matches)[index]) { (*matches)[index] = next } return } - seen[file.fold] = len(*matches) + seen[key] = len(*matches) *matches = append(*matches, next) } @@ -483,8 +493,13 @@ func fontFileMatchLess(left, right fontFileMatch) bool { // 返回: []string 字体文件列表 func fontFileMatchNames(matches []fontFileMatch) []string { names := make([]string, 0, len(matches)) + seen := make(map[string]bool, len(matches)) for _, match := range matches { - names = append(names, match.name) + key := strings.ToLower(match.name) + if !seen[key] { + seen[key] = true + names = append(names, match.name) + } } return names } diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 3c4d483..39bf4d3 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -15,6 +15,7 @@ package ofdgo import ( + "bytes" "encoding/xml" "fmt" "io" @@ -75,6 +76,30 @@ func (r *Reader) Fonts() ([]Font, error) { return fonts, nil } +// FontData 读取内嵌字体,集合按声明名称和样式提取为独立字体,其他格式保留原始字节 +// 入参: id 字体资源标识 +// 返回: []byte 字体数据, error 资源缺失或集合解析错误 +func (r *Reader) FontData(id string) ([]byte, error) { + if r.fontCache[id] == nil { + if _, err := r.Fonts(); err != nil { + return nil, err + } + } + f := r.fontCache[id] + if f == nil || f.FontFile == "" { + return nil, fmt.Errorf("embedded font %q not found", id) + } + data, err := r.ResData(f.FontFile) + if err != nil { + return nil, err + } + if bytes.HasPrefix(data, []byte("ttcf")) { + index := fontCollectionIndex(data, []string{f.FontName, f.FamilyName}, f.Bold, f.Italic) + return extractCollectionFont(data, index) + } + return data, nil +} + // FontInfos 获取OFD字体诊断信息 // 返回: []FontInfo 字体诊断列表, error 错误信息 func (r *Renderer) FontInfos() ([]FontInfo, error) { diff --git a/ofdgo_font_loader.go b/ofdgo_font_loader.go index 666a66e..9358e9c 100644 --- a/ofdgo_font_loader.go +++ b/ofdgo_font_loader.go @@ -40,6 +40,7 @@ const ( type fontSource struct { kind fontSourceKind index int + face int name string exact bool priority int @@ -49,6 +50,7 @@ type fontSource struct { type fontSourceKey struct { kind fontSourceKind index int + face int name string } @@ -76,7 +78,7 @@ func (r *Renderer) loadFont(fontID string) *canvas.FontFamily { fontStyle := canvasFontStyle(of) ff := canvas.NewFontFamily(of.FontName) if of.FontFile != "" { - if fontData, err := r.Reader.ResData(of.FontFile); err == nil { + if fontData, err := r.Reader.FontData(fontID); err == nil { if cidMap := getCFFCIDRuneMap(fontData); len(cidMap) > 0 { r.fontCIDMap[fontID] = cidMap } @@ -144,12 +146,12 @@ func (r *Renderer) fontSources(fontID string, font *Font, style canvas.FontStyle seen := make(map[fontSourceKey]bool) for _, dir := range r.fontDirs { for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, exact: true, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) } } for index := range r.fontFS { for _, match := range r.matchFontFS(index, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, exact: true, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) } } sortFontSources(sources) @@ -157,7 +159,7 @@ func (r *Renderer) fontSources(fontID string, font *Font, style canvas.FontStyle if canLoadSystemFonts() { for _, dir := range systemFontDirs() { for _, match := range r.matchFontFiles(dir, names, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, exact: true, priority: min(match.priority, 0)}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFile, name: match.name, face: match.face, exact: true, priority: min(match.priority, 0)}) } } for _, name := range fontSystemNames(names...) { @@ -174,7 +176,7 @@ func (r *Renderer) fontSources(fontID string, font *Font, style canvas.FontStyle sortFontSources(sources[systemStart:]) for index := range r.fontFS { for _, match := range r.matchFontFS(index, nil, bold, italic) { - sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name}) + sources = appendFontSource(sources, seen, fontSource{kind: fontSourceFS, index: index, name: match.name, face: match.face}) } } for index, fsys := range r.fontFS { @@ -202,7 +204,7 @@ func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source if source.name == "" { return sources } - key := fontSourceKey{kind: source.kind, index: source.index, name: source.name} + key := fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face} if seen[key] { return sources } @@ -219,7 +221,7 @@ func (r *Renderer) fontSourceMatch(fontID string, font *Font) (fontSource, bool) } style := canvasFontStyle(font) for _, source := range r.fontSources(fontID, font, style) { - key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name}, style: style} + key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face}, style: style} if cached, ok := r.fontCache[key]; ok { if cached != nil { return source, true @@ -238,22 +240,27 @@ func (r *Renderer) fontSourceMatch(fontID string, font *Font) (fontSource, bool) // 入参: family 字体族, source 字体来源, style Canvas字体样式 // 返回: *canvas.FontFamily 字体族 func (r *Renderer) loadFontSource(family *canvas.FontFamily, source fontSource, style canvas.FontStyle) *canvas.FontFamily { - key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name}, style: style} + key := fontCacheKey{fontSourceKey: fontSourceKey{kind: source.kind, index: source.index, name: source.name, face: source.face}, style: style} if cached, ok := r.fontCache[key]; ok { return cached } var err error + var data []byte switch source.kind { case fontSourceFile: - err = family.LoadFontFile(source.name, style) + data, err = os.ReadFile(source.name) case fontSourceFS: - var data []byte data, err = fs.ReadFile(r.fontFS[source.index], source.name) + case fontSourceSystem: + err = family.LoadSystemFont(source.name, style) + } + if err == nil && source.kind != fontSourceSystem { + if bytes.HasPrefix(data, []byte("ttcf")) { + data, err = extractCollectionFont(data, source.face) + } if err == nil { err = family.LoadFont(data, 0, style) } - case fontSourceSystem: - err = family.LoadSystemFont(source.name, style) } if err != nil { r.fontCache[key] = nil diff --git a/ofdgo_font_name.go b/ofdgo_font_name.go index bbf4479..a0714b0 100644 --- a/ofdgo_font_name.go +++ b/ofdgo_font_name.go @@ -34,42 +34,70 @@ type fontNameRecord struct { } // appendFontFileNames 使用字体内部名称匹配,缺失时保留文件名 -// 入参: candidates 字体候选, index 文件候选索引, names 字体内部名称 +// 入参: candidates 字体候选, index 文件候选索引, faces 各字体内部名称 // 返回: []fontFileCandidate 字体候选列表 -func appendFontFileNames(candidates []fontFileCandidate, index int, names []string) []fontFileCandidate { +func appendFontFileNames(candidates []fontFileCandidate, index int, faces [][]string) []fontFileCandidate { file := candidates[index] - for i, name := range names { - file.normalized = fontNormalizeName(name) - if i == 0 { - candidates[index] = file - } else { - candidates = append(candidates, file) + replaced := false + for face, names := range faces { + file.face = face + for _, name := range names { + file.normalized = fontNormalizeName(name) + if !replaced { + candidates[index] = file + replaced = true + } else { + candidates = append(candidates, file) + } } } return candidates } -// fontFileNames 读取字体或集合首项的名称 +// fontFileNames 按字体索引读取字体或集合中各项的名称 // 入参: file 字体数据 -// 返回: []string 字体名称及带样式的字体族名称 -func fontFileNames(file io.ReaderAt) []string { +// 返回: [][]string 各字体名称及带样式的字体族名称 +func fontFileNames(file io.ReaderAt) [][]string { var header [12]byte if _, err := file.ReadAt(header[:], 0); err != nil { return nil } - var offset int64 - if string(header[:4]) == "ttcf" { - if binary.BigEndian.Uint32(header[8:]) == 0 { - return nil - } + if string(header[:4]) != "ttcf" { + return [][]string{fontFaceNames(file, 0)} + } + version := binary.BigEndian.Uint32(header[4:]) + if version != 0x00010000 && version != 0x00020000 { + return nil + } + var faces [][]string + for i, count := uint32(0), binary.BigEndian.Uint32(header[8:]); i < count; i++ { var entry [4]byte - if _, err := file.ReadAt(entry[:], 12); err != nil { - return nil - } - offset = int64(binary.BigEndian.Uint32(entry[:])) - if _, err := file.ReadAt(header[:], offset); err != nil { + if _, err := file.ReadAt(entry[:], 12+int64(i)*4); err != nil { return nil } + faces = append(faces, fontFaceNames(file, int64(binary.BigEndian.Uint32(entry[:])))) + } + return faces +} + +// fontCollectionIndex 按内部名称与样式选择集合项,未匹配时保留首项 +// 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体 +// 返回: int 零起始字体索引 +func fontCollectionIndex(data []byte, names []string, bold, italic bool) int { + candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, fontFileNames(bytes.NewReader(data))) + if matches := fontFileMatches(candidates, names, bold, italic); len(matches) > 0 { + return matches[0].face + } + return 0 +} + +// fontFaceNames 读取指定字体表目录中的名称 +// 入参: file 字体数据, offset 字体表目录偏移 +// 返回: []string 字体名称及带样式的字体族名称 +func fontFaceNames(file io.ReaderAt, offset int64) []string { + var header [12]byte + if _, err := file.ReadAt(header[:], offset); err != nil { + return nil } switch string(header[:4]) { case "\x00\x01\x00\x00", "OTTO", "true":