diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index cd71828..1780582 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -89,8 +89,8 @@ a:focus-visible { display: flex; max-height: 40dvh; overflow: auto; - gap: 12px; - padding: 8px 12px 6px; + gap: 8px; + padding: 8px 10px 6px; border-bottom: 1px solid var(--line); background: var(--panel); font-size: 14px; @@ -105,18 +105,15 @@ a:focus-visible { flex: 0 0 auto; grid-template-columns: repeat(3, max-content); grid-template-rows: repeat(2, 28px) 16px; - column-gap: 6px; + column-gap: 4px; row-gap: 4px; - padding-right: 12px; + padding-right: 8px; border-right: 1px solid var(--line); } .editor-file, .editor-erase, -.editor-paragraph, -.editor-image, -.editor-group:has(#textFont), -.editor-group:has(#shapeFill) { +.editor-image { grid-template-columns: repeat(2, max-content); } @@ -192,14 +189,19 @@ a:focus-visible { } .editor-group:has(#textFont) { - grid-template-columns: 100px 54px; + grid-template-columns: 102px 54px; } .editor-paragraph { - width: 218px; + width: 194px; grid-template-columns: 54px minmax(0, 1fr); } +.editor-group:has(#shapeFill) { + width: 180px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .editor-file #saveButton { grid-row: 1 / 3; height: 100%; @@ -308,6 +310,10 @@ a:focus-visible { justify-content: space-between; } +.shape-paint .color-input { + width: 32px; +} + .shape-paint, .shape-paint label { display: flex; @@ -322,6 +328,7 @@ a:focus-visible { } .editor-group:last-child { + padding-right: 0; border-right: 0; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index adaa81f..edf7e27 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -53,6 +53,7 @@ const state = { navigationScroll: new Map(), fontSyncPending: false, fontCatalogLoading: false, + fontFacesLoading: false, fontRenderPending: false, fontSyncing: false, doc: null, @@ -1001,23 +1002,40 @@ function editorFonts(item) { } async function loadEditorFonts() { - const local = fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied"; - if (state.fontCatalogLoading || !local && !fontManager.userFonts.some(font => font.enabled && !font.faces)) return; - state.fontCatalogLoading = true; - try { - if (local) { - try { await fontManager.queryLocal(); } - catch (err) { setStatus(err?.name === "NotAllowedError" ? "字体尚未授权" : String(err.message || err)); } - } - await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data)); - const open = fontPicker.open; - const selected = state.textFonts[Number(fontPicker.value)]; - const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value; - updateTextFonts(canvasEditor.selected, true); - if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query); } - } finally { - state.fontCatalogLoading = false; + const tasks = []; + if (!state.fontCatalogLoading && fontManager.canReadLocal() && !fontManager.catalogLoaded && fontManager.permission !== "denied") { + state.fontCatalogLoading = true; + tasks.push((async () => { + try { + await fontManager.queryLocal(); + refreshEditorFonts(); + } catch (err) { + setStatus(err?.name === "NotAllowedError" ? "字体尚未授权" : String(err.message || err)); + } finally { + state.fontCatalogLoading = false; + } + })()); } + if (!state.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) { + state.fontFacesLoading = true; + tasks.push((async () => { + try { + await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data)); + refreshEditorFonts(); + } finally { + state.fontFacesLoading = false; + } + })()); + } + await Promise.all(tasks); +} + +function refreshEditorFonts() { + const open = fontPicker.open; + const selected = state.textFonts[Number(fontPicker.value)]; + const query = el.textFont.value === (selected?.fullName || selected?.name || "") ? "" : el.textFont.value; + updateTextFonts(canvasEditor.selected, true); + if (open && !el.textFont.disabled) { if (query) el.textFont.value = query; fontPicker.show(query, false); } } async function readTextFont(font) { @@ -4139,6 +4157,10 @@ function fontResult(font) { if (font.matched) { parts.push(font.embedded || font.status === "embedded" ? `内嵌 ${font.matched}` : `匹配 ${font.matched}`); } + if (font.matchedFace?.fullName) { + parts.push(font.matchedFace.fullName); + if (font.matchedFace.index > 0) parts.push(`索引 ${font.matchedFace.index}`); + } if (font.detail) { parts.push(font.detail); } diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 8d5fb22..9a57853 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -55,7 +55,7 @@ export class FontPicker { return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统"; } - show(query = "") { + show(query = "", load = true) { if (this.input.disabled) return; const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); const terms = normalize(query).split(" ").filter(Boolean); @@ -95,7 +95,7 @@ export class FontPicker { this.position(); const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value); this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected); - this.onOpen?.(); + if (load) this.onOpen?.(); } position() { @@ -316,8 +316,9 @@ export class FontManager { } async loadFaces(inspect) { - for (const file of this.userFonts) { - if (!file.enabled || file.faces) continue; + while (true) { + const file = this.userFonts.find(font => font.enabled && !font.faces); + if (!file) return; try { file.faces = await inspect(await this.read(file)); } catch { file.faces = []; } } diff --git a/ofdgo_font_file.go b/ofdgo_font_file.go index 72f4ef3..a6339a8 100644 --- a/ofdgo_font_file.go +++ b/ofdgo_font_file.go @@ -50,20 +50,7 @@ func (f FontFile) Faces() ([]FontFace, error) { if err != nil { return nil, err } - records, values := fontNameValues(tables["name"]) - name := func(ids ...uint16) string { - for _, id := range ids { - for _, record := range records { - if record.Name == id { - if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" { - return value - } - } - } - } - return "" - } - face := FontFace{Index: index, FullName: name(4, 6, 16, 1), Family: name(16, 1), Style: name(17, 2), PostScriptName: name(6), Names: fontNamesFromTable(tables["name"])} + face := fontFaceInfo(tables["name"], index) if face.FullName == "" { return nil, fmt.Errorf("font %d has no usable name", index) } @@ -72,6 +59,26 @@ func (f FontFile) Faces() ([]FontFace, error) { return faces, nil } +// fontFaceInfo 从名称表读取字体信息 +// 入参: data 名称表数据, index 零起始字体索引 +// 返回: FontFace 字体信息 +func fontFaceInfo(data []byte, index int) FontFace { + records, values := fontNameValues(data) + name := func(ids ...uint16) string { + for _, id := range ids { + for _, record := range records { + if record.Name == id { + if value := strings.TrimSpace(values[[4]uint16{record.Platform, record.Encoding, record.Language, id}]); value != "" { + return value + } + } + } + } + return "" + } + return FontFace{Index: index, FullName: name(4, 6, 16, 1), Family: name(16, 1), Style: name(17, 2), PostScriptName: name(6), Names: fontNamesFromTable(data)} +} + // Face 提取指定字体,返回独立的OpenType数据,可用于预览或AddFont,且不修改原文件 // 入参: index 零起始字体索引,非集合文件只能为0 // 返回: []byte 独立字体数据, error 格式或索引错误 diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 39bf4d3..9751683 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -20,7 +20,10 @@ import ( "fmt" "io" "path" + "slices" "sort" + + "github.com/tdewolff/font" ) const ( @@ -34,18 +37,19 @@ const ( FontStatusMissing = "missing" ) -// FontInfo OFD字体诊断信息 +// FontInfo OFD字体诊断信息,MatchedFace为实际匹配字体及原文件中的零起始索引,无法读取时为空 type FontInfo struct { - ID string `json:"id"` - FontName string `json:"fontName"` - FamilyName string `json:"familyName"` - Charset string `json:"charset"` - FontFile string `json:"fontFile"` - Embedded bool `json:"embedded"` - Status string `json:"status"` - Matched string `json:"matched"` - Detail string `json:"detail"` - Used int `json:"used"` + ID string `json:"id"` + FontName string `json:"fontName"` + FamilyName string `json:"familyName"` + Charset string `json:"charset"` + FontFile string `json:"fontFile"` + Embedded bool `json:"embedded"` + Status string `json:"status"` + Matched string `json:"matched"` + MatchedFace *FontFace `json:"matchedFace,omitempty"` + Detail string `json:"detail"` + Used int `json:"used"` } // Fonts 获取OFD声明的字体列表 @@ -100,6 +104,48 @@ func (r *Reader) FontData(id string) ([]byte, error) { return data, nil } +// embeddedFontFace 按资源缓存内嵌字体名称并返回独立副本,不解析字形轮廓 +// 入参: of OFD字体定义 +// 返回: *FontFace 字体信息, error 资源读取错误 +func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) { + name := r.ResPath(of.FontFile) + faces, ok := r.fontFaces[name] + if !ok { + data, err := r.readFile(name) + if err != nil { + return nil, err + } + if data, err = font.ToSFNT(data); err == nil { + if count, err := fontFileCount(data); err == nil { + faces = make([]*FontFace, count) + for index := range faces { + if tables, err := fontFileTables(data, index); err == nil { + info := fontFaceInfo(tables["name"], index) + faces[index] = &info + } + } + } + } + r.fontFaces[name] = faces + } + if len(faces) == 0 { + return nil, nil + } + names := make([][]string, len(faces)) + for index, face := range faces { + if face != nil { + names[index] = face.Names + } + } + index := fontNameIndex(names, []string{of.FontName, of.FamilyName}, of.Bold, of.Italic) + if faces[index] == nil { + return nil, nil + } + face := *faces[index] + face.Names = slices.Clone(face.Names) + return &face, nil +} + // FontInfos 获取OFD字体诊断信息 // 返回: []FontInfo 字体诊断列表, error 错误信息 func (r *Renderer) FontInfos() ([]FontInfo, error) { @@ -194,22 +240,11 @@ func (r *Renderer) fontInfo(font Font) FontInfo { Embedded: font.FontFile != "", } if info.Embedded { - name := r.Reader.ResPath(font.FontFile) - var err error - if !r.Reader.fontFilesChecked[name] { - var file io.ReadCloser - file, err = r.Reader.openFile(name) - if err == nil { - _, err = io.Copy(io.Discard, file) - file.Close() - if err == nil { - r.Reader.fontFilesChecked[name] = true - } - } - } + face, err := r.Reader.embeddedFontFace(font) if err == nil { info.Status = FontStatusEmbedded info.Matched = path.Base(font.FontFile) + info.MatchedFace = face info.Detail = "使用内嵌字体文件" } else { info.Status = FontStatusMissing @@ -217,8 +252,10 @@ func (r *Renderer) fontInfo(font Font) FontInfo { } return info } - if source, ok := r.fontSourceMatch(font.ID, &font); ok { + if source, family := r.fontSourceMatch(font.ID, &font); family != nil { info.Matched = source.name + face := fontFaceInfo(family.Face(12, canvasFontStyle(&font)).Font.SFNT.Tables["name"], source.face) + info.MatchedFace = &face if source.exact { info.Status = FontStatusMatched info.Detail = "使用外部字体文件" diff --git a/ofdgo_font_loader.go b/ofdgo_font_loader.go index 9358e9c..cc985fd 100644 --- a/ofdgo_font_loader.go +++ b/ofdgo_font_loader.go @@ -214,26 +214,26 @@ func appendFontSource(sources []fontSource, seen map[fontSourceKey]bool, source // fontSourceMatch 获取匹配的字体来源 // 入参: fontID 字体ID, font OFD字体定义 -// 返回: fontSource 字体来源, bool 是否匹配 -func (r *Renderer) fontSourceMatch(fontID string, font *Font) (fontSource, bool) { +// 返回: fontSource 字体来源, *canvas.FontFamily 已加载的字体族,未匹配时为空 +func (r *Renderer) fontSourceMatch(fontID string, font *Font) (fontSource, *canvas.FontFamily) { if source, ok := r.fontSourceUsed[fontID]; ok { - return source, true + return source, r.fontMap[fontID] } style := canvasFontStyle(font) for _, source := range r.fontSources(fontID, font, 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 + return source, cached } continue } family := canvas.NewFontFamily(font.FontName) - if r.loadFontSource(family, source, style) != nil { - return source, true + if loaded := r.loadFontSource(family, source, style); loaded != nil { + return source, loaded } } - return fontSource{}, false + return fontSource{}, nil } // loadFontSource 加载字体来源 diff --git a/ofdgo_font_name.go b/ofdgo_font_name.go index a0714b0..8145c79 100644 --- a/ofdgo_font_name.go +++ b/ofdgo_font_name.go @@ -84,7 +84,14 @@ func fontFileNames(file io.ReaderAt) [][]string { // 入参: data 集合数据, names 字体名称, bold 是否粗体, italic 是否斜体 // 返回: int 零起始字体索引 func fontCollectionIndex(data []byte, names []string, bold, italic bool) int { - candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, fontFileNames(bytes.NewReader(data))) + return fontNameIndex(fontFileNames(bytes.NewReader(data)), names, bold, italic) +} + +// fontNameIndex 按名称与样式选择字体索引,未匹配时保留首项 +// 入参: faces 各字体名称, names 目标名称, bold 是否粗体, italic 是否斜体 +// 返回: int 零起始字体索引 +func fontNameIndex(faces [][]string, names []string, bold, italic bool) int { + candidates := appendFontFileNames([]fontFileCandidate{{}}, 0, faces) if matches := fontFileMatches(candidates, names, bold, italic); len(matches) > 0 { return matches[0].face } diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 811d451..fd6c4cb 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -37,7 +37,7 @@ type Reader struct { resourceFiles map[string]string fontCache map[string]*Font fontResourcesRead bool - fontFilesChecked map[string]bool + fontFaces map[string][]*FontFace colorSpaceCache map[string]*ColorSpace drawParamCache map[string]*DrawParam compositeGraphicUnitCache map[string]*CompositeGraphicUnit @@ -87,7 +87,7 @@ func (r *Reader) initRoot() error { r.resourcesRead = make(map[string]bool) r.resourceFiles = make(map[string]string) r.fontCache = make(map[string]*Font) - r.fontFilesChecked = make(map[string]bool) + r.fontFaces = make(map[string][]*FontFace) r.colorSpaceCache = make(map[string]*ColorSpace) r.drawParamCache = make(map[string]*DrawParam) r.compositeGraphicUnitCache = make(map[string]*CompositeGraphicUnit)