From 979b660703e3e6585134c8c379443704e772985a Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 13 Oct 2026 12:36:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B5=84=E6=BA=90=E7=AE=A1=E7=90=86=E5=8F=8A?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 30 +++ assets/webui/ofdgo.css | 131 ++++++++++++ assets/webui/ofdgo.js | 251 ++++++++++++++++++++++- assets/webui/ofdgo_wasm.js | 5 +- internal/webui/wasm.go | 91 +++++++++ ofdgo_editor_font_binding.go | 32 +-- ofdgo_editor_font_cleanup.go | 2 +- ofdgo_editor_resource.go | 374 +++++++++++++++++++++++++++++++++++ ofdgo_resource.go | 244 +++++++++++++++++++++++ 9 files changed, 1140 insertions(+), 20 deletions(-) create mode 100644 ofdgo_editor_resource.go create mode 100644 ofdgo_resource.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 7bee97c..eda30fc 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -353,6 +353,35 @@
+ +
+

资源管理

+
+ +
+
+ +
+
+
+ + + + + + + +
+ + +
+ +
+

文档管理

@@ -658,6 +687,7 @@
文档
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 7cd7aca..f380f98 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1548,6 +1548,137 @@ body[data-hide-meta] .meta-panel { width: 440px; } +.resource-dialog { + width: 600px; +} + +.resource-heading, +.resource-option { + display: grid; + grid-template-columns: minmax(0, 1fr) 90px 50px; + align-items: center; + gap: 10px; + padding: 8px 10px; + text-align: left; +} + +.resource-heading { + background: #eef1f4; + border: 1px solid var(--line); +} + +.resource-list-container { + position: relative; + margin-bottom: 12px; +} + +.resource-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-muted); + pointer-events: none; +} + +.resource-empty[hidden] { + display: none; +} + +.resource-list { + max-height: 220px; + min-height: 80px; + overflow: auto; + border: 1px solid var(--line); + border-top: 0; +} + +.resource-option { + width: 100%; + border: 0; + border-bottom: 1px solid var(--line); + background: #fff; + color: inherit; + font: inherit; + cursor: pointer; +} + +.resource-option span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resource-heading > :not(:first-child), +.resource-option > :not(:first-child) { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.resource-option[aria-selected="true"] { + background: #e4f3f0; + color: #087f70; +} + +.resource-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 12px; +} + +.resource-actions > button { + min-height: 28px; +} + +.resource-actions > [hidden] { + display: none; +} + +.resource-actions > :last-child { + margin-left: auto; +} + +.resource-binding { + border-top: 1px solid var(--line); + margin-top: 12px; + padding-top: 12px; +} + +.resource-binding .form-actions { + display: flex; + justify-content: flex-end; +} + +.resource-binding .form-actions > button { + flex: 0 0 auto; +} + +.resource-viewer { + display: flex; + align-items: center; + justify-content: center; + height: 180px; + margin-top: 12px; + overflow: auto; + background: white; +} + +.resource-viewer[hidden] { + display: none; +} + +.resource-viewer img, +.resource-viewer video { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.resource-viewer audio { + width: 100%; +} + .document-dialog .form-status:not(:empty) { margin-top: 8px; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index a066fa1..c3bf7b3 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -23,6 +23,8 @@ const batch = { items: [], formats: [], running: false, canceled: false, worker: 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}`)])); +const resourceElements = Object.fromEntries(["ManageButton", "Panel", "Form", "Kind", "List", "Empty", "Reference", "Add", "Preview", "Export", "Replace", "Delete", "Locate", "Close", "Binding", "Page", "Object", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)])); +const resourceManager = { items: [], selected: null, busy: false, sequence: 0, url: null, font: null, fileAction: null }; let wasmPromise = null; let remoteDownload = null; @@ -732,6 +734,56 @@ editorClick(el.paragraphButton, () => { el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); editorClick(el.documentAddButton, () => openCreatePanel(false, true)); editorClick(el.documentManageButton, openDocumentManagement); +resourceElements.ManageButton.addEventListener("click", openResourceManagement); +resourceElements.Close.addEventListener("click", () => resourceElements.Panel.close()); +resourceElements.Form.addEventListener("submit", event => event.preventDefault()); +resourceElements.Panel.addEventListener("cancel", event => { if (resourceManager.busy) event.preventDefault(); }); +resourceElements.Panel.addEventListener("close", () => { resourceManager.sequence++; releaseResourcePreview(); }); +resourceElements.Kind.addEventListener("change", renderResourceList); +resourceElements.Reference.addEventListener("change", updateResourceControls); +resourceElements.Page.addEventListener("change", () => resourceTask(loadResourceTargets)); +resourceElements.Object.addEventListener("change", updateResourceControls); +resourceElements.Preview.addEventListener("click", () => resourceTask(previewManagedResource)); +resourceElements.Export.addEventListener("click", () => { + const item = resourceManager.selected; + if (item) downloadResource({ id: JSON.stringify([item.kind, item.id]), fileName: resourceFileName(item) }, "ofdgoExportResource", "资源"); +}); +resourceElements.Locate.addEventListener("click", () => { + const reference = resourceManager.selected?.references?.[Number(resourceElements.Reference.value)]; + if (reference?.page >= 0) { resourceElements.Panel.close(); renderPage(reference.page); } +}); +resourceElements.Add.addEventListener("click", () => { + if (!state.editing) return; + const kind = resourceElements.Kind.value; + if (kind === "Font") { resourceElements.Panel.close(); openFontFile(el.fontInput); } + else if (kind === "Image") { resourceElements.Panel.close(); openInsertPanel(); } + else if (kind === "Attachment") openAttachmentDialog("add"); + else chooseResourceFile("add"); +}); +resourceElements.Replace.addEventListener("click", async () => { + const item = resourceManager.selected; + if (!state.editing || !item) return; + if (item.kind === "Font") { + const font = state.doc.fonts?.find(font => font.id === item.id) || {id: item.id, fontName: item.name, embedded: Boolean(item.file)}; + await openFontBinding(font); + if (!fontBindingElements.Panel.open) await resourceTask(loadManagedResources); + } else chooseResourceFile("replace"); +}); +resourceElements.Delete.addEventListener("click", () => resourceTask(async () => { + const item = resourceManager.selected; + if (item && window.confirm(`删除资源“${item.name}”?`)) await changeManagedResource("delete", item); +})); +for (const [button, action] of [[resourceElements.Bind, "bind"], [resourceElements.Unbind, "unbind"]]) { + button.addEventListener("click", () => resourceTask(() => changeManagedResource(action, resourceManager.selected))); +} +resourceElements.File.addEventListener("change", () => resourceTask(async () => { + const request = resourceManager.fileAction, file = resourceElements.File.files[0]; + if (!file || !request || request.sequence !== state.openSeq || !state.editing) return; + await changeManagedResource(request.action, request.item, file); +})); +for (const dialog of [fontBindingElements.Panel, el.attachmentDialog]) { + dialog.addEventListener("close", () => { if (resourceElements.Panel.open) resourceTask(loadManagedResources); }); +} editorClick(el.versionManageButton, openVersionManagement); el.versionClose.addEventListener("click", () => el.versionPanel.close()); el.versionPanel.addEventListener("cancel", event => { if (el.versionForm.inert) event.preventDefault(); }); @@ -1703,7 +1755,7 @@ function handleKeyDown(event) { function formDialogOpen() { 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.structureDialog.open || el.versionPanel.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; + || resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.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; } function setObjectDimension(input, value) { @@ -3478,6 +3530,8 @@ async function changeDocument(name, item, ...args) { el.versionStatus.textContent = err.message; } else if (el.documentPanel.open) { el.documentStatus.textContent = err.message; + } else if (resourceElements.Panel.open) { + resourceElements.Status.textContent = resourceError(err.message); } else { showError(err, false); } @@ -4155,6 +4209,7 @@ async function openOFD(file, remote = null) { return; } remoteDownload?.abort(); + resourceElements.Panel.close(); clearSecurityForms(); state.wasmRecoveries = 0; const openSeq = ++state.openSeq; @@ -5009,6 +5064,196 @@ async function renderPage(index, options = {}) { } } +async function openResourceManagement() { + if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return; + resourceElements.Panel.showModal(); + resourceElements.Page.replaceChildren(...state.doc.pages.map((page, index) => new Option(`第${index + 1}页`, String(index)))); + resourceElements.Page.value = String(state.pageIndex); + await resourceTask(async () => { await loadManagedResources(); if (state.editing) await loadResourceTargets(); }); +} + +async function resourceTask(action) { + if (resourceManager.busy) return; + resourceManager.busy = true; + resourceElements.Form.inert = true; + resourceElements.Status.textContent = "处理中"; + resourceElements.Panel.setAttribute("aria-busy", "true"); + try { + await action(); + if (resourceElements.Status.textContent === "处理中") resourceElements.Status.textContent = ""; + } catch (err) { + resourceElements.Status.textContent = resourceError(err.message); + } finally { + resourceManager.busy = false; + resourceElements.Form.inert = false; + resourceElements.Panel.removeAttribute("aria-busy"); + updateResourceControls(); + } +} + +function resourceError(message) { + if (/still referenced/.test(message)) return "资源仍被引用"; + if (/unsupported content/.test(message)) return "引用无法确认"; + if (/empty media|nonempty Audio/.test(message)) return "文件内容为空"; + return message; +} + +async function loadManagedResources() { + const sequence = state.openSeq; + const items = await callWASM("ofdgoResources"); + if (sequence !== state.openSeq || !resourceElements.Panel.open) return; + resourceManager.items = items || []; + renderResourceList(); +} + +function renderResourceList() { + const items = resourceManager.items.filter(item => item.kind === resourceElements.Kind.value); + const selected = items.find(item => item.id === resourceManager.selected?.id && item.kind === resourceManager.selected?.kind) || items[0]; + resourceElements.List.replaceChildren(); + resourceElements.Empty.hidden = items.length !== 0; + for (const item of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "resource-option"; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", String(item === selected)); + button.title = item.file || item.name; + for (const value of [item.name, item.file ? formatBytes(item.size) : "外部", item.complete ? String(item.references?.length || 0) : "未知"]) { + const span = document.createElement("span"); + span.textContent = value; + button.append(span); + } + button.addEventListener("click", () => selectManagedResource(item)); + button.addEventListener("keydown", event => { + const index = items.indexOf(item); + const target = { ArrowDown: Math.min(items.length - 1, index + 1), ArrowUp: Math.max(0, index - 1), Home: 0, End: items.length - 1 }[event.key]; + if (target == null) return; + event.preventDefault(); + selectManagedResource(items[target]); + resourceElements.List.children[target].focus(); + }); + resourceElements.List.append(button); + } + selectManagedResource(selected); +} + +function selectManagedResource(item) { + releaseResourcePreview(); + resourceManager.selected = item || null; + const items = resourceManager.items.filter(entry => entry.kind === resourceElements.Kind.value); + Array.from(resourceElements.List.children).forEach((node, i) => { + node.setAttribute("aria-selected", String(items[i] === item)); + node.tabIndex = items[i] === item ? 0 : -1; + }); + const references = item?.references || []; + const roles = {Font: "字体", ResourceID: "资源", Substitution: "替换", ImageMask: "蒙版", Thumbnail: "缩略", AttachID: "附件"}; + resourceElements.Reference.replaceChildren(...references.map((ref, index) => new Option(`${ref.page >= 0 ? `第${ref.page + 1}页` : ref.file}${ref.object ? ` · ${ref.object}` : ""} · ${roles[ref.role] || ref.role}`, String(index)))); + if (!references.length) resourceElements.Reference.add(new Option(!item || item.complete ? "暂无引用" : "引用未知", "")); + updateResourceControls(); +} + +function updateResourceControls() { + const item = resourceManager.selected, editing = state.editing; + const disabled = resourceManager.busy || !state.doc || document.body.hasAttribute("aria-busy"); + const media = item && ["Audio", "Video"].includes(item.kind); + resourceElements.Add.hidden = resourceElements.Replace.hidden = resourceElements.Delete.hidden = !editing; + resourceElements.Add.disabled = disabled; + resourceElements.Reference.disabled = disabled || !item?.references?.length; + resourceElements.Preview.disabled = disabled || !item?.file || item.kind === "Attachment"; + resourceElements.Export.disabled = disabled || !item?.file; + resourceElements.Replace.disabled = disabled || !item || !item.complete; + resourceElements.Replace.textContent = item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换"; + resourceElements.Delete.disabled = disabled || !item?.complete || Boolean(item?.references?.length); + resourceElements.Delete.title = item?.references?.length ? "资源仍被引用" : "删除资源"; + resourceElements.Locate.disabled = disabled || !(item?.references?.[Number(resourceElements.Reference.value)]?.page >= 0); + resourceElements.Binding.hidden = !editing || !media; + resourceElements.Bind.disabled = resourceElements.Unbind.disabled = disabled || !media || !resourceElements.Object.value; +} + +async function loadResourceTargets() { + resourceElements.Object.replaceChildren(new Option("选择图元", "")); + const page = Number(resourceElements.Page.value), sequence = state.openSeq; + const items = await callWASM("ofdgoResourceTargets", page); + if (!resourceElements.Panel.open || sequence !== state.openSeq) return; + for (const item of items || []) resourceElements.Object.add(new Option(`${item.id} · ${(item.text || "图元").slice(0, 50)}`, item.id)); + const selected = canvasEditor.selected; + if (selected?.index === page && !selected.scoped) resourceElements.Object.value = selected.id; +} + +function chooseResourceFile(action) { + const item = action === "add" ? {kind: resourceElements.Kind.value, id: ""} : resourceManager.selected; + if (!item) return; + resourceManager.fileAction = {action, item, sequence: state.openSeq}; + resourceElements.File.value = ""; + resourceElements.File.accept = {Audio: "audio/*", Video: "video/*", Image: ".png,.jpg,.jpeg,.bmp,.tif,.tiff,.jb2,.jbig2"}[item.kind] || ""; + resourceElements.File.click(); +} + +async function changeManagedResource(action, item, file) { + if (!item || !state.editing) return; + releaseResourcePreview(); + const format = file?.name.includes(".") ? file.name.split(".").at(-1).toUpperCase() : ""; + if (await changeDocument("ofdgoChangeResource", null, action, item.kind, item.id, format, file || null, Number(resourceElements.Page.value), resourceElements.Object.value)) { + await loadManagedResources(); + await loadResourceTargets(); + } +} + +function releaseResourcePreview() { + resourceManager.sequence++; + for (const player of resourceElements.Viewer.querySelectorAll("audio, video")) { player.pause(); player.removeAttribute("src"); player.load(); } + resourceElements.Viewer.replaceChildren(); + resourceElements.Viewer.hidden = true; + if (resourceManager.url) URL.revokeObjectURL(resourceManager.url); + if (resourceManager.font) document.fonts.delete(resourceManager.font); + resourceManager.url = resourceManager.font = null; +} + +async function previewManagedResource() { + const item = resourceManager.selected; + if (!item?.file) return; + if (state.doc.encryption?.encrypted && !window.confirm("资源将按明文读取,是否继续?")) return; + releaseResourcePreview(); + const sequence = resourceManager.sequence, openSeq = state.openSeq; + let result; + try { + result = await callWASM("ofdgoExportResource", JSON.stringify([item.kind, item.id]), null); + } finally { + state.exportRequestID = 0; + el.cancelExportButton.hidden = true; + } + if (sequence !== resourceManager.sequence || openSeq !== state.openSeq || !resourceElements.Panel.open) return; + const format = item.format.toLowerCase(); + const mime = {mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", png: "image/png", jpeg: "image/jpeg", jpg: "image/jpeg", bmp: "image/bmp"}[format] || "application/octet-stream"; + resourceManager.url = URL.createObjectURL(new Blob([result.blob], {type: mime})); + let element; + if (item.kind === "Font") { + const font = new FontFace("OFDResourcePreview", `url("${resourceManager.url}")`); + await font.load(); + if (sequence !== resourceManager.sequence) return; + document.fonts.add(font); + resourceManager.font = font; + element = document.createElement("span"); + element.style.fontFamily = '"OFDResourcePreview"'; + element.style.fontSize = "24px"; + element.textContent = "Aa 0123 字体预览"; + } else { + element = document.createElement(item.kind === "Image" ? "img" : item.kind === "Audio" ? "audio" : "video"); + element.src = resourceManager.url; + if (item.kind === "Image") element.alt = item.name; + else { element.controls = true; element.preload = "metadata"; } + element.addEventListener("error", () => { resourceElements.Status.textContent = "浏览器无法预览此格式"; }); + } + resourceElements.Viewer.append(element); + resourceElements.Viewer.hidden = false; +} + +function resourceFileName(item) { + let name = item.kind === "Font" ? item.file.split("/").at(-1) : item.name; + if (!name.includes(".") && item.format) name += "." + item.format.toLowerCase(); + return name; +} + function downloadAttachment(attachment) { return downloadResource(attachment, "ofdgoExportAttachment", "附件"); } @@ -9265,6 +9510,8 @@ function updateDocumentControls() { const disabled = !state.doc || !state.ready || state.exporting || busy; el.documentManageButton.hidden = !state.editing; el.documentManageButton.disabled = disabled; + resourceElements.ManageButton.disabled = disabled; + updateResourceControls(); el.documentRow.hidden = !count || !state.editing && count < 2; el.documentSelect.disabled = disabled; const actionsDisabled = disabled || !state.editing || Boolean(manager?.loading || manager?.failed); @@ -9454,7 +9701,7 @@ async function callWASM(name, ...args) { request.fontData = new Map(); args.push([...request.localFonts.keys()]); } - if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { + if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") { state.exportRequestID = id; el.cancelExportButton.hidden = false; el.cancelExportButton.disabled = false; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index c46c75f..a50eb18 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -29,7 +29,7 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportMedia" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { + if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoExportResource" || data.name === "ofdgoExportMedia" || data.name === "ofdgoSaveDocument" || data.name === "ofdgoSaveSigned" || data.name === "ofdgoImportPages") { operations.set(data.id, new AbortController()); } pending = pending.then(() => data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" ? handleBatchMessage(data) : handleMessage(data)); @@ -188,6 +188,9 @@ async function handleMessage({ id, name, args }) { catch (err) { return { error: err.message }; } }; } + if (name === "ofdgoChangeResource" && (args[0] === "add" || args[0] === "replace")) { + args[4] = new Uint8Array(new FileReaderSync().readAsArrayBuffer(args[4])); + } if (name === "ofdgoImportImages") { const files = args[0], reader = new FileReaderSync(); args[0] = files.map(file => file.name); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 51a8441..6a68ef0 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -221,6 +221,10 @@ func RunWASM() { registerAsyncCallback("ofdgoExportDocument", exportDocument) registerAsyncCallback("ofdgoExportAttachment", exportAttachment) registerAsyncCallback("ofdgoExportMedia", exportMedia) + registerCallback("ofdgoResources", documentResources) + registerCallback("ofdgoResourceTargets", resourceTargets) + registerCallback("ofdgoChangeResource", changeResource) + registerAsyncCallback("ofdgoExportResource", exportResource) registerCallback("ofdgoImages", documentImages) registerAsyncCallback("ofdgoExportImages", exportImages) registerCallback("ofdgoMatchFontFiles", matchFontFiles) @@ -794,6 +798,93 @@ func exportMedia(args []js.Value) (any, error) { return successResult(map[string]any{"format": media.Format, "kind": media.Type}), nil } +// documentResources 读取当前文档和版本的资源及引用,不执行媒体动作 +// 入参: args 无参数 +// 返回: any 资源列表, error 读取错误 +func documentResources(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + return currentSession.Reader.Resources(context.Background()) +} + +// resourceTargets 列出当前页可由普通对象接口编辑动作的图元 +// 入参: args 零基页码 +// 返回: any 图元列表, error 页面读取错误 +func resourceTargets(args []js.Value) (any, error) { + if currentEditor == nil || len(args) != 1 { + return nil, fmt.Errorf("no document is being edited") + } + items, err := currentSession.Renderer.StructureObjects(args[0].Int()) + if err != nil { + return nil, err + } + result := items[:0] + for _, item := range items { + if _, err := currentEditor.Object(args[0].Int(), item.ID); err == nil { + result = append(result, item) + } + } + return result, nil +} + +// changeResource 调用库层资源编辑,浏览器不改写资源声明和包路径 +// 入参: args 操作、类型、标识、格式、数据、页码和图元标识 +// 返回: any 编辑状态, error 资源操作错误 +func changeResource(args []js.Value) (any, error) { + if len(args) != 7 { + return nil, fmt.Errorf("invalid resource arguments") + } + return changeAtomicObjects(func() error { + ctx := context.Background() + action, kind, id := args[0].String(), args[1].String(), args[2].String() + switch action { + case "delete": + return currentEditor.DeleteResource(ctx, kind, id) + case "bind", "unbind": + return currentEditor.BindMedia(ctx, id, args[5].Int(), args[6].String(), action == "bind") + case "add", "replace": + data, err := bytesFromJS(args[4]) + if err != nil { + return err + } + if action == "add" { + _, err = currentEditor.ImportMedia(ctx, kind, args[3].String(), data) + return err + } + return currentEditor.ReplaceResource(ctx, kind, id, args[3].String(), data) + default: + return fmt.Errorf("unsupported resource action") + } + }) +} + +// exportResource 分块导出当前资源的原始文件,不接收任意包路径 +// 入参: args 类型及标识的JSON数组、分块接收函数 +// 返回: any 输出格式, error 读取或写入错误 +func exportResource(args []js.Value) (any, error) { + if currentSession == nil || len(args) != 2 { + return nil, fmt.Errorf("ofd document is not opened") + } + var key [2]string + if err := json.Unmarshal([]byte(args[0].String()), &key); err != nil { + return nil, err + } + stream, err := currentSession.Reader.OpenResource(context.Background(), key[0], key[1]) + if err != nil { + return nil, err + } + defer stream.Close() + writer := bufio.NewWriterSize(exportWriter{write: args[1]}, 1<<20) + if _, err := io.Copy(writer, stream); err != nil { + return nil, err + } + if err := writer.Flush(); err != nil { + return nil, err + } + return successResult(map[string]any{"mime": "application/octet-stream"}), nil +} + // svgFontData 读取SVG字体资源 // 入参: args 浏览器参数 // 返回: any 字体数据, error 错误信息 diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index 04e3487..d4d2ede 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -110,11 +110,11 @@ func (e *Editor) changeFont(ctx context.Context, id string, mode FontMode, file return err } if mode == FontExternal { - if err := pruneExternalFontFiles(ctx, reader, scan.fonts[editorResourceID(id)].location); err != nil { + if err := pruneUnreferencedFiles(ctx, reader, scan.fonts[editorResourceID(id)].location); err != nil { return err } } - return e.commitFontSnapshot(ctx, reader) + return e.commitResourceSnapshot(ctx, reader) } // ProcessFonts 处理包内各文档的字体,逐字体保留不能转换的资源,取消或包结构错误时整体不提交 @@ -220,10 +220,10 @@ func (e *Editor) ProcessFontsWithOptions(ctx context.Context, options FontProces if err := progress("commit", 0, 1); err != nil { return nil, err } - if err := pruneExternalFontFiles(ctx, reader, externalFiles...); err != nil { + if err := pruneUnreferencedFiles(ctx, reader, externalFiles...); err != nil { return nil, err } - err = e.commitFontSnapshot(ctx, reader) + err = e.commitResourceSnapshot(ctx, reader) } return report, err } @@ -613,10 +613,10 @@ func externalFontName(name string) string { return name } -// commitFontSnapshot 重新建立资源缓存并记录包快照,避免旧字体缓存跨撤销或重做复用 +// commitResourceSnapshot 重建资源缓存并记录包快照,避免旧缓存跨撤销或重做复用 // 入参: ctx 取消上下文, reader 修改后的独立快照 // 返回: error 重建错误 -func (e *Editor) commitFontSnapshot(ctx context.Context, reader *Reader) error { +func (e *Editor) commitResourceSnapshot(ctx context.Context, reader *Reader) error { if err := ctx.Err(); err != nil { return err } @@ -632,18 +632,18 @@ func (e *Editor) commitFontSnapshot(ctx context.Context, reader *Reader) error { return err } before := e.transactionSnapshot() - e.restoreFontSnapshot(*next) + e.restoreResourceSnapshot(*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) } + change.undo = func(e *Editor) { e.restoreResourceSnapshot(before) } + change.redo = func(e *Editor) { e.restoreResourceSnapshot(after) } } return nil } -// restoreFontSnapshot 恢复字体修改前后的包状态,保留运行配置与撤销队列 +// restoreResourceSnapshot 恢复资源修改前后的包状态,保留运行配置与撤销队列 // 入参: state 包快照 -func (e *Editor) restoreFontSnapshot(state Editor) { +func (e *Editor) restoreResourceSnapshot(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 @@ -657,10 +657,10 @@ func (e *Editor) restoreFontSnapshot(state Editor) { *e = state } -// pruneExternalFontFiles 流式核对全包字体引用并同步版本清单,保留附件及无法确认的扩展引用 -// 入参: ctx 取消上下文, reader 包快照, candidates 待清理的字体路径 +// pruneUnreferencedFiles 流式核对全包文件引用并同步版本清单,保留附件及无法确认的扩展引用 +// 入参: ctx 取消上下文, reader 包快照, candidates 待清理的文件路径 // 返回: error 取消或读取错误 -func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...string) error { +func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...string) error { if err := ctx.Err(); err != nil { return err } @@ -707,12 +707,12 @@ func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...s } if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.Name.Local) { uncertain = true - return fmt.Errorf("unknown font file dependencies in %s", name) + return fmt.Errorf("unknown file dependencies in %s", name) } for _, attr := range node.Attr { if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { uncertain = true - return fmt.Errorf("unknown font file dependencies in %s", name) + return fmt.Errorf("unknown file dependencies in %s", name) } } return nil diff --git a/ofdgo_editor_font_cleanup.go b/ofdgo_editor_font_cleanup.go index a2da546..9fc2d93 100644 --- a/ofdgo_editor_font_cleanup.go +++ b/ofdgo_editor_font_cleanup.go @@ -94,7 +94,7 @@ func (e *Editor) compactPackageFonts(ctx context.Context, parts map[string][]byt } } } - if err := pruneExternalFontFiles(ctx, view, slices.Collect(maps.Keys(candidates))...); err != nil { + if err := pruneUnreferencedFiles(ctx, view, slices.Collect(maps.Keys(candidates))...); err != nil { return err } if err := e.subsetPackageFonts(ctx, view, removed, generated); err != nil { diff --git a/ofdgo_editor_resource.go b/ofdgo_editor_resource.go new file mode 100644 index 0000000..9d8406c --- /dev/null +++ b/ofdgo_editor_resource.go @@ -0,0 +1,374 @@ +// 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" + "crypto/sha256" + "encoding/xml" + "fmt" + "path" + "slices" + "strconv" + "strings" +) + +// Resources 获取当前编辑快照的资源,覆盖正文、模板、注解、图案和复合图元 +// 入参: ctx 取消上下文 +// 返回: []ResourceInfo 资源及引用位置, error 快照或结构错误 +func (e *Editor) Resources(ctx context.Context) ([]ResourceInfo, error) { + reader, err := e.ReaderContext(ctx) + if err != nil { + return nil, err + } + return reader.Resources(ctx) +} + +// ImportMedia 导入可独立管理的音视频资源,即使尚未绑定动作也保留在当前文档中 +// 不解码或转码,格式由调用方提供;修改隔离其他文档及历史版本,支持撤销 +// 入参: ctx 取消上下文, kind 为Audio或Video, format 媒体格式, data 完整媒体数据 +// 返回: string 新资源标识, error 数据或包结构错误 +func (e *Editor) ImportMedia(ctx context.Context, kind, format string, data []byte) (string, error) { + if kind != "Audio" && kind != "Video" || len(data) == 0 { + return "", fmt.Errorf("nonempty Audio or Video resource required") + } + reader, err := e.ReaderContext(ctx) + if err != nil { + return "", err + } + working, err := reader.Editor() + if err != nil { + return "", err + } + id, err := working.AddMedia(kind, format, data) + if err != nil { + return "", err + } + resource := working.resources[len(working.resources)-1] + encoded, err := working.encodeXML(func(x *ofdXML) { x.resources(nil, []editorResource{resource}, nil) }) + if err != nil { + return "", err + } + doc, err := reader.DocContext(ctx) + if err != nil { + return "", err + } + parts := map[string][]byte{resource.name: resource.data} + name, added, err := mergePackageResources(ctx, reader, parts, doc, working.packageDirectory(), encoded) + if err != nil { + return "", err + } + entry := reader.documentRoot + content, err := reader.readFileViewContext(ctx, entry) + if err != nil { + return "", err + } + root, err := parseEditorXMLContext(ctx, content) + if err != nil { + return "", err + } + common := root.child("CommonData") + if common == nil || common.child("MaxUnitID") == nil { + return "", fmt.Errorf("missing CommonData or MaxUnitID") + } + maximum := common.child("MaxUnitID") + patches := []editorXMLPatch{{maximum.start, maximum.end, maximum.textXML("MaxUnitID", strconv.Itoa(working.maxID))}} + if added { + position := common.close + for _, child := range common.children { + if child.matchesOFD("TemplatePage") || child.matchesOFD("DefaultCS") { + position = child.start + break + } + } + patches = append(patches, editorXMLPatch{position, position, common.textXML("DocumentRes", "/"+name)}) + } + parts[entry] = editorPatchXML(content, patches) + scan := &editorFontDocument{reader: reader, index: reader.DocumentIndex()} + if err := scan.apply(ctx, parts); err != nil { + return "", err + } + return id, e.commitResourceSnapshot(ctx, reader) +} + +// ReplaceResource 替换当前文档图片、音视频或附件的文件,保留标识和全部使用位置 +// 图片同时清除受影响对象的旧替换图及复合预览;字体须使用EmbedFont或ExternalizeFont +// 入参: ctx 取消上下文, kind 资源类型, id 资源标识, format 媒体格式,图片按实际数据确定, data 完整文件 +// 返回: error 校验或提交错误,失败时文档不变 +func (e *Editor) ReplaceResource(ctx context.Context, kind, id, format string, data []byte) error { + if kind == "Font" { + return fmt.Errorf("use font binding operations for fonts") + } + return e.changeResource(ctx, kind, id, func(reader *Reader, item ResourceInfo, target editorFontText, changes map[string][]byte) error { + if len(data) == 0 { + return fmt.Errorf("empty media data") + } + if kind == "Image" { + _, actual, err := decodeImageDataContext(ctx, data) + if err != nil { + return err + } + format = actual + } + if item.Size == uint64(len(data)) && strings.EqualFold(item.Format, strings.TrimSpace(format)) { + digest, err := outputResourceDigest(ctx, reader, nil, item.File, make([]byte, 32<<10)) + if err != nil { + return err + } + if digest == sha256.Sum256(data) { + return nil + } + } + name := packageAvailableName(reader, changes, path.Join(e.packageDirectory(), "Res", "Media", "Media_"+item.ID)) + changes[name] = bytes.Clone(data) + fragment, err := editorXMLStandalone(target.file.data[target.node.start:target.node.end], target.node) + if err != nil { + return err + } + root, err := parseEditorXML(fragment) + if err != nil { + return err + } + file := root.child("MediaFile") + if file == nil { + return fmt.Errorf("missing MediaFile") + } + attribute, err := editorXMLAttributePatch(fragment, root, "Format", strings.ToUpper(strings.TrimSpace(format))) + if err != nil { + return err + } + fragment = editorPatchXML(fragment, []editorXMLPatch{attribute, {file.start, file.end, file.textXML("MediaFile", "/"+name)}}) + changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{target.node.start, target.node.end, fragment}}) + return nil + }, data) +} + +// DeleteResource 删除未被引用的资源声明,仅清理全包中可确认无引用的文件 +// 未知扩展导致引用不完整时拒绝删除;其他文档及历史版本保持不变 +// 入参: ctx 取消上下文, kind 资源类型, id 资源标识 +// 返回: error 仍有引用、结构或提交错误 +func (e *Editor) DeleteResource(ctx context.Context, kind, id string) error { + return e.changeResource(ctx, kind, id, nil, nil) +} + +// changeResource 校验引用后在独立快照中修改声明,借用既有版本隔离和历史提交路径 +// 入参: ctx 取消上下文, kind 资源类型, id 标识, replace 替换操作,nil表示删除, data 附件替换数据 +// 返回: error 引用、结构或提交错误 +func (e *Editor) changeResource(ctx context.Context, kind, id string, replace func(*Reader, ResourceInfo, editorFontText, map[string][]byte) error, data []byte) error { + reader, err := e.ReaderContext(ctx) + if err != nil { + return err + } + catalog, err := reader.resourceCatalog(ctx) + if err != nil { + return err + } + item, err := catalog.find(kind, id) + if err != nil { + return err + } + if !item.Complete { + return fmt.Errorf("resource references contain unsupported content") + } + if replace == nil && len(item.References) != 0 { + return fmt.Errorf("resource is still referenced") + } + if kind == "Attachment" { + working, err := reader.Editor() + if err != nil { + return err + } + if replace == nil { + err = working.DeleteAttachment(item.ID) + } else { + err = working.ReplaceAttachment(item.ID, data) + } + if err != nil { + return err + } + reader, err = working.ReaderContext(ctx) + if err != nil { + return err + } + return e.commitResourceSnapshot(ctx, reader) + } + target := catalog.nodes[item.Kind+"/"+item.ID] + changes := make(map[string][]byte) + if replace == nil { + node := target.node + if node.parent != nil && len(node.parent.children) == 1 { + node = node.parent + } + changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{start: node.start, end: node.end}}) + } else if err := replace(reader, item, target, changes); err != nil { + return err + } + if len(changes) == 0 { + return nil + } + if replace != nil && kind == "Image" { + if err := invalidateResourcePreviews(ctx, catalog.scan, item.ID, changes); err != nil { + return err + } + } + if err := catalog.scan.apply(ctx, changes); err != nil { + return err + } + if err := pruneUnreferencedFiles(ctx, reader, item.File); err != nil { + return err + } + return e.commitResourceSnapshot(ctx, reader) +} + +// invalidateResourcePreviews 移除图片内容变化后失效的替换图及逐级复合预览 +// 入参: ctx 取消上下文, scan 当前文档结构, id 图片标识, changes 待提交文件 +// 返回: error 解析或取消错误 +func invalidateResourcePreviews(ctx context.Context, scan *editorFontDocument, id string, changes map[string][]byte) error { + affected := map[string]bool{id: true} + for { + count := len(affected) + for name, file := range scan.files { + data, exists := changes[name] + if !exists { + data = file.data + } + root, err := parseEditorXMLContext(ctx, data) + if err != nil { + return err + } + var patches []editorXMLPatch + var visit func(*editorXML) (bool, error) + visit = func(node *editorXML) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + if !node.matchesOFD(node.name.Local) { + return false, nil + } + primary := affected[editorResourceID(node.attr("ResourceID"))] || affected[editorResourceID(node.attr("ImageMask"))] + changed := primary || affected[editorResourceID(node.attr("Substitution"))] + if node.matchesOFD("ImageObject") && primary && node.hasAttr("Substitution") { + fragment, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return false, err + } + var object ImageObject + if err := xml.Unmarshal(fragment, &object); err != nil { + return false, err + } + before := GraphicObject{Type: "ImageObject", ImageObject: object} + after := before + after.ImageObject.Substitution = "" + updated, err := editorXMLObject(data, node, before, after) + if err != nil { + return false, err + } + patches = append(patches, editorXMLPatch{node.start, node.end, updated}) + return true, nil + } + for _, child := range node.children { + used, err := visit(child) + if err != nil { + return false, err + } + changed = changed || used + } + if changed && node.matchesOFD("CompositeGraphicUnit") && node.parent != nil && node.parent.matchesOFD("CompositeGraphicUnits") { + affected[editorResourceID(node.attr("ID"))] = true + for _, key := range []string{"Thumbnail", "Substitution"} { + if child := node.child(key); child != nil { + patches = append(patches, editorXMLPatch{start: child.start, end: child.end}) + } + } + } + return changed, nil + } + if _, err := visit(root); err != nil { + return err + } + if len(patches) != 0 { + changes[name] = editorPatchXML(data, patches) + } + } + if len(affected) == count { + return nil + } + } +} + +// BindMedia 追加或移除指定图元的音视频点击动作,保留其余动作,重复绑定不新增动作 +// 入参: ctx 取消上下文, id 媒体标识, page 零基页码, object 图元标识, bind 为false时移除该媒体动作 +// 返回: error 资源、对象或提交错误 +func (e *Editor) BindMedia(ctx context.Context, id string, page int, object string, bind bool) error { + kind := "" + for _, resource := range e.resources { + if media := resource.image; media != nil && sameResourceID(media.ID, id) && (media.Type == "Audio" || media.Type == "Video") { + kind, id = media.Type, media.ID + break + } + } + if kind == "" { + items, err := e.Resources(ctx) + if err != nil { + return err + } + for _, item := range items { + if sameResourceID(item.ID, id) && (item.Kind == "Audio" || item.Kind == "Video") { + kind, id = item.Kind, item.ID + break + } + } + } + if kind == "" { + return fmt.Errorf("audio or video resource not found") + } + target, err := e.Object(page, object) + if err != nil { + return err + } + var actions []Action + switch target.Type { + case "TextObject": + actions = target.TextObject.Actions + case "ImageObject": + actions = target.ImageObject.Actions + case "Path", "PathObject": + actions = target.PathObject.Actions + case "CompositeObject", "CompositeGraphicUnit": + actions = target.CompositeGraphicUnit.Actions + default: + return fmt.Errorf("object does not support actions") + } + found := false + actions = slices.DeleteFunc(slices.Clone(actions), func(action Action) bool { + match := action.Sound != nil && sameResourceID(action.Sound.ResourceID, id) || action.Movie != nil && sameResourceID(action.Movie.ResourceID, id) + found = found || match + return match && !bind + }) + if bind && !found { + action := Action{Event: "CLICK"} + if kind == "Audio" { + action.Sound = &Sound{ResourceID: id} + } else { + action.Movie = &Movie{ResourceID: id, Operator: "Play"} + } + actions = append(actions, action) + } + if err := ctx.Err(); err != nil { + return err + } + return e.SetObjectActions(page, []string{object}, actions) +} diff --git a/ofdgo_resource.go b/ofdgo_resource.go new file mode 100644 index 0000000..610b622 --- /dev/null +++ b/ofdgo_resource.go @@ -0,0 +1,244 @@ +// 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 ( + "context" + "fmt" + "io" + "maps" + "path" + "slices" + "strings" +) + +// ResourceInfo 描述当前文档及所选版本的字体、图片、音视频或附件,Size为文件字节数 +// Kind为Font、Image、Audio、Video或Attachment,标识仅在当前文档及同类资源内有效 +// Complete表示已完整识别引用;不包含其他文档和历史版本的引用,空File表示外部字体 +type ResourceInfo struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Format string `json:"format"` + File string `json:"file"` + Size uint64 `json:"size"` + Complete bool `json:"complete"` + References []ResourceReference `json:"references"` +} + +// ResourceReference 记录资源引用所在的XML及字段,Page为零基页码,非页面文件为-1 +// Object为最近的图元标识,模板、注解、图案和复合资源通过File区分,不推断其页面实例数 +type ResourceReference struct { + File string `json:"file"` + Page int `json:"page"` + Object string `json:"object"` + Role string `json:"role"` +} + +// resourceCatalog 保存当前资源声明和原文位置,供查询及隔离修改复用 +type resourceCatalog struct { + scan *editorFontDocument + items []ResourceInfo + nodes map[string]editorFontText +} + +// Resources 列出当前文档及所选版本的资源,不读取二进制内容或执行媒体动作 +// 入参: ctx 取消上下文 +// 返回: []ResourceInfo 资源及引用位置, error 结构或读取错误 +func (r *Reader) Resources(ctx context.Context) ([]ResourceInfo, error) { + catalog, err := r.resourceCatalog(ctx) + if err != nil { + return nil, err + } + return catalog.items, nil +} + +// OpenResource 打开当前资源的原始文件,外部字体无可导出的内嵌文件 +// 入参: ctx 取消上下文, kind 资源类型, id 资源标识 +// 返回: io.ReadCloser 文件流,由调用方关闭, error 资源或文件错误 +func (r *Reader) OpenResource(ctx context.Context, kind, id string) (io.ReadCloser, error) { + catalog, err := r.resourceCatalog(ctx) + if err != nil { + return nil, err + } + item, err := catalog.find(kind, id) + if err != nil { + return nil, err + } + if item.File == "" { + return nil, fmt.Errorf("resource has no embedded file") + } + stream, err := r.openFile(item.File) + if err != nil { + return nil, err + } + return imageInput{ReadCloser: stream, context: ctx}, nil +} + +// resourceCatalog 按标准内容路径收集资源声明及引用,不把任意数字属性作为资源引用 +// 入参: ctx 取消上下文 +// 返回: *resourceCatalog 资源索引, error 结构或读取错误 +func (r *Reader) resourceCatalog(ctx context.Context) (*resourceCatalog, error) { + scan, err := scanEditorFonts(ctx, r, r.DocumentIndex()) + if err != nil { + return nil, err + } + catalog := &resourceCatalog{scan: scan, nodes: make(map[string]editorFontText), items: []ResourceInfo{}} + pages := make(map[string]int) + doc, err := r.DocContext(ctx) + if err != nil { + return nil, err + } + for i, page := range doc.Pages.Page { + pages[cleanPackagePath(r.ResPath(page.BaseLoc))] = i + } + refs := make(map[string][]ResourceReference) + for _, name := range slices.Sorted(maps.Keys(scan.files)) { + file := scan.files[name] + root, err := parseEditorXMLContext(ctx, file.data) + if err != nil { + return nil, err + } + if root.matchesOFD("Res") { + for _, group := range root.children { + for _, node := range group.children { + item := ResourceInfo{ID: editorResourceID(node.attr("ID")), Complete: !scan.unsafe} + field := "" + if group.matchesOFD("Fonts") && node.matchesOFD("Font") { + item.Kind, item.Name, field = "Font", node.attr("FontName"), "FontFile" + } else if group.matchesOFD("MultiMedias") && node.matchesOFD("MultiMedia") && slices.Contains([]string{"Image", "Audio", "Video"}, node.attr("Type")) { + item.Kind, item.Format, field = node.attr("Type"), node.attr("Format"), "MediaFile" + } else { + continue + } + if item.ID == "" { + return nil, fmt.Errorf("invalid resource ID") + } + if target := node.child(field); target != nil { + location := strings.TrimSpace(editorImportText(file.data, target)) + if location != "" { + item.File = resolveResourcePath(name, root.attr("BaseLoc"), location) + } + } + if item.Name == "" { + item.Name = path.Base(item.File) + } + if err := catalog.add(item, editorFontText{file: file, node: node}); err != nil { + return nil, err + } + } + } + } + page := -1 + if index, ok := pages[name]; ok { + page = index + } + var visit func(*editorXML, string) error + visit = func(node *editorXML, object string) error { + if err := ctx.Err(); err != nil { + return err + } + if !node.matchesOFD(node.name.Local) || node.matchesOFD("Attachments") { + return nil + } + if slices.Contains([]string{"TextObject", "PathObject", "ImageObject", "CompositeObject"}, node.name.Local) { + object = node.attr("ID") + } + add := func(role, id string) { + key := editorResourceID(id) + if role == "AttachID" { + key = "Attachment/" + id + } + if key != "" { + refs[key] = append(refs[key], ResourceReference{File: name, Page: page, Object: object, Role: role}) + } + } + for _, attr := range node.attrs { + if attr.Name.Space == "" && (editorResourceReferenceKind(attr.Name.Local, root.matchesOFD("Res")) == editorReferenceID || attr.Name.Local == "AttachID") { + add(attr.Name.Local, attr.Value) + } + } + if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) { + add(node.name.Local, strings.TrimSpace(editorImportText(file.data, node))) + } + for _, child := range node.children { + if err := visit(child, object); err != nil { + return err + } + } + return nil + } + if err := visit(root, ""); err != nil { + return nil, err + } + } + attachments, err := r.Attachments() + if err != nil { + return nil, err + } + for _, attachment := range attachments { + item := ResourceInfo{Kind: "Attachment", ID: attachment.ID, Name: attachment.Name, Format: attachment.Format, File: r.ResPath(attachment.FileLoc), Complete: !scan.unsafe} + if err := catalog.add(item, editorFontText{}); err != nil { + return nil, err + } + } + for i := range catalog.items { + item := &catalog.items[i] + key := item.ID + if item.Kind == "Attachment" { + key = "Attachment/" + item.ID + } + item.References = refs[key] + if item.File != "" { + item.Size, err = outputResourceSize(r, nil, item.File) + if err != nil { + return nil, err + } + } + } + return catalog, ctx.Err() +} + +// add 加入资源声明,拒绝同一类型内的重复标识 +// 入参: item 资源信息, node 声明位置 +// 返回: error 标识冲突 +func (c *resourceCatalog) add(item ResourceInfo, node editorFontText) error { + key := item.Kind + "/" + item.ID + if _, exists := c.nodes[key]; exists { + return fmt.Errorf("duplicate resource %s", key) + } + if item.Kind != "Attachment" { + for _, kind := range []string{"Font", "Image", "Audio", "Video"} { + if _, exists := c.nodes[kind+"/"+item.ID]; exists { + return fmt.Errorf("conflicting resource ID %s", item.ID) + } + } + } + c.nodes[key] = node + c.items = append(c.items, item) + return nil +} + +// find 查找当前视图内的资源,不接受调用方提供的任意包路径 +// 入参: kind 资源类型, id 资源标识 +// 返回: ResourceInfo 资源信息, error 未找到资源 +func (c *resourceCatalog) find(kind, id string) (ResourceInfo, error) { + for _, item := range c.items { + if item.Kind == kind && (item.ID == id || kind != "Attachment" && sameResourceID(item.ID, id)) { + return item, nil + } + } + return ResourceInfo{}, fmt.Errorf("resource not found: %s/%s", kind, id) +}