diff --git a/assets/webui/index.html b/assets/webui/index.html index 5b5a581..d26c22a 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -356,7 +356,7 @@ 资源管理 - 类型字体图片音频视频附件 + 类型字体图片音频视频附件色彩样式 名称大小引用 @@ -399,6 +399,16 @@ + + + 文档设置 + 类型书签动作模板图层扩展色彩样式 + + + + + + 版本管理 @@ -411,6 +421,7 @@ 默认 删除 切换 + 另存 关闭 @@ -635,6 +646,7 @@ 页面 目录 + 注解 搜索 @@ -645,6 +657,11 @@ + + 全部 + + + @@ -690,6 +707,7 @@ 文档 资源 + 设置 版本 管理 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index c59a483..be4d53e 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -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; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index abcc631..abdf651 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -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; diff --git a/assets/webui/ofdgo_wasm.js b/assets/webui/ofdgo_wasm.js index a50eb18..f79740e 100644 --- a/assets/webui/ofdgo_wasm.js +++ b/assets/webui/ofdgo_wasm.js @@ -29,7 +29,7 @@ self.onmessage = ({ data }) => { operations.get(data.id)?.abort(); return; } - if (data.name === "ofdgoImportImages" || data.name === "ofdgoExportImages" || data.name === "ofdgoConvertFile" || data.name === "ofdgoPackFiles" || data.name === "ofdgoConvertPDF" || data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "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 }); diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 64a58e6..d7a67be 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -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 切换错误 diff --git a/ofdgo_editor_action_query.go b/ofdgo_editor_action_query.go new file mode 100644 index 0000000..8b2aef7 --- /dev/null +++ b/ofdgo_editor_action_query.go @@ -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() +} diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 738b1c9..554f910 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -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) } } } diff --git a/ofdgo_editor_bookmark.go b/ofdgo_editor_bookmark.go new file mode 100644 index 0000000..cb63129 --- /dev/null +++ b/ofdgo_editor_bookmark.go @@ -0,0 +1,586 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" +) + +// Bookmarks 按声明顺序读取当前文档版本的命名书签,返回独立副本,不读取跳转引用。 +// 返回: []Bookmark 书签列表,无书签时为nil;error 读取、结构或名称重复错误。 +func (e *Editor) Bookmarks() ([]Bookmark, error) { + if e.source == nil { + return nil, nil + } + data, err := e.source.reader.readFile(e.source.reader.documentRoot) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + return editorBookmarks(data, root) +} + +// AddBookmark 在当前文档版本末尾添加名称唯一的书签,保留扩展内容并记录一次撤销。 +// 入参: name 非空且区分大小写的名称,不自动裁剪空白;dest 目标,PageID为当前页面标识而非页序号。 +// 返回: error 名称、目标、页面或提交错误。 +func (e *Editor) AddBookmark(name string, dest Dest) error { + return e.editBookmark("", &Bookmark{Name: name, Dest: dest}, true) +} + +// UpdateBookmark 按原名称更新书签,重命名同步当前文档全部标准跳转引用,整体可撤销。 +// 入参: name 原名称;next 完整的新书签,Dest.PageID为当前页面标识;名称不变时仅修改目标。 +// 返回: error 名称不存在、重名、目标无效、引用读取或提交错误;内容未变时不记录撤销。 +func (e *Editor) UpdateBookmark(name string, next Bookmark) error { + return e.editBookmark(name, &next, false) +} + +// DeleteBookmark 删除当前文档版本的命名书签,存在标准跳转引用时拒绝删除。 +// 入参: name 书签名称;其他文档和历史版本的引用不阻止当前版本删除。 +// 返回: error 名称不存在、仍被引用、引用读取或提交错误。 +func (e *Editor) DeleteBookmark(name string) error { + return e.editBookmark(name, nil, false) +} + +// editorBookmarks 读取标准书签定义,拒绝重复集合、重名及缺失或重复目标,不解析扩展子树。 +// 入参: data 文档原文;root 文档根节点。 +// 返回: []Bookmark 独立定义列表;error 结构或解码错误。 +func editorBookmarks(data []byte, root *editorXML) ([]Bookmark, error) { + if root.childAt("Bookmarks", 1) != nil { + return nil, fmt.Errorf("multiple bookmark collections") + } + container := root.child("Bookmarks") + if container == nil { + return nil, nil + } + var result []Bookmark + seen := make(map[string]bool) + for _, node := range container.children { + if !node.matchesOFD("Bookmark") { + continue + } + name := node.attr("Name") + if err := validateBookmarkName(name); err != nil { + return nil, err + } + if seen[name] { + return nil, fmt.Errorf("duplicate bookmark %q", name) + } + seen[name] = true + if node.child("Dest") == nil || node.childAt("Dest", 1) != nil { + return nil, fmt.Errorf("bookmark %q requires one destination", name) + } + fragment, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + var bookmark Bookmark + if err := xml.Unmarshal(fragment, &bookmark); err != nil { + return nil, err + } + result = append(result, bookmark) + } + return result, nil +} + +// validateBookmarkName 校验非空名称及XML可无损表示的Unicode字符。 +// 入参: name 名称。 +// 返回: error 名称错误。 +func validateBookmarkName(name string) error { + if strings.TrimSpace(name) == "" || !utf8.ValidString(name) { + return fmt.Errorf("bookmark name must be nonempty valid UTF-8") + } + for _, char := range name { + if char < 0x20 && char != '\t' && char != '\n' && char != '\r' || char == 0xfffe || char == 0xffff { + return fmt.Errorf("bookmark name contains an invalid XML character") + } + } + return nil +} + +// validateBookmark 校验书签名称、标准目标参数及当前编辑页树中的目标标识。 +// 入参: bookmark 待写入定义。 +// 返回: error 名称、目标或页面错误。 +func (e *Editor) validateBookmark(bookmark Bookmark) error { + if err := validateBookmarkName(bookmark.Name); err != nil { + return err + } + if _, err := bookmark.Dest.attributes(); err != nil { + return err + } + id := editorResourceID(bookmark.Dest.PageID) + for _, page := range e.pages { + if id != "" && editorResourceID(page.ID) == id { + return nil + } + } + return fmt.Errorf("bookmark destination page %q does not exist", bookmark.Dest.PageID) +} + +// editBookmark 校验定义后原子提交局部XML补丁,重命名使用包含未保存编辑的完整快照。 +// 入参: name 原名称;next 新定义,nil表示删除;add 是否新增。 +// 返回: error 校验、引用扫描或提交错误。 +func (e *Editor) editBookmark(name string, next *Bookmark, add bool) error { + if !add { + if err := validateBookmarkName(name); err != nil { + return err + } + } + if next != nil { + if err := e.validateBookmark(*next); err != nil { + return err + } + } + bookmarks, err := e.Bookmarks() + if err != nil { + return err + } + index := -1 + for i, bookmark := range bookmarks { + if !add && bookmark.Name == name { + index = i + } + if next != nil && bookmark.Name == next.Name && (add || bookmark.Name != name) { + return fmt.Errorf("bookmark %q already exists", next.Name) + } + } + if !add && index < 0 { + return fmt.Errorf("bookmark %q not found", name) + } + rename := !add && next != nil && name != next.Name + return e.Transaction(func(e *Editor) error { + base := e.source + if base == nil { + base, err = e.importBase() + if err != nil { + return err + } + } + parts := make(map[string][]byte) + if rename || next == nil { + pending, err := e.sourcePartsReferences(nil, nil) + if err != nil { + return err + } + reader := cloneEditorReader(base.reader) + if reader.files == nil { + reader.files = make(map[string][]byte) + } + maps.Copy(reader.files, pending) + parts, err = editorBookmarkReferences(reader, name, next) + if err != nil { + return err + } + for i, resource := range e.resources { + if resource.definition() == "" || len(resource.data) == 0 { + continue + } + data, err := editorBookmarkReferenceXML(resource.data, resource.name, name, next) + if err != nil { + return err + } + if !bytes.Equal(data, resource.data) { + e.resources[i].data = data + e.resources[i].vectorKey = [32]byte{} + } + } + if rename { + maps.Copy(pending, parts) + parts = pending + } + } + entry := base.reader.documentRoot + data, ok := parts[entry] + if !ok { + data, err = base.reader.readFile(entry) + if err != nil { + return err + } + } + updated, err := editorBookmarkDefinition(data, name, next, add) + if err != nil { + return err + } + if bytes.Equal(data, updated) && len(parts) == 0 { + return nil + } + parts[entry] = updated + if !rename { + return e.commitBookmarkParts(base, parts) + } + working := &Editor{} + if err := working.commitBookmarkParts(base, parts); err != nil { + return err + } + return e.commitBookmarkSnapshot(working.source.reader) + }) +} + +// commitBookmarkSnapshot 接入重命名后的包,保留已加载对象的会话排版、裁剪及尚未使用的注册资源。 +// 入参: reader 已完成隔离和重命名的包快照。 +// 返回: error 快照重建或页面加载错误。 +func (e *Editor) commitBookmarkSnapshot(reader *Reader) error { + before := e.transactionSnapshot() + fonts, images, spaces, files := e.usedResources() + used := make(map[string]bool) + for _, resource := range fonts { + used[resource.font.ID] = true + } + for _, resource := range images { + used[resource.image.ID] = true + } + for _, space := range spaces { + used[space.ID] = true + } + if err := e.commitResourceSnapshot(context.Background(), reader); err != nil { + return err + } + e.maxID = max(e.maxID, before.maxID) + e.fonts, e.images, e.resourceID = before.fonts, before.images, before.resourceID + e.fontRegistration = before.fontRegistration + for _, resource := range before.resources { + if resource.font != nil && used[resource.font.ID] || resource.image != nil && used[resource.image.ID] || resource.space != nil && used[resource.space.ID] || files[resource.name] { + continue + } + e.resources = append(e.resources, resource) + } + for i, page := range before.pages { + states := make(map[string]GraphicObject) + for _, layer := range page.Content.Layer { + for _, object := range layer.Objects { + states[editorObjectID(object)] = object + } + } + if len(states) == 0 { + continue + } + current, err := e.page(i) + if err != nil { + return err + } + for j := range current.Content.Layer { + for k := range current.Content.Layer[j].Objects { + object := ¤t.Content.Layer[j].Objects[k] + previous := states[editorObjectID(*object)] + object.state = previous.state + object.TextObject.layout = previous.TextObject.layout + object.CompositeGraphicUnit.states = previous.CompositeGraphicUnit.states + } + } + } + if before.source != nil { + e.source.annotationStates = before.source.annotationStates + } + return nil +} + +// commitBookmarkParts 隔离修改后复用注解快照提交,保守复制路径不覆盖未知扩展可能引用的原文件。 +// 入参: base 原包快照;parts 当前入口修改集。 +// 返回: error 依赖解析、隔离或提交错误。 +func (e *Editor) commitBookmarkParts(base *editorSource, parts map[string][]byte) error { + changes, err := base.reader.versionChanges(context.Background(), parts) + if err != nil { + changes, err = editorBookmarkIsolate(base.reader, parts) + if err != nil { + return err + } + } + reader := cloneEditorReader(base.reader) + if reader.files == nil { + reader.files = make(map[string][]byte) + } + maps.Copy(reader.files, changes) + source := *base + source.reader = reader + return e.commitAnnotationParts(&source, nil) +} + +// editorBookmarkIsolate 在无法完整分析其他入口时保护所有已有文件,复制修改文件及其标准引用祖先。 +// 入参: reader 原包;changes 当前入口修改集。 +// 返回: map[string][]byte 隔离后的修改集;error 当前入口依赖或版本清单错误。 +func editorBookmarkIsolate(reader *Reader, changes map[string][]byte) (map[string][]byte, error) { + ctx := context.Background() + entry, err := reader.DocumentRootContext(ctx) + if err != nil { + return nil, err + } + parts := maps.Clone(changes) + graph, err := reader.versionEditGraph(ctx, entry, parts, nil) + if err != nil { + return nil, err + } + paths := make(map[string]string) + reserved := maps.Clone(parts) + var pending []string + allocate := func(name string) { + if paths[name] != "" || !reader.fileNames[name] { + return + } + target := packageAvailableName(reader, reserved, name) + paths[name], reserved[target] = target, nil + pending = append(pending, name) + } + for _, name := range slices.Sorted(maps.Keys(parts)) { + if name != "OFD.xml" && graph.used[name] { + allocate(name) + } + } + parents := make(map[string][]string) + for _, name := range slices.Sorted(maps.Keys(graph.files)) { + for _, link := range graph.files[name].links { + parents[link.target] = append(parents[link.target], name) + } + } + for i := 0; i < len(pending); i++ { + for _, name := range parents[pending[i]] { + allocate(name) + } + } + for name, file := range graph.files { + data, err := file.rewrite(ctx, paths) + if err != nil { + return nil, err + } + if !bytes.Equal(data, file.data) { + parts[name] = data + } + } + for source, target := range paths { + data, ok := parts[source] + if !ok { + data, err = reader.readFile(source) + if err != nil { + return nil, err + } + } + parts[target] = data + delete(parts, source) + } + if reader.versionInfo != nil { + if err := reader.versionManifest(ctx, parts, paths, graph.used, reader.fileNames, nil); err != nil { + return nil, err + } + } else if target := paths[entry]; target != "" { + data, ok := parts["OFD.xml"] + if !ok { + data, err = reader.readFile("OFD.xml") + if err != nil { + return nil, err + } + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + node := root.childAt("DocBody", reader.documentIndex).child("DocRoot") + parts["OFD.xml"] = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, editorFontXMLText("/"+target))}) + } + return parts, nil +} + +// editorBookmarkDefinition 修改指定书签的标准字段,保留其扩展属性、扩展节点及其他定义原文。 +// 入参: data 文档原文;name 原名称;next 新定义,nil表示删除;add 是否新增。 +// 返回: []byte 修改后的文档;error 结构或编码错误。 +func editorBookmarkDefinition(data []byte, name string, next *Bookmark, add bool) ([]byte, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + container := root.child("Bookmarks") + if add { + context := container + if context == nil { + context = root + } + attrs, err := next.Dest.attributes() + if err != nil { + return nil, err + } + dest, err := context.containerXML("Dest", attrs, nil) + if err != nil { + return nil, err + } + item, err := context.containerXML("Bookmark", ofdAttrs{{Name: xml.Name{Local: "Name"}, Value: next.Name}}, dest) + if err != nil { + return nil, err + } + if container != nil { + if container.open == container.end { + return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, container, item)}), nil + } + return editorPatchXML(data, []editorXMLPatch{{container.close, container.close, item}}), nil + } + item, err = root.containerXML("Bookmarks", nil, item) + if err != nil { + return nil, err + } + at := root.close + for _, child := range root.children { + if child.matchesOFD(child.name.Local) && slices.Contains([]string{"Annotations", "CustomTags", "Attachments", "Extensions"}, child.name.Local) { + at = child.start + break + } + } + return editorPatchXML(data, []editorXMLPatch{{at, at, item}}), nil + } + if container == nil { + return nil, fmt.Errorf("bookmark %q not found", name) + } + for _, node := range container.children { + if !node.matchesOFD("Bookmark") || node.attr("Name") != name { + continue + } + if next == nil { + patch := editorXMLPatch{node.start, node.end, nil} + if len(container.children) == 1 && editorXMLAttributes(container, "") && len(bytes.TrimSpace(data[container.open:node.start])) == 0 && len(bytes.TrimSpace(data[node.end:container.close])) == 0 { + patch.start, patch.end = container.start, container.end + } + return editorPatchXML(data, []editorXMLPatch{patch}), nil + } + fragment, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + var current Bookmark + if err := xml.Unmarshal(fragment, ¤t); err != nil { + return nil, err + } + var patches []editorXMLPatch + if name != next.Name { + patch, err := editorXMLAttributePatch(data, node, "Name", next.Name) + if err != nil { + return nil, err + } + patches = append(patches, patch) + } + if current.Dest != next.Dest { + dest := node.child("Dest") + if dest == nil { + return nil, fmt.Errorf("bookmark %q has no destination", name) + } + attrs, err := next.Dest.attributes() + if err != nil { + return nil, err + } + values := make(map[string]string, len(attrs)) + for _, attr := range attrs { + values[attr.Name.Local] = attr.Value + } + value := bytes.Clone(data[dest.start:dest.end]) + for _, key := range []string{"Type", "PageID", "Left", "Top", "Right", "Bottom", "Zoom"} { + parsed, err := parseEditorXML(value) + if err != nil { + return nil, err + } + var replacement *string + if text, ok := values[key]; ok { + replacement = &text + } + patch, err := editorXMLAttributeChange(value, parsed, key, replacement) + if err != nil { + return nil, err + } + value = editorPatchXML(value, []editorXMLPatch{patch}) + } + patches = append(patches, editorXMLPatch{dest.start, dest.end, value}) + } + return editorPatchXML(data, patches), nil + } + return nil, fmt.Errorf("bookmark %q not found", name) +} + +// editorBookmarkReferences 扫描当前入口依赖中的标准书签跳转,拒绝带引用删除或生成重命名补丁。 +// 入参: reader 包含未保存修改的快照;name 原名称;next 新定义,nil表示删除。 +// 返回: map[string][]byte 需要修改的结构文件;error 读取、解析或被引用错误。 +func editorBookmarkReferences(reader *Reader, name string, next *Bookmark) (map[string][]byte, error) { + _, structures, err := reader.versionDependencies(context.Background(), reader.documentRoot, nil, nil) + if err != nil { + return nil, err + } + parts := make(map[string][]byte) + for _, file := range slices.Sorted(maps.Keys(structures)) { + data, err := reader.readFile(file) + if err != nil { + return nil, err + } + updated, err := editorBookmarkReferenceXML(data, file, name, next) + if err != nil { + return nil, err + } + if !bytes.Equal(data, updated) { + parts[file] = updated + } + } + return parts, nil +} + +// editorBookmarkReferenceXML 仅识别标准动作中的名称引用,保留扩展命名空间及不透明扩展负载。 +// 入参: data 结构原文;file 错误上下文中的文件名;name 原名称;next 新定义,nil表示检查删除。 +// 返回: []byte 修改后的原文;error 解析、属性编码或被引用错误。 +func editorBookmarkReferenceXML(data []byte, file, name string, next *Bookmark) ([]byte, error) { + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var patches []editorXMLPatch + var walk func(*editorXML) error + walk = func(node *editorXML) error { + if !node.matchesOFD(node.name.Local) { + return nil + } + if node.matchesOFD("Data") && node.parent != nil && node.parent.matchesOFD("Extension") { + return nil + } + if node.matchesOFD("Action") && node.parent != nil && node.parent.matchesOFD("Actions") { + for _, target := range node.children { + if !target.matchesOFD("Goto") { + continue + } + for _, bookmark := range target.children { + if !bookmark.matchesOFD("Bookmark") || bookmark.attr("Name") != name { + continue + } + if next == nil { + return fmt.Errorf("bookmark %q is referenced in %s", name, file) + } + patch, err := editorXMLAttributePatch(data, bookmark, "Name", next.Name) + if err != nil { + return err + } + patches = append(patches, patch) + } + } + } + for _, child := range node.children { + if err := walk(child); err != nil { + return err + } + } + return nil + } + if err := walk(root); err != nil { + return nil, err + } + return editorPatchXML(data, patches), nil +} diff --git a/ofdgo_editor_layer.go b/ofdgo_editor_layer.go new file mode 100644 index 0000000..5f7ebfc --- /dev/null +++ b/ofdgo_editor_layer.go @@ -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 +} diff --git a/ofdgo_editor_resource.go b/ofdgo_editor_resource.go index b27ab6b..048c96d 100644 --- a/ofdgo_editor_resource.go +++ b/ofdgo_editor_resource.go @@ -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"))] } diff --git a/ofdgo_editor_resource_style.go b/ofdgo_editor_resource_style.go new file mode 100644 index 0000000..498ed12 --- /dev/null +++ b/ofdgo_editor_resource_style.go @@ -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) +} diff --git a/ofdgo_editor_template.go b/ofdgo_editor_template.go new file mode 100644 index 0000000..740a5d3 --- /dev/null +++ b/ofdgo_editor_template.go @@ -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}) + }) +} diff --git a/ofdgo_resource.go b/ofdgo_resource.go index 50aa241..2bf38f5 100644 --- a/ofdgo_resource.go +++ b/ofdgo_resource.go @@ -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) } diff --git a/ofdgo_resource_location.go b/ofdgo_resource_location.go index 9f5dce7..6752195 100644 --- a/ofdgo_resource_location.go +++ b/ofdgo_resource_location.go @@ -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