feat(资源管理): 新增资源管理及媒体编辑
build.yaml / build (push) Successful in 50s

This commit is contained in:
xiaoqidun committed 2026-10-13 12:36:42 +08:00
1 parent 5a88a9f31f
commit 979b660703
9 files changed
+1140 -20

No files matched your search

+30
View File
@@ -353,6 +353,35 @@
<div class="form-actions"><div id="paragraphStatus" class="form-status" role="status"></div><button id="paragraphCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="resourcePanel" class="form-dialog resource-dialog" aria-labelledby="resourceTitle">
<form id="resourceForm">
<h2 id="resourceTitle" class="form-title">资源管理</h2>
<div class="form-row"><label for="resourceKind">类型</label><select id="resourceKind"><option value="Font">字体</option><option value="Image">图片</option><option value="Audio">音频</option><option value="Video">视频</option><option value="Attachment">附件</option></select></div>
<div class="resource-heading" aria-hidden="true"><span>名称</span><span>大小</span><span>引用</span></div>
<div class="resource-list-container">
<div id="resourceList" class="resource-list" role="listbox" aria-label="资源列表"></div>
<div id="resourceEmpty" class="resource-empty" hidden>暂无资源</div>
</div>
<div class="form-row"><label for="resourceReference">引用</label><select id="resourceReference" aria-label="引用位置"></select></div>
<div class="resource-actions">
<button id="resourceAdd" class="button" type="button" title="添加资源" aria-label="添加资源">添加</button>
<button id="resourcePreview" class="button" type="button">预览</button>
<button id="resourceExport" class="button" type="button">导出</button>
<button id="resourceReplace" class="button" type="button">替换</button>
<button id="resourceDelete" class="button" type="button">删除</button>
<button id="resourceLocate" class="button" type="button">定位</button>
<button id="resourceClose" class="button" type="button">关闭</button>
</div>
<div id="resourceBinding" class="resource-binding" hidden>
<div class="form-row"><label for="resourcePage">页码</label><select id="resourcePage"></select></div>
<div class="form-row"><label for="resourceObject">图元</label><select id="resourceObject"></select></div>
<div class="form-actions"><button id="resourceUnbind" class="button" type="button">解绑</button><button id="resourceBind" class="button" type="button">绑定</button></div>
</div>
<div id="resourceViewer" class="resource-viewer" hidden></div>
<div id="resourceStatus" class="form-status" role="status"></div>
<input id="resourceFile" type="file" hidden>
</form>
</dialog>
<dialog id="documentPanel" class="form-dialog document-dialog" aria-labelledby="documentTitle">
<form id="documentForm">
<h2 id="documentTitle" class="form-title">文档管理</h2>
@@ -658,6 +687,7 @@
<div class="panel-row">
<div class="panel-title compact">文档</div>
<div class="panel-actions">
<button id="resourceManageButton" class="small-button" type="button" title="资源管理" aria-label="资源管理" aria-haspopup="dialog" aria-controls="resourcePanel" disabled>资源</button>
<button id="versionManageButton" class="small-button" type="button" title="版本管理" aria-label="版本管理" aria-haspopup="dialog" aria-controls="versionPanel" hidden>版本</button>
<button id="documentManageButton" class="small-button" type="button" title="文档管理" aria-label="文档管理" aria-haspopup="dialog" aria-controls="documentPanel" hidden>管理</button>
</div>
+131
View File
@@ -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;
}
+249 -2
View File
@@ -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;
+4 -1
View File
@@ -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);
+91
View File
@@ -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 错误信息
+16 -16
View File
@@ -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
+1 -1
View File
@@ -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 {
+374
View File
@@ -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)
}
+244
View File
@@ -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)
}