mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
0139433e25
commit
c74dff1e41
14 files changed
+3625
-31
No files matched your search
+19
-1
@@ -356,7 +356,7 @@
|
||||
<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="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><option value="ColorSpace">色彩</option><option value="DrawParam">样式</option></select></div>
|
||||
<div class="resource-list-container">
|
||||
<div class="resource-heading" aria-hidden="true"><span>名称</span><span>大小</span><span>引用</span></div>
|
||||
<div class="resource-list-body">
|
||||
@@ -399,6 +399,16 @@
|
||||
<div id="documentStatus" class="form-status" role="status"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="settingsPanel" class="form-dialog settings-dialog" aria-labelledby="settingsTitle">
|
||||
<form id="settingsForm">
|
||||
<h2 id="settingsTitle" class="form-title">文档设置</h2>
|
||||
<label class="form-row"><span>类型</span><select id="settingsKind"><option value="bookmarks">书签</option><option value="actions">动作</option><option value="templates">模板</option><option value="layers">图层</option><option value="extensions">扩展</option><option value="ColorSpace" hidden>色彩</option><option value="DrawParam" hidden>样式</option></select></label>
|
||||
<div id="settingsContext"></div>
|
||||
<div id="settingsContent"></div>
|
||||
<div id="settingsActions" class="form-actions"></div>
|
||||
<div id="settingsStatus" class="form-status" role="status"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="versionPanel" class="form-dialog document-dialog" aria-labelledby="versionTitle">
|
||||
<form id="versionForm">
|
||||
<h2 id="versionTitle" class="form-title">版本管理</h2>
|
||||
@@ -411,6 +421,7 @@
|
||||
<button id="versionDefault" class="button" type="button" title="设为默认" aria-label="设为默认">默认</button>
|
||||
<button id="versionDelete" class="button" type="button" title="删除版本" aria-label="删除版本">删除</button>
|
||||
<button id="versionSelect" class="button" type="button" title="切换版本" aria-label="切换版本">切换</button>
|
||||
<button id="versionExport" class="button" type="button" title="另存版本" aria-label="另存版本">另存</button>
|
||||
<button id="versionClose" class="button" type="button">关闭</button>
|
||||
</div>
|
||||
<div id="versionStatus" class="form-status" role="status"></div>
|
||||
@@ -635,6 +646,7 @@
|
||||
<div id="navigationTabs" class="navigation-tabs" role="tablist" aria-label="文档导航" hidden>
|
||||
<button id="pagesTab" type="button" role="tab" aria-selected="true" aria-controls="pageList">页面</button>
|
||||
<button id="outlinesTab" type="button" role="tab" aria-selected="false" aria-controls="outlineList" tabindex="-1">目录</button>
|
||||
<button id="annotationsTab" type="button" role="tab" aria-selected="false" aria-controls="annotationsPanel" tabindex="-1">注解</button>
|
||||
<button id="searchTab" type="button" role="tab" aria-keyshortcuts="Control+F Meta+F" aria-selected="false" aria-controls="searchPanel" tabindex="-1">搜索</button>
|
||||
</div>
|
||||
<div id="pageSelectionTools" class="page-selection-tools" hidden>
|
||||
@@ -645,6 +657,11 @@
|
||||
<div id="navigationContent" class="navigation-content">
|
||||
<div id="pageList" class="page-list" role="tabpanel" aria-label="页面"></div>
|
||||
<div id="outlineList" role="tabpanel" aria-label="目录" hidden></div>
|
||||
<div id="annotationsPanel" role="tabpanel" aria-label="注解" hidden>
|
||||
<div class="search-form"><input id="annotationsFilter" type="search" aria-label="筛选注解" placeholder="内容、作者" autocomplete="off"><select id="annotationsKind" aria-label="注解类型"><option value="">全部</option></select></div>
|
||||
<div id="annotationsStatus" class="form-status" role="status"></div>
|
||||
<div id="annotationsList" class="annotation-summary-list"></div>
|
||||
</div>
|
||||
<div id="searchPanel" role="tabpanel" aria-label="搜索" hidden>
|
||||
<form id="searchForm" class="search-form" role="search">
|
||||
<input id="searchInput" type="search" aria-label="搜索文字" placeholder="搜索文字" autocomplete="off">
|
||||
@@ -690,6 +707,7 @@
|
||||
<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="settingsButton" class="small-button" type="button" title="文档设置" aria-label="文档设置" aria-haspopup="dialog" aria-controls="settingsPanel" 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>
|
||||
|
||||
@@ -1726,7 +1726,69 @@ body[data-hide-meta] .meta-panel {
|
||||
}
|
||||
|
||||
.document-actions.version-readonly {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#versionActions:not(.version-readonly) {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
.settings-dialog .form-row {
|
||||
grid-template-columns: 4em minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-dialog .form-row > label,
|
||||
.settings-dialog .form-row > span:not(.form-unit) {
|
||||
white-space: nowrap;
|
||||
text-align-last: justify;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.settings-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
.settings-dialog .form-status:not(:empty) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-detail {
|
||||
padding-block: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings-detail pre {
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-binding {
|
||||
margin-block: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
#settingsActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#settingsActions > .button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#settingsActions .settings-close {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.document-list {
|
||||
@@ -1817,6 +1879,58 @@ body[data-hide-meta] .meta-panel {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.annotation-summary-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.annotation-summary {
|
||||
min-width: 0;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.annotation-summary-target {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.annotation-summary-target span {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.annotation-summary-target span:first-child {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.annotation-summary .small-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#annotationsPanel .search-form {
|
||||
grid-template-columns: minmax(0, 1fr) 72px;
|
||||
}
|
||||
|
||||
#annotationsPanel select {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#annotationsStatus {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.navigation-tabs button {
|
||||
height: 28px;
|
||||
padding: 0 0 6px;
|
||||
|
||||
+663
-11
@@ -25,6 +25,8 @@ 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", "Actions", "Add", "Preview", "Export", "Rename", "Replace", "Delete", "Locate", "Close", "Binding", "BindingActions", "Page", "Object", "Pick", "Bind", "Unbind", "Viewer", "Status", "File"].map(name => [name, document.querySelector(`#resource${name}`)]));
|
||||
const resourceManager = { items: [], selected: null, targets: [], pick: null, busy: false, sequence: 0, url: null, font: null, fileAction: null };
|
||||
const settingsElements = Object.fromEntries(["Button", "Panel", "Form", "Title", "Kind", "Context", "Content", "Actions", "Status"].map(name => [name, document.querySelector(`#settings${name}`)]));
|
||||
const settings = { sequence: 0, busy: false, page: 0, scope: "document", object: "", path: [], selected: -1, data: null, actions: [] };
|
||||
|
||||
let wasmPromise = null;
|
||||
let remoteDownload = null;
|
||||
@@ -275,6 +277,7 @@ const el = {
|
||||
versionDefault: document.querySelector("#versionDefault"),
|
||||
versionDelete: document.querySelector("#versionDelete"),
|
||||
versionSelect: document.querySelector("#versionSelect"),
|
||||
versionExport: document.querySelector("#versionExport"),
|
||||
versionClose: document.querySelector("#versionClose"),
|
||||
infoPanel: document.querySelector("#infoPanel"),
|
||||
infoForm: document.querySelector("#infoForm"),
|
||||
@@ -494,6 +497,12 @@ const el = {
|
||||
finishPageSelection: document.querySelector("#finishPageSelection"),
|
||||
navigationContent: document.querySelector("#navigationContent"),
|
||||
pagesTab: document.querySelector("#pagesTab"),
|
||||
annotationsTab: document.querySelector("#annotationsTab"),
|
||||
annotationsPanel: document.querySelector("#annotationsPanel"),
|
||||
annotationsFilter: document.querySelector("#annotationsFilter"),
|
||||
annotationsKind: document.querySelector("#annotationsKind"),
|
||||
annotationsStatus: document.querySelector("#annotationsStatus"),
|
||||
annotationsList: document.querySelector("#annotationsList"),
|
||||
outlinesTab: document.querySelector("#outlinesTab"),
|
||||
pageList: document.querySelector("#pageList"),
|
||||
outlineList: document.querySelector("#outlineList"),
|
||||
@@ -753,12 +762,15 @@ resourceElements.Add.addEventListener("click", () => {
|
||||
if (kind === "Font") openFontFile(el.fontInput);
|
||||
else if (kind === "Image") openInsertPanel();
|
||||
else if (kind === "Attachment") openAttachmentDialog("add");
|
||||
else if (kind === "ColorSpace" || kind === "DrawParam") openResourceSettings(kind, "");
|
||||
else chooseResourceFile("add");
|
||||
});
|
||||
resourceElements.Replace.addEventListener("click", async () => {
|
||||
const item = resourceManager.selected;
|
||||
if (!state.editing || !item) return;
|
||||
if (item.kind === "Font") {
|
||||
if (item.kind === "ColorSpace" || item.kind === "DrawParam") {
|
||||
await openResourceSettings(item.kind, item.id);
|
||||
} else 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);
|
||||
@@ -979,8 +991,10 @@ annotationFields.CreateForm.addEventListener("submit", async event => {
|
||||
el.annotationForm.addEventListener("submit", async event => {
|
||||
if (!state.annotationEdit) return;
|
||||
event.preventDefault();
|
||||
const fromSummary = state.annotationEdit.fromSummary;
|
||||
if (await changeAnnotations(state.annotationEdit, "update", el.annotationRemark.value, el.annotationCreator.value)) {
|
||||
el.annotationNote.close();
|
||||
if (fromSummary) showNavigation(el.annotationsTab);
|
||||
canvasEditor.focus();
|
||||
}
|
||||
});
|
||||
@@ -1480,6 +1494,15 @@ el.credentialsForm.addEventListener("submit", async event => {
|
||||
el.togglePagesButton.addEventListener("click", () => toggleSidebar("pages"));
|
||||
el.toggleMetaButton.addEventListener("click", () => toggleSidebar("meta"));
|
||||
el.pagesTab.addEventListener("click", () => showNavigation(el.pagesTab));
|
||||
el.annotationsTab.addEventListener("click", () => showNavigation(el.annotationsTab));
|
||||
el.annotationsFilter.addEventListener("input", renderAnnotationSummary);
|
||||
el.annotationsKind.addEventListener("change", renderAnnotationSummary);
|
||||
el.versionExport.addEventListener("click", exportManagedVersion);
|
||||
editorClick(settingsElements.Button, openDocumentSettings);
|
||||
settingsElements.Kind.addEventListener("change", () => { settings.selected = -1; loadDocumentSettings(); });
|
||||
settingsElements.Form.addEventListener("submit", event => event.preventDefault());
|
||||
settingsElements.Panel.addEventListener("cancel", event => { if (settings.busy) event.preventDefault(); });
|
||||
settingsElements.Panel.addEventListener("close", () => { settings.sequence++; });
|
||||
el.outlinesTab.addEventListener("click", () => showNavigation(el.outlinesTab));
|
||||
el.searchTab.addEventListener("click", focusSearch);
|
||||
el.navigationTabs.addEventListener("keydown", (event) => {
|
||||
@@ -1488,7 +1511,7 @@ el.navigationTabs.addEventListener("keydown", (event) => {
|
||||
}
|
||||
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const tabs = [el.pagesTab, el.outlinesTab, el.searchTab].filter((tab) => !tab.hidden);
|
||||
const tabs = [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].filter((tab) => !tab.hidden);
|
||||
const current = tabs.indexOf(event.target);
|
||||
const index = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1
|
||||
: (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||
@@ -2337,6 +2360,34 @@ function updateVersionControls() {
|
||||
el.versionDelete.disabled = unavailable || !state.editing || !entry?.id || manager.entries.length < 2;
|
||||
el.versionDefault.disabled = unavailable || !state.editing || Boolean(entry?.default);
|
||||
el.versionSelect.disabled = unavailable || entry?.id === (state.doc?.documentVersion || "");
|
||||
el.versionExport.disabled = unavailable || !entry?.id;
|
||||
}
|
||||
|
||||
async function exportManagedVersion() {
|
||||
const manager = state.versionManagement;
|
||||
const entry = manager?.entries?.find(item => item.id === manager.id);
|
||||
if (!entry?.id || !state.ready || state.exporting || manager.loading || manager.failed || document.body.hasAttribute("aria-busy")) return;
|
||||
if (state.doc.signatureCount && !window.confirm("版本副本可能使原签名失效,是否继续?")) return;
|
||||
const openSeq = state.openSeq;
|
||||
const name = `${baseFileName()}_${entry.name || `版本${entry.index}`}`.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_") + ".ofd";
|
||||
state.exporting = true;
|
||||
setBusy(true, "正在另存版本");
|
||||
el.versionStatus.textContent = "正在另存版本";
|
||||
try {
|
||||
const file = window.showSaveFilePicker ? await window.showSaveFilePicker({suggestedName: name, types: [{description: "OFD", accept: {"application/ofd": [".ofd"]}}]}) : null;
|
||||
if (openSeq !== state.openSeq) return;
|
||||
const result = await callWASM("ofdgoExportVersion", entry.id, file);
|
||||
if (openSeq !== state.openSeq) return;
|
||||
if (result.blob) downloadBytes(result.blob, result.mime, name);
|
||||
el.versionStatus.textContent = `已另存(${formatBytes(result.size)})`;
|
||||
} catch (error) {
|
||||
if (openSeq === state.openSeq) el.versionStatus.textContent = error.name === "AbortError" ? "另存已取消" : error.message;
|
||||
} finally {
|
||||
state.exporting = false;
|
||||
state.exportRequestID = 0;
|
||||
el.cancelExportButton.hidden = true;
|
||||
if (openSeq === state.openSeq) { setBusy(false); updateControls(); }
|
||||
}
|
||||
}
|
||||
|
||||
function managedDocumentIndex() {
|
||||
@@ -3498,6 +3549,7 @@ async function changeDocument(name, item, ...args) {
|
||||
}
|
||||
if (openSeq === state.openSeq) {
|
||||
if (!restoring) rememberEditorView(before, revision, reindex);
|
||||
if (el.annotationsTab.getAttribute("aria-selected") === "true") await loadAnnotationSummary();
|
||||
if (!toolbarFocus && !changingFont) canvasEditor.focus();
|
||||
return true;
|
||||
}
|
||||
@@ -3505,7 +3557,9 @@ async function changeDocument(name, item, ...args) {
|
||||
refreshManagement = false;
|
||||
if (name === "ofdgoChangeFontBinding") err.message = fontBindingError(err.message);
|
||||
if (openSeq === state.openSeq) {
|
||||
if (fontBindingElements.Panel.open) {
|
||||
if (settingsElements.Panel.open) {
|
||||
settingsElements.Status.textContent = err.message;
|
||||
} else if (fontBindingElements.Panel.open) {
|
||||
fontBindingElements.Status.textContent = fontBindingError(err.message);
|
||||
} else if (el.annotationCreate.open) {
|
||||
el.annotationCreateStatus.textContent = err.message;
|
||||
@@ -5075,6 +5129,527 @@ async function renderPage(index, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function settingsField(container, label, id, value = "", type = "text", unit = "") {
|
||||
const row = document.createElement("label"), title = document.createElement("span");
|
||||
row.className = "form-row";
|
||||
title.textContent = label;
|
||||
const field = document.createElement(type === "textarea" ? "textarea" : "input");
|
||||
field.id = `settings${id}`;
|
||||
field.setAttribute("aria-label", label);
|
||||
if (type === "textarea") field.rows = 3;
|
||||
else field.type = type;
|
||||
if (type === "number") field.step = "any";
|
||||
if (type === "checkbox") field.checked = Boolean(value);
|
||||
else field.value = value ?? "";
|
||||
row.append(title, field);
|
||||
if (unit) {
|
||||
const wrapper = document.createElement("span"), suffix = document.createElement("span");
|
||||
wrapper.className = "form-unit";
|
||||
suffix.textContent = unit;
|
||||
wrapper.append(field, suffix);
|
||||
row.append(wrapper);
|
||||
}
|
||||
container.append(row);
|
||||
return field;
|
||||
}
|
||||
|
||||
function settingsSelect(container, label, id, choices, value, change) {
|
||||
const row = document.createElement("label"), title = document.createElement("span"), field = document.createElement("select");
|
||||
row.className = "form-row";
|
||||
title.textContent = label;
|
||||
field.id = `settings${id}`;
|
||||
field.setAttribute("aria-label", label);
|
||||
field.replaceChildren(...choices.map(([value, text]) => new Option(text, String(value))));
|
||||
if (value != null && choices.some(([key]) => String(key) === String(value))) field.value = String(value);
|
||||
if (change) field.addEventListener("change", () => change(field.value));
|
||||
row.append(title, field);
|
||||
container.append(row);
|
||||
return field;
|
||||
}
|
||||
|
||||
function settingsButton(label, action, disabled = false, id = "") {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "button";
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
if (id) button.id = `settings${id}`;
|
||||
button.addEventListener("click", action);
|
||||
settingsElements.Actions.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function settingsValue(id) {
|
||||
const field = document.querySelector(`#settings${id}`);
|
||||
return field?.type === "checkbox" ? field.checked : field?.value ?? "";
|
||||
}
|
||||
|
||||
async function openDocumentSettings() {
|
||||
if (!state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy")) return;
|
||||
settings.page = state.pageIndex;
|
||||
settings.object = canvasEditor.selected?.index === settings.page && !canvasEditor.selected.scoped ? canvasEditor.selected.id : "";
|
||||
settings.path = state.outlineSelection || [];
|
||||
settings.selected = -1;
|
||||
settings.resource = null;
|
||||
settingsElements.Kind.disabled = false;
|
||||
for (const option of settingsElements.Kind.options) option.hidden = option.value === "actions" && !state.editing || ["ColorSpace", "DrawParam"].includes(option.value);
|
||||
if (settingsElements.Kind.selectedOptions[0]?.hidden) settingsElements.Kind.value = "bookmarks";
|
||||
settingsElements.Panel.showModal();
|
||||
await loadDocumentSettings();
|
||||
}
|
||||
|
||||
async function openResourceSettings(kind, id) {
|
||||
if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return;
|
||||
settings.resource = {kind, id};
|
||||
const page = Number(resourceElements.Page.value);
|
||||
settings.page = resourceElements.Page.value !== "" && Number.isInteger(page) && page >= 0 && page < state.doc.pages.length ? page : state.pageIndex;
|
||||
settings.object = resourceElements.Object.value || "";
|
||||
settingsElements.Kind.querySelector(`option[value="${kind}"]`).hidden = false;
|
||||
settingsElements.Kind.value = kind;
|
||||
settingsElements.Kind.disabled = true;
|
||||
settingsElements.Panel.showModal();
|
||||
await loadDocumentSettings();
|
||||
}
|
||||
|
||||
async function loadDocumentSettings() {
|
||||
const sequence = ++settings.sequence, openSeq = state.openSeq;
|
||||
const kind = settingsElements.Kind.value;
|
||||
settings.busy = true;
|
||||
settingsElements.Form.inert = true;
|
||||
settingsElements.Status.textContent = "正在读取信息";
|
||||
try {
|
||||
const data = ["bookmarks", "actions", "extensions"].includes(kind) ? await callWASM("ofdgoDocumentNavigation", kind) : {};
|
||||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||||
data.resources = ["bookmarks", "extensions"].includes(kind) ? [] : await callWASM("ofdgoResources") || [];
|
||||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||||
if (kind === "actions" && state.editing) {
|
||||
if (settings.scope === "object") {
|
||||
const page = await loadPageData(settings.page);
|
||||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||||
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
|
||||
if (!data.objects.some(item => item.id === settings.object)) settings.object = data.objects[0]?.id || "";
|
||||
}
|
||||
if (settings.scope === "outline" && !settings.path.length) settings.path = outlineSettingsChoices()[0]?.[0] ? JSON.parse(outlineSettingsChoices()[0][0]) : [];
|
||||
data.actions = (settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length) ? [] : await callWASM("ofdgoActionList", settings.scope, settings.page, settings.object, settings.path) || [];
|
||||
}
|
||||
if (kind === "templates" || kind === "layers") {
|
||||
data.page = await callWASM("ofdgoPageSettings", settings.page);
|
||||
if (kind === "layers" && state.editing) {
|
||||
const page = await loadPageData(settings.page);
|
||||
data.objects = (page.objects || []).filter(item => item.type !== "Annotation" && !item.scoped);
|
||||
}
|
||||
}
|
||||
if (kind === "ColorSpace" || kind === "DrawParam") {
|
||||
data.resource = settings.resource.id ? await callWASM("ofdgoStyleResource", kind, settings.resource.id) : {};
|
||||
if (state.editing) {
|
||||
const page = await loadPageData(settings.page);
|
||||
data.objects = (page.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped);
|
||||
}
|
||||
}
|
||||
if (sequence !== settings.sequence || openSeq !== state.openSeq || !settingsElements.Panel.open) return;
|
||||
settings.data = data;
|
||||
settings.actions = data.actions || [];
|
||||
settingsElements.Status.textContent = "";
|
||||
renderDocumentSettings();
|
||||
} catch (error) {
|
||||
if (sequence === settings.sequence && openSeq === state.openSeq) {
|
||||
settingsElements.Content.replaceChildren();
|
||||
settingsElements.Actions.replaceChildren();
|
||||
settingsButton("关闭", () => settingsElements.Panel.close());
|
||||
settingsElements.Status.textContent = error.message;
|
||||
}
|
||||
} finally {
|
||||
if (sequence === settings.sequence) { settings.busy = false; settingsElements.Form.inert = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderDocumentSettings() {
|
||||
settingsElements.Context.replaceChildren();
|
||||
settingsElements.Content.replaceChildren();
|
||||
settingsElements.Actions.replaceChildren();
|
||||
const kind = settingsElements.Kind.value;
|
||||
settingsElements.Title.textContent = {ColorSpace: "色彩设置", DrawParam: "样式设置"}[kind] || "文档设置";
|
||||
if (kind === "actions") renderActionSettings();
|
||||
else if (kind === "bookmarks") renderBookmarkSettings();
|
||||
else if (kind === "extensions") renderExtensionSettings();
|
||||
else if (kind === "templates") renderTemplateSettings();
|
||||
else if (kind === "layers") renderLayerSettings();
|
||||
else if (kind === "ColorSpace" || kind === "DrawParam") renderStyleResourceSettings(kind);
|
||||
settingsButton("关闭", () => settingsElements.Panel.close(), false, "Close").classList.add("settings-close");
|
||||
}
|
||||
|
||||
function settingsPages(container, id, value, change) {
|
||||
return settingsSelect(container, "页码", id, state.doc.pages.map((page, index) => [index, `第${index + 1}页`]), value, change);
|
||||
}
|
||||
|
||||
function outlineSettingsChoices() {
|
||||
const result = [];
|
||||
const visit = (nodes, path = [], prefix = "") => {
|
||||
for (const [index, node] of (nodes || []).entries()) {
|
||||
const next = [...path, index], title = prefix + node.title;
|
||||
result.push([JSON.stringify(next), title]);
|
||||
visit(node.children, next, title + " / ");
|
||||
}
|
||||
};
|
||||
visit(state.doc?.outlines);
|
||||
return result;
|
||||
}
|
||||
|
||||
function actionSettingsLabel(action) {
|
||||
if (action.Goto) return action.Goto.Bookmark ? `书签 · ${action.Goto.Bookmark.Name}` : `页面 · ${pageIndexByID(state.doc.pages, action.Goto.Dest?.PageID) + 1}`;
|
||||
if (action.URI) return `网址 · ${action.URI.URI}`;
|
||||
const [key, label, id] = action.Sound ? ["Audio", "音频", action.Sound.ResourceID] : action.Movie ? ["Video", "视频", action.Movie.ResourceID] : ["Attachment", "附件", action.GotoA?.AttachID];
|
||||
return `${label} · ${settings.data.resources.find(item => item.kind === key && item.id === referenceIDKey(id))?.name || id || ""}`;
|
||||
}
|
||||
|
||||
function renderActionSettings() {
|
||||
const context = settingsElements.Context, content = settingsElements.Content;
|
||||
settingsSelect(context, "归属", "Scope", [["document", "文档"], ["page", "页面"], ["object", "图元"], ["outline", "目录"]], settings.scope, value => { settings.scope = value; settings.selected = -1; loadDocumentSettings(); });
|
||||
if (settings.scope === "page" || settings.scope === "object") settingsPages(context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
|
||||
if (settings.scope === "object") settingsSelect(context, "图元", "Object", (settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || ({TextObject: "文字", PathObject: "图形", ImageObject: "图片"}[item.type] || "组合")}`]), settings.object, value => { settings.object = value; settings.selected = -1; loadDocumentSettings(); });
|
||||
if (settings.scope === "outline") settingsSelect(context, "目录", "Outline", outlineSettingsChoices(), JSON.stringify(settings.path), value => { settings.path = JSON.parse(value); settings.selected = -1; loadDocumentSettings(); });
|
||||
if (settings.selected >= settings.actions.length) settings.selected = -1;
|
||||
settingsSelect(context, "动作", "Entry", [[-1, "新增动作"], ...settings.actions.map((item, index) => [index, `${index + 1} · ${actionSettingsLabel(item)}`])], settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||||
const action = settings.actions[settings.selected] || {};
|
||||
const type = action.Goto ? action.Goto.Bookmark ? "Bookmark" : "Goto" : ["URI", "GotoA", "Sound", "Movie"].find(key => action[key]) || "Goto";
|
||||
settingsSelect(content, "类型", "ActionType", [["Goto", "页面"], ["Bookmark", "书签"], ["URI", "网址"], ["GotoA", "附件"], ["Sound", "音频"], ["Movie", "视频"]], type, value => renderActionFields(value, action));
|
||||
const fields = document.createElement("div");
|
||||
fields.id = "settingsActionFields";
|
||||
content.append(fields);
|
||||
renderActionFields(type, action);
|
||||
const missing = settings.scope === "object" && !settings.object || settings.scope === "outline" && !settings.path.length;
|
||||
settingsButton(settings.selected < 0 ? "添加" : "修改", () => writeActionSettings("save"), missing, "Save");
|
||||
settingsButton("删除", () => writeActionSettings("delete"), settings.selected < 0, "Delete");
|
||||
settingsButton("上移", () => writeActionSettings("up"), settings.selected <= 0, "Up");
|
||||
settingsButton("下移", () => writeActionSettings("down"), settings.selected < 0 || settings.selected >= settings.actions.length - 1, "Down");
|
||||
}
|
||||
|
||||
function renderActionFields(type, action) {
|
||||
const fields = document.querySelector("#settingsActionFields");
|
||||
fields.replaceChildren();
|
||||
if (type === "Goto") renderDestinationFields(fields, action.Goto?.Dest);
|
||||
else if (type === "Bookmark") settingsSelect(fields, "书签", "Bookmark", (settings.data.bookmarks || []).map(item => [item.Name, item.Name]), action.Goto?.Bookmark?.Name);
|
||||
else if (type === "URI") {
|
||||
settingsField(fields, "网址", "URI", action.URI?.URI);
|
||||
settingsField(fields, "基址", "Base", action.URI?.Base);
|
||||
settingsField(fields, "目标", "Target", action.URI?.Target);
|
||||
} else {
|
||||
const kind = {GotoA: "Attachment", Sound: "Audio", Movie: "Video"}[type];
|
||||
settingsSelect(fields, "资源", "Resource", settings.data.resources.filter(item => item.kind === kind).map(item => [item.id, item.name]), referenceIDKey(action[type]?.ResourceID || action[type]?.AttachID));
|
||||
if (type === "GotoA") settingsSelect(fields, "窗口", "NewWindow", [["", "默认"], ["true", "新建"], ["false", "当前"]], action.GotoA?.NewWindow == null ? "" : String(action.GotoA.NewWindow));
|
||||
if (type === "Sound") {
|
||||
const volume = settingsField(fields, "音量", "Volume", action.Sound?.Volume ?? "", "number");
|
||||
volume.min = "0"; volume.max = "100"; volume.step = "1"; volume.placeholder = "默认";
|
||||
settingsField(fields, "循环", "Repeat", action.Sound?.Repeat, "checkbox");
|
||||
settingsField(fields, "同步", "Synchronous", action.Sound?.Synchronous, "checkbox");
|
||||
}
|
||||
if (type === "Movie") settingsSelect(fields, "操作", "Operator", [["", "默认"], ["Play", "播放"], ["Stop", "停止"], ["Pause", "暂停"], ["Resume", "继续"]], action.Movie?.Operator || "");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDestinationFields(container, dest = {}) {
|
||||
const type = dest?.Type || "XYZ";
|
||||
settingsSelect(container, "定位", "DestType", [["XYZ", "坐标"], ["Fit", "适页"], ["FitH", "适宽"], ["FitV", "适高"], ["FitR", "区域"]], type, () => updateDestinationFields());
|
||||
const index = pageIndexByID(state.doc.pages, dest?.PageID);
|
||||
const pages = state.doc.pages.map((page, index) => [page.id, `第${index + 1}页`]);
|
||||
if (dest?.PageID && index < 0) pages.push([dest.PageID, "页面缺失"]);
|
||||
settingsSelect(container, "页码", "DestPage", pages, index >= 0 ? state.doc.pages[index].id : dest?.PageID || state.doc.pages[settings.page]?.id);
|
||||
for (const [id, label] of [["Left", "左侧"], ["Top", "顶部"], ["Right", "右侧"], ["Bottom", "底部"], ["Zoom", "缩放"]]) {
|
||||
const field = settingsField(container, label, id, dest?.[`Omit${id}`] ? "" : dest?.[id] ?? "", "number");
|
||||
field.placeholder = id === "Zoom" ? "保持" : "默认";
|
||||
}
|
||||
updateDestinationFields();
|
||||
}
|
||||
|
||||
function updateDestinationFields() {
|
||||
const type = settingsValue("DestType"), required = {XYZ: ["Left", "Top", "Zoom"], Fit: [], FitH: ["Top"], FitV: ["Left"], FitR: ["Left", "Top", "Right", "Bottom"]}[type] || [];
|
||||
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) {
|
||||
const field = document.querySelector(`#settings${id}`);
|
||||
field.closest("label").hidden = !required.includes(id);
|
||||
field.disabled = !state.editing || !required.includes(id);
|
||||
}
|
||||
}
|
||||
|
||||
function destinationFromSettings() {
|
||||
const dest = {Type: settingsValue("DestType"), PageID: settingsValue("DestPage")};
|
||||
for (const id of ["Left", "Top", "Right", "Bottom", "Zoom"]) dest[id] = Number(settingsValue(id));
|
||||
for (const id of ["Left", "Top", "Zoom"]) dest[`Omit${id}`] = settingsValue(id) === "";
|
||||
return dest;
|
||||
}
|
||||
|
||||
async function commitSettings(name, ...args) {
|
||||
if (!state.editing || settings.busy || document.body.hasAttribute("aria-busy")) return false;
|
||||
settings.busy = true;
|
||||
settingsElements.Form.inert = true;
|
||||
settingsElements.Status.textContent = "正在保存修改";
|
||||
try {
|
||||
if (!await changeDocument(name, null, ...args)) {
|
||||
if (settingsElements.Status.textContent === "正在保存修改") settingsElements.Status.textContent = el.statusText.textContent;
|
||||
return false;
|
||||
}
|
||||
await loadDocumentSettings();
|
||||
return true;
|
||||
} finally { settings.busy = false; settingsElements.Form.inert = false; }
|
||||
}
|
||||
|
||||
async function writeActionSettings(operation) {
|
||||
if (!settingsElements.Form.reportValidity()) return;
|
||||
const actions = structuredClone(settings.actions), index = settings.selected;
|
||||
if (operation === "save") {
|
||||
const original = actions[index] || {};
|
||||
const action = {Event: {document: "DO", page: "PO", object: "CLICK", outline: original.Event || "CLICK"}[settings.scope], Region: original.Region || null};
|
||||
const type = settingsValue("ActionType");
|
||||
if (type === "Goto") action.Goto = {Dest: destinationFromSettings()};
|
||||
else if (type === "Bookmark") action.Goto = {Bookmark: {Name: settingsValue("Bookmark")}};
|
||||
else if (type === "URI") action.URI = {URI: settingsValue("URI"), Base: settingsValue("Base"), Target: settingsValue("Target")};
|
||||
else if (type === "GotoA") action.GotoA = {AttachID: settingsValue("Resource"), NewWindow: settingsValue("NewWindow") === "" ? null : settingsValue("NewWindow") === "true"};
|
||||
else if (type === "Sound") action.Sound = {ResourceID: settingsValue("Resource"), Volume: settingsValue("Volume") === "" ? null : Number(settingsValue("Volume")), Repeat: settingsValue("Repeat"), Synchronous: settingsValue("Synchronous")};
|
||||
else action.Movie = {ResourceID: settingsValue("Resource"), Operator: settingsValue("Operator")};
|
||||
if (index < 0) { settings.selected = actions.length; actions.push(action); }
|
||||
else actions[index] = action;
|
||||
} else if (operation === "delete") { actions.splice(index, 1); settings.selected = Math.min(index, actions.length - 1); }
|
||||
else {
|
||||
const next = index + (operation === "up" ? -1 : 1);
|
||||
[actions[index], actions[next]] = [actions[next], actions[index]];
|
||||
settings.selected = next;
|
||||
}
|
||||
if (!await commitSettings("ofdgoChangeActions", settings.scope, settings.page, settings.object, settings.path, JSON.stringify(actions))) settings.selected = index;
|
||||
}
|
||||
|
||||
function renderStyleResourceSettings(kind) {
|
||||
const content = settingsElements.Content, value = settings.data.resource || {};
|
||||
const existing = Boolean(settings.resource.id);
|
||||
if (kind === "ColorSpace") {
|
||||
settingsSelect(content, "模型", "ColorType", [["RGB", "RGB"], ["GRAY", "GRAY"], ["CMYK", "CMYK"]], value.Type || "RGB");
|
||||
settingsSelect(content, "位深", "Bits", [1, 2, 4, 8, 16].map(bits => [bits, String(bits)]), value.BitsPerComponent || 8);
|
||||
settingsField(content, "调色板", "Palette", (value.Palette || []).join("\n"), "textarea");
|
||||
if (value.Profile) appendInfoLine(content, "配置", value.Profile);
|
||||
const file = settingsField(content, "配置文件", "Profile", "", "file");
|
||||
file.accept = ".icc,.icm";
|
||||
if (value.Profile) settingsField(content, "移除配置", "ClearProfile", false, "checkbox");
|
||||
} else {
|
||||
settingsSelect(content, "继承", "Relative", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam" && item.id !== settings.resource.id).map(item => [item.id, item.name])], referenceIDKey(value.Relative));
|
||||
settingsSelect(content, "字体", "Font", [["", "继承"], ...settings.data.resources.filter(item => item.kind === "Font").map(item => [item.id, item.name])], referenceIDKey(value.Font));
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "settings-grid";
|
||||
content.append(grid);
|
||||
for (const [id, label, unit] of [["Size", "字号", "mm"], ["Weight", "字重", ""], ["LineWidth", "线宽", "mm"], ["MiterLimit", "截断值", ""]]) {
|
||||
const field = settingsField(grid, label, id, value[id] || (value[`${id}Set`] ? 0 : ""), "number", unit);
|
||||
field.min = "0";
|
||||
field.placeholder = "继承";
|
||||
if (id === "Weight") { field.step = "100"; field.max = "900"; }
|
||||
}
|
||||
settingsField(content, "斜体", "Italic", value.Italic, "checkbox");
|
||||
settingsSelect(content, "端点", "Cap", [["", "继承"], ["Butt", "平头"], ["Round", "圆头"], ["Square", "方头"]], value.Cap || "");
|
||||
settingsSelect(content, "连接", "Join", [["", "继承"], ["Miter", "尖角"], ["Round", "圆角"], ["Bevel", "斜角"]], value.Join || "");
|
||||
settingsField(content, "虚线", "DashPattern", value.DashPattern || "");
|
||||
settingsField(content, "偏移", "DashOffset", value.DashOffset ?? "", "number", "mm");
|
||||
for (const [key, label] of [["FillColor", "填充"], ["StrokeColor", "描边"]]) {
|
||||
settingsSelect(content, label, key + "Mode", [["keep", "原有"], ["inherit", "继承"], ["solid", "颜色"]], "keep");
|
||||
const field = settingsField(content, label + "颜色", key, "#000000", "color");
|
||||
field.closest("label").hidden = true;
|
||||
document.querySelector(`#settings${key}Mode`).addEventListener("change", event => { field.closest("label").hidden = event.target.value !== "solid"; });
|
||||
}
|
||||
}
|
||||
content.querySelectorAll("input,select,textarea").forEach(field => { field.disabled = !state.editing; });
|
||||
if (!state.editing) return;
|
||||
settingsButton(existing ? "修改" : "添加", () => writeStyleResource(kind), false, "Save");
|
||||
if (!existing) return;
|
||||
const binding = document.createElement("div");
|
||||
binding.className = "settings-binding";
|
||||
content.append(binding);
|
||||
settingsPages(binding, "TargetPage", settings.page, value => { settings.page = Number(value); loadSettingsObjects(); });
|
||||
settingsSelect(binding, "图元", "TargetObject", [["", "选择图元"], ...(settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || "图元"}`])], settings.object);
|
||||
if (kind === "ColorSpace") {
|
||||
settingsSelect(binding, "应用", "PaintTarget", [["fill", "填充"], ["stroke", "描边"]], "fill");
|
||||
settingsSelect(binding, "方式", "PaintMode", [["value", "分量"], ["index", "索引"]], "value");
|
||||
settingsField(binding, "颜色值", "PaintValue", value.Type === "CMYK" ? "0 0 0 255" : value.Type === "GRAY" ? "0" : "0 0 0");
|
||||
}
|
||||
settingsButton("应用", () => applyStyleResource(kind), false, "Apply");
|
||||
if (kind === "DrawParam") settingsButton("解绑", () => applyStyleResource(kind, true), false, "Unbind");
|
||||
}
|
||||
|
||||
async function loadSettingsObjects() {
|
||||
const sequence = settings.sequence, page = settings.page, openSeq = state.openSeq;
|
||||
const field = document.querySelector("#settingsTargetObject");
|
||||
field.disabled = true;
|
||||
try {
|
||||
const data = await loadPageData(page);
|
||||
if (sequence !== settings.sequence || page !== settings.page || openSeq !== state.openSeq) return;
|
||||
field.replaceChildren(new Option("选择图元", ""), ...(data.objects || []).filter(item => ["TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit"].includes(item.type) && !item.scoped).map(item => new Option(`${item.id} · ${item.text || "图元"}`, item.id)));
|
||||
} catch (error) { if (sequence === settings.sequence) settingsElements.Status.textContent = error.message; }
|
||||
finally { if (sequence === settings.sequence) field.disabled = false; }
|
||||
}
|
||||
|
||||
async function writeStyleResource(kind) {
|
||||
if (!settingsElements.Form.reportValidity()) return;
|
||||
const sequence = settings.sequence, openSeq = state.openSeq, resource = settings.resource, open = settingsElements.Panel.open;
|
||||
const value = structuredClone(settings.data.resource || {});
|
||||
let profile = null;
|
||||
const colors = {};
|
||||
if (kind === "ColorSpace") {
|
||||
value.Type = settingsValue("ColorType");
|
||||
value.BitsPerComponent = Number(settingsValue("Bits"));
|
||||
value.Palette = settingsValue("Palette").split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
const file = document.querySelector("#settingsProfile").files[0];
|
||||
if (file) {
|
||||
try { profile = new Uint8Array(await file.arrayBuffer()); }
|
||||
catch (error) { if (sequence === settings.sequence && openSeq === state.openSeq) settingsElements.Status.textContent = error.message; return; }
|
||||
if (sequence !== settings.sequence || openSeq !== state.openSeq || resource !== settings.resource || open !== settingsElements.Panel.open) return;
|
||||
}
|
||||
else if (settingsValue("ClearProfile")) profile = new Uint8Array();
|
||||
} else {
|
||||
for (const id of ["Relative", "Font", "Cap", "Join", "DashPattern"]) value[id] = settingsValue(id);
|
||||
for (const id of ["Size", "Weight", "LineWidth", "MiterLimit"]) value[id] = Number(settingsValue(id));
|
||||
value.LineWidthSet = settingsValue("LineWidth") !== "";
|
||||
value.MiterLimitSet = settingsValue("MiterLimit") !== "";
|
||||
value.DashOffset = settingsValue("DashOffset") === "" ? null : Number(settingsValue("DashOffset"));
|
||||
value.Italic = settingsValue("Italic");
|
||||
for (const key of ["FillColor", "StrokeColor"]) {
|
||||
const mode = settingsValue(key + "Mode");
|
||||
if (mode === "inherit") value[key] = null;
|
||||
if (mode === "solid") colors[key] = settingsValue(key);
|
||||
}
|
||||
}
|
||||
const before = new Set(settings.data.resources.filter(item => item.kind === kind).map(item => item.id));
|
||||
if (await commitSettings("ofdgoChangeStyleResource", kind, settings.resource.id, JSON.stringify(value), profile, JSON.stringify(colors))) {
|
||||
await resourceTask(loadManagedResources);
|
||||
if (!settings.resource.id) {
|
||||
settings.resource.id = resourceManager.items.find(item => item.kind === kind && !before.has(item.id))?.id || "";
|
||||
await loadDocumentSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyStyleResource(kind, clear = false) {
|
||||
const object = settingsValue("TargetObject");
|
||||
if (!object) { settingsElements.Status.textContent = "请选择图元"; return; }
|
||||
settings.object = object;
|
||||
const paint = kind === "ColorSpace" ? {target: settingsValue("PaintTarget"), mode: settingsValue("PaintMode"), value: settingsValue("PaintValue")} : {};
|
||||
if (await commitSettings("ofdgoApplyStyleResource", kind, clear ? "" : settings.resource.id, settings.page, object, JSON.stringify(paint))) await resourceTask(loadManagedResources);
|
||||
}
|
||||
|
||||
function renderLayerSettings() {
|
||||
const entries = settings.data.page.layers || [], content = settingsElements.Content;
|
||||
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); settings.selected = -1; loadDocumentSettings(); });
|
||||
if (settings.selected >= entries.length) settings.selected = -1;
|
||||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||||
const types = {Background: "背景", Body: "正文", Foreground: "前景"};
|
||||
const options = entries.map((entry, index) => [index, `${entry.ID} · ${types[entry.Type] || entry.Type} · ${entry.ObjectCount}个图元`]);
|
||||
if (state.editing) options.unshift([-1, "新增图层"]);
|
||||
settingsSelect(settingsElements.Context, "图层", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||||
const entry = entries[settings.selected];
|
||||
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无图层"; return; }
|
||||
settingsSelect(content, "类型", "LayerType", Object.entries(types), entry?.Type || "Body");
|
||||
settingsSelect(content, "样式", "DrawParam", [["", "无"], ...settings.data.resources.filter(item => item.kind === "DrawParam").map(item => [item.id, item.name])], referenceIDKey(entry?.DrawParam));
|
||||
content.querySelectorAll("select").forEach(field => { field.disabled = !state.editing; });
|
||||
if (!state.editing) return;
|
||||
const write = async (operation, order = 0, objects = []) => {
|
||||
const selected = settings.selected;
|
||||
if (!entry && operation === "add") settings.selected = entries.length;
|
||||
if (!await commitSettings("ofdgoChangeLayer", operation, settings.page, entry?.ID || "", settingsValue("LayerType"), settingsValue("DrawParam"), order, objects)) settings.selected = selected;
|
||||
};
|
||||
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save");
|
||||
settingsButton("删除", () => write("delete"), !entry || entry.ObjectCount > 0, "Delete");
|
||||
settingsButton("上移", () => write("move", entry.Order - 1), !entry || entry.Order === 0, "Up");
|
||||
settingsButton("下移", () => write("move", entry.Order + 1), !entry || entry.Order >= entries.filter(item => item.Type === entry.Type).length - 1, "Down");
|
||||
if (entry) {
|
||||
const objects = settingsSelect(content, "图元", "LayerObjects", (settings.data.objects || []).map(item => [item.id, `${item.id} · ${item.text || "图元"}`]), null);
|
||||
objects.multiple = true;
|
||||
objects.size = Math.min(4, Math.max(2, objects.options.length));
|
||||
for (const option of objects.options) option.selected = canvasEditor.items().some(item => item.index === settings.page && item.id === option.value && !item.scoped);
|
||||
settingsButton("移入", () => write("objects", 0, [...objects.selectedOptions].map(option => option.value)), !objects.options.length, "MoveObjects");
|
||||
}
|
||||
}
|
||||
|
||||
function renderTemplateSettings() {
|
||||
const entries = settings.data.page.templates || [], bindings = settings.data.page.bindings || [], content = settingsElements.Content;
|
||||
settingsPages(settingsElements.Context, "Page", settings.page, value => { settings.page = Number(value); loadDocumentSettings(); });
|
||||
if (settings.selected >= entries.length) settings.selected = -1;
|
||||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||||
const options = entries.map((entry, index) => [index, `${entry.Name || entry.ID}${bindings.some(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) ? " · 已绑定" : ""}`]);
|
||||
if (state.editing) options.unshift([-1, "新增模板"]);
|
||||
settingsSelect(settingsElements.Context, "模板", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||||
const entry = entries[settings.selected];
|
||||
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无模板"; return; }
|
||||
settingsField(content, "名称", "Name", entry?.Name);
|
||||
settingsSelect(content, "层次", "TemplateOrder", [["Background", "背景"], ["Foreground", "前景"]], entry?.ZOrder || "Background");
|
||||
if (entry) appendInfoLine(content, "文件", entry.BaseLoc);
|
||||
content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
|
||||
const bound = entry ? bindings.findIndex(binding => referenceIDKey(binding.TemplateID) === referenceIDKey(entry.ID)) : -1;
|
||||
if (entry && bound >= 0) {
|
||||
const order = bindings[bound].ZOrder;
|
||||
if (!state.editing) appendInfoLine(content, "绑定层次", {Background: "背景", Foreground: "前景"}[order] || "默认");
|
||||
}
|
||||
if (!state.editing) return;
|
||||
settingsPages(content, "SourcePage", settings.page);
|
||||
document.querySelector("#settingsSourcePage").closest("label").firstElementChild.textContent = "来源页";
|
||||
const write = async (operation, order = 0) => {
|
||||
if (operation === "replace" && !window.confirm("替换模板内容?当前文档中引用此模板的页面将同步更新。")) return;
|
||||
const selected = settings.selected;
|
||||
if (operation === "add") settings.selected = entries.length;
|
||||
if (!await commitSettings("ofdgoChangeTemplate", operation, ["add", "replace"].includes(operation) ? Number(settingsValue("SourcePage")) : settings.page, entry?.ID || "", settingsValue("Name"), ["bind", "order"].includes(operation) ? settingsValue("BindingOrder") : settingsValue("TemplateOrder"), order)) settings.selected = selected;
|
||||
};
|
||||
settingsButton(entry ? "修改" : "添加", () => write(entry ? "update" : "add"), false, "Save");
|
||||
settingsButton("替换", () => write("replace"), !entry, "Replace");
|
||||
settingsButton("删除", () => write("delete"), !entry || bound >= 0, "Delete");
|
||||
if (entry) {
|
||||
settingsSelect(content, "绑定层次", "BindingOrder", [["", "默认"], ["Background", "背景"], ["Foreground", "前景"]], bound < 0 ? "" : bindings[bound].ZOrder || "");
|
||||
settingsButton(bound < 0 ? "绑定" : "应用", () => write(bound < 0 ? "bind" : "order"), false, "Bind");
|
||||
settingsButton("解绑", () => write("unbind"), bound < 0, "Unbind");
|
||||
settingsButton("上移", () => write("move", bound - 1), bound <= 0, "Up");
|
||||
settingsButton("下移", () => write("move", bound + 1), bound < 0 || bound >= bindings.length - 1, "Down");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookmarkSettings() {
|
||||
const entries = settings.data.bookmarks || [];
|
||||
if (settings.selected >= entries.length) settings.selected = -1;
|
||||
if (!state.editing && settings.selected < 0 && entries.length) settings.selected = 0;
|
||||
const options = entries.map((entry, index) => [index, entry.Name]);
|
||||
if (state.editing) options.unshift([-1, "新增书签"]);
|
||||
settingsSelect(settingsElements.Context, "书签", "Entry", options, settings.selected, value => { settings.selected = Number(value); renderDocumentSettings(); });
|
||||
const entry = entries[settings.selected];
|
||||
if (!entry && !state.editing) { settingsElements.Status.textContent = "暂无书签"; return; }
|
||||
settingsField(settingsElements.Content, "名称", "Name", entry?.Name).required = true;
|
||||
renderDestinationFields(settingsElements.Content, entry?.Dest);
|
||||
settingsElements.Content.querySelectorAll("input,select").forEach(field => { field.disabled = !state.editing; });
|
||||
updateDestinationFields();
|
||||
settingsButton("定位", () => {
|
||||
settingsElements.Panel.close();
|
||||
const d = entry.Dest;
|
||||
navigateDestination({type: d.Type, pageID: d.PageID, left: d.Left, top: d.Top, right: d.Right, bottom: d.Bottom, zoom: d.Zoom, omitLeft: d.OmitLeft, omitTop: d.OmitTop, omitZoom: d.OmitZoom}, false);
|
||||
}, !entry, "Locate");
|
||||
if (!state.editing) return;
|
||||
settingsButton(entry ? "修改" : "添加", async () => {
|
||||
if (!settingsElements.Form.reportValidity()) return;
|
||||
const bookmark = {Name: settingsValue("Name"), Dest: destinationFromSettings()};
|
||||
const selected = settings.selected;
|
||||
if (!entry) settings.selected = entries.length;
|
||||
if (!await commitSettings("ofdgoChangeBookmark", entry ? "update" : "add", entry?.Name || "", JSON.stringify(bookmark))) settings.selected = selected;
|
||||
}, false, "Save");
|
||||
settingsButton("删除", () => commitSettings("ofdgoChangeBookmark", "delete", entry.Name, "{}"), !entry, "Delete");
|
||||
}
|
||||
|
||||
function renderExtensionSettings() {
|
||||
const entries = settings.data.extensions || [];
|
||||
if (!entries.length) settingsElements.Status.textContent = "暂无扩展";
|
||||
for (const entry of entries) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "settings-detail";
|
||||
for (const [key, label] of [["AppName", "应用"], ["Company", "单位"], ["AppVersion", "版本"], ["Date", "日期"], ["RefID", "引用"]]) appendInfoLine(section, label, entry[key]);
|
||||
for (const property of entry.Property || []) appendInfoLine(section, property.Name, property.Value);
|
||||
for (const value of entry.ExtendData || []) appendInfoLine(section, "文件", value);
|
||||
for (const value of entry.Data || []) {
|
||||
for (const attr of value.Attr || []) appendInfoLine(section, attr.Name.Local, attr.Value);
|
||||
const text = document.createElement("pre");
|
||||
text.textContent = value.Content;
|
||||
section.append(text);
|
||||
}
|
||||
settingsElements.Content.append(section);
|
||||
}
|
||||
}
|
||||
|
||||
async function openResourceManagement() {
|
||||
if (!state.doc || !state.ready || document.body.hasAttribute("aria-busy")) return;
|
||||
if (resourceManager.pick) { finishResourcePick(); return; }
|
||||
@@ -5132,7 +5707,7 @@ async function renderResourceList() {
|
||||
button.setAttribute("role", "option");
|
||||
button.setAttribute("aria-selected", String(item === selected));
|
||||
button.title = item.file || item.name;
|
||||
for (const value of [item.name, item.missing ? "缺失" : item.file ? formatBytes(item.size) : "外部", item.complete ? String(item.references?.length || 0) : "未知"]) {
|
||||
for (const value of [item.name, item.missing ? "缺失" : item.file ? formatBytes(item.size) : item.kind === "Font" ? "外部" : "-", item.complete ? String(item.references?.length || 0) : "未知"]) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value;
|
||||
button.append(span);
|
||||
@@ -5181,10 +5756,12 @@ function updateResourceControls() {
|
||||
resourceElements.Rename.disabled = disabled || item?.kind !== "Attachment";
|
||||
resourceElements.Add.disabled = disabled;
|
||||
resourceElements.Reference.disabled = disabled || !item?.references?.length;
|
||||
resourceElements.Preview.disabled = disabled || !item?.file || item.missing || item.kind === "Attachment";
|
||||
const style = item && ["ColorSpace", "DrawParam"].includes(item.kind);
|
||||
resourceElements.Preview.disabled = disabled || (!style && (!item?.file || item.missing || item.kind === "Attachment"));
|
||||
resourceElements.Preview.textContent = style ? "查看" : "预览";
|
||||
resourceElements.Export.disabled = disabled || !item?.file || item.missing;
|
||||
resourceElements.Replace.disabled = disabled || !item || item.kind !== "Attachment" && !item.complete;
|
||||
resourceElements.Replace.textContent = item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换";
|
||||
resourceElements.Replace.textContent = style ? "修改" : item?.kind === "Font" ? item.file ? "外置" : "嵌入" : "替换";
|
||||
resourceElements.Delete.disabled = disabled || !item || item.kind !== "Attachment" && (!item.complete || Boolean(item.references?.length));
|
||||
resourceElements.Delete.title = item?.kind === "Attachment" ? "删除附件" : item?.references?.length ? "资源仍被引用" : "删除资源";
|
||||
resourceElements.Locate.disabled = disabled || !item?.references?.length;
|
||||
@@ -5345,6 +5922,10 @@ function releaseResourcePreview() {
|
||||
}
|
||||
|
||||
async function previewManagedResource() {
|
||||
if (["ColorSpace", "DrawParam"].includes(resourceManager.selected?.kind)) {
|
||||
await openResourceSettings(resourceManager.selected.kind, resourceManager.selected.id);
|
||||
return;
|
||||
}
|
||||
const item = resourceManager.selected;
|
||||
if (!item?.file) return;
|
||||
if (state.doc.encryption?.encrypted && !window.confirm("资源将按明文读取,是否继续?")) return;
|
||||
@@ -7657,7 +8238,7 @@ function focusSearch() {
|
||||
|
||||
function showNavigation(selected) {
|
||||
const scrollTop = el.navigationContent.scrollTop;
|
||||
for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.searchTab, el.searchPanel]]) {
|
||||
for (const [tab, panel] of [[el.pagesTab, el.pageList], [el.outlinesTab, el.outlineList], [el.annotationsTab, el.annotationsPanel], [el.searchTab, el.searchPanel]]) {
|
||||
if (tab.getAttribute("aria-selected") === "true") {
|
||||
state.navigationScroll.set(tab, scrollTop);
|
||||
}
|
||||
@@ -7669,10 +8250,75 @@ function showNavigation(selected) {
|
||||
el.pageSelectionTools.hidden = !state.editing || !state.pageMultiSelect || selected !== el.pagesTab;
|
||||
el.navigationContent.scrollTop = state.navigationScroll.get(selected) || 0;
|
||||
if (selected === el.pagesTab) syncThumbnailWindow();
|
||||
if (selected === el.annotationsTab) loadAnnotationSummary();
|
||||
}
|
||||
|
||||
async function loadAnnotationSummary() {
|
||||
if (!state.doc || !state.ready) return;
|
||||
const seq = state.openSeq;
|
||||
if (state.annotationSummary?.seq === seq) { renderAnnotationSummary(); return; }
|
||||
const summary = {seq, items: [], loading: true};
|
||||
state.annotationSummary = summary;
|
||||
el.annotationsList.replaceChildren();
|
||||
el.annotationsStatus.textContent = "正在读取注解";
|
||||
try {
|
||||
const items = await callWASM("ofdgoAnnotationInfos");
|
||||
if (seq !== state.openSeq || state.annotationSummary !== summary) return;
|
||||
summary.items = items || [];
|
||||
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
|
||||
const kind = el.annotationsKind.value;
|
||||
el.annotationsKind.replaceChildren(new Option("全部", ""), ...[...new Set(summary.items.map(item => item.type))].map(type => new Option(labels[type] || type, type)));
|
||||
el.annotationsKind.value = [...el.annotationsKind.options].some(option => option.value === kind) ? kind : "";
|
||||
summary.loading = false;
|
||||
renderAnnotationSummary();
|
||||
} catch (error) {
|
||||
if (seq === state.openSeq && state.annotationSummary === summary) {
|
||||
state.annotationSummary = null;
|
||||
el.annotationsStatus.textContent = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderAnnotationSummary() {
|
||||
const summary = state.annotationSummary;
|
||||
if (!summary || summary.loading || summary.seq !== state.openSeq) return;
|
||||
const query = el.annotationsFilter.value.trim().toLocaleLowerCase();
|
||||
const kind = el.annotationsKind.value;
|
||||
const labels = {Path: "批注", Highlight: "高亮", Stamp: "印章", Watermark: "水印", Link: "链接"};
|
||||
const items = summary.items.filter(item => (!kind || item.type === kind) && `${item.remark || ""}\n${item.creator || ""}\n${item.id}`.toLocaleLowerCase().includes(query));
|
||||
el.annotationsStatus.textContent = summary.items.length ? `${items.length} / ${summary.items.length}` : "暂无注解";
|
||||
el.annotationsList.replaceChildren(...items.map(item => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "annotation-summary";
|
||||
const target = document.createElement("button");
|
||||
target.type = "button";
|
||||
target.className = "annotation-summary-target";
|
||||
target.title = "定位注解";
|
||||
const heading = document.createElement("span"), content = document.createElement("span");
|
||||
heading.textContent = `第${item.page}页 · ${labels[item.type] || item.type}${item.visible ? "" : " · 隐藏"}`;
|
||||
content.textContent = item.remark || item.id;
|
||||
target.append(heading, content);
|
||||
target.addEventListener("click", () => focusPageRegion(item, "注解", "center"));
|
||||
row.append(target);
|
||||
appendInfoLine(row, "作者", item.creator);
|
||||
appendInfoLine(row, "时间", formatDocumentTime(item.lastModDate));
|
||||
if (state.editing) {
|
||||
const edit = document.createElement("button");
|
||||
edit.type = "button";
|
||||
edit.className = "small-button";
|
||||
edit.textContent = "修改";
|
||||
edit.addEventListener("click", () => {
|
||||
if (!state.editing || document.body.hasAttribute("aria-busy") || summary.seq !== state.openSeq) return;
|
||||
editAnnotationDetails({...item, id: `annotation:${item.id}`, index: item.page - 1, type: "Annotation", fromSummary: true});
|
||||
});
|
||||
row.append(edit);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderOutlines(reset = true) {
|
||||
const selected = !reset && [el.pagesTab, el.outlinesTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true");
|
||||
const selected = !reset && [el.pagesTab, el.outlinesTab, el.annotationsTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true");
|
||||
const scrollTop = reset ? 0 : el.navigationContent.scrollTop;
|
||||
if (reset) {
|
||||
state.navigationScroll.clear();
|
||||
@@ -8031,13 +8677,18 @@ const pageIndexes = new WeakMap();
|
||||
function pageIndexByID(pages, id) {
|
||||
let indexes = pageIndexes.get(pages);
|
||||
if (!indexes) {
|
||||
indexes = new Map(pages.map((page, position) => [page.id, {page, position}]));
|
||||
indexes = new Map(pages.map((page, position) => [referenceIDKey(page.id), {page, position}]));
|
||||
pageIndexes.set(pages, indexes);
|
||||
}
|
||||
const entry = indexes.get(id);
|
||||
const entry = indexes.get(referenceIDKey(id));
|
||||
return entry ? entry.page.index ?? entry.position : -1;
|
||||
}
|
||||
|
||||
function referenceIDKey(id) {
|
||||
const value = String(id ?? ""), trimmed = value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
||||
return /^\d+$/.test(trimmed) && Number(trimmed) > 0 && Number(trimmed) <= 4294967295 ? trimmed.replace(/^0+(?=\d)/, "") : value;
|
||||
}
|
||||
|
||||
function selectedPageIndexes() {
|
||||
if (!state.pageSelection.size) return state.pageMultiSelect ? [] : [state.pageIndex];
|
||||
return [...state.pageSelection].map(id => pageIndexByID(state.doc.pages, id)).filter(index => index >= 0).sort((a, b) => a - b);
|
||||
@@ -9580,6 +10231,7 @@ function updateDocumentControls() {
|
||||
el.documentManageButton.hidden = !state.editing;
|
||||
el.documentManageButton.disabled = disabled;
|
||||
resourceElements.ManageButton.disabled = disabled;
|
||||
settingsElements.Button.disabled = disabled;
|
||||
updateResourceControls();
|
||||
el.documentRow.hidden = !count || !state.editing && count < 2;
|
||||
el.documentSelect.disabled = disabled;
|
||||
@@ -9768,7 +10420,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 === "ofdgoExportResource" || name === "ofdgoSaveDocument" || name === "ofdgoSaveSigned" || name === "ofdgoImportPages") {
|
||||
if (name === "ofdgoImportImages" || name === "ofdgoExportImages" || name === "ofdgoConvertPDF" || name === "ofdgoExportPage" || name === "ofdgoExportDocument" || name === "ofdgoExportAttachment" || name === "ofdgoExportResource" || name === "ofdgoExportVersion" || 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 === "ofdgoExportResource" || 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 === "ofdgoExportVersion" || 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));
|
||||
@@ -240,6 +240,11 @@ async function handleMessage({ id, name, args }) {
|
||||
self.postMessage({ id, type: "export", stage: "pages", completed, total });
|
||||
finish(done);
|
||||
});
|
||||
} else if (name === "ofdgoExportVersion") {
|
||||
args.push((phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
finish(done);
|
||||
}, done => finish(done));
|
||||
} else if (saving) {
|
||||
args.push((phase, completed, total, done) => {
|
||||
self.postMessage({ id, type: "export", stage: "prepare", phase, completed, total });
|
||||
|
||||
@@ -194,6 +194,18 @@ func RunWASM() {
|
||||
registerCallback("ofdgoOpen", openDocument)
|
||||
registerCallback("ofdgoSelectDocument", selectDocument)
|
||||
registerCallback("ofdgoDocumentVersions", documentVersions)
|
||||
registerAsyncCallback("ofdgoExportVersion", exportVersion)
|
||||
registerCallback("ofdgoAnnotationInfos", annotationInfos)
|
||||
registerCallback("ofdgoDocumentNavigation", documentNavigation)
|
||||
registerCallback("ofdgoChangeBookmark", changeBookmark)
|
||||
registerCallback("ofdgoActionList", actionList)
|
||||
registerCallback("ofdgoChangeActions", changeActions)
|
||||
registerCallback("ofdgoStyleResource", styleResource)
|
||||
registerCallback("ofdgoChangeStyleResource", changeStyleResource)
|
||||
registerCallback("ofdgoApplyStyleResource", applyStyleResource)
|
||||
registerCallback("ofdgoPageSettings", pageSettings)
|
||||
registerCallback("ofdgoChangeLayer", changeLayer)
|
||||
registerCallback("ofdgoChangeTemplate", changeTemplate)
|
||||
registerCallback("ofdgoSelectVersion", selectDocumentVersion)
|
||||
registerCallback("ofdgoChangeVersion", changeDocumentVersion)
|
||||
registerCallback("ofdgoChangeDocument", changePackageDocument)
|
||||
@@ -3680,6 +3692,370 @@ func documentVersions(args []js.Value) (any, error) {
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// styleResource 读取当前文档中的颜色空间或共享绘制参数
|
||||
// 入参: args 类型和资源标识
|
||||
// 返回: any 资源定义, error 读取错误
|
||||
func styleResource(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) != 2 {
|
||||
return nil, fmt.Errorf("invalid style resource")
|
||||
}
|
||||
ctx := context.Background()
|
||||
switch args[0].String() {
|
||||
case "ColorSpace":
|
||||
return currentSession.Reader.ColorSpace(ctx, args[1].String())
|
||||
case "DrawParam":
|
||||
return currentSession.Reader.DrawParam(ctx, args[1].String(), false)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid style resource kind")
|
||||
}
|
||||
}
|
||||
|
||||
// changeStyleResource 注册或替换共享样式资源,未重新选择ICC时保留既有配置
|
||||
// 入参: args 类型、标识、定义JSON、可选ICC字节和RGB颜色增量JSON
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func changeStyleResource(args []js.Value) (any, error) {
|
||||
if len(args) != 5 {
|
||||
return nil, fmt.Errorf("invalid style resource arguments")
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
ctx := context.Background()
|
||||
kind, id := args[0].String(), args[1].String()
|
||||
if kind == "ColorSpace" {
|
||||
var space ofdgo.ColorSpace
|
||||
if err := json.Unmarshal([]byte(args[2].String()), &space); err != nil {
|
||||
return err
|
||||
}
|
||||
var profile []byte
|
||||
var err error
|
||||
if !args[3].IsNull() && !args[3].IsUndefined() {
|
||||
profile, err = bytesFromJS(args[3])
|
||||
} else if id != "" {
|
||||
var original *ofdgo.ColorSpace
|
||||
original, err = currentEditor.ColorSpace(id)
|
||||
if err == nil && original.Profile != "" {
|
||||
var stream io.ReadCloser
|
||||
stream, err = currentSession.Reader.OpenResource(ctx, kind, id)
|
||||
if err == nil {
|
||||
defer stream.Close()
|
||||
profile, err = io.ReadAll(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
_, err = currentEditor.ImportColorSpace(ctx, space, profile)
|
||||
return err
|
||||
}
|
||||
return currentEditor.ReplaceColorSpace(ctx, id, space, profile)
|
||||
}
|
||||
if kind != "DrawParam" {
|
||||
return fmt.Errorf("invalid style resource kind")
|
||||
}
|
||||
var draw ofdgo.DrawParam
|
||||
var colors map[string]string
|
||||
if err := json.Unmarshal([]byte(args[2].String()), &draw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(args[4].String()), &colors); err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range colors {
|
||||
paint := &ofdgo.FillColor{}
|
||||
if key == "FillColor" && draw.FillColor != nil {
|
||||
paint.Alpha = draw.FillColor.Alpha
|
||||
} else if key == "StrokeColor" && draw.StrokeColor != nil {
|
||||
paint.Alpha = draw.StrokeColor.Alpha
|
||||
}
|
||||
if err := setEditorColor(paint, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "FillColor" {
|
||||
draw.FillColor = paint
|
||||
} else if key == "StrokeColor" {
|
||||
draw.StrokeColor = (*ofdgo.StrokeColor)(paint)
|
||||
} else {
|
||||
return fmt.Errorf("invalid paint target")
|
||||
}
|
||||
}
|
||||
if id == "" {
|
||||
_, err := currentEditor.ImportDrawParam(ctx, draw)
|
||||
return err
|
||||
}
|
||||
return currentEditor.ReplaceDrawParam(ctx, id, draw)
|
||||
})
|
||||
}
|
||||
|
||||
// applyStyleResource 为指定图元设置共享绘制参数或颜色,库层负责颜色及对象校验
|
||||
// 入参: args 类型、资源标识、页码、图元标识及颜色JSON
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func applyStyleResource(args []js.Value) (any, error) {
|
||||
if len(args) != 5 {
|
||||
return nil, fmt.Errorf("invalid style application")
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
page, ids := args[2].Int(), []string{args[3].String()}
|
||||
if args[0].String() == "DrawParam" {
|
||||
return currentEditor.SetObjectDrawParam(page, ids, args[1].String())
|
||||
}
|
||||
if args[0].String() != "ColorSpace" || args[1].String() == "" {
|
||||
return fmt.Errorf("invalid color space")
|
||||
}
|
||||
var paint struct{ Target, Mode, Value string }
|
||||
if err := json.Unmarshal([]byte(args[4].String()), &paint); err != nil {
|
||||
return err
|
||||
}
|
||||
fill := &ofdgo.FillColor{ColorSpace: args[1].String()}
|
||||
if paint.Mode == "index" {
|
||||
index, err := strconv.Atoi(paint.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fill.Index = &index
|
||||
} else if paint.Mode == "value" {
|
||||
fill.Value = paint.Value
|
||||
} else {
|
||||
return fmt.Errorf("invalid color mode")
|
||||
}
|
||||
style := ofdgo.ObjectStyle{}
|
||||
if paint.Target == "fill" {
|
||||
style.FillColor = fill
|
||||
} else if paint.Target == "stroke" {
|
||||
style.StrokeColor = (*ofdgo.StrokeColor)(fill)
|
||||
} else {
|
||||
return fmt.Errorf("invalid paint target")
|
||||
}
|
||||
return currentEditor.StyleObjects(page, ids, style)
|
||||
})
|
||||
}
|
||||
|
||||
// pageSettings 获取当前页的图层、模板定义和绑定信息,不修改文档
|
||||
// 入参: args 页面索引
|
||||
// 返回: any 页面设置, error 读取错误
|
||||
func pageSettings(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) != 1 {
|
||||
return nil, fmt.Errorf("invalid page settings")
|
||||
}
|
||||
reader := currentSession.Reader
|
||||
layers, err := reader.Layers(args[0].Int())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates, err := reader.Templates()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := reader.PageContentByIndex(args[0].Int())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"layers": layers, "templates": templates, "bindings": page.Template}, nil
|
||||
}
|
||||
|
||||
// changeLayer 调用图层管理接口,组合修改使用同一次事务
|
||||
// 入参: args 操作、页码、图层标识、类型、绘制参数、顺序及图元标识
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func changeLayer(args []js.Value) (any, error) {
|
||||
if len(args) != 7 {
|
||||
return nil, fmt.Errorf("invalid layer arguments")
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
page, id := args[1].Int(), args[2].String()
|
||||
switch args[0].String() {
|
||||
case "add":
|
||||
_, err := currentEditor.AddLayer(page, args[3].String(), args[4].String())
|
||||
return err
|
||||
case "update":
|
||||
return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
|
||||
if err := editor.SetLayerType(page, id, args[3].String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return editor.SetLayerDrawParam(page, id, args[4].String())
|
||||
})
|
||||
case "delete":
|
||||
return currentEditor.DeleteLayer(page, id)
|
||||
case "move":
|
||||
return currentEditor.MoveLayer(page, id, args[5].Int())
|
||||
case "objects":
|
||||
return currentEditor.MoveObjectsToLayer(page, stringsFromJS(args[6]), id)
|
||||
default:
|
||||
return fmt.Errorf("invalid layer operation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// changeTemplate 调用模板及页面绑定接口,组合修改使用同一次事务
|
||||
// 入参: args 操作、页码、模板标识、名称、层次及顺序
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func changeTemplate(args []js.Value) (any, error) {
|
||||
if len(args) != 6 {
|
||||
return nil, fmt.Errorf("invalid template arguments")
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
page, id, order := args[1].Int(), args[2].String(), args[4].String()
|
||||
switch args[0].String() {
|
||||
case "add":
|
||||
_, err := currentEditor.CreateTemplateFromPage(page, args[3].String(), order)
|
||||
return err
|
||||
case "update":
|
||||
return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
|
||||
if err := editor.RenameTemplate(id, args[3].String()); err != nil {
|
||||
return err
|
||||
}
|
||||
return editor.SetTemplateZOrder(id, order)
|
||||
})
|
||||
case "replace":
|
||||
return currentEditor.ReplaceTemplateFromPage(id, page)
|
||||
case "delete":
|
||||
return currentEditor.DeleteTemplate(id)
|
||||
case "bind":
|
||||
return currentEditor.BindTemplate(page, id, order)
|
||||
case "unbind":
|
||||
return currentEditor.UnbindTemplate(page, id)
|
||||
case "order":
|
||||
return currentEditor.SetPageTemplateZOrder(page, id, order)
|
||||
case "move":
|
||||
return currentEditor.MovePageTemplate(page, id, args[5].Int())
|
||||
default:
|
||||
return fmt.Errorf("invalid template operation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// documentNavigation 按需读取命名书签或通用扩展,不解释业务数据
|
||||
// 入参: args 设置类型
|
||||
// 返回: any 文档导航和扩展信息, error 读取错误
|
||||
func documentNavigation(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) != 1 {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
switch args[0].String() {
|
||||
case "bookmarks", "actions":
|
||||
bookmarks, err := currentSession.Reader.Bookmarks()
|
||||
return map[string]any{"bookmarks": bookmarks}, err
|
||||
case "extensions":
|
||||
extensions, err := currentSession.Reader.Extensions()
|
||||
return map[string]any{"extensions": extensions}, err
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid navigation kind")
|
||||
}
|
||||
}
|
||||
|
||||
// changeBookmark 调用库层书签操作,引用更新和版本隔离由编辑器完成
|
||||
// 入参: args 操作、原名称及书签JSON
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func changeBookmark(args []js.Value) (any, error) {
|
||||
if len(args) != 3 {
|
||||
return nil, fmt.Errorf("invalid bookmark arguments")
|
||||
}
|
||||
var bookmark ofdgo.Bookmark
|
||||
if err := json.Unmarshal([]byte(args[2].String()), &bookmark); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
switch args[0].String() {
|
||||
case "add":
|
||||
return currentEditor.AddBookmark(bookmark.Name, bookmark.Dest)
|
||||
case "update":
|
||||
return currentEditor.UpdateBookmark(args[1].String(), bookmark)
|
||||
case "delete":
|
||||
return currentEditor.DeleteBookmark(args[1].String())
|
||||
default:
|
||||
return fmt.Errorf("invalid bookmark operation")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// actionList 获取编辑对象的动作序列,返回独立数据,不触发动作
|
||||
// 入参: args 归属、页码、图元标识和目录路径
|
||||
// 返回: any 动作列表, error 归属或读取错误
|
||||
func actionList(args []js.Value) (any, error) {
|
||||
if currentEditor == nil || len(args) != 4 {
|
||||
return nil, fmt.Errorf("invalid action selection")
|
||||
}
|
||||
switch args[0].String() {
|
||||
case "document":
|
||||
return currentEditor.DocumentActions(), nil
|
||||
case "page":
|
||||
return currentEditor.PageActions(args[1].Int())
|
||||
case "object":
|
||||
return currentEditor.ObjectActions(args[1].Int(), args[2].String())
|
||||
case "outline":
|
||||
return currentEditor.OutlineActions(indexesFromJS(args[3]))
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid action scope")
|
||||
}
|
||||
}
|
||||
|
||||
// changeActions 将动作列表交给库层校验和原子提交
|
||||
// 入参: args 归属、页码、图元标识、目录路径及动作JSON
|
||||
// 返回: any 编辑状态, error 校验或提交错误
|
||||
func changeActions(args []js.Value) (any, error) {
|
||||
if len(args) != 5 {
|
||||
return nil, fmt.Errorf("invalid action arguments")
|
||||
}
|
||||
var actions []ofdgo.Action
|
||||
if err := json.Unmarshal([]byte(args[4].String()), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
if err := currentEditor.ValidateActionTargets(context.Background(), actions); err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0].String() {
|
||||
case "document":
|
||||
return currentEditor.SetDocumentActions(actions)
|
||||
case "page":
|
||||
return currentEditor.SetPageActions(args[1].Int(), actions)
|
||||
case "object":
|
||||
return currentEditor.SetObjectActions(args[1].Int(), []string{args[2].String()}, actions)
|
||||
case "outline":
|
||||
return currentEditor.SetOutlineActions(indexesFromJS(args[3]), actions)
|
||||
default:
|
||||
return fmt.Errorf("invalid action scope")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// annotationInfos 读取当前文档及版本的注释摘要,不解析页面图元
|
||||
// 入参: args 保留参数
|
||||
// 返回: any 注释摘要, error 读取错误
|
||||
func annotationInfos(args []js.Value) (any, error) {
|
||||
if currentSession == nil {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
return currentSession.Reader.AnnotationInfos()
|
||||
}
|
||||
|
||||
// exportVersion 提取指定历史版本并另存,不切换当前视图或修改编辑历史
|
||||
// 入参: args 版本标识、写出回调、进度回调及取消检查
|
||||
// 返回: any 保存结果, error 提取或写入错误
|
||||
func exportVersion(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) != 4 || args[0].Type() != js.TypeString || args[0].String() == "" {
|
||||
return nil, fmt.Errorf("invalid version export")
|
||||
}
|
||||
ctx := newOutputContext(args[3])
|
||||
reader := currentSession.Reader
|
||||
if currentEditor != nil {
|
||||
var err error
|
||||
reader, err = currentEditor.ReaderContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
version, err := reader.ExtractVersionContext(ctx, args[0].String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer version.Close()
|
||||
editor, err := version.Editor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return saveEditor(editor, []js.Value{args[1], args[2], js.Null(), js.ValueOf(map[string]any{"mode": 0}), args[3]})
|
||||
}
|
||||
|
||||
// selectDocumentVersion 切换阅读版本,已有编辑器时保留修改及历史,否则直接创建阅读视图
|
||||
// 入参: args 版本标识
|
||||
// 返回: any 文档信息, error 切换错误
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// PageActions 获取当前文档及版本中页面自身的完整动作独立副本,不合并模板或执行动作
|
||||
// 入参: page 从0开始的页面索引
|
||||
// 返回: []Action 原顺序动作,未设置时为nil, error 页面或解析错误
|
||||
func (e *Editor) PageActions(page int) ([]Action, error) {
|
||||
content, err := e.page(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneEditorData(content.Actions), nil
|
||||
}
|
||||
|
||||
// ObjectActions 获取普通页面图元自身的完整动作独立副本,不展开共享复合资源或执行动作
|
||||
// 入参: page 从0开始的页面索引, id 与SetObjectActions相同的对象标识
|
||||
// 返回: []Action 原顺序动作,未设置时为nil, error 页面、对象或类型错误
|
||||
func (e *Editor) ObjectActions(page int, id string) ([]Action, error) {
|
||||
layer, index, err := e.findObject(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions, err := resourceObjectActions(layer.Objects[index])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cloneEditorData(actions), nil
|
||||
}
|
||||
|
||||
// OutlineActions 获取当前目录节点自身的完整动作独立副本,不含子目录或执行动作
|
||||
// 入参: path 从0开始的非空层级索引
|
||||
// 返回: []Action 原顺序动作,未设置时为nil, error 路径或解析错误
|
||||
func (e *Editor) OutlineActions(path []int) ([]Action, error) {
|
||||
if len(path) == 0 {
|
||||
return nil, fmt.Errorf("outline path must not be empty")
|
||||
}
|
||||
data, err := e.outlineXML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node, err := editorOutlineNode(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node = node.child("Actions")
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err = editorXMLStandalone(data[node.start:node.end], node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(data))
|
||||
start, err := decoder.Token()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var actions []Action
|
||||
if err := decodeOFDActions(decoder, start.(xml.StartElement), &actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
// ValidateActionTargets 校验动作结构及当前文档、版本内的页面、书签、附件和音视频引用
|
||||
// 可用于提交前检查,不改变现有Set方法允许悬空目标的语义;不检查事件归属或访问外部URI
|
||||
// 已注册但未绑定的媒体也可引用;仅检查资源声明及类型,不解码媒体或执行动作
|
||||
// 入参: ctx 取消上下文, actions 待校验动作列表
|
||||
// 返回: error 首个错误及从0开始的动作索引,取消错误支持errors.Is
|
||||
func (e *Editor) ValidateActionTargets(ctx context.Context, actions []Action) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
var resources []ResourceInfo
|
||||
resourcesLoaded := false
|
||||
for index, action := range actions {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateActions([]Action{action}); err != nil {
|
||||
return fmt.Errorf("action %d: %w", index, err)
|
||||
}
|
||||
if action.Goto != nil {
|
||||
dest := action.Goto.Dest
|
||||
if bookmark := action.Goto.Bookmark; bookmark != nil {
|
||||
if e.source != nil {
|
||||
for _, entry := range e.source.document.Bookmarks.Bookmark {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.Name == bookmark.Name {
|
||||
value := entry.Dest
|
||||
dest = &value
|
||||
}
|
||||
}
|
||||
}
|
||||
if dest == nil {
|
||||
return fmt.Errorf("action %d: bookmark %q not found", index, bookmark.Name)
|
||||
}
|
||||
}
|
||||
if _, err := dest.attributes(); err != nil {
|
||||
return fmt.Errorf("action %d: %w", index, err)
|
||||
}
|
||||
found := false
|
||||
for _, page := range e.pages {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if sameResourceID(page.ID, dest.PageID) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("action %d: destination page %q not found", index, dest.PageID)
|
||||
}
|
||||
}
|
||||
var kind, id string
|
||||
switch {
|
||||
case action.GotoA != nil:
|
||||
kind, id = "Attachment", action.GotoA.AttachID
|
||||
case action.Sound != nil:
|
||||
kind, id = "Audio", action.Sound.ResourceID
|
||||
case action.Movie != nil:
|
||||
kind, id = "Video", action.Movie.ResourceID
|
||||
default:
|
||||
continue
|
||||
}
|
||||
registered := false
|
||||
if kind != "Attachment" {
|
||||
for _, resource := range e.resources {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if media := resource.image; media != nil && media.Type == kind && sameResourceID(media.ID, id) {
|
||||
registered = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if registered {
|
||||
continue
|
||||
}
|
||||
if !resourcesLoaded {
|
||||
var err error
|
||||
resources, err = e.Resources(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("action %d: %w", index, err)
|
||||
}
|
||||
resourcesLoaded = true
|
||||
}
|
||||
if _, err := findResource(resources, kind, id); err != nil {
|
||||
return fmt.Errorf("action %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
+24
-2
@@ -626,6 +626,7 @@ func (e *Editor) replaceLayers(page int, layers []Layer) {
|
||||
}
|
||||
|
||||
// replacePreparedLayers 提交已隔离的图层容器,历史启用时仍保留独立快照
|
||||
// 图层回放同步补回文档或版本快照中尚未加载的页面原文、属性和对象来源
|
||||
// 入参: page 页面索引, after 已准备且不再由调用方修改的图层列表
|
||||
func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
||||
if e.historyLimit == 0 {
|
||||
@@ -633,13 +634,34 @@ func (e *Editor) replacePreparedLayers(page int, after []Layer) {
|
||||
e.recordChange()
|
||||
return
|
||||
}
|
||||
before := copyEditorPage(e.pages[page]).Content.Layer
|
||||
before := copyEditorPage(e.pages[page])
|
||||
var reader *Reader
|
||||
var source *editorSourcePage
|
||||
var origins map[string]*editorObjectOrigin
|
||||
if e.source != nil {
|
||||
reader, source = e.source.reader, e.source.pages[before.ID]
|
||||
if source != nil && source.original != nil {
|
||||
origins = make(map[string]*editorObjectOrigin, len(source.nodes))
|
||||
for id := range source.nodes {
|
||||
origins[id] = e.source.origins[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
apply := func(e *Editor, layers []Layer) {
|
||||
if source != nil && source.original != nil && e.source != nil && e.source.reader == reader && e.pages[page].ID == before.ID {
|
||||
if current := e.source.pages[before.ID]; current != nil && current.original == nil {
|
||||
e.source.pages[before.ID] = source
|
||||
for id, origin := range origins {
|
||||
e.source.origins[id] = origin
|
||||
}
|
||||
e.pages[page] = copyEditorPage(before)
|
||||
}
|
||||
}
|
||||
e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
|
||||
}
|
||||
apply(e, after)
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { apply(e, before) }
|
||||
change.undo = func(e *Editor) { apply(e, before.Content.Layer) }
|
||||
change.redo = func(e *Editor) { apply(e, after) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Bookmarks 按声明顺序读取当前文档版本的命名书签,返回独立副本,不读取跳转引用。
|
||||
// 返回: []Bookmark 书签列表,无书签时为nil;error 读取、结构或名称重复错误。
|
||||
func (e *Editor) Bookmarks() ([]Bookmark, error) {
|
||||
if e.source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := e.source.reader.readFile(e.source.reader.documentRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorBookmarks(data, root)
|
||||
}
|
||||
|
||||
// AddBookmark 在当前文档版本末尾添加名称唯一的书签,保留扩展内容并记录一次撤销。
|
||||
// 入参: name 非空且区分大小写的名称,不自动裁剪空白;dest 目标,PageID为当前页面标识而非页序号。
|
||||
// 返回: error 名称、目标、页面或提交错误。
|
||||
func (e *Editor) AddBookmark(name string, dest Dest) error {
|
||||
return e.editBookmark("", &Bookmark{Name: name, Dest: dest}, true)
|
||||
}
|
||||
|
||||
// UpdateBookmark 按原名称更新书签,重命名同步当前文档全部标准跳转引用,整体可撤销。
|
||||
// 入参: name 原名称;next 完整的新书签,Dest.PageID为当前页面标识;名称不变时仅修改目标。
|
||||
// 返回: error 名称不存在、重名、目标无效、引用读取或提交错误;内容未变时不记录撤销。
|
||||
func (e *Editor) UpdateBookmark(name string, next Bookmark) error {
|
||||
return e.editBookmark(name, &next, false)
|
||||
}
|
||||
|
||||
// DeleteBookmark 删除当前文档版本的命名书签,存在标准跳转引用时拒绝删除。
|
||||
// 入参: name 书签名称;其他文档和历史版本的引用不阻止当前版本删除。
|
||||
// 返回: error 名称不存在、仍被引用、引用读取或提交错误。
|
||||
func (e *Editor) DeleteBookmark(name string) error {
|
||||
return e.editBookmark(name, nil, false)
|
||||
}
|
||||
|
||||
// editorBookmarks 读取标准书签定义,拒绝重复集合、重名及缺失或重复目标,不解析扩展子树。
|
||||
// 入参: data 文档原文;root 文档根节点。
|
||||
// 返回: []Bookmark 独立定义列表;error 结构或解码错误。
|
||||
func editorBookmarks(data []byte, root *editorXML) ([]Bookmark, error) {
|
||||
if root.childAt("Bookmarks", 1) != nil {
|
||||
return nil, fmt.Errorf("multiple bookmark collections")
|
||||
}
|
||||
container := root.child("Bookmarks")
|
||||
if container == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var result []Bookmark
|
||||
seen := make(map[string]bool)
|
||||
for _, node := range container.children {
|
||||
if !node.matchesOFD("Bookmark") {
|
||||
continue
|
||||
}
|
||||
name := node.attr("Name")
|
||||
if err := validateBookmarkName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("duplicate bookmark %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
if node.child("Dest") == nil || node.childAt("Dest", 1) != nil {
|
||||
return nil, fmt.Errorf("bookmark %q requires one destination", name)
|
||||
}
|
||||
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bookmark Bookmark
|
||||
if err := xml.Unmarshal(fragment, &bookmark); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, bookmark)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateBookmarkName 校验非空名称及XML可无损表示的Unicode字符。
|
||||
// 入参: name 名称。
|
||||
// 返回: error 名称错误。
|
||||
func validateBookmarkName(name string) error {
|
||||
if strings.TrimSpace(name) == "" || !utf8.ValidString(name) {
|
||||
return fmt.Errorf("bookmark name must be nonempty valid UTF-8")
|
||||
}
|
||||
for _, char := range name {
|
||||
if char < 0x20 && char != '\t' && char != '\n' && char != '\r' || char == 0xfffe || char == 0xffff {
|
||||
return fmt.Errorf("bookmark name contains an invalid XML character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateBookmark 校验书签名称、标准目标参数及当前编辑页树中的目标标识。
|
||||
// 入参: bookmark 待写入定义。
|
||||
// 返回: error 名称、目标或页面错误。
|
||||
func (e *Editor) validateBookmark(bookmark Bookmark) error {
|
||||
if err := validateBookmarkName(bookmark.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := bookmark.Dest.attributes(); err != nil {
|
||||
return err
|
||||
}
|
||||
id := editorResourceID(bookmark.Dest.PageID)
|
||||
for _, page := range e.pages {
|
||||
if id != "" && editorResourceID(page.ID) == id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("bookmark destination page %q does not exist", bookmark.Dest.PageID)
|
||||
}
|
||||
|
||||
// editBookmark 校验定义后原子提交局部XML补丁,重命名使用包含未保存编辑的完整快照。
|
||||
// 入参: name 原名称;next 新定义,nil表示删除;add 是否新增。
|
||||
// 返回: error 校验、引用扫描或提交错误。
|
||||
func (e *Editor) editBookmark(name string, next *Bookmark, add bool) error {
|
||||
if !add {
|
||||
if err := validateBookmarkName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if next != nil {
|
||||
if err := e.validateBookmark(*next); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
bookmarks, err := e.Bookmarks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
index := -1
|
||||
for i, bookmark := range bookmarks {
|
||||
if !add && bookmark.Name == name {
|
||||
index = i
|
||||
}
|
||||
if next != nil && bookmark.Name == next.Name && (add || bookmark.Name != name) {
|
||||
return fmt.Errorf("bookmark %q already exists", next.Name)
|
||||
}
|
||||
}
|
||||
if !add && index < 0 {
|
||||
return fmt.Errorf("bookmark %q not found", name)
|
||||
}
|
||||
rename := !add && next != nil && name != next.Name
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
base := e.source
|
||||
if base == nil {
|
||||
base, err = e.importBase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
parts := make(map[string][]byte)
|
||||
if rename || next == nil {
|
||||
pending, err := e.sourcePartsReferences(nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader := cloneEditorReader(base.reader)
|
||||
if reader.files == nil {
|
||||
reader.files = make(map[string][]byte)
|
||||
}
|
||||
maps.Copy(reader.files, pending)
|
||||
parts, err = editorBookmarkReferences(reader, name, next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, resource := range e.resources {
|
||||
if resource.definition() == "" || len(resource.data) == 0 {
|
||||
continue
|
||||
}
|
||||
data, err := editorBookmarkReferenceXML(resource.data, resource.name, name, next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(data, resource.data) {
|
||||
e.resources[i].data = data
|
||||
e.resources[i].vectorKey = [32]byte{}
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
maps.Copy(pending, parts)
|
||||
parts = pending
|
||||
}
|
||||
}
|
||||
entry := base.reader.documentRoot
|
||||
data, ok := parts[entry]
|
||||
if !ok {
|
||||
data, err = base.reader.readFile(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updated, err := editorBookmarkDefinition(data, name, next, add)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bytes.Equal(data, updated) && len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
parts[entry] = updated
|
||||
if !rename {
|
||||
return e.commitBookmarkParts(base, parts)
|
||||
}
|
||||
working := &Editor{}
|
||||
if err := working.commitBookmarkParts(base, parts); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.commitBookmarkSnapshot(working.source.reader)
|
||||
})
|
||||
}
|
||||
|
||||
// commitBookmarkSnapshot 接入重命名后的包,保留已加载对象的会话排版、裁剪及尚未使用的注册资源。
|
||||
// 入参: reader 已完成隔离和重命名的包快照。
|
||||
// 返回: error 快照重建或页面加载错误。
|
||||
func (e *Editor) commitBookmarkSnapshot(reader *Reader) error {
|
||||
before := e.transactionSnapshot()
|
||||
fonts, images, spaces, files := e.usedResources()
|
||||
used := make(map[string]bool)
|
||||
for _, resource := range fonts {
|
||||
used[resource.font.ID] = true
|
||||
}
|
||||
for _, resource := range images {
|
||||
used[resource.image.ID] = true
|
||||
}
|
||||
for _, space := range spaces {
|
||||
used[space.ID] = true
|
||||
}
|
||||
if err := e.commitResourceSnapshot(context.Background(), reader); err != nil {
|
||||
return err
|
||||
}
|
||||
e.maxID = max(e.maxID, before.maxID)
|
||||
e.fonts, e.images, e.resourceID = before.fonts, before.images, before.resourceID
|
||||
e.fontRegistration = before.fontRegistration
|
||||
for _, resource := range before.resources {
|
||||
if resource.font != nil && used[resource.font.ID] || resource.image != nil && used[resource.image.ID] || resource.space != nil && used[resource.space.ID] || files[resource.name] {
|
||||
continue
|
||||
}
|
||||
e.resources = append(e.resources, resource)
|
||||
}
|
||||
for i, page := range before.pages {
|
||||
states := make(map[string]GraphicObject)
|
||||
for _, layer := range page.Content.Layer {
|
||||
for _, object := range layer.Objects {
|
||||
states[editorObjectID(object)] = object
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
continue
|
||||
}
|
||||
current, err := e.page(i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for j := range current.Content.Layer {
|
||||
for k := range current.Content.Layer[j].Objects {
|
||||
object := ¤t.Content.Layer[j].Objects[k]
|
||||
previous := states[editorObjectID(*object)]
|
||||
object.state = previous.state
|
||||
object.TextObject.layout = previous.TextObject.layout
|
||||
object.CompositeGraphicUnit.states = previous.CompositeGraphicUnit.states
|
||||
}
|
||||
}
|
||||
}
|
||||
if before.source != nil {
|
||||
e.source.annotationStates = before.source.annotationStates
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// commitBookmarkParts 隔离修改后复用注解快照提交,保守复制路径不覆盖未知扩展可能引用的原文件。
|
||||
// 入参: base 原包快照;parts 当前入口修改集。
|
||||
// 返回: error 依赖解析、隔离或提交错误。
|
||||
func (e *Editor) commitBookmarkParts(base *editorSource, parts map[string][]byte) error {
|
||||
changes, err := base.reader.versionChanges(context.Background(), parts)
|
||||
if err != nil {
|
||||
changes, err = editorBookmarkIsolate(base.reader, parts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
reader := cloneEditorReader(base.reader)
|
||||
if reader.files == nil {
|
||||
reader.files = make(map[string][]byte)
|
||||
}
|
||||
maps.Copy(reader.files, changes)
|
||||
source := *base
|
||||
source.reader = reader
|
||||
return e.commitAnnotationParts(&source, nil)
|
||||
}
|
||||
|
||||
// editorBookmarkIsolate 在无法完整分析其他入口时保护所有已有文件,复制修改文件及其标准引用祖先。
|
||||
// 入参: reader 原包;changes 当前入口修改集。
|
||||
// 返回: map[string][]byte 隔离后的修改集;error 当前入口依赖或版本清单错误。
|
||||
func editorBookmarkIsolate(reader *Reader, changes map[string][]byte) (map[string][]byte, error) {
|
||||
ctx := context.Background()
|
||||
entry, err := reader.DocumentRootContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := maps.Clone(changes)
|
||||
graph, err := reader.versionEditGraph(ctx, entry, parts, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := make(map[string]string)
|
||||
reserved := maps.Clone(parts)
|
||||
var pending []string
|
||||
allocate := func(name string) {
|
||||
if paths[name] != "" || !reader.fileNames[name] {
|
||||
return
|
||||
}
|
||||
target := packageAvailableName(reader, reserved, name)
|
||||
paths[name], reserved[target] = target, nil
|
||||
pending = append(pending, name)
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(parts)) {
|
||||
if name != "OFD.xml" && graph.used[name] {
|
||||
allocate(name)
|
||||
}
|
||||
}
|
||||
parents := make(map[string][]string)
|
||||
for _, name := range slices.Sorted(maps.Keys(graph.files)) {
|
||||
for _, link := range graph.files[name].links {
|
||||
parents[link.target] = append(parents[link.target], name)
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(pending); i++ {
|
||||
for _, name := range parents[pending[i]] {
|
||||
allocate(name)
|
||||
}
|
||||
}
|
||||
for name, file := range graph.files {
|
||||
data, err := file.rewrite(ctx, paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.Equal(data, file.data) {
|
||||
parts[name] = data
|
||||
}
|
||||
}
|
||||
for source, target := range paths {
|
||||
data, ok := parts[source]
|
||||
if !ok {
|
||||
data, err = reader.readFile(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
parts[target] = data
|
||||
delete(parts, source)
|
||||
}
|
||||
if reader.versionInfo != nil {
|
||||
if err := reader.versionManifest(ctx, parts, paths, graph.used, reader.fileNames, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if target := paths[entry]; target != "" {
|
||||
data, ok := parts["OFD.xml"]
|
||||
if !ok {
|
||||
data, err = reader.readFile("OFD.xml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
node := root.childAt("DocBody", reader.documentIndex).child("DocRoot")
|
||||
parts["OFD.xml"] = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, editorFontXMLText("/"+target))})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// editorBookmarkDefinition 修改指定书签的标准字段,保留其扩展属性、扩展节点及其他定义原文。
|
||||
// 入参: data 文档原文;name 原名称;next 新定义,nil表示删除;add 是否新增。
|
||||
// 返回: []byte 修改后的文档;error 结构或编码错误。
|
||||
func editorBookmarkDefinition(data []byte, name string, next *Bookmark, add bool) ([]byte, error) {
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
container := root.child("Bookmarks")
|
||||
if add {
|
||||
context := container
|
||||
if context == nil {
|
||||
context = root
|
||||
}
|
||||
attrs, err := next.Dest.attributes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dest, err := context.containerXML("Dest", attrs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := context.containerXML("Bookmark", ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: next.Name}}, dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if container != nil {
|
||||
if container.open == container.end {
|
||||
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, container, item)}), nil
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{container.close, container.close, item}}), nil
|
||||
}
|
||||
item, err = root.containerXML("Bookmarks", nil, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at := root.close
|
||||
for _, child := range root.children {
|
||||
if child.matchesOFD(child.name.Local) && slices.Contains([]string{"Annotations", "CustomTags", "Attachments", "Extensions"}, child.name.Local) {
|
||||
at = child.start
|
||||
break
|
||||
}
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{at, at, item}}), nil
|
||||
}
|
||||
if container == nil {
|
||||
return nil, fmt.Errorf("bookmark %q not found", name)
|
||||
}
|
||||
for _, node := range container.children {
|
||||
if !node.matchesOFD("Bookmark") || node.attr("Name") != name {
|
||||
continue
|
||||
}
|
||||
if next == nil {
|
||||
patch := editorXMLPatch{node.start, node.end, nil}
|
||||
if len(container.children) == 1 && editorXMLAttributes(container, "") && len(bytes.TrimSpace(data[container.open:node.start])) == 0 && len(bytes.TrimSpace(data[node.end:container.close])) == 0 {
|
||||
patch.start, patch.end = container.start, container.end
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{patch}), nil
|
||||
}
|
||||
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var current Bookmark
|
||||
if err := xml.Unmarshal(fragment, ¤t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
if name != next.Name {
|
||||
patch, err := editorXMLAttributePatch(data, node, "Name", next.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches = append(patches, patch)
|
||||
}
|
||||
if current.Dest != next.Dest {
|
||||
dest := node.child("Dest")
|
||||
if dest == nil {
|
||||
return nil, fmt.Errorf("bookmark %q has no destination", name)
|
||||
}
|
||||
attrs, err := next.Dest.attributes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make(map[string]string, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
values[attr.Name.Local] = attr.Value
|
||||
}
|
||||
value := bytes.Clone(data[dest.start:dest.end])
|
||||
for _, key := range []string{"Type", "PageID", "Left", "Top", "Right", "Bottom", "Zoom"} {
|
||||
parsed, err := parseEditorXML(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var replacement *string
|
||||
if text, ok := values[key]; ok {
|
||||
replacement = &text
|
||||
}
|
||||
patch, err := editorXMLAttributeChange(value, parsed, key, replacement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value = editorPatchXML(value, []editorXMLPatch{patch})
|
||||
}
|
||||
patches = append(patches, editorXMLPatch{dest.start, dest.end, value})
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
}
|
||||
return nil, fmt.Errorf("bookmark %q not found", name)
|
||||
}
|
||||
|
||||
// editorBookmarkReferences 扫描当前入口依赖中的标准书签跳转,拒绝带引用删除或生成重命名补丁。
|
||||
// 入参: reader 包含未保存修改的快照;name 原名称;next 新定义,nil表示删除。
|
||||
// 返回: map[string][]byte 需要修改的结构文件;error 读取、解析或被引用错误。
|
||||
func editorBookmarkReferences(reader *Reader, name string, next *Bookmark) (map[string][]byte, error) {
|
||||
_, structures, err := reader.versionDependencies(context.Background(), reader.documentRoot, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := make(map[string][]byte)
|
||||
for _, file := range slices.Sorted(maps.Keys(structures)) {
|
||||
data, err := reader.readFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updated, err := editorBookmarkReferenceXML(data, file, name, next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !bytes.Equal(data, updated) {
|
||||
parts[file] = updated
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
// editorBookmarkReferenceXML 仅识别标准动作中的名称引用,保留扩展命名空间及不透明扩展负载。
|
||||
// 入参: data 结构原文;file 错误上下文中的文件名;name 原名称;next 新定义,nil表示检查删除。
|
||||
// 返回: []byte 修改后的原文;error 解析、属性编码或被引用错误。
|
||||
func editorBookmarkReferenceXML(data []byte, file, name string, next *Bookmark) ([]byte, error) {
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
var walk func(*editorXML) error
|
||||
walk = func(node *editorXML) error {
|
||||
if !node.matchesOFD(node.name.Local) {
|
||||
return nil
|
||||
}
|
||||
if node.matchesOFD("Data") && node.parent != nil && node.parent.matchesOFD("Extension") {
|
||||
return nil
|
||||
}
|
||||
if node.matchesOFD("Action") && node.parent != nil && node.parent.matchesOFD("Actions") {
|
||||
for _, target := range node.children {
|
||||
if !target.matchesOFD("Goto") {
|
||||
continue
|
||||
}
|
||||
for _, bookmark := range target.children {
|
||||
if !bookmark.matchesOFD("Bookmark") || bookmark.attr("Name") != name {
|
||||
continue
|
||||
}
|
||||
if next == nil {
|
||||
return fmt.Errorf("bookmark %q is referenced in %s", name, file)
|
||||
}
|
||||
patch, err := editorXMLAttributePatch(data, bookmark, "Name", next.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patches = append(patches, patch)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// LayerInfo 描述普通页面图层,Order为同类型零基顺序,ObjectCount包含页块中的图元。
|
||||
type LayerInfo struct {
|
||||
ID string
|
||||
Type string
|
||||
DrawParam string
|
||||
Order int
|
||||
ObjectCount int
|
||||
}
|
||||
|
||||
// Layers 按文件顺序返回普通页面图层,省略的类型规范化为Body,不包含模板图层。
|
||||
// 入参: page 零基页面索引
|
||||
// 返回: []LayerInfo 独立图层清单, error 页面读取错误
|
||||
func (e *Editor) Layers(page int) ([]LayerInfo, error) {
|
||||
p, err := e.page(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorLayerInfos(p), nil
|
||||
}
|
||||
|
||||
// Layers 直接读取普通页面图层,不创建编辑器或分配新标识,返回值不持有页面可变数据。
|
||||
// 入参: page 零基页面索引
|
||||
// 返回: []LayerInfo 按文件顺序排列的图层, error 页面读取错误
|
||||
func (r *Reader) Layers(page int) ([]LayerInfo, error) {
|
||||
p, err := r.PageContentByIndex(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorLayerInfos(p), nil
|
||||
}
|
||||
|
||||
// editorLayerInfos 将页面图层投影为不共享可变容器的管理清单。
|
||||
// 入参: p 已读取的页面
|
||||
// 返回: []LayerInfo 图层清单
|
||||
func editorLayerInfos(p *PageContent) []LayerInfo {
|
||||
result := make([]LayerInfo, 0, len(p.Content.Layer))
|
||||
orders := make(map[string]int)
|
||||
for _, layer := range p.Content.Layer {
|
||||
kind := editorLayerType(layer.Type)
|
||||
result = append(result, LayerInfo{layer.ID, kind, layer.DrawParam, orders[kind], len(layer.Objects)})
|
||||
orders[kind]++
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AddLayer 追加空图层并返回新标识,空类型采用Body,空DrawParam表示不设置继承参数。
|
||||
// 入参: page 页面索引, layerType 图层类型, drawParam 绘制参数标识
|
||||
// 返回: string 新图层标识, error 类型、资源或结构错误
|
||||
func (e *Editor) AddLayer(page int, layerType, drawParam string) (string, error) {
|
||||
layerType = editorLayerType(layerType)
|
||||
if !validEditorLayerType(layerType) {
|
||||
return "", fmt.Errorf("invalid layer type %q", layerType)
|
||||
}
|
||||
var id string
|
||||
err := e.editLayerPage(page, func(r *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
if err := e.validateLayerDrawParam(page, drawParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id = e.nextID()
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "Type"}, Value: layerType}}
|
||||
attrs.add("DrawParam", drawParam)
|
||||
content := root.child("Content")
|
||||
fragment, err := root.containerXML("Layer", attrs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if content != nil {
|
||||
return editorAppendStructure(data, content, fragment), nil
|
||||
}
|
||||
fragment, err = root.containerXML("Content", nil, fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at := root.close
|
||||
if actions := root.child("Actions"); actions != nil {
|
||||
at = actions.start
|
||||
}
|
||||
if root.open == root.end {
|
||||
return editorAppendStructure(data, root, fragment), nil
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{at, at, fragment}}), nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// SetLayerType 设置图层类型,不改变图元坐标及绘制参数;同类型顺序由文件位置决定。
|
||||
// 入参: page 页面索引, id 图层标识, layerType 图层类型
|
||||
// 返回: error 标识、类型或结构错误
|
||||
func (e *Editor) SetLayerType(page int, id, layerType string) error {
|
||||
layerType = editorLayerType(layerType)
|
||||
if !validEditorLayerType(layerType) {
|
||||
return fmt.Errorf("invalid layer type %q", layerType)
|
||||
}
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root.child("Content"), "Layer", "ID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if editorLayerType(node.attr("Type")) == layerType {
|
||||
return data, nil
|
||||
}
|
||||
return editorStructureAttribute(data, node, "Type", layerType)
|
||||
})
|
||||
}
|
||||
|
||||
// SetLayerDrawParam 设置图层绘制参数,空标识清除引用,图元局部覆盖保持不变。
|
||||
// 入参: page 页面索引, id 图层标识, drawParam 绘制参数标识
|
||||
// 返回: error 标识、资源或结构错误
|
||||
func (e *Editor) SetLayerDrawParam(page int, id, drawParam string) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root.child("Content"), "Layer", "ID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := e.validateLayerDrawParam(page, drawParam); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node.attr("DrawParam") == drawParam {
|
||||
return data, nil
|
||||
}
|
||||
return editorStructureAttribute(data, node, "DrawParam", drawParam)
|
||||
})
|
||||
}
|
||||
|
||||
// MoveLayer 调整图层在同类型图层中的零基顺序,保留其他类型及扩展节点的位置。
|
||||
// 入参: page 页面索引, id 图层标识, order 同类型目标顺序
|
||||
// 返回: error 标识、顺序或结构错误
|
||||
func (e *Editor) MoveLayer(page int, id string, order int) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root.child("Content"), "Layer", "ID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes []*editorXML
|
||||
for _, child := range root.child("Content").children {
|
||||
if child.matchesOFD("Layer") && editorLayerType(child.attr("Type")) == editorLayerType(node.attr("Type")) {
|
||||
nodes = append(nodes, child)
|
||||
}
|
||||
}
|
||||
return editorMoveStructure(data, nodes, node, order)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteLayer 删除空图层,含图元、页块或扩展子元素时拒绝;可先在Transaction中迁移图元。
|
||||
// 入参: page 页面索引, id 图层标识
|
||||
// 返回: error 标识、非空图层或结构错误
|
||||
func (e *Editor) DeleteLayer(page int, id string) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
content := root.child("Content")
|
||||
node, err := editorFindStructureID(content, "Layer", "ID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(node.children) != 0 {
|
||||
return nil, fmt.Errorf("layer %q is not empty", id)
|
||||
}
|
||||
if len(content.children) == 1 && len(content.attrs) == 0 {
|
||||
return editorPatchXML(data, []editorXMLPatch{{content.start, content.end, nil}}), nil
|
||||
}
|
||||
if len(editorStructureChildren(content, "Layer")) == 1 {
|
||||
return nil, fmt.Errorf("last layer contains a preserved Content container")
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}}), nil
|
||||
})
|
||||
}
|
||||
|
||||
// MoveObjectsToLayer 将普通页面选区按原绘制顺序追加到目标层,保持标识、坐标、扩展及有效样式。
|
||||
// 不接受模板或注解标识;页块成员须先通过已有复合编辑接口处理,失败不产生部分移动。
|
||||
// 入参: page 页面索引, ids 不重复的普通图元标识, layerID 目标图层标识
|
||||
// 返回: error 选区、继承参数或结构错误
|
||||
func (e *Editor) MoveObjectsToLayer(page int, ids []string, layerID string) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
target, err := editorFindStructureID(root.child("Content"), "Layer", "ID", layerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := make(map[string]bool)
|
||||
for _, id := range ids {
|
||||
key := editorResourceID(id)
|
||||
if key == "" || wanted[key] {
|
||||
return nil, fmt.Errorf("invalid or duplicate object ID %q", id)
|
||||
}
|
||||
wanted[key] = true
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
var added []byte
|
||||
layers := editorStructureChildren(root.child("Content"), "Layer")
|
||||
slices.SortStableFunc(layers, func(a, b *editorXML) int { return layerOrder(a.attr("Type")) - layerOrder(b.attr("Type")) })
|
||||
for _, layer := range layers {
|
||||
nodes := make(map[string]*editorXML)
|
||||
if err := editorXMLObjects(layer, nodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ordered := make([]*editorXML, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
ordered = append(ordered, node)
|
||||
}
|
||||
slices.SortFunc(ordered, func(a, b *editorXML) int { return a.start - b.start })
|
||||
for _, node := range ordered {
|
||||
key := editorResourceID(node.attr("ID"))
|
||||
if !wanted[key] {
|
||||
continue
|
||||
}
|
||||
delete(wanted, key)
|
||||
if layer == target {
|
||||
continue
|
||||
}
|
||||
if node.parent != layer {
|
||||
return nil, fmt.Errorf("object %q belongs to a PageBlock", key)
|
||||
}
|
||||
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if layer.attr("DrawParam") != target.attr("DrawParam") {
|
||||
member, err := newEditorCompositeNode(fragment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
member.drawParams = []string{layer.attr("DrawParam")}
|
||||
member.defaults, err = e.editorDrawParam(layer.attr("DrawParam"), make(map[string]bool))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if member.object.Type == "TextObject" || member.object.Type == "ImageObject" {
|
||||
styled, err := e.compositeMemberStyle(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := member.update(styled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := e.isolateCompositeStyle(member); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fragment, _, err = e.writeCompositeNode(member)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
added = append(added, fragment...)
|
||||
patches = append(patches, editorXMLPatch{node.start, node.end, nil})
|
||||
}
|
||||
}
|
||||
if len(wanted) != 0 {
|
||||
return nil, fmt.Errorf("selection contains missing ordinary page objects")
|
||||
}
|
||||
if len(added) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
if target.open == target.end {
|
||||
patches = append(patches, editorXMLContent(data, target, added))
|
||||
} else {
|
||||
patches = append(patches, editorXMLPatch{target.close, target.close, added})
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
})
|
||||
}
|
||||
|
||||
// editorLayerType 将标准省略类型规范化,不改变已明确指定的类型。
|
||||
// 入参: value 原类型
|
||||
// 返回: string 有效类型
|
||||
func editorLayerType(value string) string {
|
||||
if value == "" {
|
||||
return "Body"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// validEditorLayerType 按正文表15校验可管理的图层类型。
|
||||
// 入参: value 类型值
|
||||
// 返回: bool 是否为支持的标准类型
|
||||
func validEditorLayerType(value string) bool {
|
||||
return value == "Body" || value == "Background" || value == "Foreground"
|
||||
}
|
||||
|
||||
// validateLayerDrawParam 在目标页面资源环境中核验完整绘制参数继承链。
|
||||
// 入参: page 页面索引, id 绘制参数标识
|
||||
// 返回: error 页面或资源错误
|
||||
func (e *Editor) validateLayerDrawParam(page int, id string) error {
|
||||
if _, err := e.page(page); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := e.editorDrawParam(id, make(map[string]bool))
|
||||
return err
|
||||
}
|
||||
|
||||
// editLayerPage 在完整包快照上编辑页面原文,事务失败回滚,提交时隔离共享页面及历史版本。
|
||||
// 入参: page 页面索引, edit 页面原文修改回调
|
||||
// 返回: error 读取、修改或提交错误
|
||||
func (e *Editor) editLayerPage(page int, edit func(*Reader, []byte, *editorXML) ([]byte, error)) error {
|
||||
return e.Transaction(func(_ *Editor) error {
|
||||
if _, err := e.page(page); err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := e.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
doc, err := r.Doc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := r.ResPath(doc.Pages.Page[page].BaseLoc)
|
||||
data, err := r.readFile(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := edit(r, data, root)
|
||||
if err != nil || bytes.Equal(data, next) {
|
||||
return err
|
||||
}
|
||||
parts := map[string][]byte{name: next}
|
||||
shared := slices.ContainsFunc(doc.CommonData.TemplatePage, func(ref TemplatePage) bool { return r.ResPath(ref.BaseLoc) == name })
|
||||
for i, ref := range doc.Pages.Page {
|
||||
shared = shared || i != page && r.ResPath(ref.BaseLoc) == name
|
||||
}
|
||||
if shared {
|
||||
private := packageAvailableName(r, parts, name)
|
||||
delete(parts, name)
|
||||
parts[private] = next
|
||||
document, err := r.readFile(r.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := parseEditorXML(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refNode := entry.child("Pages").childAt("Page", page)
|
||||
parts[r.documentRoot], err = editorStructureAttribute(document, refNode, "BaseLoc", "/"+private)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return e.commitLayerTemplateParts(r, parts)
|
||||
})
|
||||
}
|
||||
|
||||
// editorStructureChildren 仅枚举当前标准命名空间中的直接子元素。
|
||||
// 入参: root 容器节点, kind 子元素名称
|
||||
// 返回: []*editorXML 文件顺序节点清单
|
||||
func editorStructureChildren(root *editorXML, kind string) []*editorXML {
|
||||
var nodes []*editorXML
|
||||
if root != nil {
|
||||
for _, node := range root.children {
|
||||
if node.matchesOFD(kind) {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// editorFindStructureID 按数值标识查找唯一直接子元素,拒绝缺失及歧义标识。
|
||||
// 入参: root 容器节点, kind 元素名称, attribute 标识属性名, id 标识值
|
||||
// 返回: *editorXML 唯一节点, error 标识错误
|
||||
func editorFindStructureID(root *editorXML, kind, attribute, id string) (*editorXML, error) {
|
||||
var found *editorXML
|
||||
if editorResourceID(id) == "" {
|
||||
return nil, fmt.Errorf("invalid %s ID %q", kind, id)
|
||||
}
|
||||
for _, node := range editorStructureChildren(root, kind) {
|
||||
if sameResourceID(node.attr(attribute), id) {
|
||||
if found != nil {
|
||||
return nil, fmt.Errorf("ambiguous %s ID %q", kind, id)
|
||||
}
|
||||
found = node
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
return nil, fmt.Errorf("%s %q not found", kind, id)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// editorStructureAttribute 局部替换或删除无命名空间属性,保留扩展及其余原文。
|
||||
// 入参: data 原文, node 目标节点, name 属性名, value 新值,空值删除属性
|
||||
// 返回: []byte 更新后的完整原文, error XML错误
|
||||
func editorStructureAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) {
|
||||
var desired *string
|
||||
if value != "" {
|
||||
desired = &value
|
||||
}
|
||||
patch, err := editorXMLAttributeChange(data, node, name, desired)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{patch}), nil
|
||||
}
|
||||
|
||||
// editorAppendStructure 向普通或自闭合容器追加已绑定命名空间的元素。
|
||||
// 入参: data 原文, node 容器节点, fragment 待追加片段
|
||||
// 返回: []byte 更新后的完整原文
|
||||
func editorAppendStructure(data []byte, node *editorXML, fragment []byte) []byte {
|
||||
if node.open == node.end {
|
||||
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, fragment)})
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{node.close, node.close, fragment}})
|
||||
}
|
||||
|
||||
// editorMoveStructure 在指定同类节点位置间移动完整原文,不触及夹杂的扩展元素。
|
||||
// 入参: data 原文, nodes 同类节点, node 被移动节点, order 目标零基顺序
|
||||
// 返回: []byte 更新后的完整原文, error 顺序错误
|
||||
func editorMoveStructure(data []byte, nodes []*editorXML, node *editorXML, order int) ([]byte, error) {
|
||||
if order < 0 || order >= len(nodes) {
|
||||
return nil, fmt.Errorf("order %d out of range", order)
|
||||
}
|
||||
from := slices.Index(nodes, node)
|
||||
if from == order {
|
||||
return data, nil
|
||||
}
|
||||
ordered := slices.Clone(nodes)
|
||||
moveEditorItem(ordered, from, order)
|
||||
patches := make([]editorXMLPatch, len(nodes))
|
||||
for i, old := range nodes {
|
||||
next := ordered[i]
|
||||
patches[i] = editorXMLPatch{old.start, old.end, data[next.start:next.end]}
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
}
|
||||
|
||||
// commitLayerTemplateParts 持久化结构与已注册资源,隔离共享文件并恢复未变图元的会话编辑状态。
|
||||
// 入参: reader 当前独立包快照, parts 文件修改集
|
||||
// 返回: error 资源、隔离或提交错误
|
||||
func (e *Editor) commitLayerTemplateParts(reader *Reader, parts map[string][]byte) error {
|
||||
ctx := context.Background()
|
||||
data := parts[reader.documentRoot]
|
||||
var err error
|
||||
if data == nil {
|
||||
data, err = reader.readFile(reader.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common := root.child("CommonData")
|
||||
if common == nil {
|
||||
return fmt.Errorf("document has no CommonData")
|
||||
}
|
||||
var definitions []byte
|
||||
registered := make(map[string]bool)
|
||||
for _, kind := range []string{"PublicRes", "DocumentRes"} {
|
||||
for _, node := range editorStructureChildren(common, kind) {
|
||||
registered[reader.ResPath(editorImportText(data, node))] = true
|
||||
}
|
||||
}
|
||||
var fonts, images []editorResource
|
||||
var spaces []ColorSpace
|
||||
for _, resource := range e.resources {
|
||||
if resource.name != "" {
|
||||
parts[resource.name] = resource.data
|
||||
}
|
||||
if resource.definition() != "" && !registered[resource.name] {
|
||||
definitions = append(definitions, common.textXML("DocumentRes", "/"+resource.name)...)
|
||||
registered[resource.name] = true
|
||||
}
|
||||
if resource.font != nil {
|
||||
fonts = append(fonts, resource)
|
||||
}
|
||||
if resource.image != nil {
|
||||
images = append(images, resource)
|
||||
}
|
||||
if resource.space != nil {
|
||||
spaces = append(spaces, *resource.space)
|
||||
}
|
||||
}
|
||||
if len(fonts)+len(images)+len(spaces) > 0 {
|
||||
encoded, err := e.encodeXML(func(x *ofdXML) { x.resources(fonts, images, spaces) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := reader.Doc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, added, err := mergePackageResources(ctx, reader, parts, doc, e.packageDirectory(), encoded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if added {
|
||||
definitions = append(definitions, common.textXML("DocumentRes", "/"+name)...)
|
||||
}
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
if maximum := common.child("MaxUnitID"); maximum != nil {
|
||||
value, _ := strconv.Atoi(editorImportText(data, maximum))
|
||||
if e.maxID > value {
|
||||
patches = append(patches, editorXMLPatch{maximum.start, maximum.end, maximum.textXML("MaxUnitID", strconv.Itoa(e.maxID))})
|
||||
}
|
||||
}
|
||||
if len(definitions) > 0 {
|
||||
at := common.close
|
||||
for _, node := range common.children {
|
||||
if node.matchesOFD("TemplatePage") || node.matchesOFD("DefaultCS") {
|
||||
at = node.start
|
||||
break
|
||||
}
|
||||
}
|
||||
patches = append(patches, editorXMLPatch{at, at, definitions})
|
||||
}
|
||||
parts[reader.documentRoot] = editorPatchXML(data, patches)
|
||||
scan := &editorFontDocument{reader: reader, index: reader.DocumentIndex()}
|
||||
if err := scan.apply(ctx, parts); err != nil {
|
||||
return err
|
||||
}
|
||||
previous := e.transactionSnapshot()
|
||||
if err := e.commitResourceSnapshot(ctx, reader); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range e.pages {
|
||||
oldIndex := slices.IndexFunc(previous.pages, func(p PageContent) bool { return p.ID == e.pages[i].ID })
|
||||
if oldIndex < 0 || len(previous.pages[oldIndex].Content.Layer) == 0 {
|
||||
continue
|
||||
}
|
||||
states := make(map[string]GraphicObject)
|
||||
for _, layer := range previous.pages[oldIndex].Content.Layer {
|
||||
for _, object := range layer.Objects {
|
||||
states[editorObjectID(object)] = object
|
||||
}
|
||||
}
|
||||
page, err := e.page(i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for j := range page.Content.Layer {
|
||||
for k := range page.Content.Layer[j].Objects {
|
||||
object := &page.Content.Layer[j].Objects[k]
|
||||
if old, ok := states[editorObjectID(*object)]; ok {
|
||||
object.state = old.state
|
||||
object.TextObject.layout = old.TextObject.layout
|
||||
object.CompositeGraphicUnit.states = old.CompositeGraphicUnit.states
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -158,8 +158,8 @@ func (e *Editor) ImportMedia(ctx context.Context, kind, format string, data []by
|
||||
// 入参: 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")
|
||||
if !slices.Contains([]string{"Image", "Audio", "Video", "Attachment"}, kind) {
|
||||
return fmt.Errorf("use typed definition or font binding operations for %s", kind)
|
||||
}
|
||||
return e.changeResource(ctx, kind, id, func(catalog *resourceCatalog, item ResourceInfo, changes map[string][]byte) error {
|
||||
reader := catalog.scan.reader
|
||||
@@ -347,10 +347,15 @@ func validateResourceImageMask(ctx context.Context, catalog *resourceCatalog, id
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// invalidateResourcePreviews 沿绘制参数和复合引用移除图片变化后失效的替换图及底纹预览
|
||||
// 入参: ctx 取消上下文, scan 当前文档结构, id 图片标识, changes 待提交文件
|
||||
// invalidateResourcePreviews 沿颜色、绘制参数和复合引用移除资源变化后失效的替换图及底纹预览
|
||||
// 入参: ctx 取消上下文, scan 当前文档结构, id 资源标识, changes 待提交文件
|
||||
// 返回: error 解析或取消错误
|
||||
func invalidateResourcePreviews(ctx context.Context, scan *editorFontDocument, id string, changes map[string][]byte) error {
|
||||
doc, err := scan.reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultCS := strconv.Itoa(doc.CommonData.DefaultCS)
|
||||
affected := map[string]bool{id: true}
|
||||
for {
|
||||
count := len(affected)
|
||||
@@ -373,7 +378,10 @@ func invalidateResourcePreviews(ctx context.Context, scan *editorFontDocument, i
|
||||
return false, nil
|
||||
}
|
||||
primary := affected[editorResourceID(node.attr("ResourceID"))] || affected[editorResourceID(node.attr("ImageMask"))]
|
||||
changed := primary || affected[editorResourceID(node.attr("Substitution"))] || affected[editorResourceID(node.attr("DrawParam"))]
|
||||
changed := primary || affected[editorResourceID(node.attr("Substitution"))] || affected[editorResourceID(node.attr("DrawParam"))] || affected[editorResourceID(node.attr("ColorSpace"))]
|
||||
if resourceColorNode(node) && node.attr("ColorSpace") == "" {
|
||||
changed = changed || affected[defaultCS]
|
||||
}
|
||||
if node.matchesOFD("DrawParam") {
|
||||
changed = changed || affected[editorResourceID(node.attr("Relative"))]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,606 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ColorSpace 获取当前文档及版本的颜色空间定义,不读取或解析ICC文件
|
||||
// 入参: ctx 取消上下文, id 资源标识
|
||||
// 返回: *ColorSpace 独立副本,Profile为包内绝对引用, error 查询或解析错误
|
||||
func (r *Reader) ColorSpace(ctx context.Context, id string) (*ColorSpace, error) {
|
||||
catalog, err := r.resourceCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item, err := findResource(catalog.items, "ColorSpace", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var space ColorSpace
|
||||
if err := catalog.styleDefinition(ctx, item, &space); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
space.ID, space.Profile = item.ID, ""
|
||||
if item.File != "" {
|
||||
space.Profile = "/" + item.File
|
||||
}
|
||||
return &space, ctx.Err()
|
||||
}
|
||||
|
||||
// DrawParam 获取当前文档的绘制参数,解析继承时拒绝缺失、类型错误及循环引用
|
||||
// 入参: ctx 取消上下文, id 资源标识, resolved 是否合并基础绘制参数
|
||||
// 返回: *DrawParam 独立副本,未解析时保留Relative, error 查询或继承错误
|
||||
func (r *Reader) DrawParam(ctx context.Context, id string, resolved bool) (*DrawParam, error) {
|
||||
catalog, err := r.resourceCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var chain []DrawParam
|
||||
seen := make(map[string]bool)
|
||||
for {
|
||||
item, err := findResource(catalog.items, "DrawParam", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seen[item.ID] {
|
||||
return nil, fmt.Errorf("cyclic draw parameter %s", item.ID)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
var draw DrawParam
|
||||
if err := catalog.styleDefinition(ctx, item, &draw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
draw.ID = item.ID
|
||||
if !resolved {
|
||||
return &draw, nil
|
||||
}
|
||||
if draw.ResourceID != "" || draw.Link != "" || draw.BaseLoc != "" {
|
||||
return nil, fmt.Errorf("external draw parameter cannot be resolved")
|
||||
}
|
||||
chain = append(chain, draw)
|
||||
if draw.Relative == "" {
|
||||
break
|
||||
}
|
||||
id = draw.Relative
|
||||
}
|
||||
result := &DrawParam{}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
result = mergeDrawParam(*result, &chain[i])
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
// styleDefinition 从声明原文解码样式定义,补齐继承的XML命名空间
|
||||
// 入参: ctx 取消上下文, item 目录条目, target 解码目标
|
||||
// 返回: error 取消或XML解析错误
|
||||
func (c *resourceCatalog) styleDefinition(ctx context.Context, item ResourceInfo, target any) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.nodes[item.Kind+"/"+item.ID]
|
||||
data, err := editorXMLStandalone(entry.file.data[entry.node.start:entry.node.end], entry.node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return xml.Unmarshal(data, target)
|
||||
}
|
||||
|
||||
// ColorSpace 读取编辑器中的颜色空间,包括尚未绑定图元的新注册定义
|
||||
// 入参: id 颜色空间标识
|
||||
// 返回: *ColorSpace 独立副本,Profile由库管理, error 资源不存在
|
||||
func (e *Editor) ColorSpace(id string) (*ColorSpace, error) {
|
||||
for _, resource := range e.resources {
|
||||
if resource.space != nil && sameResourceID(resource.space.ID, id) {
|
||||
space := *resource.space
|
||||
space.Palette, space.profile = slices.Clone(space.Palette), nil
|
||||
return &space, nil
|
||||
}
|
||||
}
|
||||
if e.source != nil {
|
||||
return e.source.reader.ColorSpace(context.Background(), id)
|
||||
}
|
||||
return nil, fmt.Errorf("color space %q not found", id)
|
||||
}
|
||||
|
||||
// ImportColorSpace 持久注册颜色空间及可选ICC,未绑定图元时仍保留声明并支持撤销
|
||||
// 入参: ctx 取消上下文, space 类型、位深及调色板,ID和Profile由库生成, profile 完整ICC或nil
|
||||
// 返回: string 新资源标识, error 定义校验或隔离提交错误
|
||||
func (e *Editor) ImportColorSpace(ctx context.Context, space ColorSpace, profile []byte) (string, error) {
|
||||
return e.importResourceStyle(ctx, func(working *Editor) (string, []byte, map[string][]byte, error) {
|
||||
id, err := working.AddColorSpace(space, profile)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
resource := working.resources[len(working.resources)-1]
|
||||
data, err := working.encodeXML(func(x *ofdXML) { x.resources(nil, nil, []ColorSpace{*resource.space}) })
|
||||
parts := make(map[string][]byte)
|
||||
if resource.name != "" {
|
||||
parts[resource.name] = bytes.Clone(resource.data)
|
||||
}
|
||||
return id, data, parts, err
|
||||
})
|
||||
}
|
||||
|
||||
// ImportDrawParam 持久注册共享绘制参数,未绑定图元时仍保留声明并支持撤销
|
||||
// 入参: ctx 取消上下文, draw 参数定义,ID由库生成,引用可来自当前文档或编辑器的待注册资源
|
||||
// 返回: string 新资源标识, error 定义校验或隔离提交错误
|
||||
func (e *Editor) ImportDrawParam(ctx context.Context, draw DrawParam) (string, error) {
|
||||
return e.importResourceStyle(ctx, func(working *Editor) (string, []byte, map[string][]byte, error) {
|
||||
id, err := working.AddDrawParam(draw)
|
||||
if err != nil {
|
||||
return "", nil, nil, err
|
||||
}
|
||||
resource := working.resources[len(working.resources)-1]
|
||||
return id, resource.data, make(map[string][]byte), nil
|
||||
})
|
||||
}
|
||||
|
||||
// importResourceStyle 在独立快照中生成并合并样式声明,更新最大标识并隔离共享资源文件
|
||||
// 入参: ctx 取消上下文, create 已校验定义及附属文件的生成操作
|
||||
// 返回: string 持久资源标识, error 生成、引用或提交错误
|
||||
func (e *Editor) importResourceStyle(ctx context.Context, create func(*Editor) (string, []byte, map[string][]byte, error)) (string, error) {
|
||||
reader, err := e.resourceStyleReader(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
working, err := reader.Editor()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
id, data, parts, err := create(working)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := applyResourceStyleParts(ctx, reader, working.maxID, data, parts, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
catalog, err := reader.resourceCatalog(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := validateResourceStyleGraph(ctx, catalog, id, nil); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, e.commitResourceSnapshot(ctx, reader)
|
||||
}
|
||||
|
||||
// resourceStyleReader 为样式资源操作补齐待注册声明及附属文件,不改变公开快照的按引用导出语义
|
||||
// 入参: ctx 取消上下文
|
||||
// 返回: *Reader 包含待注册字体、颜色空间及参数依赖的独立快照, error 读取或隔离错误
|
||||
func (e *Editor) resourceStyleReader(ctx context.Context) (*Reader, error) {
|
||||
reader, err := e.ReaderContext(ctx)
|
||||
if err != nil || len(e.resources) == 0 {
|
||||
return reader, err
|
||||
}
|
||||
catalog, err := reader.resourceCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := make(map[string]bool)
|
||||
for _, file := range catalog.scan.files {
|
||||
root, err := parseEditorXMLContext(ctx, file.data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !root.matchesOFD("Res") {
|
||||
continue
|
||||
}
|
||||
for _, group := range root.children {
|
||||
for _, node := range group.children {
|
||||
if node.matchesOFD(editorResourceElement(group.name.Local)) {
|
||||
known[editorResourceID(node.attr("ID"))] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var fonts, images []editorResource
|
||||
var spaces []ColorSpace
|
||||
var indexes []string
|
||||
parts := make(map[string][]byte)
|
||||
for _, resource := range e.resources {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id := resource.definition()
|
||||
switch {
|
||||
case resource.font != nil:
|
||||
id = resource.font.ID
|
||||
case resource.image != nil:
|
||||
id = resource.image.ID
|
||||
case resource.space != nil:
|
||||
id = resource.space.ID
|
||||
}
|
||||
if id == "" || known[editorResourceID(id)] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case resource.font != nil:
|
||||
fonts = append(fonts, resource)
|
||||
case resource.image != nil:
|
||||
images = append(images, resource)
|
||||
case resource.space != nil:
|
||||
spaces = append(spaces, *resource.space)
|
||||
default:
|
||||
indexes = append(indexes, resource.name)
|
||||
}
|
||||
if resource.name != "" {
|
||||
parts[resource.name] = bytes.Clone(resource.data)
|
||||
}
|
||||
}
|
||||
if len(fonts)+len(images)+len(spaces)+len(indexes) == 0 {
|
||||
return reader, ctx.Err()
|
||||
}
|
||||
var data []byte
|
||||
if len(fonts)+len(images)+len(spaces) != 0 {
|
||||
data, err = e.encodeXML(func(x *ofdXML) { x.resources(fonts, images, spaces) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := applyResourceStyleParts(ctx, reader, e.maxID, data, parts, indexes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reader, ctx.Err()
|
||||
}
|
||||
|
||||
// applyResourceStyleParts 合并资源声明及独立索引,统一更新最大标识并隔离其他文档和历史版本
|
||||
// 入参: ctx 取消上下文, reader 独立快照, maximumID 最大注册标识, data 合并声明, parts 附属文件, indexes 独立索引路径
|
||||
// 返回: error 结构、读取或隔离错误
|
||||
func applyResourceStyleParts(ctx context.Context, reader *Reader, maximumID int, data []byte, parts map[string][]byte, indexes []string) error {
|
||||
doc, err := reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) != 0 {
|
||||
name, added, err := mergePackageResources(ctx, reader, parts, doc, path.Dir(reader.documentRoot), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if added {
|
||||
indexes = append(slices.Clone(indexes), name)
|
||||
}
|
||||
}
|
||||
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")
|
||||
current, err := strconv.Atoi(editorImportText(content, maximum))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patches := []editorXMLPatch{{maximum.start, maximum.end, maximum.textXML("MaxUnitID", strconv.Itoa(max(current, maximumID)))}}
|
||||
if len(indexes) != 0 {
|
||||
position := common.close
|
||||
for _, child := range common.children {
|
||||
if child.matchesOFD("TemplatePage") || child.matchesOFD("DefaultCS") {
|
||||
position = child.start
|
||||
break
|
||||
}
|
||||
}
|
||||
var refs []byte
|
||||
for _, name := range indexes {
|
||||
refs = append(refs, common.textXML("DocumentRes", "/"+name)...)
|
||||
}
|
||||
patches = append(patches, editorXMLPatch{position, position, refs})
|
||||
}
|
||||
parts[entry] = editorPatchXML(content, patches)
|
||||
scan := &editorFontDocument{reader: reader, index: reader.DocumentIndex()}
|
||||
return scan.apply(ctx, parts)
|
||||
}
|
||||
|
||||
// ReplaceColorSpace 原子替换颜色空间,保留当前文档内的资源标识并隔离其他文档及历史版本
|
||||
// 入参: ctx 取消上下文, id 既有标识, space 新定义,ID和Profile由库管理, profile 完整ICC,nil移除配置
|
||||
// 返回: error 位深、调色板、ICC或既有颜色不兼容错误,失败不改变文档,成功支持撤销
|
||||
func (e *Editor) ReplaceColorSpace(ctx context.Context, id string, space ColorSpace, profile []byte) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
working := NewEditor()
|
||||
if _, err := working.AddColorSpace(space, profile); err != nil {
|
||||
return err
|
||||
}
|
||||
space = *working.resources[0].space
|
||||
return e.changeResourceStyle(ctx, "ColorSpace", id, func(catalog *resourceCatalog, item ResourceInfo, changes map[string][]byte) error {
|
||||
space.ID = item.ID
|
||||
if err := validateResourceColors(ctx, catalog, space); err != nil {
|
||||
return err
|
||||
}
|
||||
space.Profile = ""
|
||||
if len(profile) != 0 {
|
||||
name := packageAvailableName(catalog.scan.reader, changes, path.Join(e.packageDirectory(), "Res", "ColorSpaces", "ColorSpace_"+item.ID+".icc"))
|
||||
space.Profile = "/" + name
|
||||
changes[name] = bytes.Clone(profile)
|
||||
}
|
||||
data, err := working.encodeXML(func(x *ofdXML) { x.resources(nil, nil, []ColorSpace{space}) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node := root.child("ColorSpaces").child("ColorSpace")
|
||||
fragment, err := editorXMLStandalone(data[node.start:node.end], node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceResourceStyle(ctx, catalog, item, fragment, changes)
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceDrawParam 原子替换共享绘制参数,保留全部既有绑定及图元的局部覆盖
|
||||
// 入参: ctx 取消上下文, id 既有标识, draw 新定义,ID由库管理,不接受外部定义引用
|
||||
// 返回: error 参数、依赖或循环错误,失败不改变文档,成功隔离其他文档及历史版本并支持撤销
|
||||
func (e *Editor) ReplaceDrawParam(ctx context.Context, id string, draw DrawParam) error {
|
||||
return e.changeResourceStyle(ctx, "DrawParam", id, func(catalog *resourceCatalog, item ResourceInfo, changes map[string][]byte) error {
|
||||
working, err := catalog.scan.reader.Editor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
added, err := working.AddDrawParam(draw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragment, err := working.drawParamXML(added)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, fragment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fragment, err = editorXMLAttribute(fragment, root, "ID", item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceResourceStyle(ctx, catalog, item, fragment, changes)
|
||||
})
|
||||
}
|
||||
|
||||
// changeResourceStyle 在补齐待注册资源的快照中替换定义,依赖与目标仅在全部校验通过后一次提交
|
||||
// 入参: ctx 取消上下文, kind 资源类型, id 目标标识, replace 定义替换操作
|
||||
// 返回: error 引用、校验或提交错误,失败不改变注册资源和撤销状态
|
||||
func (e *Editor) changeResourceStyle(ctx context.Context, kind, id string, replace func(*resourceCatalog, ResourceInfo, map[string][]byte) error) error {
|
||||
reader, err := e.resourceStyleReader(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
catalog, err := reader.resourceCatalog(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := findResource(catalog.items, kind, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !item.Complete {
|
||||
return fmt.Errorf("resource references contain unsupported content")
|
||||
}
|
||||
changes := make(map[string][]byte)
|
||||
if err := replace(catalog, item, 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)
|
||||
}
|
||||
|
||||
// replaceResourceStyle 更新声明原文并验证资源依赖,清除受影响的复合及底纹预览
|
||||
// 入参: ctx 取消上下文, catalog 当前目录, item 目标条目, fragment 新声明, changes 待提交修改
|
||||
// 返回: error 不支持的定义、引用或预览处理错误
|
||||
func replaceResourceStyle(ctx context.Context, catalog *resourceCatalog, item ResourceInfo, fragment []byte, changes map[string][]byte) error {
|
||||
target := catalog.nodes[item.Kind+"/"+item.ID]
|
||||
if item.Kind == "DrawParam" && (target.node.attr("Link") != "" || target.node.attr("BaseLoc") != "" || target.node.attr("ResourceID") != "") {
|
||||
return fmt.Errorf("external draw parameter cannot be replaced")
|
||||
}
|
||||
changes[target.file.name] = editorPatchXML(target.file.data, []editorXMLPatch{{target.node.start, target.node.end, fragment}})
|
||||
if err := validateResourceStyleGraph(ctx, catalog, item.ID, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
return invalidateResourcePreviews(ctx, catalog.scan, item.ID, changes)
|
||||
}
|
||||
|
||||
// resourceColorNode 判断可携带基本颜色或复杂填充的标准节点
|
||||
// 入参: node XML节点
|
||||
// 返回: bool 是否为颜色节点
|
||||
func resourceColorNode(node *editorXML) bool {
|
||||
return node.matchesOFD(node.name.Local) && slices.Contains([]string{"FillColor", "StrokeColor", "BorderColor", "Color", "BackColor"}, node.name.Local)
|
||||
}
|
||||
|
||||
// validateResourceColors 校验显式及默认颜色空间的既有颜色,防止位深或调色板更新使引用失效
|
||||
// 入参: ctx 取消上下文, catalog 当前资源目录, space 替换后的颜色空间
|
||||
// 返回: error 不兼容的颜色值或调色板索引
|
||||
func validateResourceColors(ctx context.Context, catalog *resourceCatalog, space ColorSpace) error {
|
||||
doc, err := catalog.scan.reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
working := NewEditor()
|
||||
space.Profile, space.profile = "", nil
|
||||
working.resources = []editorResource{{space: &space}}
|
||||
for _, file := range catalog.scan.files {
|
||||
root, err := parseEditorXMLContext(ctx, file.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var visit func(*editorXML) error
|
||||
visit = func(node *editorXML) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !node.matchesOFD(node.name.Local) {
|
||||
return nil
|
||||
}
|
||||
id := node.attr("ColorSpace")
|
||||
if id == "" {
|
||||
id = strconv.Itoa(doc.CommonData.DefaultCS)
|
||||
}
|
||||
if resourceColorNode(node) && sameResourceID(id, space.ID) {
|
||||
var paint FillColor
|
||||
if err := xml.Unmarshal(file.data[node.start:node.end], &paint); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := working.Color(&FillColor{Value: paint.Value, Index: paint.Index, Alpha: paint.Alpha, ColorSpace: space.ID}); err != nil {
|
||||
return fmt.Errorf("color in %s: %w", file.name, err)
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if err := visit(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// validateResourceStyleGraph 检查替换定义的传递依赖,拒绝缺失资源、错误类型及跨底纹复合资源的循环
|
||||
// 入参: ctx 取消上下文, catalog 当前目录, id 替换标识, changes 待提交修改
|
||||
// 返回: error 资源引用或循环错误
|
||||
func validateResourceStyleGraph(ctx context.Context, catalog *resourceCatalog, id string, changes map[string][]byte) error {
|
||||
types := make(map[string]string)
|
||||
graph := make(map[string][]string)
|
||||
roots := make(map[string]*editorXML)
|
||||
contents := make(map[string][]byte)
|
||||
for name, file := range catalog.scan.files {
|
||||
data, ok := changes[name]
|
||||
if !ok {
|
||||
data = file.data
|
||||
}
|
||||
root, err := parseEditorXMLContext(ctx, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roots[name], contents[name] = root, data
|
||||
if !root.matchesOFD("Res") {
|
||||
continue
|
||||
}
|
||||
for _, group := range root.children {
|
||||
for _, node := range group.children {
|
||||
if node.matchesOFD(editorResourceElement(group.name.Local)) {
|
||||
key := editorResourceID(node.attr("ID"))
|
||||
if key == "" || types[key] != "" {
|
||||
return fmt.Errorf("invalid or duplicate resource ID %q", key)
|
||||
}
|
||||
types[key] = node.name.Local
|
||||
location := ""
|
||||
if node.matchesOFD("CompositeGraphicUnit") {
|
||||
location = node.attr("BaseLoc")
|
||||
} else if node.matchesOFD("DrawParam") {
|
||||
location = node.attr("Link")
|
||||
}
|
||||
if location != "" {
|
||||
file := "file/" + resolveResourcePath(name, root.attr("BaseLoc"), location)
|
||||
graph[key] = append(graph[key], file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range roots {
|
||||
types["file/"+name] = "File"
|
||||
}
|
||||
for name, root := range roots {
|
||||
var visit func(*editorXML) error
|
||||
visit = func(node *editorXML) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !node.matchesOFD(node.name.Local) {
|
||||
return nil
|
||||
}
|
||||
owner := resourceReferenceOwner(node)
|
||||
if !root.matchesOFD("Res") {
|
||||
owner = "file/" + name
|
||||
}
|
||||
if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) {
|
||||
graph[owner] = append(graph[owner], editorResourceID(editorImportText(contents[name], node)))
|
||||
}
|
||||
for _, attr := range node.attrs {
|
||||
if attr.Name.Space == "" && attr.Value != "" && slices.Contains([]string{"Relative", "DrawParam", "ColorSpace", "Font", "ResourceID", "ImageMask", "Substitution", "Thumbnail"}, attr.Name.Local) {
|
||||
key := editorResourceID(attr.Value)
|
||||
graph[owner] = append(graph[owner], key)
|
||||
kind := ""
|
||||
switch attr.Name.Local {
|
||||
case "Relative", "DrawParam":
|
||||
kind = "DrawParam"
|
||||
case "ColorSpace", "Font":
|
||||
kind = attr.Name.Local
|
||||
}
|
||||
if kind != "" && types[key] != kind {
|
||||
graph[owner] = append(graph[owner], "")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, child := range node.children {
|
||||
if err := visit(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state := make(map[string]int)
|
||||
var visit func(string) error
|
||||
visit = func(key string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if types[key] == "" || state[key] == 1 {
|
||||
return fmt.Errorf("missing, mistyped or cyclic style resource %q", key)
|
||||
}
|
||||
if state[key] == 2 {
|
||||
return nil
|
||||
}
|
||||
state[key] = 1
|
||||
for _, dependency := range graph[key] {
|
||||
if err := visit(dependency); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state[key] = 2
|
||||
return nil
|
||||
}
|
||||
return visit(id)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
// 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"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path"
|
||||
)
|
||||
|
||||
// Templates 返回当前文档及所选版本的模板定义副本,省略的ZOrder返回Background。
|
||||
// 返回: []TemplatePage 独立模板清单, error 文档读取错误
|
||||
func (e *Editor) Templates() ([]TemplatePage, error) {
|
||||
if e.source == nil {
|
||||
return []TemplatePage{}, nil
|
||||
}
|
||||
result := append([]TemplatePage{}, e.source.document.CommonData.TemplatePage...)
|
||||
for i := range result {
|
||||
result[i].ZOrder = editorTemplateOrder(result[i].ZOrder)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Templates 直接返回当前文档及版本的模板清单,不创建编辑器,省略的ZOrder返回Background。
|
||||
// 返回: []TemplatePage 独立模板定义副本, error 文档读取错误
|
||||
func (r *Reader) Templates() ([]TemplatePage, error) {
|
||||
doc, err := r.Doc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := append([]TemplatePage{}, doc.CommonData.TemplatePage...)
|
||||
for i := range result {
|
||||
result[i].ZOrder = editorTemplateOrder(result[i].ZOrder)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PageTemplates 按文件顺序返回普通页面模板引用,ZOrder为空表示沿用模板默认层次。
|
||||
// 入参: page 零基页面索引
|
||||
// 返回: []Template 独立引用清单, error 页面读取错误
|
||||
func (e *Editor) PageTemplates(page int) ([]Template, error) {
|
||||
p, err := e.page(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]Template{}, p.Template...), nil
|
||||
}
|
||||
|
||||
// BindTemplate 在页面末尾绑定模板,空ZOrder沿用模板默认层次,重复绑定拒绝。
|
||||
// 入参: page 页面索引, id 模板标识, zOrder 页面覆盖层次
|
||||
// 返回: error 标识、层次或重复绑定错误
|
||||
func (e *Editor) BindTemplate(page int, id, zOrder string) error {
|
||||
if !validEditorTemplateOrder(zOrder) {
|
||||
return fmt.Errorf("invalid template ZOrder %q", zOrder)
|
||||
}
|
||||
return e.editLayerPage(page, func(r *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
template, err := editorTemplateDefinition(r, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range editorStructureChildren(root, "Template") {
|
||||
if sameResourceID(node.attr("TemplateID"), id) {
|
||||
return nil, fmt.Errorf("template %q is already bound", id)
|
||||
}
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "TemplateID"}, Value: template.ID}}
|
||||
attrs.add("ZOrder", zOrder)
|
||||
fragment, err := root.containerXML("Template", attrs, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at := root.open
|
||||
for _, node := range editorStructureChildren(root, "Template") {
|
||||
at = node.end
|
||||
}
|
||||
if root.open == root.end {
|
||||
return editorAppendStructure(data, root, fragment), nil
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{at, at, fragment}}), nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnbindTemplate 解除当前页面的唯一指定模板引用,保留模板定义与其他页面绑定。
|
||||
// 入参: page 页面索引, id 模板标识
|
||||
// 返回: error 引用或结构错误
|
||||
func (e *Editor) UnbindTemplate(page int, id string) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root, "Template", "TemplateID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}}), nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetPageTemplateZOrder 设置页面模板的前景或背景层次,空值删除覆盖以沿用模板默认值。
|
||||
// 入参: page 页面索引, id 模板标识, zOrder 页面覆盖层次
|
||||
// 返回: error 引用、层次或结构错误
|
||||
func (e *Editor) SetPageTemplateZOrder(page int, id, zOrder string) error {
|
||||
if !validEditorTemplateOrder(zOrder) {
|
||||
return fmt.Errorf("invalid template ZOrder %q", zOrder)
|
||||
}
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root, "Template", "TemplateID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node.attr("ZOrder") == zOrder {
|
||||
return data, nil
|
||||
}
|
||||
return editorStructureAttribute(data, node, "ZOrder", zOrder)
|
||||
})
|
||||
}
|
||||
|
||||
// MovePageTemplate 调整引用在页面全部模板引用中的零基文件顺序,同层次引用按此顺序绘制。
|
||||
// 入参: page 页面索引, id 模板标识, order 全部引用中的目标顺序
|
||||
// 返回: error 引用、顺序或结构错误
|
||||
func (e *Editor) MovePageTemplate(page int, id string, order int) error {
|
||||
return e.editLayerPage(page, func(_ *Reader, data []byte, root *editorXML) ([]byte, error) {
|
||||
node, err := editorFindStructureID(root, "Template", "TemplateID", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorMoveStructure(data, editorStructureChildren(root, "Template"), node, order)
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTemplateFromPage 复制指定普通页面自身内容为模板,不改变源页或自动绑定,空层次采用Background。
|
||||
// 复用页面复制的标识重映射并保留原XML及资源;模板引用不递归复制,注解和签章不属于模板内容。
|
||||
// 模板编辑可使用普通页面副本及现有编辑接口,再通过ReplaceTemplateFromPage保持模板身份提交。
|
||||
// 入参: page 源页面索引, name 模板名称, zOrder 默认前景背景层次
|
||||
// 返回: string 新模板标识, error 页面、标识重映射或提交错误
|
||||
func (e *Editor) CreateTemplateFromPage(page int, name, zOrder string) (string, error) {
|
||||
if !validEditorTemplateOrder(zOrder) {
|
||||
return "", fmt.Errorf("invalid template ZOrder %q", zOrder)
|
||||
}
|
||||
var id string
|
||||
err := e.Transaction(func(_ *Editor) error {
|
||||
if _, err := e.page(page); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := e.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
doc, err := r.Doc()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source := r.ResPath(doc.Pages.Page[page].BaseLoc)
|
||||
data, err := r.readFile(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
for _, node := range editorStructureChildren(root, "Template") {
|
||||
patches = append(patches, editorXMLPatch{node.start, node.end, nil})
|
||||
}
|
||||
if root.child("Area") == nil {
|
||||
content, err := r.PageContentByIndex(page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var fields []byte
|
||||
for _, field := range [][2]string{{"PhysicalBox", content.Area.PhysicalBox}, {"ApplicationBox", content.Area.ApplicationBox}, {"ContentBox", content.Area.ContentBox}, {"BleedBox", content.Area.BleedBox}} {
|
||||
if field[1] != "" {
|
||||
fields = append(fields, root.textXML(field[0], field[1])...)
|
||||
}
|
||||
}
|
||||
area, err := root.containerXML("Area", nil, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := root.close
|
||||
for _, node := range root.children {
|
||||
if node.matchesOFD("Content") || node.matchesOFD("Actions") {
|
||||
at = node.start
|
||||
break
|
||||
}
|
||||
}
|
||||
if root.open == root.end {
|
||||
data = editorAppendStructure(data, root, area)
|
||||
} else {
|
||||
patches = append(patches, editorXMLPatch{at, at, area})
|
||||
}
|
||||
}
|
||||
data = editorPatchXML(data, patches)
|
||||
root, err = parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !editorXMLCopyable(root) {
|
||||
return fmt.Errorf("template source contains unsupported identifiers")
|
||||
}
|
||||
ids := make(map[string]string)
|
||||
if err := e.collectCompositeIDs(root, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err = editorXMLRemapIDs(data, root, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = e.nextID()
|
||||
location := packageAvailableName(r, nil, path.Join(path.Dir(source), "Template_"+id+".xml"))
|
||||
document, err := r.readFile(r.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry, err := parseEditorXML(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common := entry.child("CommonData")
|
||||
if common == nil {
|
||||
return fmt.Errorf("document has no CommonData")
|
||||
}
|
||||
attrs := ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: id}, {Name: xml.Name{Local: "BaseLoc"}, Value: "/" + location}, {Name: xml.Name{Local: "ZOrder"}, Value: editorTemplateOrder(zOrder)}}
|
||||
attrs.add("Name", name)
|
||||
fragment, err := common.containerXML("TemplatePage", attrs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := common.close
|
||||
if node := common.child("DefaultCS"); node != nil {
|
||||
at = node.start
|
||||
}
|
||||
document = editorPatchXML(document, []editorXMLPatch{{at, at, fragment}})
|
||||
return e.commitLayerTemplateParts(r, map[string][]byte{location: data, r.documentRoot: document})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RenameTemplate 修改当前文档模板名称,空字符串清空名称,不修改共享模板内容。
|
||||
// 入参: id 模板标识, name 模板名称
|
||||
// 返回: error 标识或结构错误
|
||||
func (e *Editor) RenameTemplate(id, name string) error {
|
||||
return e.editTemplateDefinition(id, func(_ *Reader, data []byte, node *editorXML) ([]byte, error) {
|
||||
if node.attr("Name") == name {
|
||||
return data, nil
|
||||
}
|
||||
return editorStructureAttribute(data, node, "Name", name)
|
||||
})
|
||||
}
|
||||
|
||||
// SetTemplateZOrder 设置模板默认前景背景层次,不修改页面显式覆盖,空值采用Background。
|
||||
// 入参: id 模板标识, zOrder 默认前景背景层次
|
||||
// 返回: error 标识、层次或结构错误
|
||||
func (e *Editor) SetTemplateZOrder(id, zOrder string) error {
|
||||
if !validEditorTemplateOrder(zOrder) {
|
||||
return fmt.Errorf("invalid template ZOrder %q", zOrder)
|
||||
}
|
||||
zOrder = editorTemplateOrder(zOrder)
|
||||
return e.editTemplateDefinition(id, func(_ *Reader, data []byte, node *editorXML) ([]byte, error) {
|
||||
if editorTemplateOrder(node.attr("ZOrder")) == zOrder {
|
||||
return data, nil
|
||||
}
|
||||
return editorStructureAttribute(data, node, "ZOrder", zOrder)
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceTemplateFromPage 复制普通页自身内容替换模板内容,保持模板标识、名称、层次及全部绑定。
|
||||
// 源页及其他文档和历史版本保持不变,整个替换仅产生一条撤销记录。
|
||||
// 入参: id 目标模板标识, page 普通源页面索引
|
||||
// 返回: error 标识、复制或提交错误
|
||||
func (e *Editor) ReplaceTemplateFromPage(id string, page int) error {
|
||||
return e.Transaction(func(_ *Editor) error {
|
||||
list, err := e.Templates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, item := range list {
|
||||
if sameResourceID(item.ID, id) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("template %q not found", id)
|
||||
}
|
||||
created, err := e.CreateTemplateFromPage(page, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := e.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := r.readFile(r.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common := root.child("CommonData")
|
||||
old, err := editorFindStructureID(common, "TemplatePage", "ID", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := editorFindStructureID(common, "TemplatePage", "ID", created)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patch, err := editorXMLAttributePatch(data, old, "BaseLoc", next.attr("BaseLoc"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = editorPatchXML(data, []editorXMLPatch{patch, {next.start, next.end, nil}})
|
||||
return e.commitLayerTemplateParts(r, map[string][]byte{r.documentRoot: data})
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteTemplate 删除当前文档未被普通页面引用的模板定义,保留可能被其他文档、版本或扩展引用的文件。
|
||||
// 入参: id 模板标识
|
||||
// 返回: error 标识、被引用或结构错误
|
||||
func (e *Editor) DeleteTemplate(id string) error {
|
||||
return e.editTemplateDefinition(id, func(r *Reader, data []byte, node *editorXML) ([]byte, error) {
|
||||
doc, err := r.Doc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range doc.Pages.Page {
|
||||
page, err := r.PageContentByIndex(i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ref := range page.Template {
|
||||
if sameResourceID(ref.TemplateID, id) {
|
||||
return nil, fmt.Errorf("template %q is referenced by page %d", id, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}}), nil
|
||||
})
|
||||
}
|
||||
|
||||
// editorTemplateOrder 将省略的模板默认层次规范化为Background。
|
||||
// 入参: value 原始层次
|
||||
// 返回: string 有效默认层次
|
||||
func editorTemplateOrder(value string) string {
|
||||
if value == "" {
|
||||
return "Background"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// validEditorTemplateOrder 校验模板前景背景枚举及未指定状态。
|
||||
// 入参: value 层次值
|
||||
// 返回: bool 是否有效
|
||||
func validEditorTemplateOrder(value string) bool {
|
||||
return value == "" || value == "Background" || value == "Foreground"
|
||||
}
|
||||
|
||||
// editorTemplateDefinition 查找当前阅读器中的唯一模板定义,拒绝缺失及歧义标识。
|
||||
// 入参: reader 当前阅读器, id 模板标识
|
||||
// 返回: TemplatePage 定义副本, error 文档或标识错误
|
||||
func editorTemplateDefinition(reader *Reader, id string) (TemplatePage, error) {
|
||||
doc, err := reader.Doc()
|
||||
if err != nil {
|
||||
return TemplatePage{}, err
|
||||
}
|
||||
var result TemplatePage
|
||||
for _, item := range doc.CommonData.TemplatePage {
|
||||
if sameResourceID(item.ID, id) {
|
||||
if result.ID != "" {
|
||||
return TemplatePage{}, fmt.Errorf("ambiguous template ID %q", id)
|
||||
}
|
||||
result = item
|
||||
}
|
||||
}
|
||||
if result.ID == "" {
|
||||
return result, fmt.Errorf("template %q not found", id)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// editTemplateDefinition 在事务快照中局部修改模板声明,保留命名空间、扩展及其他版本。
|
||||
// 入参: id 模板标识, edit 文档原文修改回调
|
||||
// 返回: error 读取、修改或提交错误
|
||||
func (e *Editor) editTemplateDefinition(id string, edit func(*Reader, []byte, *editorXML) ([]byte, error)) error {
|
||||
return e.Transaction(func(_ *Editor) error {
|
||||
r, err := e.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
data, err := r.readFile(r.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := editorFindStructureID(root.child("CommonData"), "TemplatePage", "ID", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := edit(r, data, node)
|
||||
if err != nil || bytes.Equal(data, next) {
|
||||
return err
|
||||
}
|
||||
return e.commitLayerTemplateParts(r, map[string][]byte{r.documentRoot: next})
|
||||
})
|
||||
}
|
||||
+17
-7
@@ -24,9 +24,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResourceInfo 描述当前文档及所选版本的字体、图片、音视频或附件,Size为文件字节数
|
||||
// Kind为Font、Image、Audio、Video或Attachment,标识仅在当前文档及同类资源内有效
|
||||
// Complete表示已完整识别引用;不包含其他文档和历史版本的引用,空File表示外部字体
|
||||
// ResourceInfo 描述当前文档及所选版本的资源,Size为文件字节数
|
||||
// Kind为Font、Image、Audio、Video、Attachment、ColorSpace或DrawParam
|
||||
// Complete表示已完整识别引用;不包含其他文档和历史版本的引用
|
||||
// ColorSpace的File为ICC文件,未声明ICC及内联DrawParam的File为空且Missing为false
|
||||
// Missing表示声明的文件不在包内,与外部字体及零字节文件区分,不表示文件解码结果
|
||||
type ResourceInfo struct {
|
||||
Kind string `json:"kind"`
|
||||
@@ -79,7 +80,8 @@ func (r *Reader) Resources(ctx context.Context) ([]ResourceInfo, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OpenResource 打开当前资源的原始文件,外部字体无可导出的内嵌文件
|
||||
// OpenResource 打开当前资源的原始文件,ColorSpace导出ICC,无ICC或内联DrawParam不提供文件流
|
||||
// 外部字体同样无可导出的内嵌文件,定义读取使用ColorSpace或DrawParam
|
||||
// 入参: ctx 取消上下文, kind 资源类型, id 资源标识
|
||||
// 返回: io.ReadCloser 文件流,由调用方关闭, error 资源或文件错误
|
||||
func (r *Reader) OpenResource(ctx context.Context, kind, id string) (io.ReadCloser, error) {
|
||||
@@ -152,6 +154,14 @@ func (r *Reader) resourceCatalog(ctx context.Context) (*resourceCatalog, error)
|
||||
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 if group.matchesOFD("ColorSpaces") && node.matchesOFD("ColorSpace") {
|
||||
item.Kind, item.Name = "ColorSpace", node.attr("Type")
|
||||
if profile := strings.TrimSpace(node.attr("Profile")); profile != "" {
|
||||
item.File = resolveResourcePath(name, root.attr("BaseLoc"), profile)
|
||||
item.Format = "ICC"
|
||||
}
|
||||
} else if group.matchesOFD("DrawParams") && node.matchesOFD("DrawParam") {
|
||||
item.Kind, item.Name = "DrawParam", "DrawParam "+item.ID
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
@@ -205,7 +215,7 @@ func (r *Reader) resourceCatalog(ctx context.Context) (*resourceCatalog, error)
|
||||
add(attr.Name.Local, attr.Value)
|
||||
}
|
||||
}
|
||||
if slices.Contains([]string{"Thumbnail", "Substitution"}, node.name.Local) {
|
||||
if slices.Contains([]string{"Thumbnail", "Substitution", "DefaultCS"}, node.name.Local) {
|
||||
add(node.name.Local, strings.TrimSpace(editorImportText(file.data, node)))
|
||||
}
|
||||
for _, child := range node.children {
|
||||
@@ -245,7 +255,7 @@ func (r *Reader) resourceCatalog(ctx context.Context) (*resourceCatalog, error)
|
||||
item.Missing = true
|
||||
}
|
||||
} else {
|
||||
item.Missing = item.Kind != "Font"
|
||||
item.Missing = item.Kind != "Font" && item.Kind != "ColorSpace" && item.Kind != "DrawParam"
|
||||
}
|
||||
}
|
||||
return catalog, ctx.Err()
|
||||
@@ -260,7 +270,7 @@ func (c *resourceCatalog) add(item ResourceInfo, node editorFontText) error {
|
||||
return fmt.Errorf("duplicate resource %s", key)
|
||||
}
|
||||
if item.Kind != "Attachment" {
|
||||
for _, kind := range []string{"Font", "Image", "Audio", "Video"} {
|
||||
for _, kind := range []string{"Font", "Image", "Audio", "Video", "ColorSpace", "DrawParam"} {
|
||||
if _, exists := c.nodes[kind+"/"+item.ID]; exists {
|
||||
return fmt.Errorf("conflicting resource ID %s", item.ID)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, refer
|
||||
return nil, fmt.Errorf("resource reference not found")
|
||||
}
|
||||
ref := item.References[reference]
|
||||
doc, err := r.Reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := make(map[string]bool)
|
||||
users := make(map[string][]string)
|
||||
for _, file := range catalog.scan.files {
|
||||
@@ -66,6 +70,13 @@ func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, refer
|
||||
object = node.attr("ID")
|
||||
}
|
||||
owner := resourceReferenceOwner(node)
|
||||
if owner != "" && resourceColorNode(node) && node.attr("ColorSpace") == "" && doc.CommonData.DefaultCS != 0 {
|
||||
dependency := fmt.Sprint(doc.CommonData.DefaultCS)
|
||||
users[dependency] = append(users[dependency], owner)
|
||||
if ref.Role == "DefaultCS" && sameResourceID(dependency, item.ID) {
|
||||
wanted[owner] = true
|
||||
}
|
||||
}
|
||||
add := func(role, value string) {
|
||||
if owner == "" {
|
||||
return
|
||||
@@ -120,10 +131,6 @@ func (r *Renderer) ResourceLocations(ctx context.Context, kind, id string, refer
|
||||
}
|
||||
}
|
||||
}
|
||||
doc, err := r.Reader.DocContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := *r
|
||||
view.RenderAnnotations, view.pageContext = true, ctx
|
||||
var result []ResourceLocation
|
||||
|
||||
Reference in new issue
Block a user