文档
+
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)
+}