mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
1 parent
5a88a9f31f
commit
979b660703
9 files changed
+1140
-20
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 错误信息
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user