feat(阅读编辑): 完善阅读编辑与资源管理
build.yaml / build (push) Successful in 58s

This commit is contained in:
xiaoqidun committed 2026-10-13 16:34:47 +08:00
1 parent 0139433e25
commit c74dff1e41
14 files changed
+3625 -31

No files matched your search

+19 -1
View File
@@ -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>
+114
View File
@@ -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
View File
@@ -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;
+6 -1
View File
@@ -29,7 +29,7 @@ self.onmessage = ({ data }) => {
operations.get(data.id)?.abort();
return;
}
if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "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 });
+376
View File
@@ -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 切换错误
+181
View File
@@ -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
View File
@@ -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) }
}
}
+586
View File
@@ -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 := &current.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, &current); 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
}
+578
View File
@@ -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
}
+13 -5
View File
@@ -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"))]
}
+606
View File
@@ -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)
}
+431
View File
@@ -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
View File
@@ -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)
}
+11 -4
View File
@@ -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