From 7a23dcc6dd9a35bc948598881d69fa7d3ab95597 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 28 Oct 2026 21:33:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AD=97=E4=BD=93=E5=A4=84=E7=90=86):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AD=97=E4=BD=93=E5=90=8D=E7=A7=B0=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=B2=97=E6=96=9C=E4=BD=93=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 276 ++++++++++++++++++++++++++++-------- assets/webui/ofdgo_edit.js | 9 +- assets/webui/ofdgo_font.js | 57 ++++++-- internal/base/font/file.go | 45 +++++- internal/base/font/name.go | 2 +- internal/webui/webui.go | 66 ++++++++- internal/webview/webview.go | 2 +- ofdgo_editor_selection.go | 89 ++++++++++++ ofdgo_font.go | 63 ++++++++ ofdgo_font_info.go | 42 +++++- 10 files changed, 574 insertions(+), 77 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index fe88644..3b45359 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -699,7 +699,7 @@ const fontManager = new FontManager({ onChange: scheduleFontSync, onPermissionChange: updateFontPermissionHint, }); -const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); +const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts, groupTextFonts); const textFormatButtons = [...document.querySelectorAll("[data-text-format]")]; const textBackground = document.querySelector("#textBackground"); const editorInputs = new Map(); @@ -4152,15 +4152,61 @@ function currentTextStyle() { const text = selectedText(canvasEditor.selected) || state.textDefaults; if (canvasEditor.input?.rich) { const rich = canvasEditor.input.rich, layout = richTextLayout(rich.model, ...rich.selection()); - return { ...text, ...rich.state(), ...layout, paragraphHeight: layout.lineHeight }; + return { ...text, ...rich.state(), ...textFaceStyle(rich.selectedRuns().map(run => run.format)), ...layout, paragraphHeight: layout.lineHeight }; } if (text.richText) { - const formats = text.richText.runs.map(run => run.format); + const formats = text.richText.runs.map(run => effectiveTextFormat(run.format)); const layout = richTextLayout(text.richText); return { ...text, ...layout, paragraphHeight: layout.lineHeight, ...Object.fromEntries(["font", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"] .map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined])) }; } - return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text; + if (text.items) return { ...text, ...textFaceStyle(text.items.flatMap(item => textFontRuns(item).map(run => run.format))) }; + return text === state.textDefaults ? text : effectiveTextFormat(canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text); +} + +function documentFontChoice(id) { + const font = state.doc?.fonts?.find(font => font.id === id); + return { ...(font?.status !== "fallback" && font?.status !== "missing" ? font?.matchedFace : null), id: `embedded:${id}`, name: font?.fontName || id, embedded: true }; +} + +function textFontChoice(id) { + const rich = canvasEditor.input?.rich; + const choice = rich?.choices.get(id); + return choice ? { ...choice, richKey: id } : id ? documentFontChoice(id) : state.textDefaults.fontChoice; +} + +function effectiveTextFormat(format) { + const font = textFontChoice(format.font), face = canvasEditor.input?.rich?.faces.get(format.font); + return { ...format, bold: Boolean(format.bold || font?.bold || Number(face?.weight) >= 600), italic: Boolean(format.italic || font?.italic || face?.style === "italic") }; +} + +function textFaceStyle(formats) { + formats = formats.map(effectiveTextFormat); + return Object.fromEntries(["bold", "italic"].map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined])); +} + +function textFontRuns(item) { + return (item?.items || [item || state.textDefaults]).flatMap(item => item.richText?.runs || [{ text: item.text || "", format: { font: item.font || "", bold: Boolean(item.bold), italic: Boolean(item.italic) } }]); +} + +function textFontSource(font) { + return font.embedded ? "embedded" : font.file || ["stored", "upload"].includes(font.source) ? "upload" : "system"; +} + +function textFontIdentity(font) { + return `${textFontSource(font)}:${font.embedded ? font.richKey || font.id?.slice(9) : font.id ?? font.postscriptName}`; +} + +async function groupTextFonts(fonts, tab = documentTab()) { + if (!fonts.some(font => font.familyKey)) return fonts.map((_, index) => [index]); + const { groups } = await callTabWASM(tab, "ofdgoFontMenu", JSON.stringify({ faces: fonts.map(font => ({ familyKey: font.familyKey, bold: font.bold, italic: font.italic })), sources: fonts.map(textFontSource) })); + return groups; +} + +function sharedTextFont(formats) { + const choices = formats.map(format => textFontChoice(format.font)); + const first = choices[0]; + return first && choices.every(font => font && (font.familyKey && font.familyKey === first.familyKey && textFontSource(font) === textFontSource(first) || textFontIdentity(font) === textFontIdentity(first))) ? formats[0].font : undefined; } async function loadRichText(item, firstFace, tab = documentTab(), firstData = null, reflow = false) { @@ -4260,12 +4306,13 @@ function flushEditorInput(control) { function flushEditorInputs(event) { const tasks = [...editorInputs.values()].filter(control => !event || !control.input.contains(event.target)) - .map(flushEditorInput).concat(fontPicker.prepareCommit(event)).filter(Boolean); + .map(flushEditorInput).concat(fontPicker.prepareCommit(event), canvasEditor.input?.fontTask).filter(Boolean); return tasks.length ? Promise.all(tasks) : null; } async function changeRangeFormat(key, value) { if (!state.ready || document.body.hasAttribute("data-busy") || canvasEditor.input?.composing) return; + if (key === "bold" || key === "italic") return changeTextFace(null, key, value ?? currentTextStyle()[key] !== true); const tab = documentTab(), editing = canvasEditor.input; try { if (!canvasEditor.selected && !canvasEditor.input) { @@ -4414,7 +4461,7 @@ function editorFonts(item) { for (const id of member.richText ? member.richText.runs.map(run => run.format.font) : [member.font]) { if (!id || embedded.has(id)) continue; const font = state.doc?.fonts?.find(font => font.id === id); - embedded.set(id, { id: `embedded:${id}`, name: font?.fontName || (id === member.font ? member.fontName : "") || id, + embedded.set(id, { ...documentFontChoice(id), name: font?.fontName || (id === member.font ? member.fontName : "") || id, embedded: true, disabled: !canEditObject(member, "textContent") }); } } @@ -4430,6 +4477,7 @@ async function loadEditorFonts(requestPermission = true, tab = documentTab(), wa try { await fontManager.queryLocal(); refreshEditorFonts(); + await loadEditorFonts(false, tab); } catch (err) { if (requestPermission && isForegroundDocument(tab)) setStatus(err?.name === "NotAllowedError" ? "系统字体未授权" : String(err.message || err)); } finally { @@ -4438,7 +4486,7 @@ async function loadEditorFonts(requestPermission = true, tab = documentTab(), wa })(); tasks.push(target.fontCatalogLoading); } - if (!target.fontFacesLoading && fontManager.userFonts.some(font => font.enabled && !font.faces)) { + if (!target.fontFacesLoading && (fontManager.userFonts.some(font => font.enabled && !font.faces) || fontManager.catalog.some(font => font.blob && !font.menuFamily && !font.metadataFailed))) { const wasmSeq = target.wasmSeq; const current = () => !tab.closed && !target.wasmExited && target.wasmSeq === wasmSeq; target.fontFacesLoading = (async () => { @@ -4496,7 +4544,7 @@ function updateTextFonts(item, refresh = false, preserve = false) { item = selectedText(item); const rich = canvasEditor.input?.rich; if (rich) { - const format = rich.state(), id = format.font; + const id = sharedTextFont(rich.selectedRuns().map(run => run.format)); const key = `rich:${id ?? "mixed"}:${rich.faces.size}`; if (refresh || state.textFontID !== key) { if (!preserve && document.activeElement === el.textFont && fontPicker.open) return; @@ -4504,7 +4552,8 @@ function updateTextFonts(item, refresh = false, preserve = false) { state.textFonts = editorFonts(null); for (const font of rich.faces.keys()) { const choice = rich.choices?.get(font); - state.textFonts.unshift({ ...(choice || { id: `embedded:${font}`, + if (choice) state.textFonts = state.textFonts.filter(value => textFontIdentity(value) !== textFontIdentity(choice)); + state.textFonts.unshift({ ...(choice || { ...documentFontChoice(font), name: state.doc?.fonts?.find(value => value.id === font)?.fontName || (font === rich.editing.item.font ? rich.editing.item.fontName : "") || font, embedded: !rich.imports.has(font) }), richKey: font }); } @@ -4513,7 +4562,7 @@ function updateTextFonts(item, refresh = false, preserve = false) { } return; } - const id = item?.type === "TextObject" && !item.draft ? item.richText ? currentTextStyle().font : item.font : null; + const id = item?.type === "TextObject" && !item.draft ? sharedTextFont(textFontRuns(item).map(run => run.format)) : null; const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : item?.richText ? `rich:${item.id}:${item.richText.runs.map(run => run.format.font).join(",")}` : id; if (refresh || state.textFontID !== key) { @@ -4531,59 +4580,170 @@ function updateTextFonts(item, refresh = false, preserve = false) { } } -async function changeTextFont() { +function textFontStyleKey(format) { + return JSON.stringify([format.font || "", Boolean(format.bold), Boolean(format.italic)]); +} + +async function prepareTextFaces(tab, runs, font, key, value, rich) { + const { openSeq, wasmSeq, doc } = tab.state; + const current = () => !tab.closed && openSeq === tab.state.openSeq && wasmSeq === tab.state.wasmSeq && doc === tab.state.doc && (!rich || canvasEditor.input === rich.editing); + const callWASM = (name, ...args) => { + if (!current()) throw new Error("文档已更改"); + return callTabWASM(tab, name, ...args); + }; + const choices = new Map(), data = new Map(); + const originals = new Map(runs.map(run => [run.format.font || "", textFontChoice(run.format.font)])); + const add = font => { if (font && !font.disabled && !choices.has(textFontIdentity(font))) choices.set(textFontIdentity(font), { ...font }); }; + if (font) add(font); + if (rich) for (const id of rich.faces.keys()) add({ ...textFontChoice(id), richKey: id }); + for (const original of originals.values()) add(original); + for (const font of tab.state.doc?.fonts || []) if (font.embedded || font.status === "matched") add(documentFontChoice(font.id)); + for (const font of fontManager.editorFonts()) add(font); + const read = async choice => { + const id = textFontIdentity(choice); + if (!data.has(id)) data.set(id, choice.richKey && (rich?.imports.get(choice.richKey) || rich?.resources.get(choice.richKey)) || await readTextFont(choice, tab)); + if (!current()) throw new Error("文档已更改"); + return data.get(id); + }; + const candidates = [...choices.values()]; + for (const choice of candidates) { + if (choice.menuFamily || ![...originals.values()].some(font => textFontIdentity(font || {}) === textFontIdentity(choice)) && (!font || textFontIdentity(choice) !== textFontIdentity(font))) continue; + const [metadata] = await callWASM("ofdgoFontFaces", await read(choice)); + if (metadata) Object.assign(choice, metadata, { index: choice.index }); + } + const groups = await groupTextFonts(candidates, tab), changes = new Map(), prepared = new Map(); + for (const run of runs) { + const from = { font: run.format.font || "", bold: Boolean(run.format.bold), italic: Boolean(run.format.italic) }; + const original = choices.get(textFontIdentity(originals.get(from.font) || {})); + const target = font ? choices.get(textFontIdentity(font)) : original; + if (!target) throw new Error("字体不可用"); + const bold = key === "bold" ? value : Boolean(from.bold || original?.bold); + const italic = key === "italic" ? value : Boolean(from.italic || original?.italic); + const group = groups.find(group => group.includes(candidates.indexOf(target))).map(index => candidates[index]); + let { index } = await callWASM("ofdgoFontMenu", JSON.stringify({ faces: group.map(({ bold, italic }) => ({ bold, italic })), bold, italic })); + if (index < 0 && font) index = group.indexOf(target); + if (index < 0) throw new Error("此字体没有对应款式"); + const selected = group[index], identity = textFontIdentity(selected); + if (!prepared.has(identity)) prepared.set(identity, { choice: selected, key: selected.richKey || (selected.embedded ? selected.id.slice(9) : `temp-font-${crypto.randomUUID()}`), data: await read(selected), text: "" }); + const entry = prepared.get(identity); + entry.text += run.text; + changes.set(textFontStyleKey(from), { from, to: { font: entry.key, + bold: !key && selected.bold && bold ? from.bold : Boolean(bold || selected.bold), + italic: !key && selected.italic && italic ? from.italic : Boolean(italic || selected.italic) } }); + } + for (const entry of prepared.values()) { + try { await callWASM("ofdgoCheckTextFont", entry.data, entry.text); } + catch (error) { + if (!error.missingGlyphs || !entry.choice.embedded || !entry.choice.familyKey) throw error; + let recovered = false; + for (const choice of candidates.filter(choice => !choice.embedded && choice.familyKey === entry.choice.familyKey && choice.postscriptName === entry.choice.postscriptName)) { + try { + const bytes = await read(choice); + await callWASM("ofdgoCheckTextFont", bytes, entry.text); + entry.data = bytes; + entry.choice = { ...choice, richKey: entry.key }; + recovered = true; + break; + } catch { if (!current()) throw error; } + } + if (!recovered) throw error; + } + if (!current()) throw new Error("文档已更改"); + if (rich) entry.face = await new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, entry.data, fontFaceStyle(entry.data)).load(); + } + return { changes: [...changes.values()], prepared: [...prepared.values()], fonts: [...prepared.values()].filter(entry => !entry.choice.embedded).map(({ key, data, text }) => ({ key, data, text })) }; +} + +function changeTextFont() { + const font = state.textFonts[Number(fontPicker.value)]; + if (font && !font.disabled) return changeTextFace(font); +} + +async function changeTextFace(font, key, value) { const tab = documentTab(), state = tab.state; const openSeq = state.openSeq; - const callWASM = (name, ...args) => callTabWASM(tab, name, ...args); const item = selectedText(canvasEditor.selected); - const font = state.textFonts[Number(fontPicker.value)]; - if (!state.ready || document.body.hasAttribute("data-busy") || !font || font.disabled) return; + if (!state.ready || document.body.hasAttribute("data-busy")) return; const editing = canvasEditor.input; - if (editing) { - if (editing.composing) return; - try { - if (!await ensureRichInput() || canvasEditor.input !== editing || !isForegroundDocument(tab)) return; - } catch (error) { - if (canvasEditor.input === editing && isForegroundDocument(tab)) showError(error, false); - return; - } - editing.rich?.remember(); - tab.editTask = true; - setBusy(true, "", 0, "", tab); - try { - const data = font.richKey && editing.rich?.imports.has(font.richKey) ? editing.rich.imports.get(font.richKey) : await readTextFont(font, tab); - if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return; - const [start, end] = editing.rich?.selection() || [0, editTextValue(editing.input).length]; - await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input).slice(start, end)); - if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return; - const face = new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, data, fontFaceStyle(data)); - await face.load(); - if (!isForegroundDocument(tab) || openSeq !== state.openSeq || canvasEditor.input !== editing) return; - editing.fontChoice = font; - if (item.draft && !font.richKey) state.textDefaults.fontChoice = font; - if (editing.rich) { - const key = font.richKey || (font.embedded ? font.id.slice(9) : `temp-font-${crypto.randomUUID()}`); - editing.rich.choices ||= new Map(); - editing.rich.choices.set(key, font); - canvasEditor.setTextFont(face, font.embedded ? null : data, key); - } else canvasEditor.setTextFont(face, data); - } catch (err) { - if (isForegroundDocument(tab) && openSeq === state.openSeq) { - updateTextFonts(canvasEditor.selected, true); - showError(err, false); + const current = () => isForegroundDocument(tab) && openSeq === state.openSeq && canvasEditor.input === editing; + if (!editing && !item) { + const source = font || state.textDefaults.fontChoice; + const bold = key === "bold" ? value : Boolean(state.textDefaults.bold), italic = key === "italic" ? value : Boolean(state.textDefaults.italic); + let selected = source; + if (source?.familyKey && (key || bold || italic)) { + const busy = {}; + tab.editTask = busy; + setBusy(true, "", 0, "", tab); + try { + const fonts = fontManager.editorFonts().filter(font => font.familyKey === source.familyKey && textFontSource(font) === textFontSource(source)), groups = await groupTextFonts(fonts, tab); + if (!current()) return; + const index = fonts.findIndex(choice => textFontIdentity(choice) === textFontIdentity(source)); + const faces = (groups.find(group => group.includes(index)) || []).map(index => fonts[index]); + const result = await callTabWASM(tab, "ofdgoFontMenu", JSON.stringify({ faces: faces.map(({ bold, italic }) => ({ bold, italic })), bold, italic })); + if (!current()) return; + selected = faces[result.index] || (font ? source : null); + if (!selected) throw new Error("此字体没有对应款式"); + } catch (error) { + if (current()) showError(error, false); + return; + } finally { + if (tab.editTask === busy) { tab.editTask = false; setBusy(false, "", 0, "", tab); } } - } finally { - tab.editTask = false; - setBusy(false, "", 0, "", tab); } - } else if (item?.type === "TextObject") { - if (canEditObject(item, "replaceFont") && (!font.embedded || currentTextStyle().font !== font.id.slice(9))) { - const changed = await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, () => readTextFont(font, tab), 0, null); - if (isForegroundDocument(tab) && (changed || openSeq === state.openSeq)) updateTextFonts(canvasEditor.selected, true); - } - } else { - state.textDefaults.fontChoice = font; + state.textDefaults.fontChoice = selected; + state.textDefaults.bold = Boolean(bold || selected?.bold); + state.textDefaults.italic = Boolean(italic || selected?.italic); + updateTextFonts(null, true); + updateObjectControls(null); + return; } + if (!editing && item?.type === "TextObject") { + if (!key && font?.embedded && textFontRuns(item).every(run => run.format.font === font.id.slice(9))) return; + const reflow = Boolean(key && !canEditObject(item, "layoutKnown")); + if (!canEditObject(item, "replaceFont") || reflow && !confirmTextReflow(item)) return; + await changeDocument("ofdgoStyleTextFonts", { ...item, id: (item.items || [item]).map(member => member.id) }, async () => { + const result = await prepareTextFaces(tab, textFontRuns(item), font, key, value); + return { changes: JSON.stringify(result.changes), fonts: result.fonts, reflow }; + }); + if (isForegroundDocument(tab)) updateTextFonts(canvasEditor.selected, true); + return; + } + if (editing.composing) return; + const task = (async () => { + try { + if (!await ensureRichInput() || !current()) return; + const rich = editing.rich; + rich.remember(); + const runs = rich.selectedRuns(), before = rich.model.runs, caret = rich.selection().join(); + const busy = {}; + tab.editTask = busy; + setBusy(true, "", 0, "", tab); + try { + const result = await prepareTextFaces(tab, runs, font, key, value, rich); + if (!current() || rich.model.runs !== before || rich.selection().join() !== caret || editing.composing || editing.saving) return; + for (const entry of result.prepared) { + const previous = rich.faces.get(entry.key); + rich.faces.set(entry.key, entry.face); + if (previous && ![...rich.faces.values()].includes(previous)) document.fonts.delete(previous); + if (!entry.choice.embedded) rich.imports.set(entry.key, entry.data); + rich.choices.set(entry.key, entry.choice); + document.fonts.add(entry.face); + } + const mapping = new Map(result.changes.map(change => [textFontStyleKey(change.from), change.to])); + rich.apply(format => ({ ...format, ...mapping.get(textFontStyleKey(format)) })); + if (item?.draft && font && !font.richKey) state.textDefaults.fontChoice = font; + updateTextFonts(canvasEditor.selected, true); + updateObjectControls(canvasEditor.selected); + } finally { + if (tab.editTask === busy) { tab.editTask = false; setBusy(false, "", 0, "", tab); } + } + } catch (error) { + if (current()) { updateTextFonts(canvasEditor.selected, true); showError(error, false); } + } + })(); + if (editing) editing.fontTask = task; + try { await task; } + finally { if (editing?.fontTask === task) editing.fontTask = null; } } async function insertObject(event) { @@ -4916,8 +5076,8 @@ async function changeDocument(name, item, ...args) { const scopedRichText = scoped && name === "ofdgoSaveRichText"; if (scopedRichText) args.push(scope); const textSelection = name === "ofdgoUpdateTextSelection"; - if (scoped && !operation && !textSelection && !scopedRichText && name !== "ofdgoLayoutTexts") throw new Error("内部对象暂不支持此操作"); - const doc = ["ofdgoStyleText", "ofdgoFormatText", "ofdgoLayoutTexts"].includes(name) + if (scoped && !operation && !textSelection && !scopedRichText && !["ofdgoLayoutTexts", "ofdgoStyleTextFonts"].includes(name)) throw new Error("内部对象暂不支持此操作"); + const doc = ["ofdgoStyleText", "ofdgoFormatText", "ofdgoLayoutTexts", "ofdgoStyleTextFonts"].includes(name) ? await callWASM(name, item.index, ids, ...args) : textSelection ? await callWASM(name, item.index, item.selectionIDs, ...args) diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 64f6fe4..cb699f7 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -236,6 +236,10 @@ export class RichTextSession { const runs = sliceRuns(this.model.runs, start, end); return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined])); } + selectedRuns() { + const [start, end] = this.selection(); + return start === end ? [{ text: "", format: { ...this.typing } }] : sliceRuns(this.model.runs, start, end); + } remember() { this.selection(); this.entries[this.index].caret = [...this.caret]; @@ -302,13 +306,14 @@ export class RichTextSession { this.remember(); const [start, end] = this.selection(); const before = this.model.runs; + const apply = format => typeof values === "function" ? values(format) : { ...format, ...values }; this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end]; if (start !== end) this.model.runs = normalizeRuns([ ...sliceRuns(this.model.runs, 0, start), - ...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: { ...run.format, ...values } })), + ...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: apply(run.format) })), ...sliceRuns(this.model.runs, end, this.text.length), ]); - this.typing = { ...this.typing, ...values }; + this.typing = apply(this.typing); this.record(before); this.render(); this.editor.options.onTextChange(); diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index d2cbdab..1ff57c8 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -1,8 +1,8 @@ const DATABASE = "ofdgo"; export class FontPicker { - constructor(input, toggle, list, onChange, onOpen) { - Object.assign(this, { input, toggle, list, onChange, onOpen, fonts: [], value: "", active: -1, open: false }); + constructor(input, toggle, list, onChange, onOpen, groupFonts) { + Object.assign(this, { input, toggle, list, onChange, onOpen, groupFonts, fonts: [], groups: [], value: "", active: -1, open: false }); input.addEventListener("input", () => this.show(input.value)); input.addEventListener("focus", () => input.select()); input.addEventListener("pointerdown", event => { if (event.button === 0 && !this.open) event.preventDefault(); }); @@ -28,9 +28,10 @@ export class FontPicker { const open = preserve && this.open; if (!open) this.close(); this.fonts = fonts; + this.groups = fonts.map((_, index) => [index]); this.names = new Map(); for (const font of fonts) { - const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); + const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); this.names.set(name, (this.names.get(name) || 0) + 1); } const key = font => font?.richKey || font?.id || font?.postscriptName; @@ -41,6 +42,22 @@ export class FontPicker { const index = active ? this.matches.findIndex(({ font }) => !font.disabled && key(font) === key(active)) : -1; if (index >= 0) this.activate(index); } else this.restore(); + if (this.groupFonts) this.grouping = this.groupFonts(fonts).then(groups => { + if (this.fonts !== fonts) return; + this.groups = groups; + this.names.clear(); + for (const group of groups) { + const font = fonts[group[0]], name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); + this.names.set(name, (this.names.get(name) || 0) + 1); + } + if (this.open) { + const active = this.matches[this.active]?.index; + this.show(this.query, false); + const index = this.matches.findIndex(({ group, font }) => group.includes(active) && !font.disabled); + if (index >= 0) this.activate(index); + } + else this.restore(); + }).catch(() => {}); } setDisabled(disabled) { @@ -50,7 +67,7 @@ export class FontPicker { restore() { const font = this.fonts[Number(this.value)]; - const value = font?.fullName || font?.name || ""; + const value = font?.menuFamily || font?.fullName || font?.name || ""; if (this.input.value !== value) { this.input.value = value; if (document.activeElement === this.input) this.input.select(); @@ -60,7 +77,7 @@ export class FontPicker { } sourceLabel(font) { - const name = (font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); + const name = (font.menuFamily || font.fullName || font.name).normalize("NFKC").toLocaleLowerCase(); if (this.names.get(name) < 2) return ""; return font.embedded ? "内嵌" : font.file || ["stored", "upload"].includes(font.source) ? "上传" : "系统"; } @@ -70,8 +87,14 @@ export class FontPicker { this.query = query; const normalize = value => value.normalize("NFKC").toLocaleLowerCase().replace(/\s+/g, " ").trim(); const terms = normalize(query).split(" ").filter(Boolean); - this.matches = this.fonts.map((font, index) => ({ font, index })).filter(({ font }) => { - const name = normalize([font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])].filter(Boolean).join(" ")); + this.matches = this.groups.map(group => { + const index = group.find(index => !this.fonts[index].bold && !this.fonts[index].italic && !this.fonts[index].disabled) ?? group[0]; + return { font: this.fonts[index], index, group }; + }).filter(({ group }) => { + const name = normalize(group.flatMap(index => { + const font = this.fonts[index]; + return [font.menuFamily, font.menuStyle, font.fullName, font.name, font.family, font.style, font.postscriptName, ...(font.names || [])]; + }).filter(Boolean).join(" ")); return terms.every(term => name.includes(term)); }); this.list.replaceChildren(); @@ -82,7 +105,7 @@ export class FontPicker { option.dataset.index = String(index); option.setAttribute("data-disabled", String(Boolean(font.disabled))); const name = document.createElement("span"); - name.textContent = font.fullName || font.name; + name.textContent = font.menuFamily || font.fullName || font.name; option.append(name); const source = this.sourceLabel(font); if (source) { @@ -103,7 +126,7 @@ export class FontPicker { if (!this.open) this.list.showPopover(); this.open = true; this.position(); - const selected = this.matches.findIndex(({ font, index }) => !font.disabled && String(index) === this.value); + const selected = this.matches.findIndex(({ font, group }) => !font.disabled && group.includes(Number(this.value))); this.activate(selected < 0 ? this.matches.findIndex(({ font }) => !font.disabled) : selected); if (load) this.onOpen?.(); } @@ -140,7 +163,7 @@ export class FontPicker { commitExact() { const value = this.input.value.trim().toLocaleLowerCase(); const matches = font => font && !font.disabled - && [font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value); + && [font.menuFamily, font.fullName, font.name, font.postscriptName, ...(font.names || [])].some(name => name?.toLocaleLowerCase() === value); const selected = Number(this.value); const index = matches(this.fonts[selected]) ? selected : this.fonts.findIndex(matches); if (index >= 0) return this.choose(index); @@ -340,10 +363,22 @@ export class FontManager { async loadFaces(inspect, current = () => true) { while (current()) { const file = this.userFonts.find(font => font.enabled && !font.faces); - if (!file) return; + if (!file) break; try { file.faces = await inspect(await this.read(file)); } catch { if (!current()) return; file.faces = []; } } + const pending = this.catalog.filter(font => font.blob && !font.menuFamily && !font.metadataFailed); + await Promise.all(Array.from({ length: Math.min(4, pending.length) }, async () => { + while (current() && pending.length) { + const font = pending.shift(); + try { + const faces = await inspect(await this.read(font)); + const face = faces.find(face => face.postscriptName === font.postscriptName); + if (face) Object.assign(font, { index: face.index, menuFamily: face.menuFamily, menuStyle: face.menuStyle, familyKey: face.familyKey, bold: face.bold, italic: face.italic, names: face.names }); + else font.metadataFailed = true; + } catch { if (current()) font.metadataFailed = true; } + } + })); } async defaultFont(order, inspect, current = () => true) { diff --git a/internal/base/font/file.go b/internal/base/font/file.go index 13b4864..ee6b353 100644 --- a/internal/base/font/file.go +++ b/internal/base/font/file.go @@ -16,6 +16,7 @@ package font import ( "bytes" + "crypto/sha256" "encoding/binary" "fmt" "strings" @@ -23,6 +24,8 @@ import ( ) // Face 字体文件中的字体名称与零起始索引,不依赖文件名 +// Family和Style为排版族,MenuFamily和MenuStyle为兼容四款式的菜单名称 +// FamilyKey区分兼容族、厂商和版本,未知时为空;Bold和Italic表示字体固有样式 type Face struct { Index int `json:"index"` FullName string `json:"fullName"` @@ -30,6 +33,11 @@ type Face struct { Style string `json:"style"` PostScriptName string `json:"postscriptName"` Names []string `json:"names"` + MenuFamily string `json:"menuFamily"` + MenuStyle string `json:"menuStyle"` + FamilyKey string `json:"familyKey"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` } // FaceInfo 从名称表读取字体信息 @@ -68,7 +76,40 @@ func faceInfoLanguage(data []byte, index int, platform, language uint16) Face { } return name(ids...) } - return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values)} + key := "" + if family, version := name(1), name(5); family != "" && version != "" { + key = fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join([]string{family, version, name(8)}, "\x00")))) + } + family, style := localized(1), localized(2) + if family == "" { + family = localized(16, 4, 6) + } + if style == "" { + style = localized(17) + } + return Face{Index: index, FullName: localized(4, 6, 16, 1), Family: localized(16, 1), Style: localized(17, 2), PostScriptName: name(6), Names: fontNamesFromValues(records, values), MenuFamily: family, MenuStyle: style, FamilyKey: key} +} + +// TableFaceInfo 读取名称及字体固有款式,名称键不随显示语言变化 +// 入参: tables 字体表, index 集合索引, platform 平台标识, language 语言标识 +// 返回: Face 字体信息 +func TableFaceInfo(tables map[string][]byte, index int, platform, language uint16) Face { + face := faceInfoLanguage(tables["name"], index, platform, language) + key := face.FamilyKey + if head := tables["head"]; len(head) >= 46 { + key += string(head[4:8]) + style := binary.BigEndian.Uint16(head[44:]) + face.Bold, face.Italic = style&1 != 0, style&2 != 0 + } + if os2 := tables["OS/2"]; len(os2) >= 64 { + key += string(os2[58:62]) + style := binary.BigEndian.Uint16(os2[62:]) + face.Bold, face.Italic = style&32 != 0, style&1 != 0 || style&512 != 0 + } + if face.FamilyKey != "" { + face.FamilyKey = fmt.Sprintf("%x", sha256.Sum256([]byte(key))) + } + return face } // FileCount 校验字体或集合头并读取字体数量 @@ -114,7 +155,7 @@ func ReadLocalizedFaces(data []byte, platform, language uint16) ([]Face, error) if err != nil { return nil, err } - face := faceInfoLanguage(tables["name"], index, platform, language) + face := TableFaceInfo(tables, index, platform, language) if face.FullName == "" { return nil, fmt.Errorf("font %d has no usable name", index) } diff --git a/internal/base/font/name.go b/internal/base/font/name.go index a83332d..fb8ec3c 100644 --- a/internal/base/font/name.go +++ b/internal/base/font/name.go @@ -142,7 +142,7 @@ func fontNameValues(data []byte) ([]fontNameRecord, map[[4]uint16]string) { values := make(map[[4]uint16]string) for _, record := range records { switch record.Name { - case 1, 2, 4, 6, 16, 17: + case 1, 2, 4, 5, 6, 8, 16, 17: default: continue } diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 19756bb..95f7ca4 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -757,6 +757,13 @@ func (s *Session) Details() DocumentDetails { } info.SignatureCount = len(info.Signatures) if fonts, err := s.Renderer.DeclaredFontInfos(); err == nil { + for index := range fonts { + if fonts[index].Embedded { + if face, err := s.Reader.FontFace(fonts[index].ID, 3, 0x0804); err == nil { + fonts[index].MatchedFace = face + } + } + } info.Fonts = fonts info.FontCount = len(fonts) } @@ -3172,7 +3179,23 @@ func (e *businessEngine) fontFaces(args []platformValue) (any, error) { if err != nil { return nil, err } - return (ofdgo.FontFile{Data: data}).Faces() + return (ofdgo.FontFile{Data: data}).LocalizedFaces(3, 0x0804) +} + +// fontMenu 返回按来源隔离的字体族索引及所需款式候选 +// 入参: args 字体信息、来源及可选目标款式的JSON +// 返回: any 分组和款式索引, error 参数错误 +func (e *businessEngine) fontMenu(args []platformValue) (any, error) { + var request struct { + Faces []ofdgo.FontFace `json:"faces"` + Sources []string `json:"sources"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` + } + if err := json.Unmarshal([]byte(args[0].String()), &request); err != nil { + return nil, err + } + return map[string]any{"groups": ofdgo.FontFamilyIndexes(request.Faces, request.Sources), "index": ofdgo.FontStyleIndex(request.Faces, request.Bold, request.Italic)}, nil } // fontPreferenceOrder 按字体族偏好排列编辑字体候选索引 @@ -4969,6 +4992,45 @@ func (e *businessEngine) formatText(args []platformValue) (any, error) { }) } +// styleTextFonts 预检并原子导入各款式字体,批量替换文字字体及粗斜体 +// 入参: args 页面索引、完整路径、款式映射JSON、字体数据集合及重排标记 +// 返回: any 文档信息, error 字体或编辑错误 +func (e *businessEngine) styleTextFonts(args []platformValue) (any, error) { + return e.changeObjects(func() error { + paths, err := e.editorTargetPaths(e.stringsFromJS(args[1])) + if err != nil { + return err + } + var changes []ofdgo.TextFontChange + if err := json.Unmarshal([]byte(args[2].Get("changes").String()), &changes); err != nil { + return err + } + fonts := make(map[string]string) + input := args[2].Get("fonts") + for index := 0; index < input.Length(); index++ { + entry := input.Index(index) + data, err := e.bytesFromJS(entry.Get("data")) + if err != nil { + return err + } + if err := e.checkFontGlyphs(data, entry.Get("text").String()); err != nil { + return err + } + id, err := e.currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + fonts[entry.Get("key").String()] = id + } + for index := range changes { + if id, ok := fonts[changes[index].To.Font]; ok { + changes[index].To.Font = id + } + } + return e.currentEditor.StyleTargetsFonts(args[0].Int(), paths, changes, args[2].Get("reflow").Bool()) + }) +} + // layoutTexts 批量修改段落增量,未指定的选项沿用各对象原值 // 入参: args 页面索引、完整路径数组和段落JSON // 返回: any 文档信息, error 校验或提交错误 @@ -8029,6 +8091,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoMatchFontFiles": e.matchFontFiles, "ofdgoChangeFontBinding": e.changeFontBinding, "ofdgoFontFaces": e.fontFaces, + "ofdgoFontMenu": e.fontMenu, "ofdgoFontFace": e.fontFace, "ofdgoCreateDocument": e.createDocument, "ofdgoImportImages": e.importImages, @@ -8062,6 +8125,7 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an "ofdgoUpdateTextSelection": e.updateTextSelection, "ofdgoStyleText": e.styleText, "ofdgoFormatText": e.formatText, + "ofdgoStyleTextFonts": e.styleTextFonts, "ofdgoGetRichText": e.getRichText, "ofdgoRichTextFonts": e.richTextFonts, "ofdgoSaveRichText": e.saveRichText, diff --git a/internal/webview/webview.go b/internal/webview/webview.go index 0e28419..200ba28 100644 --- a/internal/webview/webview.go +++ b/internal/webview/webview.go @@ -1049,7 +1049,7 @@ func (h *Host) readFonts() { continue } for _, face := range faces { - h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "file": file}) + h.fonts = append(h.fonts, map[string]any{"index": face.Index, "postscriptName": face.PostScriptName, "fullName": face.FullName, "family": face.Family, "style": face.Style, "names": face.Names, "menuFamily": face.MenuFamily, "menuStyle": face.MenuStyle, "familyKey": face.FamilyKey, "bold": face.Bold, "italic": face.Italic, "file": file}) for _, alias := range append([]string{face.FullName, face.Family, face.PostScriptName, strings.TrimSpace(face.Family + " " + face.Style)}, face.Names...) { if alias != "" && h.fontFiles[alias+".ttf"] == "" { h.fontFiles[alias+".ttf"] = name + "\x00" + strconv.Itoa(face.Index) diff --git a/ofdgo_editor_selection.go b/ofdgo_editor_selection.go index 04c55dd..85507ca 100644 --- a/ofdgo_editor_selection.go +++ b/ofdgo_editor_selection.go @@ -886,6 +886,95 @@ func (e *Editor) FormatTargetsText(page int, paths []ObjectPath, patch TextForma return e.formatTargetsText(page, paths, patch, false) } +// TextFontStyle 标识文字引用的字体资源及显式粗斜体样式 +type TextFontStyle struct { + Font string `json:"font"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` +} + +// TextFontChange 描述一种原字体款式到目标字体款式的替换 +type TextFontChange struct { + From TextFontStyle `json:"from"` + To TextFontStyle `json:"to"` +} + +// StyleTargetsFonts 原子替换跨容器文字的各款式,保留混排属性及对象变换 +// 目标字体须已注册,可用Transaction将AddFont与本操作合并为一条历史记录 +// 入参: page 页面索引, paths 完整文字路径, changes 字体款式映射, reflow 允许显式重排 +// 返回: error 字体、路径或排版错误,失败时回滚全部修改 +func (e *Editor) StyleTargetsFonts(page int, paths []ObjectPath, changes []TextFontChange, reflow bool) error { + mapping := make(map[TextFontStyle]TextFontStyle, len(changes)) + for _, change := range changes { + if change.To.Font == "" { + return fmt.Errorf("text requires a font") + } + mapping[change.From] = change.To + } + return e.Transaction(func(e *Editor) error { + for _, path := range paths { + if path.Annotation != "" { + return ErrNotRichText + } + var object GraphicObject + var err error + if len(path.Children) == 0 { + object, err = e.Object(page, path.ID) + } else { + parent := path + parent.Children = path.Children[:len(path.Children)-1] + var members []CompositeMember + members, err = e.CompositeObjectsAt(page, parent, path.Children[len(path.Children)-1:]) + if err == nil { + object = members[0].Object + } + } + if err != nil { + return err + } + if object.Type == "TextObject" && !reflow { + text := object.TextObject + from := TextFontStyle{Font: text.Font, Bold: text.Weight >= 700, Italic: text.Italic} + to, ok := mapping[from] + if !ok || to == from { + continue + } + if to.Bold == from.Bold && to.Italic == from.Italic { + if err := e.StyleTargetsText(page, []ObjectPath{path}, TextStyle{Font: to.Font}); err != nil { + return err + } + continue + } + } + value, _, _, err := e.PrepareRichText(page, path, reflow) + if err != nil { + return err + } + changed := false + for index := range value.Runs { + format := &value.Runs[index].Format + from := TextFontStyle{Font: format.Font, Bold: format.Bold, Italic: format.Italic} + if to, ok := mapping[from]; ok && to != from { + format.Font, format.Bold, format.Italic = to.Font, to.Bold, to.Italic + changed = true + } + } + if !changed { + continue + } + if reflow { + err = e.ReflowRichText(page, path, value) + } else { + err = e.UpdateTargetRichText(page, path, value, 0, 0) + } + if err != nil { + return err + } + } + return nil + }) +} + // ReflowTargetsText 显式重排跨容器文字并应用格式增量,保留对象位置及组合结构 // 入参: page 页面索引, paths 完整文字路径, patch 格式增量 // 返回: error 路径、格式或排版错误 diff --git a/ofdgo_font.go b/ofdgo_font.go index 31edec7..04e693c 100644 --- a/ofdgo_font.go +++ b/ofdgo_font.go @@ -24,8 +24,71 @@ import ( ) // FontFace 字体文件中的字体名称与零起始索引,不依赖文件名 +// Family和Style保留排版族,MenuFamily和MenuStyle提供兼容四款式的菜单名称 +// FamilyKey用于同来源内的族关联,Bold和Italic表示字体固有样式 type FontFace = basefont.Face +// FontFamilyIndexes 按兼容字体族、版本及来源分组,同款式重复时整族保留独立入口 +// 入参: faces 字体信息, sources 每项的来源标识,空列表表示同一来源 +// 返回: [][]int 按首次出现顺序分组的候选索引 +func FontFamilyIndexes(faces []FontFace, sources []string) [][]int { + groups := make([][]int, 0, len(faces)) + keys := make([]string, len(faces)) + styles := make(map[string]map[[2]bool]bool) + ambiguous := make(map[string]bool) + for index, face := range faces { + key := face.FamilyKey + if len(sources) > index { + key += "\x00" + sources[index] + } + keys[index] = key + if styles[key] == nil { + styles[key] = make(map[[2]bool]bool) + } + style := [2]bool{face.Bold, face.Italic} + ambiguous[key] = ambiguous[key] || styles[key][style] + styles[key][style] = true + } + indexes := make(map[string]int) + for index, face := range faces { + key := keys[index] + if face.FamilyKey == "" || ambiguous[key] { + groups = append(groups, []int{index}) + continue + } + group, exists := indexes[key] + if !exists { + group, indexes[key] = len(groups), len(groups) + groups = append(groups, nil) + } + groups[group] = append(groups[group], index) + } + return groups +} + +// FontStyleIndex 选择同族候选中最接近的真实款式,允许补充合成样式但不抵消固有样式 +// 入参: faces 同来源同族候选, bold 粗体, italic 斜体 +// 返回: int 候选索引,无可用款式时为-1 +func FontStyleIndex(faces []FontFace, bold, italic bool) int { + selected, best := -1, -1 + for index, face := range faces { + if face.Bold && !bold || face.Italic && !italic { + continue + } + score := 0 + if face.Bold == bold { + score++ + } + if face.Italic == italic { + score++ + } + if score > best { + selected, best = index, score + } + } + return selected +} + // FontPreferenceOrder 按字体族偏好排列候选索引,同族优先常规样式,其余保持原顺序 // 入参: faces 候选字体, families 按优先级排列的字体族别名组 // 返回: []int 候选字体的零起始索引,不修改输入列表 diff --git a/ofdgo_font_info.go b/ofdgo_font_info.go index 9d5dc33..cb1d570 100644 --- a/ofdgo_font_info.go +++ b/ofdgo_font_info.go @@ -107,6 +107,46 @@ func (r *Reader) FontData(id string) ([]byte, error) { return r.fontData(id, false) } +// FontFace 读取指定内嵌资源的本地化字体信息,集合按资源声明选取,不依赖系统字体 +// 入参: id 字体资源标识, platform 名称表平台标识, language 名称表语言标识 +// 返回: *FontFace 独立字体信息, error 资源或解析错误 +func (r *Reader) FontFace(id string, platform, language uint16) (*FontFace, error) { + if r.fontDefinition(id) == nil { + if _, err := r.Fonts(); err != nil { + return nil, err + } + } + font := r.fontDefinition(id) + if font == nil || font.FontFile == "" { + return nil, fmt.Errorf("embedded font %q not found", id) + } + name := r.ResPath(font.FontFile) + key := fmt.Sprintf("%s\x00%d:%d", name, platform, language) + faces, ok := r.fontFaces[key] + if !ok { + data, err := r.readFileView(name) + if err != nil { + return nil, err + } + values, err := (FontFile{Data: data}).LocalizedFaces(platform, language) + if err != nil { + return nil, err + } + faces = make([]*FontFace, len(values)) + for index := range values { + faces[index] = &values[index] + } + r.fontFaces[key] = faces + } + names := make([][]string, len(faces)) + for index, face := range faces { + names[index] = face.Names + } + face := *faces[basefont.NameIndex(names, []string{font.FontName, font.FamilyName}, font.Bold, font.Italic)] + face.Names = slices.Clone(face.Names) + return &face, nil +} + // fontData 按调用方所有权读取内嵌字体,集合字体始终独立提取 // 入参: id 字体资源标识, readonly 是否允许借用只读资源 // 返回: []byte 字体数据, error 资源或集合错误 @@ -251,7 +291,7 @@ func (r *Reader) embeddedFontFace(of Font) (*FontFace, error) { faces = make([]*FontFace, count) for index := range faces { if tables, err := basefont.FileTables(data, index); err == nil { - info := basefont.FaceInfo(tables["name"], index) + info := basefont.TableFaceInfo(tables, index, 0xffff, 0xffff) faces[index] = &info } }