diff --git a/assets/webui/index.html b/assets/webui/index.html index ac559d5..526bd24 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -89,7 +89,7 @@ - 对象 +
@@ -161,9 +161,39 @@ - 页面 +
+ +
+
+
+
+
+
+ +
+
%
+
+
+ +
mm
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index e2534a3..95bef89 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1470,6 +1470,95 @@ button.outline-link:hover { cursor: default; } +.edit-object.edit-contour, +.edit-object.edit-contour.selected, +.edit-object.edit-contour.erasing { + outline: none; + background: transparent; +} + +.edit-outline { + display: none; + position: absolute; + width: 100%; + height: 100%; + min-width: 1px; + min-height: 1px; + overflow: visible; + pointer-events: none; + fill: none; + stroke: var(--accent); + stroke-width: calc(1px / var(--surface-scale)); +} + +.edit-outline > * { + vector-effect: non-scaling-stroke; +} + +.edit-object:hover > .edit-outline, +.edit-object.selected > .edit-outline, +.edit-object.erasing > .edit-outline { + display: block; +} + +.edit-object.erasing > .edit-outline { + stroke-dasharray: 3 2; +} + +.outline-tools { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.outline-link.selected { + background: var(--accent-soft); + color: var(--accent); +} + +.page-list-item { + position: relative; +} + +.thumb-grip { + position: absolute; + right: 8px; + bottom: 8px; + width: 24px; + height: 28px; + cursor: grab; + touch-action: none; + color: var(--text-muted); +} + +.thumb-grip::before { + content: ""; + position: absolute; + left: 7px; + top: 7px; + width: 3px; + height: 3px; + background: currentColor; + box-shadow: 6px 0 currentColor, 0 6px currentColor, 6px 6px currentColor, 0 12px currentColor, 6px 12px currentColor; +} + +.page-list-item.reordering { + opacity: 0.5; +} + +.page-list-item.drop-before::before, +.page-list-item.drop-after::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--accent); +} + +.page-list-item.drop-before::before { top: -6px; } +.page-list-item.drop-after::after { bottom: -6px; } + .edit-line::before { position: absolute; inset: calc(-4px / var(--surface-scale)); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 53324ca..db9d69c 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -39,6 +39,9 @@ const state = { savedRevision: null, insertObject: null, importPageCount: 0, + outlineSelection: null, + outlineAction: "add", + styleOriginal: null, objectClipboard: null, textFonts: [], textFontID: null, @@ -164,6 +167,35 @@ const el = { movePagePrevButton: document.querySelector("#movePagePrevButton"), movePageNextButton: document.querySelector("#movePageNextButton"), pageSettingsButton: document.querySelector("#pageSettingsButton"), + batchPagesButton: document.querySelector("#batchPagesButton"), + batchPagesPanel: document.querySelector("#batchPagesPanel"), + batchPagesForm: document.querySelector("#batchPagesForm"), + batchPageAction: document.querySelector("#batchPageAction"), + batchPageRange: document.querySelector("#batchPageRange"), + batchPagesStatus: document.querySelector("#batchPagesStatus"), + batchPagesCancel: document.querySelector("#batchPagesCancel"), + batchPagesSubmit: document.querySelector("#batchPagesSubmit"), + objectStyleButton: document.querySelector("#objectStyleButton"), + objectStylePanel: document.querySelector("#objectStylePanel"), + objectStyleForm: document.querySelector("#objectStyleForm"), + objectOpacity: document.querySelector("#objectOpacity"), + objectStrokeFields: document.querySelector("#objectStrokeFields"), + objectDash: document.querySelector("#objectDash"), + objectDashRow: document.querySelector("#objectDashRow"), + objectDashPattern: document.querySelector("#objectDashPattern"), + objectDashOffset: document.querySelector("#objectDashOffset"), + objectCap: document.querySelector("#objectCap"), + objectJoin: document.querySelector("#objectJoin"), + objectStyleStatus: document.querySelector("#objectStyleStatus"), + objectStyleCancel: document.querySelector("#objectStyleCancel"), + outlinePanel: document.querySelector("#outlinePanel"), + outlineForm: document.querySelector("#outlineForm"), + outlineTitle: document.querySelector("#outlineTitle"), + outlineParent: document.querySelector("#outlineParent"), + outlinePage: document.querySelector("#outlinePage"), + outlineStatus: document.querySelector("#outlineStatus"), + outlineCancel: document.querySelector("#outlineCancel"), + outlineSubmit: document.querySelector("#outlineSubmit"), pagePanel: document.querySelector("#pagePanel"), pageForm: document.querySelector("#pageForm"), pagePortrait: document.querySelector("#pagePortrait"), @@ -549,6 +581,41 @@ editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, " editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1)); editorClick(el.movePageNextButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex + 1)); editorClick(el.pageSettingsButton, openPagePanel); +editorClick(el.batchPagesButton, () => { + el.batchPageAction.value = "copy"; + el.batchPagesSubmit.textContent = "复制"; + el.batchPageRange.value = String(state.pageIndex + 1); + el.batchPagesStatus.textContent = ""; + el.batchPagesPanel.showModal(); + el.batchPageRange.select(); +}); +el.batchPagesCancel.addEventListener("click", () => el.batchPagesPanel.close()); +el.batchPageAction.addEventListener("change", () => { + el.batchPagesSubmit.textContent = el.batchPageAction.value === "copy" ? "复制" : "删除"; +}); +el.batchPagesForm.addEventListener("submit", async event => { + event.preventDefault(); + if (await changeDocument("ofdgoBatchPages", null, el.batchPageAction.value, el.batchPageRange.value)) el.batchPagesPanel.close(); +}); +editorClick(el.objectStyleButton, openObjectStyle); +el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close()); +el.objectDash.addEventListener("change", () => { + el.objectDashRow.hidden = el.objectDash.value !== "custom"; + el.objectDashPattern.required = !el.objectDashRow.hidden; +}); +el.objectStyleForm.addEventListener("submit", async event => { + event.preventDefault(); + const style = readObjectStyle(); + const item = canvasEditor.selected; + if (await changeDocument("ofdgoStyleObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, style)) el.objectStylePanel.close(); +}); +el.outlineCancel.addEventListener("click", () => el.outlinePanel.close()); +el.outlineForm.addEventListener("submit", async event => { + event.preventDefault(); + const path = state.outlineAction === "add" ? JSON.parse(el.outlineParent.value) : state.outlineSelection; + const page = el.outlinePage.value === "" ? -1 : Number(el.outlinePage.value) - 1; + if (await changeDocument("ofdgoChangeOutline", null, state.outlineAction, path, el.outlineTitle.value, page)) el.outlinePanel.close(); +}); el.pageCancel.addEventListener("click", () => el.pagePanel.close()); el.pageWidth.addEventListener("input", updatePageDirection); el.pageHeight.addEventListener("input", updatePageDirection); @@ -797,7 +864,73 @@ function handleKeyDown(event) { } function formDialogOpen() { - return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open; + return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open + || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open; +} + +function openObjectStyle() { + const items = canvasEditor.items(); + if (!items.length) return; + const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; + const alpha = shared("alpha", 255), dash = shared("dashPattern", ""); + el.objectOpacity.value = alpha === null ? "" : String(Math.round((1 - alpha / 255) * 10000) / 100); + el.objectOpacity.placeholder = alpha === null ? "混合" : ""; + el.objectStrokeFields.hidden = !items.every(item => item.type === "PathObject"); + el.objectDash.value = dash === null ? "mixed" : dash === "" ? "solid" : "custom"; + el.objectDashPattern.value = dash || ""; + el.objectDashRow.hidden = el.objectDash.value !== "custom"; + el.objectDashPattern.required = !el.objectStrokeFields.hidden && !el.objectDashRow.hidden; + el.objectDashOffset.value = shared("dashOffset", 0) ?? ""; + el.objectCap.value = shared("cap", "") || (shared("cap", "") === null ? "mixed" : "Butt"); + el.objectJoin.value = shared("join", "") || (shared("join", "") === null ? "mixed" : "Miter"); + state.styleOriginal = Object.fromEntries(["objectOpacity", "objectDash", "objectDashPattern", "objectDashOffset", "objectCap", "objectJoin"].map(key => [key, el[key].value])); + el.objectStyleStatus.textContent = ""; + el.objectStylePanel.showModal(); +} + +function readObjectStyle() { + const style = {}, changed = key => el[key].value !== state.styleOriginal[key]; + if (changed("objectOpacity") && el.objectOpacity.value !== "") style.alpha = Math.round((1 - Number(el.objectOpacity.value) / 100) * 255); + if (!el.objectStrokeFields.hidden) { + if (changed("objectDash") || changed("objectDashPattern")) { + const patterns = { solid: "", dash: "3 2", dot: "0.3 1.5", dashdot: "3 1.5 0.3 1.5", custom: el.objectDashPattern.value.trim() }; + if (el.objectDash.value in patterns) style.dashPattern = patterns[el.objectDash.value]; + } + if (changed("objectDashOffset") && el.objectDashOffset.value !== "") style.dashOffset = Number(el.objectDashOffset.value); + if (changed("objectCap") && el.objectCap.value !== "mixed") style.cap = el.objectCap.value; + if (changed("objectJoin") && el.objectJoin.value !== "mixed") style.join = el.objectJoin.value; + } + return style; +} + +function openOutlinePanel(action) { + state.outlineAction = action; + let outline = null, items = state.doc.outlines || []; + for (const index of state.outlineSelection || []) { outline = items[index]; items = outline.children || []; } + el.outlineTitle.value = action === "add" ? "" : outline.title; + el.outlinePage.value = action === "add" ? String(state.pageIndex + 1) : outline.page || ""; + el.outlinePage.max = String(state.doc.pageCount); + el.outlineParent.replaceChildren(); + const addOption = (path, title) => { + const option = document.createElement("option"); + option.value = JSON.stringify(path); + option.textContent = title; + option.title = title; + el.outlineParent.append(option); + }; + addOption([], "无"); + const walk = (items, path = []) => items.forEach((item, index) => { + const next = [...path, index]; + addOption(next, `${" ".repeat(path.length)}${item.title}`); + walk(item.children || [], next); + }); + walk(state.doc.outlines || []); + el.outlineParent.value = JSON.stringify(action === "add" ? state.outlineSelection || [] : state.outlineSelection.slice(0, -1)); + el.outlineParent.disabled = action !== "add"; + el.outlineTitle.disabled = el.outlinePage.disabled = action === "delete"; + el.outlineSubmit.textContent = { add: "新增", update: "确定", delete: "删除" }[action]; + el.outlineStatus.textContent = action === "delete" && outline.children?.length ? "同时删除子目录" : ""; + el.outlinePanel.showModal(); } function openImportPanel() { @@ -899,6 +1032,8 @@ async function toggleEditor() { setPan(false); } updateControls(); + renderOutlines(false); + renderPageList(); applyFit(false); return; } @@ -1432,6 +1567,13 @@ async function changeDocument(name, item, ...args) { updateControls(); return true; } + if (name === "ofdgoChangeOutline") { + state.doc.outlines = doc.outlines; + renderOutlines(false); + showNavigation(el.outlinesTab); + updateControls(); + return true; + } const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name); if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") { state.selectObjects = true; @@ -1457,7 +1599,13 @@ async function changeDocument(name, item, ...args) { } } catch (err) { if (openSeq === state.openSeq) { - if (el.infoPanel.open) { + if (el.batchPagesPanel.open) { + el.batchPagesStatus.textContent = err.message; + } else if (el.objectStylePanel.open) { + el.objectStyleStatus.textContent = err.message; + } else if (el.outlinePanel.open) { + el.outlineStatus.textContent = err.message; + } else if (el.infoPanel.open) { el.infoStatus.textContent = err.message; } else if (el.pagePanel.open) { el.pageStatus.textContent = err.message; @@ -1984,6 +2132,9 @@ async function openOFD(file) { el.paragraphPanel.close(); el.infoPanel.close(); el.importPanel.close(); + el.batchPagesPanel.close(); + el.objectStylePanel.close(); + el.outlinePanel.close(); updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); } catch (err) { @@ -2439,7 +2590,7 @@ async function openDocument(options = {}) { resetPageFlow(options.keepPreview); renderPageList(); if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) { - renderOutlines(); + renderOutlines(!options.keepPreview); } renderMeta(); renderPageFlow(); @@ -3514,18 +3665,36 @@ function showNavigation(selected) { el.pageListPanel.scrollTop = state.navigationScroll.get(selected) || 0; } -function renderOutlines() { - state.navigationScroll.clear(); - el.pageListPanel.scrollTop = 0; +function renderOutlines(reset = true) { + const selected = !reset && [el.pagesTab, el.outlinesTab, el.searchTab].find(tab => tab.getAttribute("aria-selected") === "true"); + const scrollTop = reset ? 0 : el.pageListPanel.scrollTop; + if (reset) state.navigationScroll.clear(); const outlines = state.doc.outlines || []; el.pageListTitle.hidden = true; el.navigationTabs.hidden = false; - el.outlinesTab.hidden = outlines.length === 0; + el.outlinesTab.hidden = outlines.length === 0 && !state.editing; el.outlineList.replaceChildren(); + state.outlineSelection = null; + if (state.editing) { + const tools = document.createElement("div"); + tools.className = "outline-tools"; + for (const [action, label] of [["add", "新增"], ["update", "修改"], ["delete", "删除"]]) { + const button = document.createElement("button"); + button.className = "button"; + button.type = "button"; + button.textContent = label; + button.dataset.outlineAction = action; + button.disabled = action !== "add"; + editorClick(button, () => openOutlinePanel(action)); + tools.append(button); + } + el.outlineList.append(tools); + } if (outlines.length > 0) { el.outlineList.append(createOutlineList(outlines)); } - showNavigation(el.pagesTab); + el.pageListPanel.scrollTop = scrollTop; + showNavigation(selected && !selected.hidden ? selected : el.pagesTab); } function resetSearch(clearInput = true) { @@ -3703,17 +3872,18 @@ function renderSearchHighlights(index) { } } -function createOutlineList(outlines) { +function createOutlineList(outlines, path = []) { const list = document.createElement("ul"); list.className = "outline-list"; - for (const outline of outlines) { + for (const [index, outline] of outlines.entries()) { + const current = [...path, index]; const item = document.createElement("li"); const hasChildren = outline.children?.length > 0; const row = document.createElement(hasChildren ? "summary" : "div"); if (!hasChildren) { row.className = "outline-leaf"; } - const link = document.createElement(outline.page ? "button" : "span"); + const link = document.createElement(outline.page || state.editing ? "button" : "span"); link.className = "outline-link"; const title = document.createElement("span"); title.textContent = outline.title; @@ -3725,16 +3895,24 @@ function createOutlineList(outlines) { page.textContent = String(outline.page); page.setAttribute("aria-label", `第 ${outline.page} 页`); link.append(page); + } + if (outline.page || state.editing) { + link.type = "button"; link.addEventListener("click", (event) => { event.preventDefault(); - renderPage(outline.page - 1); + if (state.editing) { + state.outlineSelection = current; + for (const node of el.outlineList.querySelectorAll(".outline-link")) node.classList.toggle("selected", node === link); + for (const button of el.outlineList.querySelectorAll("[data-outline-action]")) button.disabled = false; + } + if (outline.page) renderPage(outline.page - 1); }); } row.append(link); if (hasChildren) { const details = document.createElement("details"); details.open = outline.expanded; - details.append(row, createOutlineList(outline.children)); + details.append(row, createOutlineList(outline.children, current)); item.append(details); } else { item.append(row); @@ -3778,6 +3956,7 @@ function renderPageList() { size.textContent = `${formatSize(page.width)} x ${formatSize(page.height)} mm`; button.append(thumb, label, size); + if (state.editing && pageCan("move", page.index)) enablePageDrag(button, page.index); button.addEventListener("click", () => renderPage(page.index)); fragment.append(button); thumbnailTargets.push([button, page.index]); @@ -3797,6 +3976,83 @@ function resetThumbnails() { } } +function pageDropIndex(from, target, after) { + const position = target + Number(after); + return position > from ? position - 1 : position; +} + +function enablePageDrag(button, index) { + const grip = document.createElement("span"); + grip.className = "thumb-grip"; + grip.title = "拖动排序"; + grip.setAttribute("aria-hidden", "true"); + button.append(grip); + grip.addEventListener("click", event => event.stopPropagation()); + grip.addEventListener("pointerdown", event => { + if (event.button !== 0 || document.body.hasAttribute("aria-busy")) return; + event.preventDefault(); + event.stopPropagation(); + const seq = state.openSeq, pointer = event.pointerId; + let x = event.clientX, y = event.clientY, target = index, marker = null, frame = 0, dragging = false; + const clearMarker = () => { marker?.classList.remove("drop-before", "drop-after"); marker = null; }; + const locate = () => { + clearMarker(); + target = index; + const node = document.elementFromPoint(x, y)?.closest(".page-list-item"); + if (!node || !el.pageList.contains(node)) return; + const rect = node.getBoundingClientRect(), after = y > rect.top + rect.height / 2; + target = pageDropIndex(index, Number(node.dataset.pageIndex), after); + if (target !== index) { marker = node; marker.classList.add(after ? "drop-after" : "drop-before"); } + }; + const tick = () => { + if (seq !== state.openSeq || !state.editing) { finish(false); return; } + if (dragging) { + const rect = el.pageListPanel.getBoundingClientRect(); + const delta = y < rect.top + 48 ? -10 : y > rect.bottom - 48 ? 10 : 0; + if (delta) el.pageListPanel.scrollTop += delta; + locate(); + } + frame = requestAnimationFrame(tick); + }; + const move = next => { + if (next.pointerId !== pointer) return; + x = next.clientX; y = next.clientY; + dragging ||= Math.hypot(x - event.clientX, y - event.clientY) > 4; + button.classList.toggle("reordering", dragging); + if (dragging) locate(); + }; + const finish = async commit => { + cancelAnimationFrame(frame); + grip.removeEventListener("pointermove", move); + grip.removeEventListener("pointerup", up); + grip.removeEventListener("pointercancel", cancel); + grip.removeEventListener("lostpointercapture", cancel); + clearMarker(); + button.classList.remove("reordering"); + if (grip.hasPointerCapture(pointer)) grip.releasePointerCapture(pointer); + if (commit && dragging && target !== index && seq === state.openSeq && await canvasEditor.commitText() && await canvasEditor.commitCrop()) { + await changeDocument("ofdgoChangePage", null, "move", index, target); + } + }; + const up = next => { if (next.pointerId === pointer) finish(true); }; + const cancel = () => finish(false); + grip.setPointerCapture(pointer); + grip.addEventListener("pointermove", move); + grip.addEventListener("pointerup", up); + grip.addEventListener("pointercancel", cancel); + grip.addEventListener("lostpointercapture", cancel); + frame = requestAnimationFrame(tick); + }); + button.addEventListener("keydown", async event => { + if (!event.altKey || !["ArrowUp", "ArrowDown"].includes(event.key) || document.body.hasAttribute("aria-busy")) return; + event.preventDefault(); + const target = index + (event.key === "ArrowUp" ? -1 : 1); + if (target >= 0 && target < state.doc.pageCount && await canvasEditor.commitText() && await canvasEditor.commitCrop()) { + if (await changeDocument("ofdgoChangePage", null, "move", index, target)) el.pageList.querySelector(`[data-page-index="${target}"]`)?.focus(); + } + }); +} + function layoutThumbnail(button, page) { if (page.width <= 0 || page.height <= 0) { return; @@ -4665,6 +4921,7 @@ function updateEditorTools() { const pagesDisabled = !state.editing || !state.ready || state.exporting; el.insertTextButton.disabled = el.insertImageButton.disabled = pagesDisabled || !pageCan("insert"); el.saveButton.disabled = el.addPageButton.disabled = pagesDisabled; + el.batchPagesButton.disabled = pagesDisabled; el.copyPageButton.disabled = pagesDisabled || !pageCan("copy"); el.pageSettingsButton.disabled = pagesDisabled || !pageCan("resize"); el.deletePageButton.disabled = pagesDisabled || !pageCan("delete") || state.doc?.pageCount <= 1; @@ -4681,6 +4938,7 @@ function updateObjectControls(item, reset = false) { const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting; el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete"); el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy"); + el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform"); const cropping = Boolean(canvasEditor.crop); el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled; @@ -4803,6 +5061,12 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.paragraphForm.inert = busy; el.infoForm.inert = busy; el.importForm.inert = busy; + el.batchPagesForm.inert = busy; + el.objectStyleForm.inert = busy; + el.outlineForm.inert = busy; + el.outlineList.inert = busy; + el.navigationTabs.inert = busy; + el.pageList.inert = busy; el.editorTools.inert = busy; el.fontList.inert = busy; if (!busy) { diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 75a98d3..230b1cf 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -249,6 +249,18 @@ export class CanvasEditor { node.setAttribute("role", "button"); node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]); const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; + if (object.type === "PathObject" && (object.shape || object.outline)) { + node.classList.add("edit-contour"); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.classList.add("edit-outline"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("preserveAspectRatio", "none"); + const contour = document.createElementNS("http://www.w3.org/2000/svg", object.shape === "line" ? "line" : object.shape === "ellipse" ? "ellipse" : object.shape === "rectangle" ? "rect" : "path"); + if (!object.shape) contour.setAttribute("d", object.outline); + svg.append(contour); + node.append(svg); + item.contour = { svg, path: contour }; + } this.nodes.set(node, item); node.addEventListener("focus", () => this.select(item)); const handles = !canEditObject(object, "transform") ? [] : object.shape === "line" ? ["start", "end"] @@ -297,6 +309,7 @@ export class CanvasEditor { left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`, width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`, }); + if (item.contour) item.contour.svg.setAttribute("viewBox", `${item.x} ${item.y} ${item.width} ${item.height}`); if (item.textFrame) { const { matrix: [a, b, c, d, e, f], width, height } = item.textFrame; for (const handle of item.node.children) { @@ -317,11 +330,21 @@ export class CanvasEditor { }); if (item.shape === "line") { for (const handle of item.node.children) { + if (!handle.dataset.corner) continue; const end = handle.dataset.corner === "end"; handle.style.left = `${(box.x + (end ? box.width : 0) - x) * PX_PER_MM}px`; handle.style.top = `${(box.y + (end ? box.height : 0) - y) * PX_PER_MM}px`; } } + if (item.contour) { + const { svg, path } = item.contour; + const width = Math.abs(box.width), height = Math.abs(box.height); + svg.setAttribute("viewBox", `0 0 ${width || 1} ${height || 1}`); + const values = item.shape === "line" ? { x1: box.x - x, y1: box.y - y, x2: box.x + box.width - x, y2: box.y + box.height - y } + : item.shape === "ellipse" ? { cx: width / 2, cy: height / 2, rx: width / 2, ry: height / 2 } + : { x: 0, y: 0, width, height }; + for (const [key, value] of Object.entries(values)) path.setAttribute(key, String(value)); + } } items() { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 87bfbe6..c33c443 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -110,6 +110,9 @@ func RunWASM() { registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoUpdateInfo", updateInfo) registerCallback("ofdgoChangePage", changePage) + registerCallback("ofdgoBatchPages", batchPages) + registerCallback("ofdgoChangeOutline", changeOutline) + registerCallback("ofdgoStyleObjects", styleObjects) registerCallback("ofdgoLoadImport", loadImport) registerCallback("ofdgoImportPages", importPages) registerCallback("ofdgoInsertText", insertText) @@ -722,6 +725,40 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if missing := capability.MissingGlyphs; missing != nil { item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } + var alpha *int + switch object.Type { + case "TextObject": + alpha = object.TextObject.Alpha + case "ImageObject": + alpha = object.ImageObject.Alpha + case "PathObject": + alpha = object.PathObject.Alpha + } + item["alpha"] = 255 + if alpha != nil { + item["alpha"] = *alpha + } + if object.Type == "PathObject" { + path := object.PathObject + item["dashPattern"], item["cap"], item["join"] = path.DashPattern, path.Cap, path.Join + item["dashOffset"] = 0.0 + if path.DashOffset != nil { + item["dashOffset"] = *path.DashOffset + } + kind, geometry := path.Shape() + if kind != "" && capability.Update { + item["shape"] = string(kind) + item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} + } else if outline, err := path.Outline(); err == nil { + item["outline"] = outline + } + if capability.Update { + item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke + item["fillColor"] = editorColorHex(path.FillColor) + item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) + item["lineWidth"] = path.LineWidth * editorPathScale(path) + } + } if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() if err != nil { @@ -729,18 +766,6 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { } item["imageBounds"] = editorBox(full) } - if object.Type == "PathObject" && capability.Update { - path := object.PathObject - kind, geometry := path.Shape() - if kind != "" { - item["shape"] = string(kind) - item["geometry"] = map[string]any{"x": geometry.X, "y": geometry.Y, "width": geometry.W, "height": geometry.H} - } - item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke - item["fillColor"] = editorColorHex(path.FillColor) - item["strokeColor"] = editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) - item["lineWidth"] = path.LineWidth * editorPathScale(path) - } if object.Type == "TextObject" { value, layout := object.TextObject.TextLayout() item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size @@ -1010,6 +1035,96 @@ func changePage(args []js.Value) (any, error) { return editorPageInfo{info, index}, nil } +// batchPages 按页码范围批量复制或删除页面 +// 入参: args 操作及页码表达式 +// 返回: any 文档信息及目标页, error 错误信息 +func batchPages(args []js.Value) (any, error) { + index := 0 + info, err := changeObjects(func() error { + indexes, err := ofdgo.ParsePageRange(args[1].String(), currentEditor.PageCount()) + if err != nil { + return err + } + switch args[0].String() { + case "copy": + copies, err := currentEditor.CopyPages(indexes) + if err == nil && len(copies) > 0 { + index = copies[0] + } + return err + case "delete": + if len(indexes) == currentEditor.PageCount() { + return fmt.Errorf("the document must contain at least one page") + } + if err := currentEditor.DeletePages(indexes); err != nil { + return err + } + index = min(indexes[0], currentEditor.PageCount()-1) + return nil + default: + return fmt.Errorf("unsupported page action %q", args[0].String()) + } + }) + if err != nil { + return nil, err + } + return editorPageInfo{info.(editorInfo), index}, nil +} + +// changeOutline 新增、修改或删除层级目录 +// 入参: args 操作、层级索引、标题及目标页面索引 +// 返回: any 文档信息, error 错误信息 +func changeOutline(args []js.Value) (any, error) { + return changeObjects(func() error { + path := make([]int, args[1].Length()) + for i := range path { + path[i] = args[1].Index(i).Int() + } + switch args[0].String() { + case "add": + _, err := currentEditor.AddOutline(path, args[2].String(), args[3].Int()) + return err + case "update": + return currentEditor.UpdateOutline(path, args[2].String(), args[3].Int()) + case "delete": + return currentEditor.DeleteOutline(path) + default: + return fmt.Errorf("unsupported outline action %q", args[0].String()) + } + }) +} + +// styleObjects 原子更新对象透明度和路径描边属性 +// 入参: args 页面索引、对象标识数组及样式,省略字段保持原值 +// 返回: any 文档信息, error 错误信息 +func styleObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + style := ofdgo.ObjectStyle{} + value := args[2] + if field := value.Get("alpha"); !field.IsUndefined() { + v := field.Int() + style.Alpha = &v + } + if field := value.Get("dashPattern"); !field.IsUndefined() { + v := field.String() + style.DashPattern = &v + } + if field := value.Get("dashOffset"); !field.IsUndefined() { + v := field.Float() + style.DashOffset = &v + } + if field := value.Get("cap"); !field.IsUndefined() { + v := field.String() + style.Cap = &v + } + if field := value.Get("join"); !field.IsUndefined() { + v := field.String() + style.Join = &v + } + return currentEditor.StyleObjects(args[0].Int(), stringsFromJS(args[1]), style) + }) +} + // clearImport 释放待插页文档 func clearImport() { if pendingImport != nil { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 023bb86..ecb8a82 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -50,6 +50,7 @@ type Editor struct { revision uint64 serial uint64 source *editorSource + outlines []byte } // editorResource 文档内嵌资源 @@ -174,70 +175,165 @@ func (e *Editor) AddPage(width, height float64) (int, error) { // 入参: index 原页面索引 // 返回: int 副本页面索引, error 错误信息 func (e *Editor) CopyPage(index int) (int, error) { - if e.originalPage(index) { - source, err := e.Reader() - if err != nil { - return 0, err - } - at := len(e.pages) - _, err = e.importPages(source, []int{index}, at, true) - return at, err - } - source, err := e.page(index) + indexes, err := e.CopyPages([]int{index}) if err != nil { return 0, err } - page := PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}} - for i, layer := range source.Content.Layer { - page.Content.Layer[i] = Layer{Type: layer.Type, Objects: make([]GraphicObject, len(layer.Objects))} - for j, object := range layer.Objects { - page.Content.Layer[i].Objects[j], err = cloneEditorObject(object) - if err != nil { - return 0, err + return indexes[0], nil +} + +// CopyPages 按指定顺序复制页面并追加到文档末尾,一次操作计入一条撤销记录 +// 入参: indexes 不重复的原页面索引 +// 返回: []int 副本页面索引, error 错误信息 +func (e *Editor) CopyPages(indexes []int) ([]int, error) { + if err := e.validatePageIndexes(indexes); err != nil { + return nil, err + } + if len(indexes) == 0 { + return nil, nil + } + at := len(e.pages) + if e.source != nil { + source, err := e.Reader() + if err != nil { + return nil, err + } + if _, err := e.importPages(source, indexes, at, true); err != nil { + return nil, err + } + result := make([]int, len(indexes)) + for i := range result { + result[i] = at + i + } + return result, nil + } + pages := make([]PageContent, len(indexes)) + for n, index := range indexes { + source := &e.pages[index] + page := &pages[n] + *page = PageContent{Area: source.Area, Content: Content{Layer: make([]Layer, len(source.Content.Layer))}} + for i, layer := range source.Content.Layer { + page.Content.Layer[i] = Layer{Type: layer.Type, Objects: make([]GraphicObject, len(layer.Objects))} + for j, object := range layer.Objects { + copy, err := cloneEditorObject(object) + if err != nil { + return nil, err + } + page.Content.Layer[i].Objects[j] = copy } } } - page.ID = e.nextID() - for i := range page.Content.Layer { - layer := &page.Content.Layer[i] - layer.ID = e.nextID() - for j := range layer.Objects { - object := &layer.Objects[j] - origin := e.objectOrigin(editorObjectID(*object)) - id := e.nextID() - if origin != nil { - e.source.origins[id] = origin + result := make([]int, len(pages)) + for n := range pages { + page := &pages[n] + page.ID = e.nextID() + for i := range page.Content.Layer { + layer := &page.Content.Layer[i] + layer.ID = e.nextID() + for j := range layer.Objects { + object := &layer.Objects[j] + id := e.nextID() + switch object.Type { + case "TextObject": + object.TextObject.ID = id + case "PathObject": + object.PathObject.ID = id + case "ImageObject": + object.ImageObject.ID = id + } } - switch object.Type { - case "TextObject": - object.TextObject.ID = id - case "PathObject": - object.PathObject.ID = id - case "ImageObject": - object.ImageObject.ID = id + } + result[n] = at + n + } + e.pages = append(e.pages, pages...) + if change := e.recordChange(); change != nil { + for i := range pages { + pages[i] = copyEditorPage(pages[i]) + } + change.undo = func(e *Editor) { e.pages = e.pages[:at] } + change.redo = func(e *Editor) { + for _, page := range pages { + e.pages = append(e.pages, copyEditorPage(page)) } } } - e.pages = append(e.pages, page) - index = len(e.pages) - 1 - e.recordPageAddition(index) - return index, nil + return result, nil } // DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据 // 入参: index 页面索引 // 返回: error 错误信息 func (e *Editor) DeletePage(index int) error { - page, err := e.page(index) - if err != nil { + return e.DeletePages([]int{index}) +} + +// DeletePages 删除指定页面,输出时清理页面引用,一次操作计入一条撤销记录 +// 入参: indexes 不重复的页面索引 +// 返回: error 错误信息 +func (e *Editor) DeletePages(indexes []int) error { + if err := e.validatePageIndexes(indexes); err != nil { return err } + if len(indexes) == 0 { + return nil + } + indexes = slices.Clone(indexes) + slices.Sort(indexes) + saved := make([]PageContent, len(indexes)) + for i, index := range indexes { + page, err := e.page(index) + if err != nil { + return err + } + saved[i] = copyEditorPage(*page) + } + remove := func(e *Editor) { + kept, next := e.pages[:0], 0 + for index, page := range e.pages { + if next < len(indexes) && index == indexes[next] { + next++ + } else { + kept = append(kept, page) + } + } + clear(e.pages[len(kept):]) + e.pages = kept + } if change := e.recordChange(); change != nil { - saved := copyEditorPage(*page) - change.undo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(saved)) } - change.redo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) } + change.undo = func(e *Editor) { + pages := make([]PageContent, len(e.pages)+len(saved)) + kept, removed := 0, 0 + for index := range pages { + if removed < len(indexes) && index == indexes[removed] { + pages[index] = copyEditorPage(saved[removed]) + removed++ + } else { + pages[index] = e.pages[kept] + kept++ + } + } + e.pages = pages + } + change.redo = remove + } + remove(e) + return nil +} + +// validatePageIndexes 校验页面索引范围和重复值 +// 入参: indexes 页面索引 +// 返回: error 错误信息 +func (e *Editor) validatePageIndexes(indexes []int) error { + seen := make(map[int]bool, len(indexes)) + for _, index := range indexes { + if index < 0 || index >= len(e.pages) { + return fmt.Errorf("page index %d out of range", index) + } + if seen[index] { + return fmt.Errorf("duplicate page index %d", index) + } + seen[index] = true } - e.pages = slices.Delete(e.pages, index, index+1) return nil } @@ -553,30 +649,8 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, if obj.Rule != "" && obj.Rule != "NonZero" && obj.Rule != "Even-Odd" { return GraphicObject{}, fmt.Errorf("invalid fill rule %q", obj.Rule) } - if !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 { - return GraphicObject{}, fmt.Errorf("invalid path stroke dimensions") - } - if obj.Cap != "" && obj.Cap != "Butt" && obj.Cap != "Round" && obj.Cap != "Square" { - return GraphicObject{}, fmt.Errorf("invalid line cap %q", obj.Cap) - } - if obj.DashOffset != nil && !finite(*obj.DashOffset) { - return GraphicObject{}, fmt.Errorf("invalid dash offset") - } - if obj.DashPattern != "" { - values, err := creationNumbers(obj.DashPattern, len(strings.Fields(obj.DashPattern))) - if err != nil { - return GraphicObject{}, err - } - total := 0.0 - for _, value := range values { - if value < 0 { - return GraphicObject{}, fmt.Errorf("dash lengths must not be negative") - } - total += value - } - if total == 0 { - return GraphicObject{}, fmt.Errorf("dash pattern must have a positive length") - } + if err := validateEditorStroke(*obj); err != nil { + return GraphicObject{}, err } obj.ID = id boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 566045d..c8223f6 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -342,10 +342,10 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error { return e.updateObjects(page, objects, false) } -// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区 -// 入参: page 页面索引, objects 新对象, geometry 是否保留原内容的几何操作 +// updateObjects 校验内容更新或几何与外观变换,原子提交跨图层选区 +// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容 // 返回: error 错误信息 -func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) error { +func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveContent bool) error { ids := make([]string, len(objects)) for i, object := range objects { ids[i] = editorObjectID(object) @@ -361,11 +361,11 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) if err != nil { return err } - if !capability.Transform || !geometry && !e.sourceRGB() { + if !capability.Transform || !preserveContent && !e.sourceRGB() { return fmt.Errorf("object %q is read-only for this operation: %w", ids[i], capability.editError()) } } - if geometry && e.originalPage(page) { + if preserveContent && e.originalPage(page) { if err := validateEditorGeometry(object); err != nil { return err } diff --git a/ofdgo_editor_outline.go b/ofdgo_editor_outline.go new file mode 100644 index 0000000..a9ea505 --- /dev/null +++ b/ofdgo_editor_outline.go @@ -0,0 +1,405 @@ +// 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" + "slices" + "strconv" + "strings" +) + +// Outlines 获取当前目录层级,目标页码从1开始,无目标时为0 +// 返回: []OutlineInfo 目录信息, error 错误信息 +func (e *Editor) Outlines() ([]OutlineInfo, error) { + data, err := e.outlineXML() + if err != nil { + return nil, err + } + var doc Document + if err := xml.Unmarshal(data, &doc.Outlines); err != nil { + return nil, err + } + if e.source != nil { + doc.Bookmarks = e.source.document.Bookmarks + } + for _, page := range e.pages { + doc.Pages.Page = append(doc.Pages.Page, Page{ID: page.ID}) + } + return doc.OutlineInfos(), nil +} + +// AddOutline 在指定父目录末尾添加节点,空路径表示根目录 +// 入参: parent 从0开始的层级索引, title 标题, page 目标页面索引,-1表示无目标 +// 返回: []int 新节点路径, error 错误信息 +func (e *Editor) AddOutline(parent []int, title string, page int) ([]int, error) { + if err := e.validateOutline(title, page); err != nil { + return nil, err + } + data, err := e.outlineXML() + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + node, err := editorOutlineNode(root, parent) + if err != nil { + return nil, err + } + attrs := ofdAttrs{} + attrs.add("Title", title) + item, err := editorXMLContainer("OutlineElem", attrs, e.outlineAction(page)) + if err != nil { + return nil, err + } + var content []byte + if node.open != node.end { + content = bytes.Clone(data[node.open:node.close]) + } + content = append(content, item...) + result := append(slices.Clone(parent), len(editorOutlineChildren(node))) + err = e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)})) + return result, err +} + +// UpdateOutline 修改目录标题和目标,目标未改变时保留原跳转坐标及动作 +// 入参: path 从0开始的层级索引, title 标题, page 目标页面索引,-1表示无目标 +// 返回: error 错误信息 +func (e *Editor) UpdateOutline(path []int, title string, page int) error { + if err := e.validateOutline(title, page); err != nil { + return err + } + data, err := e.outlineXML() + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + node, err := editorOutlineNode(root, path) + if err != nil { + return err + } + if len(path) == 0 { + return fmt.Errorf("outline path must not be empty") + } + infos, err := e.Outlines() + if err != nil { + return err + } + var info OutlineInfo + for _, index := range path { + info = infos[index] + infos = info.Children + } + var patches []editorXMLPatch + if info.Page != page+1 { + actions := node.child("Actions") + if actions != nil { + var removals []editorXMLPatch + for _, action := range actions.children { + if action.name.Space != actions.name.Space && action.name.Space != ofdNamespace && action.name.Space != "" { + continue + } + if action.name.Local == "Action" && action.child("Goto") != nil { + removals = append(removals, editorXMLPatch{action.start - actions.open, action.end - actions.open, nil}) + } + } + var content []byte + if actions.open != actions.end { + content = editorPatchXML(data[actions.open:actions.close], removals) + } + added := e.outlineAction(page) + if len(added) > 0 { + addRoot, err := parseEditorXML(added) + if err != nil { + return err + } + action := addRoot.children[0] + standalone, err := editorXMLStandalone(added[action.start:action.end], action) + if err != nil { + return err + } + content = append(content, standalone...) + } + patches = append(patches, editorXMLContent(data, actions, content)) + } else if page >= 0 { + var content []byte + content = append(content, e.outlineAction(page)...) + if node.open != node.end { + content = append(content, data[node.open:node.close]...) + } + patches = append(patches, editorXMLContent(data, node, content)) + } + } + updated := editorPatchXML(data, patches) + root, err = parseEditorXML(updated) + if err != nil { + return err + } + node, err = editorOutlineNode(root, path) + if err != nil { + return err + } + value, err := editorOutlineAttribute(updated, node, "Title", title) + if err != nil { + return err + } + return e.setOutlineXML(editorPatchXML(updated, []editorXMLPatch{{node.start, node.end, value}})) +} + +// DeleteOutline 删除目录节点及其子节点 +// 入参: path 从0开始的层级索引 +// 返回: error 错误信息 +func (e *Editor) DeleteOutline(path []int) error { + if len(path) == 0 { + return fmt.Errorf("outline path must not be empty") + } + data, err := e.outlineXML() + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + node, err := editorOutlineNode(root, path) + if err != nil { + return err + } + return e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}})) +} + +// validateOutline 校验目录标题和目标页面 +// 入参: title 标题, page 页面索引 +// 返回: error 错误信息 +func (e *Editor) validateOutline(title string, page int) error { + if strings.TrimSpace(title) == "" { + return fmt.Errorf("outline title must not be empty") + } + if page < -1 || page >= len(e.pages) { + return fmt.Errorf("page index %d out of range", page) + } + return nil +} + +// outlineXML 获取保留命名空间的目录原文 +// 返回: []byte XML内容, error 错误信息 +func (e *Editor) outlineXML() ([]byte, error) { + if e.outlines != nil { + return e.outlines, nil + } + if e.source != nil { + reader := e.source.reader + data, err := reader.readFile(reader.ResPath(reader.OFD.DocBody[0].DocRoot)) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if node := root.child("Outlines"); node != nil { + return editorXMLStandalone(data[node.start:node.end], node) + } + } + return editorXMLContainer("Outlines", nil, nil) +} + +// outlineAction 编码页面适配跳转,负数表示无目标 +// 入参: page 页面索引 +// 返回: []byte 动作集合 +func (e *Editor) outlineAction(page int) []byte { + if page < 0 { + return nil + } + data, _ := encodeOFDXML(func(x *ofdXML) { + x.root("Actions", nil) + x.start("Action", ofdAttrs{{Name: xml.Name{Local: "Event"}, Value: "CLICK"}}) + x.start("Goto", nil) + x.start("Dest", ofdAttrs{{Name: xml.Name{Local: "Type"}, Value: "Fit"}, {Name: xml.Name{Local: "PageID"}, Value: e.pages[page].ID}}) + x.end("Dest") + x.end("Goto") + x.end("Action") + x.end("Actions") + }) + return bytes.TrimPrefix(data, []byte(xml.Header)) +} + +// editorOutlineChildren 获取标准目录子节点 +// 入参: node 父节点 +// 返回: []*editorXML 子节点 +func editorOutlineChildren(node *editorXML) []*editorXML { + var children []*editorXML + for _, child := range node.children { + if child.name.Local == "OutlineElem" && (child.name.Space == node.name.Space || child.name.Space == ofdNamespace || child.name.Space == "") { + children = append(children, child) + } + } + return children +} + +// editorOutlineNode 按层级路径定位目录 +// 入参: root 目录根节点, path 层级索引 +// 返回: *editorXML 节点, error 错误信息 +func editorOutlineNode(root *editorXML, path []int) (*editorXML, error) { + for _, index := range path { + children := editorOutlineChildren(root) + if index < 0 || index >= len(children) { + return nil, fmt.Errorf("outline index %d out of range", index) + } + root = children[index] + } + return root, nil +} + +// editorOutlineAttribute 修改单个标准属性,保留节点其余原文 +// 入参: data 原文, node 节点, name 属性名, value 新值 +// 返回: []byte 修改后的节点, error 错误信息 +func editorOutlineAttribute(data []byte, node *editorXML, name, value string) ([]byte, error) { + before, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: node.attr(name)}}, nil) + if err != nil { + return nil, err + } + after, err := editorXMLContainer(node.name.Local, ofdAttrs{{Name: xml.Name{Local: name}, Value: value}}, nil) + if err != nil { + return nil, err + } + return editorXMLMerge(data, node, before, after) +} + +// setOutlineXML 更新目录及可选叶节点计数,保存一次撤销记录 +// 入参: data 新目录原文 +// 返回: error 错误信息 +func (e *Editor) setOutlineXML(data []byte) error { + root, err := parseEditorXML(data) + if err != nil { + return err + } + var patches []editorXMLPatch + var leaves func(*editorXML) int + leaves = func(node *editorXML) int { + children := editorOutlineChildren(node) + if len(children) == 0 { + return 1 + } + count := 0 + for _, child := range children { + count += leaves(child) + } + return count + } + var walk func(*editorXML) error + walk = func(node *editorXML) error { + if node.attr("Count") != "" { + count := 0 + for _, child := range editorOutlineChildren(node) { + count += leaves(child) + } + value, err := editorOutlineAttribute(data, node, "Count", strconv.Itoa(count)) + if err != nil { + return err + } + updated, err := parseEditorXML(value) + if err != nil { + return err + } + if node.open != node.end { + patches = append(patches, editorXMLPatch{node.start, node.open, value[:updated.open]}) + } else { + patches = append(patches, editorXMLPatch{node.start, node.end, value}) + } + } + for _, child := range editorOutlineChildren(node) { + if err := walk(child); err != nil { + return err + } + } + return nil + } + if err := walk(root); err != nil { + return err + } + data = editorPatchXML(data, patches) + current, err := e.outlineXML() + if err != nil { + return err + } + if bytes.Equal(current, data) { + return nil + } + before := e.outlines + e.outlines = data + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { e.outlines = before } + change.redo = func(e *Editor) { e.outlines = data } + } + return nil +} + +// withOutlines 将修改后的目录写入文档,清除指向已删除页面的新增跳转 +// 入参: data 文档原文 +// 返回: []byte 更新文档, error 错误信息 +func (e *Editor) withOutlines(data []byte) ([]byte, error) { + if e.outlines == nil { + return data, nil + } + outline, err := parseEditorXML(e.outlines) + if err != nil { + return nil, err + } + valid := make(map[string]bool, len(e.pages)) + for _, page := range e.pages { + valid[page.ID] = true + } + var removals []editorXMLPatch + var walk func(*editorXML) + walk = func(node *editorXML) { + if actions := node.child("Actions"); actions != nil { + for _, action := range actions.children { + if action.name.Local != "Action" || action.name.Space != actions.name.Space && action.name.Space != ofdNamespace && action.name.Space != "" { + continue + } + if target := action.child("Goto"); target != nil { + if dest := target.child("Dest"); dest != nil && !valid[dest.attr("PageID")] { + removals = append(removals, editorXMLPatch{action.start, action.end, nil}) + } + } + } + } + for _, child := range editorOutlineChildren(node) { + walk(child) + } + } + walk(outline) + value := editorPatchXML(e.outlines, removals) + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + if node := root.child("Outlines"); node != nil { + return editorPatchXML(data, []editorXMLPatch{{node.start, node.end, value}}), nil + } + pages := root.child("Pages") + if pages == nil { + return nil, fmt.Errorf("document has no Pages") + } + return editorPatchXML(data, []editorXMLPatch{{pages.end, pages.end, value}}), nil +} diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 709d6de..95fd915 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -159,6 +159,22 @@ func (e *Editor) sourceParts() (map[string][]byte, error) { } parts["OFD.xml"] = data } + if e.outlines != nil { + name := reader.ResPath(reader.OFD.DocBody[0].DocRoot) + data, ok := parts[name] + if !ok { + var err error + data, err = reader.readFile(name) + if err != nil { + return nil, err + } + } + updated, err := e.withOutlines(data) + if err != nil { + return nil, err + } + parts[name] = updated + } if err := e.prunePageReferences(parts); err != nil { return nil, err } diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 254bd3a..8741e43 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -14,7 +14,10 @@ package ofdgo -import "fmt" +import ( + "fmt" + "strings" +) // editorDrawParam 解析原文档的绘制参数继承链,缺失或循环引用不进入编辑快照 // 入参: id 绘制参数标识, visited 已访问标识 @@ -89,3 +92,104 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic } return cloneEditorObject(object) } + +// ObjectStyle 对象外观,nil字段保持原值,长度单位为对象坐标系中的毫米 +type ObjectStyle struct { + Alpha *int + DashPattern *string + DashOffset *float64 + Cap *string + Join *string +} + +// StyleObjects 原子更新文字、图片和路径透明度,路径另支持描边样式 +// 入参: page 页面索引, ids 对象标识, style 待修改属性 +// 返回: error 错误信息 +func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { + if style.Alpha != nil && (*style.Alpha < 0 || *style.Alpha > 255) { + return fmt.Errorf("alpha must be between 0 and 255") + } + objects, _, err := e.selectedObjects(page, ids) + if err != nil { + return err + } + stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil + for i := range objects { + object := cloneEditorData(objects[i]) + if stroke && object.Type != "PathObject" { + return fmt.Errorf("stroke style requires path objects") + } + switch object.Type { + case "TextObject": + if style.Alpha != nil { + object.TextObject.Alpha = style.Alpha + } + case "ImageObject": + if style.Alpha != nil { + object.ImageObject.Alpha = style.Alpha + } + case "PathObject": + path := &object.PathObject + if style.Alpha != nil { + path.Alpha = style.Alpha + } + if style.DashPattern != nil { + path.DashPattern = *style.DashPattern + } + if style.DashOffset != nil { + path.DashOffset = style.DashOffset + } + if style.Cap != nil { + path.Cap = *style.Cap + } + if style.Join != nil { + path.Join = *style.Join + } + if stroke { + if err := validateEditorStroke(*path); err != nil { + return err + } + if path.Join != "" && path.Join != "Miter" && path.Join != "Round" && path.Join != "Bevel" { + return fmt.Errorf("invalid line join %q", path.Join) + } + } + default: + return fmt.Errorf("unsupported object type %q", object.Type) + } + objects[i] = object + } + return e.updateObjects(page, objects, true) +} + +// validateEditorStroke 校验路径描边尺寸、端点和虚线 +// 入参: path 路径对象 +// 返回: error 错误信息 +func validateEditorStroke(path PathObject) error { + if !finite(path.LineWidth) || path.LineWidth < 0 || !finite(path.MiterLimit) || path.MiterLimit < 0 { + return fmt.Errorf("invalid path stroke dimensions") + } + if path.Cap != "" && path.Cap != "Butt" && path.Cap != "Round" && path.Cap != "Square" { + return fmt.Errorf("invalid line cap %q", path.Cap) + } + if path.DashOffset != nil && !finite(*path.DashOffset) { + return fmt.Errorf("invalid dash offset") + } + if path.DashPattern == "" { + return nil + } + values, err := creationNumbers(path.DashPattern, len(strings.Fields(path.DashPattern))) + if err != nil { + return err + } + total := 0.0 + for _, value := range values { + if value < 0 { + return fmt.Errorf("dash lengths must not be negative") + } + total += value + } + if total == 0 { + return fmt.Errorf("dash pattern must have a positive length") + } + return nil +} diff --git a/ofdgo_shape.go b/ofdgo_shape.go index 53b0ad1..e619eef 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -19,8 +19,30 @@ import ( "math" "strconv" "strings" + + "github.com/tdewolff/canvas" ) +// Outline 获取页面坐标系中的SVG路径轮廓,不包含描边宽度和填充 +// 入参: p 路径对象 +// 返回: string SVG路径数据, error 错误信息 +func (p PathObject) Outline() (string, error) { + if err := creationPath(p.AbbreviatedData); err != nil { + return "", err + } + if _, err := creationBox(p.Boundary); err != nil { + return "", err + } + if p.CTM != "" { + if _, err := creationNumbers(p.CTM, 6); err != nil { + return "", err + } + } + r := &Renderer{} + path := r.buildPath(p, 0, NewMatrix(p.CTM), false) + return path.Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), nil +} + // ShapeKind 基本图形类型 type ShapeKind string diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 42d0717..07e4ad1 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -129,6 +129,12 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error { if err != nil { return err } + if name == "Doc_0/Document.xml" { + data, err = e.withOutlines(data) + if err != nil { + return err + } + } return write(name, data, false) } if err := writeXML("OFD.xml", func(x *ofdXML) {