From 5f790e5408a22d2334f130b699d0683957fb9d2b Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Sat, 10 Oct 2026 10:51:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AD=97=E4=BD=93=E5=A4=84=E7=90=86):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AD=97=E4=BD=93=E5=86=85=E5=A4=96=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=89=B9=E9=87=8F=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 11 + assets/webui/ofdgo.css | 57 +++- assets/webui/ofdgo.js | 171 ++++++++++- internal/webui/wasm.go | 35 +++ ofdgo_convert.go | 37 ++- ofdgo_editor_font_binding.go | 551 +++++++++++++++++++++++++++++++++++ ofdgo_editor_font_scan.go | 296 +++++++++++++++++++ ofdgo_font_resource_sfnt.go | 32 +- 8 files changed, 1173 insertions(+), 17 deletions(-) create mode 100644 ofdgo_editor_font_binding.go create mode 100644 ofdgo_editor_font_scan.go diff --git a/assets/webui/index.html b/assets/webui/index.html index ba42690..2f0e4fd 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -185,6 +185,7 @@
+
@@ -434,6 +435,16 @@ + +
+

嵌入字体

+
字体
+
+ +
外置后,其他设备需具备此字体
+
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 42e378c..cd4ba34 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -731,10 +731,14 @@ button:not(:disabled):hover { margin: 0 auto; } -.convert-options:has(#convertDPIRow[hidden]) { +.convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) { + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + .convert-options .form-row { gap: 8px; margin-bottom: 0; @@ -974,9 +978,15 @@ button:not(:disabled):hover { width: 100%; margin: 0; } - .convert-options:has(#convertDPIRow[hidden]) { + .convert-options:has(#convertDPIRow[hidden]):has(#convertFontRow[hidden]) { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .convert-options:has(#convertDPIRow:not([hidden])):has(#convertFontRow:not([hidden])) .form-row:last-child { + grid-column: 1 / -1; + } .convert-options .form-row { grid-template-columns: minmax(0, 1fr); gap: 6px; @@ -2816,6 +2826,39 @@ audio.page-media { overflow-wrap: anywhere; } +.doc-font-head:has(.font-binding-action) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 6px; +} + +.doc-font-head:has(.font-binding-action) .doc-font-name { + grid-column: 1; + grid-row: 1; +} + +.doc-font-head:has(.font-binding-action) .doc-font-badges { + grid-column: 2; + grid-row: 1; + margin: 0; +} + +.font-binding-action { + grid-column: 3; + grid-row: 1; + white-space: nowrap; +} + +#fontBindingName { + min-width: 0; + overflow-wrap: anywhere; +} + +#fontBindingHint { + margin-bottom: 12px; +} + .info-name-button { display: inline; padding: 0; @@ -2882,6 +2925,16 @@ audio.page-media { white-space: nowrap; } +.doc-font-head .font-badge, +.font-binding-action { + width: 36px; + height: 24px; + padding: 0; + font-size: 12px; + font-weight: 400; + line-height: 1.35; +} + .font-badge.embedded, .font-badge.matched, .font-badge.valid { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index f2067bb..e21c8ea 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -20,7 +20,9 @@ const STATUS = { const wasmRequests = new Map(); const metaContents = new WeakMap(); const batch = { items: [], formats: [], running: false, canceled: false, worker: null, requests: new Map(), sequence: 0, activeID: 0, progressTime: 0 }; -const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)])); +const batchElements = Object.fromEntries(["Button", "Panel", "Form", "Input", "Add", "Clear", "Count", "Format", "Compression", "FontRow", "FontMode", "Destination", "DPIRow", "DPI", "List", "Empty", "Progress", "Status", "Close", "Cancel", "Start"].map(name => [name, document.querySelector(`#convert${name}`)])); +const fontBinding = { font: null, choices: [], sequence: 0 }; +const fontBindingElements = Object.fromEntries(["Panel", "Form", "Title", "Name", "SourceRow", "Source", "File", "Hint", "Status", "Cancel", "Submit"].map(name => [name, document.querySelector(`#fontBinding${name}`)])); let wasmPromise = null; let remoteDownload = null; @@ -1201,6 +1203,46 @@ batchElements.Panel.addEventListener("dragover", event => { event.preventDefault batchElements.Panel.addEventListener("drop", event => { event.preventDefault(); event.stopPropagation(); if (!batch.running) addBatchFiles(event.dataTransfer.files); }); batchElements.Destination.querySelector('[value="directory"]').disabled = !window.showDirectoryPicker; batchElements.Destination.value = window.showDirectoryPicker ? "directory" : "archive"; +fontBindingElements.Cancel.addEventListener("click", () => fontBindingElements.Panel.close()); +fontBindingElements.Panel.addEventListener("close", () => { fontBinding.font = null; fontBinding.choices = []; fontBinding.sequence++; fontBindingElements.File.value = ""; }); +fontBindingElements.Source.addEventListener("change", () => { + if (fontBindingElements.Source.value === "file") { fontBindingElements.Source.value = ""; fontBindingElements.File.click(); } +}); +fontBindingElements.File.addEventListener("change", async () => { + const file = fontBindingElements.File.files[0], openSeq = state.openSeq; + if (!file) return; + const sequence = ++fontBinding.sequence; + fontBindingElements.File.value = ""; + try { + const data = new Uint8Array(await file.arrayBuffer()); + const faces = await callWASM("ofdgoFontFaces", data); + if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return; + for (const face of faces) { + const value = String(fontBinding.choices.length); + fontBinding.choices.push({ data, index: face.index }); + fontBindingElements.Source.add(new Option(face.fullName || face.family || file.name, value)); + if (faces.length === 1) fontBindingElements.Source.value = value; + } + fontBindingElements.Status.textContent = faces.length > 1 ? "请选择字体" : ""; + } catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = err.message; } +}); +fontBindingElements.Form.addEventListener("submit", async event => { + event.preventDefault(); + const font = fontBinding.font, sequence = fontBinding.sequence, openSeq = state.openSeq; + if (!font || document.body.hasAttribute("aria-busy") || !fontBindingElements.Form.reportValidity()) return; + fontBindingElements.Form.inert = true; + try { + let data = null; + if (!font.embedded) { + const choice = fontBinding.choices[Number(fontBindingElements.Source.value)]; + if (!choice) throw new Error("请选择字体"); + data = choice.data ? (await callWASM("ofdgoFontFace", choice.data, choice.index || 0)).bytes : await readTextFont(choice); + } + if (sequence !== fontBinding.sequence || openSeq !== state.openSeq) return; + if (await changeDocument("ofdgoChangeFontBinding", null, font.id, font.embedded ? 2 : 1, data)) fontBindingElements.Panel.close(); + } catch (err) { if (sequence === fontBinding.sequence) fontBindingElements.Status.textContent = fontBindingError(err.message); } + finally { if (sequence === fontBinding.sequence) fontBindingElements.Form.inert = false; } +}); el.newButton.addEventListener("click", openCreatePanel); el.editButton.addEventListener("click", toggleEditor); el.editButton.addEventListener("pointerdown", (event) => { @@ -1567,7 +1609,7 @@ function handleKeyDown(event) { } function formDialogOpen() { - return Boolean(sealPreview) || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open + return Boolean(sealPreview) || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } @@ -2141,6 +2183,7 @@ async function toggleEditor() { if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; const anchor = scaleAnchor(0); state.editing = !state.editing; + renderDocumentFonts(); resetCompositeScope(); canvasEditor.clear(); canvasEditor.setTool(""); @@ -2156,6 +2199,7 @@ async function toggleEditor() { await refreshPageDisplay(anchor); } catch (err) { state.editing = !state.editing; + renderDocumentFonts(); updateControls(); showError(err, false); } @@ -3010,10 +3054,17 @@ async function changeDocument(name, item, ...args) { const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; const { scrollLeft, scrollTop } = el.viewerPanel; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; + const changingFont = name === "ofdgoChangeFontBinding"; let refreshManagement = false; state.imageImporting = name === "ofdgoImportImages"; if (importing) state.importing = true; setBusy(true, importing ? "正在导入页面" : "", null); + const progressTimer = changingFont ? window.setTimeout(() => { + if (openSeq === state.openSeq && document.body.hasAttribute("aria-busy")) { + el.progressPanel.hidden = false; + setProgress("正在处理字体", null); + } + }, 250) : null; try { args = await Promise.all(args); if (openSeq !== state.openSeq) { @@ -3046,7 +3097,9 @@ async function changeDocument(name, item, ...args) { refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring); const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0]; const nextDocument = doc.documents?.[doc.documentIndex || 0]; - const documentChanged = previousDocument?.root !== nextDocument?.root || previousDocument?.id !== nextDocument?.id; + const sameDocument = previousDocument?.id && previousDocument.id === nextDocument?.id && state.doc.documentIndex === doc.documentIndex; + const documentChanged = previousDocument?.id !== nextDocument?.id || previousDocument?.root !== nextDocument?.root + && !(sameDocument && (name === "ofdgoChangeFontBinding" || restoring)); const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring && JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id])); if (packageChanged) { @@ -3123,7 +3176,9 @@ async function changeDocument(name, item, ...args) { } if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs }; openSeq = ++state.openSeq; - if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { + if (changingFont) { + await refreshFontBinding(doc, openSeq); + } else if (item || name === "ofdgoPasteObjects" || inserting || editingAnnotation) { await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection); } else { const samePage = pageIndexByID(doc.pages, previous.id); @@ -3137,13 +3192,16 @@ async function changeDocument(name, item, ...args) { } if (openSeq === state.openSeq) { if (!restoring) rememberEditorView(before, revision, reindex); - if (!toolbarFocus) canvasEditor.focus(); + if (!toolbarFocus && !changingFont) canvasEditor.focus(); return true; } } catch (err) { refreshManagement = false; + if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message); if (openSeq === state.openSeq) { - if (el.annotationCreate.open) { + if (fontBindingElements.Panel.open) { + fontBindingElements.Status.textContent = fontBindingError(err.message); + } else if (el.annotationCreate.open) { el.annotationCreateStatus.textContent = err.message; } else if (el.annotationNote.open) { el.annotationStatus.textContent = err.message; @@ -3176,6 +3234,7 @@ async function changeDocument(name, item, ...args) { } } } finally { + if (progressTimer !== null) window.clearTimeout(progressTimer); if (importing) { state.importing = false; state.imageImporting = false; @@ -3192,6 +3251,52 @@ async function changeDocument(name, item, ...args) { } } +async function refreshFontBinding(doc, openSeq) { + const indices = new Set([state.pageIndex, ...state.visiblePages, ...state.visibleThumbnails]); + for (const shell of el.svgHost.querySelectorAll(".page-shell.rendered")) indices.add(Number(shell.dataset.pageIndex)); + state.doc = doc; + resetSearch(); + state.documentSelection = null; + resetPageLoading(); + state.pageCache.clear(); + canvasEditor.clear(); + try { + try { + const details = await callWASM("ofdgoDocumentInfo"); + if (openSeq !== state.openSeq) return; + Object.assign(doc, details, { detailsPending: false, detailsError: "" }); + } catch (err) { + if (openSeq !== state.openSeq) return; + Object.assign(doc, { detailsPending: false, detailsError: err.message }); + } + for (const index of indices) { + try { + const page = await loadPageData(index, { openSeq, priority: 0, refresh: true }); + if (openSeq !== state.openSeq) return; + if (page) { + mountPageSVG(index, page, openSeq); + updateThumbnail(index, openSeq); + } + } catch (err) { + if (openSeq !== state.openSeq) return; + unmountPage(index); + markFlowPageError(index, err); + markThumbnailError(index); + showError(err, false); + } + } + } finally { + if (openSeq === state.openSeq) { + releasePageResources(); + renderMeta(true); + updateControls(); + loadDocumentDetails(openSeq); + for (const shell of el.svgHost.children) observeFlowPage(shell); + for (const button of el.pageList.children) observeThumbnail(button, Number(button.dataset.pageIndex), openSeq); + } + } +} + async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { state.doc = doc; resetSearch(); @@ -3860,6 +3965,7 @@ async function openOFD(file, remote = null) { setDirty(false); el.createPanel.close(); el.insertPanel.close(); + fontBindingElements.Panel.close(); el.pagePanel.close(); el.paragraphPanel.close(); el.infoPanel.close(); @@ -5239,7 +5345,8 @@ function renderBatchList() { } function updateBatchControls() { - for (const name of ["Add", "Clear", "Format", "Compression", "Destination", "DPI"]) batchElements[name].disabled = batch.running; + for (const name of ["Add", "Clear", "Format", "Compression", "FontMode", "Destination", "DPI"]) batchElements[name].disabled = batch.running; + batchElements.FontRow.hidden = !(batch.items.length ? batch.items : [null]).some(item => (item?.format || batchElements.Format.value) === "ofd"); batchElements.Compression.disabled ||= (batch.items.length ? batch.items : [null]).every(item => (item?.format || batchElements.Format.value) === "txt"); batchElements.Clear.disabled ||= !batch.items.length; batchElements.Start.disabled = batch.running || !batch.items.length || !batch.formats.length; @@ -5362,7 +5469,7 @@ async function runBatch() { const fontKey = [fontManager.permission, fontManager.records().map(font => [font.id, font.name, font.enabled, font.checksum])]; for (const item of batch.items) { const format = batch.formats.find(format => format.value === (item.format || defaultFormat.value)); - const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression]); + const key = JSON.stringify([format.value, pageRange(item), batchUsesDPI(item) ? dpi : 0, archive, backend, fontKey, compression, format.value === "ofd" ? Number(batchElements.FontMode.value) : 0]); if (item.settingsKey !== key) { item.status = "pending"; item.text = "待转"; @@ -5396,7 +5503,8 @@ async function runBatch() { } } else destination = await window.showDirectoryPicker({ mode: "readwrite" }); if (batch.canceled) return; - if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value))) { + if (jobs.some(item => /\.pdf$/i.test(item.file.name) || !["ofd", "txt"].includes(item.format || defaultFormat.value) + || (item.format || defaultFormat.value) === "ofd" && batchElements.FontMode.value === "1")) { await requestLocalFontsBeforeOpen(); } if (batch.canceled) return; @@ -5416,13 +5524,14 @@ async function runBatch() { for (let suffix = 2; archive && names.has(outputName()); suffix++) unique = `${base} (${suffix})`; names.add(outputName()); const options = { format: format.value, pages: pageRange(item), dpi, backend, archive, compression, base: unique, credentials: null, - fontNames: [...batch.localFonts.keys()] }; + fontNames: [...batch.localFonts.keys()], fontMode: format.value === "ofd" ? Number(batchElements.FontMode.value) : 0 }; try { for (;;) { try { const result = await batchCall("ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress)); for (const file of result.files) entries.push(file); item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`); + for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`); if (result.unchanged && !result.bytes) { batchStatus(item, "unchanged", "原样"); item.details = ["无需转换,源文件未改动"]; @@ -8224,6 +8333,16 @@ function renderDocumentFonts() { const metaText = fontMeta(font); head.append(badges, name); + if (state.editing) { + const action = document.createElement("button"); + action.type = "button"; + action.className = "small-button font-binding-action"; + action.textContent = font.embedded ? "外置" : "嵌入"; + action.title = font.embedded ? "外置字体" : "嵌入字体"; + action.setAttribute("aria-label", action.title); + action.addEventListener("click", () => openFontBinding(font)); + head.append(action); + } row.append(head); appendFontDetail(row, metaText); appendFontDetail(row, resultText); @@ -8232,6 +8351,37 @@ function renderDocumentFonts() { el.docFontList.append(fragment); } +async function openFontBinding(font) { + if (!state.editing || document.body.hasAttribute("aria-busy")) return; + if (!font.embedded && font.status === "matched") { + await changeDocument("ofdgoChangeFontBinding", null, font.id, 1); + return; + } + fontBinding.font = font; + fontBinding.sequence++; + fontBindingElements.Form.inert = false; + fontBinding.choices = fontManager.editorFonts().filter(choice => !choice.disabled); + fontBindingElements.Title.textContent = font.embedded ? "外置字体" : "嵌入字体"; + fontBindingElements.Name.textContent = font.fontName || font.familyName || font.id; + fontBindingElements.SourceRow.hidden = font.embedded; + fontBindingElements.Source.disabled = font.embedded; + fontBindingElements.Source.replaceChildren(new Option("请选择", ""), ...fontBinding.choices.map((choice, i) => new Option(choice.fullName || choice.name, String(i))), new Option("添加字体", "file")); + fontBindingElements.Hint.hidden = !font.embedded; + fontBindingElements.Status.textContent = ""; + fontBindingElements.Submit.textContent = font.embedded ? "外置" : "嵌入"; + fontBindingElements.Panel.showModal(); +} + +function fontBindingError(message) { + if (/requires the original font/.test(message)) return "字形编号需匹配原字体"; + if (/non-character glyph|private character|index-encoded/.test(message)) return "字形映射无法外置"; + if (/unsupported content/.test(message)) return "扩展引用无法确认"; + if (/does not permit/.test(message)) return "字体不允许嵌入"; + if (/no glyph|glyph is unavailable/.test(message)) return "字体缺少所需字形"; + if (/unavailable|not found|no name/.test(message)) return "匹配字体不可用"; + return message; +} + function appendFontDetail(row, text) { if (!text) { return; @@ -8847,6 +8997,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.pageList.inert = busy; el.editorTools.inert = busy || Boolean(sealPreview); el.fontList.inert = busy; + el.docFontList.inert = busy; if (!busy) { el.progressPanel.hidden = true; if (state.fontSyncPending) { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index dd37271..6758578 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -211,6 +211,7 @@ func RunWASM() { registerCallback("ofdgoImages", documentImages) registerAsyncCallback("ofdgoExportImages", exportImages) registerCallback("ofdgoMatchFontFiles", matchFontFiles) + registerCallback("ofdgoChangeFontBinding", changeFontBinding) registerCallback("ofdgoFontFaces", fontFaces) registerCallback("ofdgoFontFace", fontFace) registerCallback("ofdgoCreateDocument", createDocument) @@ -467,6 +468,13 @@ func convertFile(args []js.Value) (any, error) { return awaitExport(args[4], progress.Stage, progress.Completed, progress.Total) }, } + if value := settings.Get("fontMode"); value.Type() == js.TypeNumber { + mode := value.Int() + if mode < 0 || mode > int(ofdgo.FontExternal) { + return nil, fmt.Errorf("invalid font mode") + } + options.Fonts = ofdgo.FontMode(mode) + } if value := settings.Get("compression"); !value.IsNull() && !value.IsUndefined() { if err := compressionFromJS(value, &options.Compression); err != nil { return nil, err @@ -3472,6 +3480,33 @@ func changeAttachment(args []js.Value) (any, error) { }) } +// changeFontBinding 调用库层字体资源操作,统一更新预览与撤销状态 +// 入参: args 字体标识、保存方式及可选字体数据 +// 返回: any 编辑状态, error 字体处理错误 +func changeFontBinding(args []js.Value) (any, error) { + if len(args) < 2 { + return nil, fmt.Errorf("请选择字体") + } + return changeAtomicObjects(func() error { + id, mode := args[0].String(), args[1].Int() + if mode == int(ofdgo.FontExternal) { + return currentEditor.ExternalizeFont(context.Background(), id) + } + if mode != int(ofdgo.FontEmbed) { + return fmt.Errorf("字体方式无效") + } + var file ofdgo.FontFile + if len(args) > 2 && !args[2].IsNull() && !args[2].IsUndefined() { + data, err := bytesFromJS(args[2]) + if err != nil { + return err + } + file.Data = data + } + return currentEditor.EmbedFont(context.Background(), id, file, 0) + }) +} + // changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构 // 入参: args 操作及标题、页面尺寸、文档索引或移动位置 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_convert.go b/ofdgo_convert.go index 166a055..a02157b 100644 --- a/ofdgo_convert.go +++ b/ofdgo_convert.go @@ -18,6 +18,7 @@ import ( "context" "fmt" "io" + "slices" "strings" "github.com/xiaoqidun/pdfgo" @@ -33,7 +34,7 @@ type OutputFormat struct { Raster bool `json:"raster,omitempty"` } -// ConvertProgress 回报open、pages、convert、prepare、write、write.*及export阶段,Total为0表示总量未知 +// ConvertProgress 回报open、pages、convert、fonts、prepare、write、write.*及export阶段,Total为0表示总量未知 type ConvertProgress struct { Stage string `json:"stage"` Completed int `json:"completed"` @@ -48,6 +49,7 @@ type ConvertProgress struct { // RendererOptions同时用于PDF局部合成和输出,在PDF.RendererOptions之后应用 // Backends统一配置转换与输出,优先于PDF和RendererOptions中的后端设置 // Compression统一配置输出及PDF图片导入,优先于PDF.Compression,文本输出不优化图片 +// Fonts仅用于OFD输出,按字体处理全包引用,未处理原因通过报告返回 // PageOutput仅用于逐页格式,同步调用export写出一页并自行处理提交或回滚;此时output可为nil // 未提供PageOutput时,逐页格式打包ZIP;所有回调返回错误均会停止转换 type ConvertOptions struct { @@ -57,6 +59,7 @@ type ConvertOptions struct { PageRange string SkipUnchanged bool Compression CompressionOptions + Fonts FontMode Backends *RenderBackends ReaderOptions []ReaderOption RendererOptions []RendererOption @@ -74,6 +77,7 @@ type ConvertReport struct { Bytes int64 `json:"bytes"` Unchanged bool `json:"unchanged,omitempty"` PDF PDFImportReport `json:"pdf"` + Fonts []FontChange `json:"fonts,omitempty"` } // convertReader 将取消上下文传递到随机读取 @@ -129,6 +133,9 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ if report.Format.Value == "" { return report, fmt.Errorf("unsupported output format %s", options.Format) } + if options.Fonts > FontExternal || options.Fonts != FontKeep && format != "ofd" { + return report, fmt.Errorf("font processing requires OFD output and a valid mode") + } if options.PageOutput != nil && !report.Format.Paged { return report, fmt.Errorf("page output requires a paged format") } @@ -175,7 +182,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ if report.Input != "pdf" && report.Input != "ofd" { return report, fmt.Errorf("unsupported input format") } - if options.SkipUnchanged && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { + if options.SkipUnchanged && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged && report.Input == format && options.Pages == nil && strings.TrimSpace(options.PageRange) == "" { report.Unchanged = true return report, nil } @@ -246,7 +253,7 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ report.Pages = len(indices) writer := convertWriter{context: ctx, writer: output, count: &report.Bytes} if format == "ofd" { - if options.Pages == nil && editor == nil && options.Compression.Mode == CompressionUnchanged { + if options.Pages == nil && editor == nil && options.Fonts == FontKeep && options.Compression.Mode == CompressionUnchanged { if err = progress("write", 0, 0); err != nil { return report, err } @@ -261,6 +268,30 @@ func Convert(ctx context.Context, source io.ReaderAt, size int64, output io.Writ } } editor.OnWriteProgress = writeProgress + if options.Fonts != FontKeep { + if err = progress("fonts", 0, 0); err != nil { + return report, err + } + configuration := NewRenderer(&Reader{}, options.RendererOptions...) + if options.Backends != nil { + WithRenderBackends(*options.Backends)(configuration) + } + editor.SetRenderBackends(configuration.Backends()) + editor.SetFontDirs(configuration.fontDirs...) + editor.SetFontFS(configuration.fontFS...) + report.Fonts, err = editor.ProcessFonts(ctx, options.Fonts) + if err != nil { + return report, err + } + if report.Input == "ofd" && options.Pages == nil && options.Compression.Mode == CompressionUnchanged && !slices.ContainsFunc(report.Fonts, func(change FontChange) bool { return change.Changed }) { + if err = progress("write", 0, 0); err != nil { + return report, err + } + _, err = io.Copy(writer, io.NewSectionReader(source, 0, size)) + report.Unchanged = err == nil + return report, err + } + } if options.Pages == nil { _, err = editor.WriteToWithOptions(ctx, writer, WriteOptions{Compression: options.Compression}) } else { diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go new file mode 100644 index 0000000..49a7a2b --- /dev/null +++ b/ofdgo_editor_font_binding.go @@ -0,0 +1,551 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "archive/zip" + "bytes" + "context" + "crypto/sha256" + "encoding/xml" + "fmt" + "maps" + "slices" + "strconv" + "strings" +) + +// FontMode 指定字体资源的保存方式,不改变文字位置或压缩选项 +type FontMode uint8 + +const ( + // FontKeep 保留原有嵌入方式 + FontKeep FontMode = iota + // FontEmbed 嵌入可精确匹配的外部字体 + FontEmbed + // FontExternal 移除可安全恢复字符映射的内嵌字体 + FontExternal +) + +// FontChange 返回单个字体的处理结果,Document为零基文档索引,Reason非空表示保持原样 +type FontChange struct { + Document int `json:"document"` + ID string `json:"id"` + Name string `json:"name"` + Changed bool `json:"changed"` + Reason string `json:"reason,omitempty"` +} + +// EmbedFont 嵌入当前文档指定字体,保留资源标识及全部使用位置,成功后可撤销 +// file为空时从编辑器字体来源精确匹配;显式文件表示调用方确认替换,仍校验字符和字形 +// 入参: ctx 取消上下文, id 字体标识, file 字体文件, index 集合索引 +// 返回: error 字体、映射或包结构错误,失败时文档不变 +func (e *Editor) EmbedFont(ctx context.Context, id string, file FontFile, index int) error { + return e.changeFont(ctx, id, FontEmbed, file, index) +} + +// ExternalizeFont 将当前文档指定字体改为外部引用,不保证其他设备具备该字体 +// 仅移除可由原字体字符映射等价恢复的显式字形变换,无法确认的映射保持内嵌并返回错误 +// 入参: ctx 取消上下文, id 字体标识 +// 返回: error 字体、映射或包结构错误,失败时文档不变 +func (e *Editor) ExternalizeFont(ctx context.Context, id string) error { + return e.changeFont(ctx, id, FontExternal, FontFile{}, 0) +} + +// changeFont 在独立包快照中修改单个字体,成功后一次提交 +// 入参: ctx 取消上下文, id 字体标识, mode 保存方式, file 字体数据, index 集合索引 +// 返回: error 检查或提交错误 +func (e *Editor) changeFont(ctx context.Context, id string, mode FontMode, file FontFile, index int) error { + if err := ctx.Err(); err != nil { + return err + } + reader, err := e.Reader() + if err != nil { + return err + } + scan, err := scanEditorFonts(ctx, reader, reader.DocumentIndex()) + if err != nil { + return err + } + changed, err := e.changeFontSnapshot(ctx, scan, id, mode, file, index) + if err != nil || !changed { + return err + } + return e.commitFontSnapshot(ctx, reader) +} + +// ProcessFonts 处理包内各文档的字体,逐字体保留不能转换的资源,取消或包结构错误时整体不提交 +// 返回成功与未处理明细,整个操作计入一条撤销记录 +// 入参: ctx 取消上下文, mode 保存方式 +// 返回: []FontChange 字体处理结果, error 取消或结构错误 +func (e *Editor) ProcessFonts(ctx context.Context, mode FontMode) ([]FontChange, error) { + if mode > FontExternal { + return nil, fmt.Errorf("invalid font mode") + } + if err := ctx.Err(); err != nil { + return nil, err + } + if mode == FontKeep { + return nil, nil + } + reader, err := e.Reader() + if err != nil { + return nil, err + } + var report []FontChange + changed := false + for document := range reader.DocumentCount() { + scan, err := scanEditorFonts(ctx, reader, document) + if err != nil { + return nil, err + } + ids := slices.Sorted(maps.Keys(scan.fonts)) + for _, id := range ids { + if err := ctx.Err(); err != nil { + return nil, err + } + declaration := scan.fonts[id] + if (mode == FontEmbed) == (declaration.location != "") { + continue + } + item := FontChange{Document: document, ID: id, Name: declaration.font.FontName} + item.Changed, err = e.changeFontSnapshot(ctx, scan, id, mode, FontFile{}, 0) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + item.Reason = err.Error() + } + report = append(report, item) + if item.Changed { + changed = true + scan, err = scanEditorFonts(ctx, reader, document) + if err != nil { + return nil, err + } + } + } + } + if changed { + err = e.commitFontSnapshot(ctx, reader) + } + return report, err +} + +// changeFontSnapshot 校验字体全部文字引用并修改独立阅读器,不改写正文定位 +// 入参: ctx 取消上下文, scan 文档引用, id 字体标识, mode 保存方式, file 字体文件, index 集合索引 +// 返回: bool 是否修改, error 字体或结构错误 +func (e *Editor) changeFontSnapshot(ctx context.Context, scan *editorFontDocument, id string, mode FontMode, file FontFile, index int) (bool, error) { + declaration := scan.fonts[editorResourceID(id)] + if declaration == nil { + return false, fmt.Errorf("font not found: %s", id) + } + if (mode == FontEmbed) == (declaration.location != "") { + return false, nil + } + if scan.unsafe { + return false, fmt.Errorf("font references include unsupported content") + } + if e.backends.FontResources == nil { + return false, ErrBackendUnavailable + } + var data []byte + var err error + explicit := mode == FontEmbed && len(file.Data) != 0 + if mode == FontExternal { + data, err = scan.reader.readFile(declaration.location) + if err != nil { + return false, err + } + file = FontFile{Data: data} + if bytes.HasPrefix(data, []byte("ttcf")) { + index = fontCollectionIndex(data, []string{declaration.font.FontName, declaration.font.FamilyName}, declaration.font.Bold, declaration.font.Italic) + } + } else if len(file.Data) == 0 { + file, err = e.resolveBindingFont(scan, declaration) + if err != nil { + return false, err + } + index = 0 + } + parsed, err := e.backends.FontResources.OpenFontResource(file, index) + if err != nil { + return false, err + } + if mode == FontEmbed { + if err := validateFontEmbedding(parsed.Data); err != nil { + return false, err + } + } + changes := make(map[string][]byte) + verifiedGlyphs := false + for _, name := range slices.Sorted(maps.Keys(scan.files)) { + if err := ctx.Err(); err != nil { + return false, err + } + file := scan.files[name] + var patches []editorXMLPatch + for _, node := range file.texts { + if editorResourceID(node.attr("Font")) != editorResourceID(id) { + continue + } + var object TextObject + if err := xml.Unmarshal(file.data[node.start:node.end], &object); err != nil { + return false, err + } + if err := checkFontBinding(object, parsed, mode); err != nil { + return false, err + } + if explicit && len(object.CGTransform) != 0 && !verifiedGlyphs { + source, err := e.resolveBindingFont(scan, declaration) + if err != nil || !sameBindingFont(source.Data, parsed.Data) { + return false, fmt.Errorf("font glyph mapping requires the original font") + } + verifiedGlyphs = true + } + if mode == FontExternal { + for _, child := range node.children { + if child.matchesOFD("CGTransform") { + patches = append(patches, editorXMLPatch{start: child.start, end: child.end}) + } + } + } + } + if len(patches) != 0 { + changes[name] = editorPatchXML(file.data, patches) + } + } + data = declaration.file.data + node := declaration.node + if modified, ok := changes[declaration.file.name]; ok { + data = modified + node, err = fontBindingNode(data, id) + if err != nil { + return false, err + } + } + fontFile := node.child("FontFile") + if mode == FontEmbed { + digest := sha256.Sum256(parsed.Data) + name := fmt.Sprintf("Fonts/%x%s", digest, parsed.Extension) + if existing, err := scan.reader.readFile(name); err != nil || !bytes.Equal(existing, parsed.Data) { + name = packageAvailableName(scan.reader, changes, name) + changes[name] = bytes.Clone(parsed.Data) + } + content := node.textXML("FontFile", "/"+name) + if fontFile != nil { + data = editorPatchXML(data, []editorXMLPatch{{fontFile.start, fontFile.end, content}}) + } else if node.open == node.end { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)}) + } else { + data = editorPatchXML(data, []editorXMLPatch{{node.close, node.close, content}}) + } + } else { + if fontFile == nil { + return false, fmt.Errorf("font file declaration is missing") + } + fragment := editorPatchXML(data[node.start:node.end], []editorXMLPatch{{fontFile.start - node.start, fontFile.end - node.start, nil}}) + for _, field := range []string{"FontName", "FamilyName"} { + root, err := parseEditorXML(fragment) + if err != nil { + return false, err + } + value := root.attr(field) + if stripped := externalFontName(value); stripped != value { + fragment, err = editorXMLAttribute(fragment, root, field, stripped) + if err != nil { + return false, err + } + } + } + data = editorPatchXML(data, []editorXMLPatch{{node.start, node.end, fragment}}) + } + if explicit { + for _, field := range []struct{ name, value string }{{"FontName", parsed.Font.FontName}, {"FamilyName", parsed.Font.FamilyName}, {"Charset", "unicode"}} { + node, err := fontBindingNode(data, id) + if err != nil { + return false, err + } + fragment, err := editorXMLAttribute(data, node, field.name, field.value) + if err != nil { + return false, err + } + data = editorPatchXML(data, []editorXMLPatch{{node.start, node.end, fragment}}) + } + } + changes[declaration.file.name] = data + working := *scan.reader + working.files = maps.Clone(scan.reader.files) + next := *scan + next.reader = &working + if err := next.apply(ctx, changes); err != nil { + return false, err + } + if err := working.initRoot(); err != nil { + return false, err + } + if mode == FontExternal { + if err := pruneExternalFontFiles(ctx, &working, declaration.location); err != nil { + return false, err + } + } + *scan.reader = working + return true, nil +} + +// fontBindingNode 在修改后的资源XML中重新定位字体声明 +// 入参: data 资源XML, id 字体标识 +// 返回: *editorXML 字体节点, error 解析或查找错误 +func fontBindingNode(data []byte, id string) (*editorXML, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if fonts := root.child("Fonts"); fonts != nil { + for _, node := range fonts.children { + if node.matchesOFD("Font") && editorResourceID(node.attr("ID")) == editorResourceID(id) { + return node, nil + } + } + } + return nil, fmt.Errorf("font declaration is missing") +} + +// sameBindingFont 比较独立字体的全部数据表,忽略重新封装产生的校验和差异 +// 入参: source 原字体, target 待嵌入字体 +// 返回: bool 是否保留原字体内容及字形编号 +func sameBindingFont(source, target []byte) bool { + a, err := fontFileTables(source, 0) + if err != nil { + return false + } + b, err := fontFileTables(target, 0) + if err != nil || len(a) != len(b) { + return false + } + for tag, data := range a { + other := b[tag] + if tag == "head" && len(data) >= 12 && len(other) >= 12 { + if !bytes.Equal(data[:8], other[:8]) || !bytes.Equal(data[12:], other[12:]) { + return false + } + } else if !bytes.Equal(data, other) { + return false + } + } + return true +} + +// resolveBindingFont 精确匹配当前字体并提取独立字体数据,集合索引不传递到提取结果 +// 入参: scan 文档引用, declaration 字体声明 +// 返回: FontFile 独立字体, error 匹配或解析错误 +func (e *Editor) resolveBindingFont(scan *editorFontDocument, declaration *editorFontDeclaration) (FontFile, error) { + reader := &Reader{Zip: scan.reader.Zip, files: maps.Clone(scan.reader.files), documentIndex: scan.index} + if err := reader.initRoot(); err != nil { + return FontFile{}, err + } + if _, err := reader.Doc(); err != nil { + return FontFile{}, err + } + definition := declaration.font + definition.FontFile = "" + reader.fontCache[definition.ID] = &definition + resolved, err := e.newRenderer(reader).ResolveFont(definition.ID, true) + if err != nil { + return FontFile{}, err + } + if !resolved.Exact { + return FontFile{}, fmt.Errorf("exact font is unavailable") + } + file := FontFile{Data: resolved.Data} + index := 0 + if bytes.HasPrefix(file.Data, []byte("ttcf")) && resolved.Face != nil { + index = resolved.Face.Index + } + data, err := file.Face(index) + return FontFile{Data: data}, err +} + +// checkFontBinding 核对字符覆盖及字形编号,外置仅允许等价的一对一字符映射 +// 入参: object 文字对象, font 目标字体, mode 保存方式 +// 返回: error 字符或字形无法保留的原因 +func checkFontBinding(object TextObject, font *FontResource, mode FontMode) error { + var chars []rune + for _, code := range object.TextCode { + if code.Index != "" { + return fmt.Errorf("font uses index-encoded text") + } + chars = append(chars, textCodeRunes(code.Value)...) + } + covered := make(map[int]bool) + for _, transform := range object.CGTransform { + values := strings.Fields(transform.Glyphs) + if transform.CodePosition < 0 || transform.CodeCount < 1 || transform.GlyphCount < 1 || transform.CodePosition > len(chars)-transform.CodeCount || transform.GlyphCount != len(values) { + return fmt.Errorf("invalid font glyph mapping") + } + if mode == FontExternal && transform.CodeCount != transform.GlyphCount { + return fmt.Errorf("font uses non-character glyph mapping") + } + for i, value := range values { + glyph, err := strconv.ParseUint(value, 10, 16) + if err != nil || glyph == 0 || glyph >= uint64(font.NumGlyphs()) { + return fmt.Errorf("font glyph is unavailable") + } + if mode == FontExternal && uint16(glyph) != font.GlyphIndex(chars[transform.CodePosition+i]) { + return fmt.Errorf("font uses non-character glyph mapping") + } + } + for i := transform.CodePosition; i < transform.CodePosition+transform.CodeCount; i++ { + if covered[i] { + return fmt.Errorf("overlapping font glyph mapping") + } + covered[i] = true + } + } + for i, char := range chars { + if mode == FontExternal && (char >= 0xe000 && char <= 0xf8ff || char >= 0xf0000 && char <= 0x10fffd) { + return fmt.Errorf("font uses private character mapping") + } + if !covered[i] && font.GlyphIndex(char) == 0 { + return fmt.Errorf("font has no glyph for U+%04X", char) + } + } + return nil +} + +// externalFontName 去除PDF字体子集前缀,不猜测或改写其他名称 +// 入参: name 字体名称 +// 返回: string 外部匹配名称 +func externalFontName(name string) string { + if len(name) > 7 && name[6] == '+' { + for _, char := range name[:6] { + if char < 'A' || char > 'Z' { + return name + } + } + return name[7:] + } + return name +} + +// commitFontSnapshot 重新建立资源缓存并记录包快照,避免旧字体缓存跨撤销或重做复用 +// 入参: ctx 取消上下文, reader 修改后的独立快照 +// 返回: error 重建错误 +func (e *Editor) commitFontSnapshot(ctx context.Context, reader *Reader) error { + if err := ctx.Err(); err != nil { + return err + } + clean := &Reader{Zip: reader.Zip, files: maps.Clone(reader.files), documentIndex: reader.DocumentIndex(), encryption: e.encryption} + if err := clean.initRoot(); err != nil { + return err + } + next, err := clean.Editor() + if err != nil { + return err + } + before := e.transactionSnapshot() + e.restoreFontSnapshot(*next) + if change := e.recordChange(); change != nil { + after := e.transactionSnapshot() + change.undo = func(e *Editor) { e.restoreFontSnapshot(before) } + change.redo = func(e *Editor) { e.restoreFontSnapshot(after) } + } + return nil +} + +// restoreFontSnapshot 恢复字体修改前后的包状态,保留运行配置与撤销队列 +// 入参: state 包快照 +func (e *Editor) restoreFontSnapshot(state Editor) { + state = state.transactionSnapshot() + state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit + state.serial, state.revision = e.serial, e.revision + state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS + state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress + state.fontRenderer, state.fontSourcesCache = nil, nil + state.fontMetrics = make(map[string]FontMetrics) + *e = state +} + +// pruneExternalFontFiles 保留全包仍引用的字体,未确认的扩展引用使清理保持保守 +// 入参: ctx 取消上下文, reader 包快照, candidate 原字体路径 +// 返回: error 取消或读取错误 +func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidate string) error { + used := make(map[string]bool) + for i := range reader.DocumentCount() { + files, err := reader.documentFiles(ctx, i) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return nil + } + maps.Copy(used, files) + } + removed := make(map[string]bool) + key := strings.ToLower(cleanPackagePath(candidate)) + if used[key] { + return nil + } + for _, name := range reader.fileNamesFold { + if err := ctx.Err(); err != nil { + return err + } + if !strings.HasSuffix(strings.ToLower(name), ".xml") { + continue + } + data, err := reader.readFile(name) + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return nil + } + base := "" + if root.matchesOFD("Res") { + base = root.attr("BaseLoc") + } + var retains func(*editorXML) bool + retains = func(node *editorXML) bool { + if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) { + return true + } + for _, attr := range node.attrs { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { + return true + } + } + if node.matchesOFD("FontFile") && strings.EqualFold(resolveResourcePath(name, base, strings.TrimSpace(editorImportText(data, node))), candidate) { + return true + } + return slices.ContainsFunc(node.children, retains) + } + if retains(root) { + return nil + } + } + if !used[key] { + removed[key] = true + } + for name := range reader.files { + if removed[strings.ToLower(name)] { + delete(reader.files, name) + } + } + if reader.Zip != nil { + reader.Zip = &zip.Reader{Comment: reader.Zip.Comment, File: slices.DeleteFunc(slices.Clone(reader.Zip.File), func(file *zip.File) bool { return removed[strings.ToLower(cleanPackagePath(file.Name))] })} + } + return reader.initRoot() +} diff --git a/ofdgo_editor_font_scan.go b/ofdgo_editor_font_scan.go new file mode 100644 index 0000000..388def4 --- /dev/null +++ b/ofdgo_editor_font_scan.go @@ -0,0 +1,296 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "maps" + "slices" + "strings" +) + +// editorFontDocument 保存一个文档的字体声明与完整内容引用,不进入附件和签名封装 +type editorFontDocument struct { + reader *Reader + index int + files map[string]*editorFontXML + fonts map[string]*editorFontDeclaration + unsafe bool +} + +// editorFontXML 保存内容XML及其直接文件引用 +type editorFontXML struct { + name string + data []byte + root *editorXML + links []editorFontLink + texts []*editorXML +} + +// editorFontLink 记录需要随共享资源隔离而更新的路径 +type editorFontLink struct { + node *editorXML + attribute string + target string +} + +// editorFontDeclaration 关联字体声明、所属资源文件及嵌入数据路径 +type editorFontDeclaration struct { + font Font + file *editorFontXML + node *editorXML + location string +} + +// scanEditorFonts 按标准内容路径扫描正文、模板、注解、复合图元、图案及文字裁剪 +// 入参: ctx 取消上下文, reader 包快照, index 文档索引 +// 返回: *editorFontDocument 字体引用, error 读取或结构错误 +func scanEditorFonts(ctx context.Context, reader *Reader, index int) (*editorFontDocument, error) { + scan := &editorFontDocument{reader: reader, index: index, files: make(map[string]*editorFontXML), fonts: make(map[string]*editorFontDeclaration)} + queue := []string{cleanPackagePath(reader.OFD.DocBody[index].DocRoot)} + for len(queue) != 0 { + if err := ctx.Err(); err != nil { + return nil, err + } + name := queue[0] + queue = queue[1:] + if actual := reader.fileNamesFold[strings.ToLower(name)]; actual != "" { + name = actual + } + if scan.files[name] != nil { + continue + } + data, err := reader.readFile(name) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + file := &editorFontXML{name: name, data: data, root: root} + scan.files[name] = file + base := "" + if root.matchesOFD("Res") { + base = root.attr("BaseLoc") + } + var visit func(*editorXML) error + visit = func(node *editorXML) error { + if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown { + scan.unsafe = true + return nil + } + if node.matchesOFD("Attachments") || node.matchesOFD("Signatures") { + return nil + } + if slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) { + scan.unsafe = true + return nil + } + for _, attr := range node.attrs { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { + scan.unsafe = true + } + } + if node.matchesOFD("Font") && node.parent != nil && node.parent.matchesOFD("Fonts") && root.matchesOFD("Res") { + if node.childAt("FontFile", 1) != nil { + return fmt.Errorf("multiple font files for %s", node.attr("ID")) + } + var font Font + if err := xml.Unmarshal(data[node.start:node.end], &font); err != nil { + return err + } + id := editorResourceID(font.ID) + if id == "" || scan.fonts[id] != nil { + return fmt.Errorf("missing or duplicate font ID %q", font.ID) + } + location := "" + if font.FontFile != "" { + location = resolveResourcePath(name, base, font.FontFile) + } + scan.fonts[id] = &editorFontDeclaration{font: font, file: file, node: node, location: location} + } + if node.matchesOFD("TextObject") || node.matchesOFD("Text") && node.parent != nil && node.parent.matchesOFD("Area") { + file.texts = append(file.texts, node) + if node.attr("Font") == "" { + scan.unsafe = true + } + } + attribute, value := "", "" + switch node.name.Local { + case "PublicRes", "DocumentRes", "PageRes", "Annotations": + value = strings.TrimSpace(editorImportText(data, node)) + case "Page", "TemplatePage", "CompositeGraphicUnit": + attribute, value = "BaseLoc", node.attr("BaseLoc") + case "PageAnnot": + attribute, value = "FileLoc", node.attr("FileLoc") + case "DrawParam": + attribute, value = "Link", node.attr("Link") + case "FileLoc": + if node.parent != nil && node.parent.matchesOFD("Page") && root.matchesOFD("Annotations") { + value = strings.TrimSpace(editorImportText(data, node)) + } + } + if value != "" { + target := resolveResourcePath(name, base, value) + if actual := reader.fileNamesFold[strings.ToLower(target)]; actual != "" { + target = actual + } + file.links = append(file.links, editorFontLink{node: node, attribute: attribute, target: target}) + queue = append(queue, target) + } + for _, child := range node.children { + if err := visit(child); err != nil { + return err + } + } + return nil + } + if err := visit(root); err != nil { + return nil, err + } + } + return scan, nil +} + +// apply 隔离其他文档共享的内容XML,再提交字体修改及上层路径引用 +// 入参: ctx 取消上下文, changes 按原文件路径保存的修改 +// 返回: error 引用扫描或XML修改错误 +func (s *editorFontDocument) apply(ctx context.Context, changes map[string][]byte) error { + shared := make(map[string]bool) + if s.reader.DocumentCount() > 1 { + for index := range s.reader.DocumentCount() { + if index == s.index { + continue + } + files, err := s.reader.documentFiles(ctx, index) + if err != nil { + return err + } + maps.Copy(shared, files) + } + } + paths := make(map[string]string) + for name := range changes { + if shared[strings.ToLower(name)] { + paths[name] = packageAvailableName(s.reader, changes, name+".font.xml") + } + } + for changed := true; changed; { + changed = false + for name, file := range s.files { + if paths[name] != "" || !shared[strings.ToLower(name)] { + continue + } + for _, link := range file.links { + if paths[link.target] != "" { + paths[name] = packageAvailableName(s.reader, changes, name+".font.xml") + changed = true + break + } + } + } + } + for _, name := range slices.Sorted(maps.Keys(s.files)) { + file := s.files[name] + if !slices.ContainsFunc(file.links, func(link editorFontLink) bool { return paths[link.target] != "" }) { + continue + } + data, modified := changes[name] + if !modified { + data = file.data + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + var patches []editorXMLPatch + var patch func(*editorXML) error + patch = func(node *editorXML) error { + for _, link := range file.links { + target := paths[link.target] + if target == "" || node.name != link.node.name { + continue + } + if link.attribute != "" && node.attr(link.attribute) == link.node.attr(link.attribute) { + fragment, err := editorXMLAttribute(data, node, link.attribute, "/"+target) + if err != nil { + return err + } + header, err := parseEditorXML(fragment) + if err != nil { + return err + } + patches = append(patches, editorXMLPatch{node.start, node.open, fragment[:header.open]}) + break + } + if link.attribute == "" && editorImportText(data, node) == editorImportText(file.data, link.node) { + patches = append(patches, editorXMLContent(data, node, editorFontXMLText("/"+target))) + break + } + } + for _, child := range node.children { + if err := patch(child); err != nil { + return err + } + } + return nil + } + if err := patch(root); err != nil { + return err + } + if len(patches) != 0 { + data, modified = editorPatchXML(data, patches), true + } + if modified { + changes[name] = data + } + } + rootName := cleanPackagePath(s.reader.OFD.DocBody[s.index].DocRoot) + if actual := s.reader.fileNamesFold[strings.ToLower(rootName)]; actual != "" { + rootName = actual + } + if target := paths[rootName]; target != "" { + data, err := s.reader.readFile("OFD.xml") + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + node := root.childAt("DocBody", s.index).child("DocRoot") + changes[s.reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, editorFontXMLText("/"+target))}) + } + for name, data := range changes { + if target := paths[name]; target != "" { + name = target + } + s.reader.files[name] = data + } + return nil +} + +// editorFontXMLText 编码资源路径文本,不改变包内名称 +// 入参: value 原始文本 +// 返回: []byte XML文本 +func editorFontXMLText(value string) []byte { + var data bytes.Buffer + _ = xml.EscapeText(&data, []byte(value)) + return data.Bytes() +} diff --git a/ofdgo_font_resource_sfnt.go b/ofdgo_font_resource_sfnt.go index 0ec8234..f177d0a 100644 --- a/ofdgo_font_resource_sfnt.go +++ b/ofdgo_font_resource_sfnt.go @@ -91,7 +91,32 @@ func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) } // 入参: sfnt 已解析字体 // 返回: bool 是否支持裁剪 func sfntSubsettable(sfnt *font.SFNT) bool { - return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) + return sfnt.IsTrueType && fontEmbeddingFlags(sfnt)&0x0100 == 0 && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil }) +} + +// fontEmbeddingFlags 读取OpenType的嵌入标志,未提供OS/2表时返回零 +// 入参: sfnt 已解析字体 +// 返回: uint16 嵌入标志 +func fontEmbeddingFlags(sfnt *font.SFNT) uint16 { + if table := sfnt.Tables["OS/2"]; len(table) >= 10 { + return binary.BigEndian.Uint16(table[8:10]) + } + return 0 +} + +// validateFontEmbedding 校验字体是否允许作为可编辑轮廓字体嵌入 +// 入参: data 独立OpenType字体 +// 返回: error 字体解析或嵌入限制 +func validateFontEmbedding(data []byte) error { + sfnt, err := font.ParseSFNT(data, 0) + if err != nil { + return err + } + flags := fontEmbeddingFlags(sfnt) + if flags&0x0200 != 0 || flags&0x000e != 0 && flags&0x0008 == 0 { + return fmt.Errorf("font does not permit editable embedding") + } + return nil } // sfntFontName 获取字体中的名称 @@ -116,6 +141,9 @@ func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) { if err != nil { return nil, err } + if fontEmbeddingFlags(sfnt)&0x0100 != 0 { + return bytes.Clone(data), nil + } if mapped { usage := newEditorFontUsage() for _, id := range glyphs { @@ -159,7 +187,7 @@ func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte { return nil } sfnt, err := font.ParseSFNT(data, 0) - if err != nil || !sfnt.IsTrueType { + if err != nil || !sfnt.IsTrueType || fontEmbeddingFlags(sfnt)&0x0100 != 0 { return nil } for tag := range sfnt.Tables {