diff --git a/assets/webui/index.html b/assets/webui/index.html index 526bd24..ec197af 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -103,6 +103,7 @@ + 插入
@@ -152,7 +153,7 @@ - 变换 +
@@ -173,6 +174,7 @@
+
格式
%
@@ -191,9 +193,24 @@
+
+ +
+
+
mm
+
mm
+
+
+
mm
+
mm
+
+
比例
+
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 95bef89..ccbb028 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1511,6 +1511,21 @@ button.outline-link:hover { margin-bottom: 10px; } +.form-format { + display: flex; + gap: 8px; +} + +.form-format > button { + flex: 1; +} + +.form-check { + display: flex; + align-items: center; + gap: 6px; +} + .outline-link.selected { background: var(--accent-soft); color: var(--accent); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index db9d69c..c22a70d 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js"; +import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -43,6 +43,8 @@ const state = { outlineAction: "add", styleOriginal: null, objectClipboard: null, + styleClipboard: null, + outlineExpanded: new Map(), textFonts: [], textFontID: null, textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, @@ -147,6 +149,7 @@ const el = { textSize: document.querySelector("#textSize"), textColor: document.querySelector("#textColor"), drawLineButton: document.querySelector("#drawLineButton"), + arrowTool: document.querySelector("#arrowTool"), drawRectangleButton: document.querySelector("#drawRectangleButton"), drawEllipseButton: document.querySelector("#drawEllipseButton"), eraseObjectButton: document.querySelector("#eraseObjectButton"), @@ -176,6 +179,18 @@ const el = { batchPagesCancel: document.querySelector("#batchPagesCancel"), batchPagesSubmit: document.querySelector("#batchPagesSubmit"), objectStyleButton: document.querySelector("#objectStyleButton"), + copyStyleButton: document.querySelector("#copyStyleButton"), + pasteStyleButton: document.querySelector("#pasteStyleButton"), + objectBoundsButton: document.querySelector("#objectBoundsButton"), + objectBoundsPanel: document.querySelector("#objectBoundsPanel"), + objectBoundsForm: document.querySelector("#objectBoundsForm"), + objectBoundsCancel: document.querySelector("#objectBoundsCancel"), + objectBoundsStatus: document.querySelector("#objectBoundsStatus"), + objectX: document.querySelector("#objectX"), + objectY: document.querySelector("#objectY"), + objectWidth: document.querySelector("#objectWidth"), + objectHeight: document.querySelector("#objectHeight"), + objectAspect: document.querySelector("#objectAspect"), objectStylePanel: document.querySelector("#objectStylePanel"), objectStyleForm: document.querySelector("#objectStyleForm"), objectOpacity: document.querySelector("#objectOpacity"), @@ -192,6 +207,8 @@ const el = { outlineForm: document.querySelector("#outlineForm"), outlineTitle: document.querySelector("#outlineTitle"), outlineParent: document.querySelector("#outlineParent"), + outlinePosition: document.querySelector("#outlinePosition"), + outlinePositionRow: document.querySelector("#outlinePositionRow"), outlinePage: document.querySelector("#outlinePage"), outlineStatus: document.querySelector("#outlineStatus"), outlineCancel: document.querySelector("#outlineCancel"), @@ -598,6 +615,51 @@ el.batchPagesForm.addEventListener("submit", async event => { if (await changeDocument("ofdgoBatchPages", null, el.batchPageAction.value, el.batchPageRange.value)) el.batchPagesPanel.close(); }); editorClick(el.objectStyleButton, openObjectStyle); +editorClick(el.objectBoundsButton, openObjectBounds); +el.arrowTool.addEventListener("change", async () => { + const tool = el.arrowTool.value; + el.arrowTool.value = ""; + if (await canvasEditor.commitText() && await canvasEditor.commitCrop()) { + setPan(false); + state.selectObjects = true; + canvasEditor.setTool(tool); + updateControls(); + } +}); +el.copyStyleButton.addEventListener("click", async () => { + const item = canvasEditor.selected, seq = state.openSeq; + setBusy(true); + try { + await callWASM("ofdgoCaptureStyle", item.index, item.id); + if (seq === state.openSeq) { state.styleClipboard = item.type; el.objectStylePanel.close(); } + } catch (err) { if (seq === state.openSeq) el.objectStyleStatus.textContent = err.message; } + finally { if (seq === state.openSeq) setBusy(false); } +}); +el.pasteStyleButton.addEventListener("click", async () => { + const item = canvasEditor.selected; + if (await changeDocument("ofdgoPasteStyle", { ...item, id: canvasEditor.items().map(member => member.id) })) el.objectStylePanel.close(); +}); +el.objectBoundsCancel.addEventListener("click", () => el.objectBoundsPanel.close()); +for (const input of [el.objectWidth, el.objectHeight]) input.addEventListener("input", () => { + if (!el.objectAspect.checked || !input.value || el.objectWidth.disabled || el.objectHeight.disabled) return; + const box = state.boundsOriginal; + if (input === el.objectWidth) setObjectDimension(el.objectHeight, Number(input.value) * box.height / box.width); + else setObjectDimension(el.objectWidth, Number(input.value) * box.width / box.height); +}); +el.objectAspect.addEventListener("change", () => { + if (el.objectAspect.checked) setObjectDimension(el.objectHeight, objectDimension(el.objectWidth) * state.boundsOriginal.height / state.boundsOriginal.width); +}); +el.objectBoundsForm.addEventListener("submit", async event => { + event.preventDefault(); + const item = canvasEditor.selected; + const values = [el.objectX, el.objectY, el.objectWidth, el.objectHeight].map(objectDimension); + let saved; + if (item.shape) { + if (lineShape(item.shape)) { values[2] *= Math.sign(item.geometry.width); values[3] *= Math.sign(item.geometry.height); } + saved = await changeDocument("ofdgoReshapeObject", item, ...values); + } else saved = await changeDocument("ofdgoResizeObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ...values); + if (saved) el.objectBoundsPanel.close(); +}); el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close()); el.objectDash.addEventListener("change", () => { el.objectDashRow.hidden = el.objectDash.value !== "custom"; @@ -614,8 +676,12 @@ 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(); + const saved = state.outlineAction === "move" + ? await changeDocument("ofdgoMoveOutline", null, state.outlineSelection, JSON.parse(el.outlineParent.value), Number(el.outlinePosition.value) - 1) + : await changeDocument("ofdgoChangeOutline", null, state.outlineAction, path, el.outlineTitle.value, page); + if (saved) el.outlinePanel.close(); }); +el.outlineParent.addEventListener("change", updateOutlinePosition); el.pageCancel.addEventListener("click", () => el.pagePanel.close()); el.pageWidth.addEventListener("input", updatePageDirection); el.pageHeight.addEventListener("input", updatePageDirection); @@ -865,12 +931,37 @@ 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 - || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open; + || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open; +} + +function setObjectDimension(input, value) { + input.value = String(Number(value.toFixed(2))); + input.defaultValue = input.value; + input.dataset.precise = String(value); +} + +function objectDimension(input) { + return Number(input.value === input.defaultValue ? input.dataset.precise : input.value); +} + +function openObjectBounds() { + const item = canvasEditor.selected; + const box = item.geometry || selectionBounds((item.items || [item]).map(member => member.bounds || member)); + state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) }; + for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]); + el.objectAspect.checked = true; + el.objectAspect.disabled = !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject") || lineShape(item.shape) && (!box.width || !box.height); + el.objectWidth.disabled = lineShape(item.shape) && !box.width; + el.objectHeight.disabled = lineShape(item.shape) && !box.height; + el.objectBoundsStatus.textContent = ""; + el.objectBoundsPanel.showModal(); } function openObjectStyle() { const items = canvasEditor.items(); if (!items.length) return; + el.copyStyleButton.disabled = items.length !== 1 || !canEditObject(items[0], "update"); + el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canEditObject(item, "update")); 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); @@ -921,18 +1012,59 @@ function openOutlinePanel(action) { addOption([], "无"); const walk = (items, path = []) => items.forEach((item, index) => { const next = [...path, index]; + if (action === "move" && state.outlineSelection.every((part, i) => next[i] === part)) return; 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.outlineParent.disabled = action !== "add" && action !== "move"; + el.outlineTitle.disabled = el.outlinePage.disabled = action === "delete" || action === "move"; + el.outlinePositionRow.hidden = action !== "move"; + el.outlinePosition.disabled = action !== "move"; + updateOutlinePosition(); + el.outlineSubmit.textContent = { add: "新增", update: "确定", delete: "删除", move: "确定" }[action]; el.outlineStatus.textContent = action === "delete" && outline.children?.length ? "同时删除子目录" : ""; el.outlinePanel.showModal(); } +function outlineChildren(outlines, path) { + for (const index of path) outlines = outlines[index].children || []; + return outlines; +} + +function updateOutlinePosition() { + if (state.outlineAction !== "move") return; + const parent = JSON.parse(el.outlineParent.value); + const same = JSON.stringify(parent) === JSON.stringify(state.outlineSelection.slice(0, -1)); + const count = outlineChildren(state.doc.outlines, parent).length + (same ? 0 : 1); + el.outlinePosition.max = String(count); + el.outlinePosition.value = String(same ? state.outlineSelection.at(-1) + 1 : count); +} + +function remapOutlineExpansion(name, args, next) { + const roots = state.doc.outlines || [], expanded = new Map(); + const walk = (items, visit, path = []) => items.forEach((item, index) => { + const current = [...path, index]; + visit(item, JSON.stringify(current)); + walk(item.children || [], visit, current); + }); + walk(roots, (item, key) => expanded.set(item, state.outlineExpanded.get(key) ?? item.expanded)); + const moving = name === "ofdgoMoveOutline", action = moving ? "move" : args[0], source = moving ? args[0] : args[1]; + let item; + if (action === "move" || action === "delete") item = outlineChildren(roots, source.slice(0, -1)).splice(source.at(-1), 1)[0]; + if (action === "move" || action === "add") { + let children = roots; + for (const index of next.slice(0, -1)) { + expanded.set(children[index], true); + children = children[index].children ||= []; + } + children.splice(next.at(-1), 0, item || {}); + } + state.outlineExpanded.clear(); + walk(roots, (item, key) => state.outlineExpanded.set(key, Boolean(expanded.get(item)))); +} + function openImportPanel() { if (!state.editing || document.body.hasAttribute("aria-busy")) return; state.importPageCount = 0; @@ -1050,6 +1182,7 @@ async function toggleEditor() { state.selectObjects = true; state.ofdBytes = null; state.objectClipboard = null; + state.styleClipboard = null; canvasEditor.clear(); setPan(false); await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale, @@ -1097,6 +1230,7 @@ async function createDocument(event) { } state.editing = true; state.objectClipboard = null; + state.styleClipboard = null; state.fontRenderPending = false; canvasEditor.clear(); delete state.textDefaults.fontChoice; @@ -1516,12 +1650,13 @@ async function changeShapeStyle() { function updateDrawingControls() { const tool = canvasEditor.tool; el.insertTextButton.setAttribute("aria-pressed", String(tool === "text")); - const line = tool === "line" || !tool && canvasEditor.selected?.shape === "line"; + const line = lineShape(tool) || !tool && lineShape(canvasEditor.selected?.shape); if (line) { el.shapeFill.checked = false; el.shapeStroke.checked = true; } const disabled = !state.editing || !state.ready || state.exporting; + el.arrowTool.disabled = disabled || !pageCan("insert"); for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { button.disabled = disabled || !pageCan("insert"); button.setAttribute("aria-pressed", String(tool === name)); @@ -1560,6 +1695,10 @@ async function changeDocument(name, item, ...args) { if (doc.revision === state.editorInfo?.revision) { return true; } + if ((name === "ofdgoUndo" || name === "ofdgoRedo") && JSON.stringify(doc.outlines) !== JSON.stringify(state.doc.outlines)) { + state.outlineSelection = null; + state.outlineExpanded.clear(); + } setEditorInfo(doc); if (name === "ofdgoUpdateInfo") { Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject }); @@ -1567,8 +1706,10 @@ async function changeDocument(name, item, ...args) { updateControls(); return true; } - if (name === "ofdgoChangeOutline") { + if (name === "ofdgoChangeOutline" || name === "ofdgoMoveOutline") { + remapOutlineExpansion(name, args, doc.outlinePath); state.doc.outlines = doc.outlines; + state.outlineSelection = doc.outlinePath; renderOutlines(false); showNavigation(el.outlinesTab); updateControls(); @@ -1603,6 +1744,8 @@ async function changeDocument(name, item, ...args) { el.batchPagesStatus.textContent = err.message; } else if (el.objectStylePanel.open) { el.objectStyleStatus.textContent = err.message; + } else if (el.objectBoundsPanel.open) { + el.objectBoundsStatus.textContent = err.message; } else if (el.outlinePanel.open) { el.outlineStatus.textContent = err.message; } else if (el.infoPanel.open) { @@ -2121,6 +2264,7 @@ async function openOFD(file) { state.fileName = file.name || "ofdgo.ofd"; state.editing = false; state.objectClipboard = null; + state.styleClipboard = null; state.editorInfo = null; state.fontRenderPending = false; state.savedRevision = null; @@ -2134,6 +2278,7 @@ async function openOFD(file) { el.importPanel.close(); el.batchPagesPanel.close(); el.objectStylePanel.close(); + el.objectBoundsPanel.close(); el.outlinePanel.close(); updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); @@ -3668,23 +3813,26 @@ function showNavigation(selected) { 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(); + if (reset) { + state.navigationScroll.clear(); + state.outlineExpanded.clear(); + state.outlineSelection = null; + } const outlines = state.doc.outlines || []; el.pageListTitle.hidden = true; el.navigationTabs.hidden = false; 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", "删除"]]) { + for (const [action, label] of [["add", "新增"], ["update", "修改"], ["move", "移动"], ["delete", "删除"]]) { const button = document.createElement("button"); button.className = "button"; button.type = "button"; button.textContent = label; button.dataset.outlineAction = action; - button.disabled = action !== "add"; + button.disabled = action !== "add" && !state.outlineSelection?.length; editorClick(button, () => openOutlinePanel(action)); tools.append(button); } @@ -3885,6 +4033,7 @@ function createOutlineList(outlines, path = []) { } const link = document.createElement(outline.page || state.editing ? "button" : "span"); link.className = "outline-link"; + link.classList.toggle("selected", state.editing && JSON.stringify(current) === JSON.stringify(state.outlineSelection)); const title = document.createElement("span"); title.textContent = outline.title; link.append(title); @@ -3911,7 +4060,11 @@ function createOutlineList(outlines, path = []) { row.append(link); if (hasChildren) { const details = document.createElement("details"); - details.open = outline.expanded; + const key = JSON.stringify(current); + details.open = state.outlineExpanded.get(key) ?? outline.expanded; + details.addEventListener("toggle", () => { + if (details.isConnected) state.outlineExpanded.set(key, details.open); + }); details.append(row, createOutlineList(outline.children, current)); item.append(details); } else { @@ -4940,6 +5093,7 @@ function updateObjectControls(item, reset = false) { el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy"); el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform"); const cropping = Boolean(canvasEditor.crop); + el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform"); el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange"); el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled; el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "update"); @@ -5063,6 +5217,7 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.importForm.inert = busy; el.batchPagesForm.inert = busy; el.objectStyleForm.inert = busy; + el.objectBoundsForm.inert = busy; el.outlineForm.inert = busy; el.outlineList.inert = busy; el.navigationTabs.inert = busy; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 230b1cf..85d6bb3 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -32,6 +32,34 @@ export function objectTransform(box, dx, dy, corner = "") { return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale }; } +export function textTransform(box, dx, dy, corner) { + if (!corner) return objectTransform(box, dx, dy); + const left = corner.includes("w"), top = corner.includes("n"); + const horizontal = (left ? -dx : dx) / box.width; + const vertical = (top ? -dy : dy) / box.height; + const delta = corner.length === 1 || Math.abs(vertical) > Math.abs(horizontal) ? vertical : horizontal; + const scale = Math.max(Math.min(1, 1 / box.height), 1 + delta); + return { x: corner.length === 1 ? box.width * (1 - scale) / 2 : left ? box.width * (1 - scale) : 0, + y: top ? box.height * (1 - scale) : 0, scale }; +} + +export function lineShape(shape) { + return shape === "line" || shape === "arrow" || shape === "double-arrow"; +} + +function arrowPath(shape, box) { + const { x, y, width: w, height: h } = box, length = Math.hypot(w, h); + let path = `M${x} ${y}L${x + w} ${y + h}`; + if (!length) return path; + const head = Math.min(4, length / 3), ux = w / length, uy = h / length; + for (const reverse of shape === "double-arrow" ? [false, true] : [false]) { + const px = reverse ? x : x + w, py = reverse ? y : y + h, direction = reverse ? -1 : 1; + const bx = px - ux * head * direction, by = py - uy * head * direction; + path += `M${bx - uy * head * .45} ${by + ux * head * .45}L${px} ${py}L${bx + uy * head * .45} ${by - ux * head * .45}`; + } + return path; +} + export function selectionBounds(items) { const x = Math.min(...items.map(item => item.x)), y = Math.min(...items.map(item => item.y)); return { x, y, width: Math.max(...items.map(item => item.x + item.width)) - x, @@ -96,7 +124,7 @@ function cropBox(box, bounds, dx, dy, corner) { export function constrainedPoint(from, to, shape, shift) { let dx = to.x - from.x, dy = to.y - from.y; - if (shift && shape === "line") { + if (shift && lineShape(shape)) { const directions = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]]; const [x, y] = directions[(Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) + 8) % 8]; const length = (dx * x + dy * y) / (x * x + y * y); @@ -113,7 +141,7 @@ export function constrainedPoint(from, to, shape, shift) { export function reshapeBox(item, dx, dy, handle, shift) { const box = item.geometry; if (!dx && !dy && !shift) return { ...box }; - if (item.shape === "line") { + if (lineShape(item.shape)) { const start = { x: box.x, y: box.y }, end = { x: box.x + box.width, y: box.y + box.height }; const fixed = handle === "start" ? end : start, moving = handle === "start" ? start : end; const point = constrainedPoint(fixed, { x: moving.x + dx, y: moving.y + dy }, "line", shift); @@ -136,7 +164,8 @@ export function reshapeBox(item, dx, dy, handle, shift) { function paintShape(node, shape, box) { const { x, y, width, height } = box; - const attributes = shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height } + const attributes = lineShape(shape) && shape !== "line" ? { d: arrowPath(shape, box) } + : shape === "line" ? { x1: x, y1: y, x2: x + width, y2: y + height } : shape === "ellipse" ? { cx: x + width / 2, cy: y + height / 2, rx: width / 2, ry: height / 2 } : { x, y, width, height }; for (const [key, value] of Object.entries(attributes)) { @@ -237,18 +266,21 @@ export class CanvasEditor { this.pages.set(surface, { index, width: page.width, height: page.height }); const layer = document.createElement("div"); layer.className = "edit-layer"; - for (const object of page.objects) { + const artwork = new Map([...surface.querySelectorAll("[data-ofd-object]")].map((node, order) => [node.getAttribute("data-ofd-object"), { node, order }])); + const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0)); + for (const object of objects) { const node = document.createElement("div"); node.className = "edit-object"; node.classList.toggle("read-only", !canEditObject(object, "transform")); node.title = objectEditReason(object); - if (object.shape === "line") { + if (lineShape(object.shape)) { node.classList.add("edit-line"); } node.tabIndex = 0; 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 }; + item.artwork = artwork.get(object.id)?.node; if (object.type === "PathObject" && (object.shape || object.outline)) { node.classList.add("edit-contour"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); @@ -263,10 +295,10 @@ export class CanvasEditor { } this.nodes.set(node, item); node.addEventListener("focus", () => this.select(item)); - const handles = !canEditObject(object, "transform") ? [] : object.shape === "line" ? ["start", "end"] + const handles = !canEditObject(object, "transform") ? [] : lineShape(object.shape) ? ["start", "end"] : object.shape ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : object.type === "TextObject" && canEditObject(object, "reflow") && canEditObject(object, "layoutKnown") - ? ["nw", "ne", "e", "se", "sw", "w"] : ["nw", "ne", "sw", "se"]; + ? ["nw", "n", "ne", "e", "se", "s", "sw", "w"] : ["nw", "ne", "sw", "se"]; for (const corner of handles) { const handle = document.createElement("span"); handle.className = `edit-handle edit-${corner}`; @@ -299,6 +331,11 @@ export class CanvasEditor { y: change.y + (member.y - item.y) * (change.scale - 1), scale: change.scale, }); } + if (item.artwork) { + const scale = change.scale, x = item.x * (1 - scale) + change.x, y = item.y * (1 - scale) + change.y; + if (x || y || scale !== 1) item.artwork.setAttribute("transform", `translate(${x} ${y}) scale(${scale})`); + else item.artwork.removeAttribute("transform"); + } if (item.shape) { this.placeShape(item, { x: item.geometry.x + change.x + (item.geometry.x - item.x) * (change.scale - 1), y: item.geometry.y + change.y + (item.geometry.y - item.y) * (change.scale - 1), @@ -328,7 +365,7 @@ export class CanvasEditor { left: `${x * PX_PER_MM}px`, top: `${y * PX_PER_MM}px`, width: `${Math.abs(box.width) * PX_PER_MM}px`, height: `${Math.abs(box.height) * PX_PER_MM}px`, }); - if (item.shape === "line") { + if (lineShape(item.shape)) { for (const handle of item.node.children) { if (!handle.dataset.corner) continue; const end = handle.dataset.corner === "end"; @@ -340,7 +377,8 @@ export class CanvasEditor { 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 } + const values = lineShape(item.shape) && item.shape !== "line" ? { d: arrowPath(item.shape, { x: box.x - x, y: box.y - y, width: box.width, height: box.height }) } + : 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)); @@ -430,12 +468,12 @@ export class CanvasEditor { this.cycleSelection(event); return; } - const node = event.target.closest(".edit-object, .edit-selection"); - if (node) { - event.preventDefault(); - } + let node = event.target.closest(".edit-object, .edit-selection"); + const direct = this.nodes.get(node); + const target = event.target.dataset.corner || direct && !direct.contours ? direct : this.itemsAt(event)[0]; + if (node || target) event.preventDefault(); + node = target?.node; this.viewer.focus({ preventScroll: true }); - const target = node ? this.nodes.get(node) : null; const additive = event.shiftKey || event.ctrlKey || event.metaKey || this.multiple; if (!target) { this.startMarquee(event, additive); @@ -462,7 +500,7 @@ export class CanvasEditor { .map(node => this.nodes.get(node)).filter(member => !selected.includes(member)); this.drag.targets.push({ x: 0, y: 0, ...item.page }); } - if (item.shape === "line" && this.drag.corner) { + if (lineShape(item.shape) && this.drag.corner) { Object.assign(this.drag, this.createPreview(item.surface, item.page, item.shape, { fill: false, stroke: true, strokeColor: "var(--accent)", lineWidth: item.lineWidth, })); @@ -470,13 +508,28 @@ export class CanvasEditor { this.viewer.setPointerCapture(event.pointerId); } - cycleSelection(event) { + contains(item, point, tolerance = 0) { + if (point.x < item.x - tolerance || point.x > item.x + item.width + tolerance || point.y < item.y - tolerance || point.y > item.y + item.height + tolerance) return false; + if (!item.contours) return point.x >= item.x && point.x <= item.x + item.width && point.y >= item.y && point.y <= item.y + item.height; + this.hitContext ||= document.createElement("canvas").getContext("2d"); + item.hitPaths ||= item.contours.map(contour => ({ path: new Path2D(contour.path), rule: contour.evenOdd ? "evenodd" : "nonzero" })); + this.hitContext.lineWidth = tolerance * 2; + return item.hitPaths.some(({ path, rule }) => this.hitContext.isPointInPath(path, point.x, point.y, rule) + || tolerance > 0 && this.hitContext.isPointInStroke(path, point.x, point.y)); + } + + itemsAt(event) { const surface = event.target.closest(".page-surface"); const page = this.pages.get(surface); - if (!page) return; - const point = pagePoint(event.clientX, event.clientY, surface.getBoundingClientRect(), page, this.options.rotation()); - const items = [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse() - .filter(item => point.x >= item.x && point.x <= item.x + item.width && point.y >= item.y && point.y <= item.y + item.height); + if (!page) return []; + const rect = surface.getBoundingClientRect(), rotation = this.options.rotation(); + const point = pagePoint(event.clientX, event.clientY, rect, page, rotation); + const tolerance = 3 * page.width / (rotation % 180 ? rect.height : rect.width); + return [...surface.querySelectorAll(".edit-object")].map(node => this.nodes.get(node)).reverse().filter(item => this.contains(item, point, tolerance)); + } + + cycleSelection(event) { + const items = this.itemsAt(event); if (!items.length) return; event.preventDefault(); this.viewer.focus({ preventScroll: true }); @@ -514,6 +567,15 @@ export class CanvasEditor { } const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation); + if (!drag.box && !drag.change && drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) { + const scale = (drag.rotation % 180 ? drag.rect.width : drag.rect.height) / drag.item.page.height; + const dx = to.x - from.x, dy = to.y - from.y; + const frame = drag.item.textFrame; + if ((!frame || frame.matrix[0] > 0 && frame.matrix[3] > 0) + && drag.item.height * scale < 28 && Math.abs(dy) * scale >= 3 && Math.abs(dy) > Math.abs(dx) / 2) { + drag.corner = (dy < 0 ? "n" : "s") + drag.corner; + } + } if (drag.item.type === "TextObject" && (drag.corner === "w" || drag.corner === "e")) { if (drag.item.textFrame) { const frame = drag.item.textFrame, [a, b, c, d] = frame.matrix; @@ -535,7 +597,8 @@ export class CanvasEditor { if (drag.node) paintShape(drag.node, drag.item.shape, drag.box || drag.item.geometry); return; } - drag.change = objectTransform(drag.item, to.x - from.x, to.y - from.y, drag.corner); + const transform = drag.item.type === "TextObject" ? textTransform : objectTransform; + drag.change = transform(drag.item, to.x - from.x, to.y - from.y, drag.corner); if (!drag.corner) this.snap(drag, event.altKey); this.place(drag.item, drag.change); } @@ -681,8 +744,7 @@ export class CanvasEditor { drag.node.setAttribute("d", drag.points.map((point, index) => `${index ? "L" : "M"}${point.x} ${point.y}`).join(" ") + " Z"); return; } - const item = [...drag.items].reverse().find(item => point.x >= item.x && point.x <= item.x + item.width - && point.y >= item.y && point.y <= item.y + item.height); + const item = [...drag.items].reverse().find(item => this.contains(item, point, 3 * drag.page.width / (drag.rotation % 180 ? drag.rect.height : drag.rect.width))); if (item) { drag.erased.add(item); item.node.classList.add("erasing"); } } @@ -729,7 +791,7 @@ export class CanvasEditor { this.viewer.focus({ preventScroll: true }); const shape = this.tool; const style = this.options.drawStyle(); - if (shape === "line") { + if (lineShape(shape)) { style.fill = false; style.stroke = true; } @@ -758,7 +820,7 @@ export class CanvasEditor { const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg"); preview.classList.add("edit-preview"); preview.setAttribute("viewBox", `0 0 ${page.width} ${page.height}`); - const node = document.createElementNS("http://www.w3.org/2000/svg", shape === "rectangle" ? "rect" : shape); + const node = document.createElementNS("http://www.w3.org/2000/svg", lineShape(shape) && shape !== "line" ? "path" : shape === "rectangle" ? "rect" : shape); node.setAttribute("fill", style.fill ? style.fillColor : "none"); node.setAttribute("stroke", style.stroke ? style.strokeColor : "none"); node.setAttribute("stroke-width", style.lineWidth); @@ -777,11 +839,11 @@ export class CanvasEditor { const x = Math.min(from.x, to.x), y = Math.min(from.y, to.y); const width = Math.abs(to.x - from.x), height = drag.shape === "text" ? Math.max(5, Math.abs(to.y - from.y)) : Math.abs(to.y - from.y); const valid = Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) >= 3 - && (drag.shape === "line" ? width > 0 || height > 0 : width > 0 && height > 0); - drag.box = valid ? drag.shape === "line" + && (lineShape(drag.shape) ? width > 0 || height > 0 : width > 0 && height > 0); + drag.box = valid ? lineShape(drag.shape) ? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height } : null; - paintShape(drag.node, drag.shape === "text" ? "rectangle" : drag.shape, drag.shape === "line" + paintShape(drag.node, drag.shape === "text" ? "rectangle" : drag.shape, lineShape(drag.shape) ? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height }); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index c33c443..f1deb76 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -80,6 +80,9 @@ var currentSession *Session // pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 var pendingImport *ofdgo.Reader +// copiedStyle 当前文档中的独立样式快照 +var copiedStyle *ofdgo.GraphicObject + // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` @@ -113,6 +116,10 @@ func RunWASM() { registerCallback("ofdgoBatchPages", batchPages) registerCallback("ofdgoChangeOutline", changeOutline) registerCallback("ofdgoStyleObjects", styleObjects) + registerCallback("ofdgoMoveOutline", moveOutline) + registerCallback("ofdgoCaptureStyle", captureStyle) + registerCallback("ofdgoPasteStyle", pasteStyle) + registerCallback("ofdgoResizeObjects", resizeObjects) registerCallback("ofdgoLoadImport", loadImport) registerCallback("ofdgoImportPages", importPages) registerCallback("ofdgoInsertText", insertText) @@ -229,6 +236,7 @@ func openDocument(args []js.Value) (any, error) { clearImport() currentEditor = nil copiedObjects = nil + copiedStyle = nil if currentSession != nil { _ = currentSession.Close() currentSession = nil @@ -759,6 +767,17 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { item["lineWidth"] = path.LineWidth * editorPathScale(path) } } + if object.Type == "PathObject" || object.Type == "ImageObject" { + contours, err := currentSession.Renderer.ObjectContours(object, layer.DrawParam) + if err != nil { + return nil, err + } + paths := make([]any, len(contours)) + for i, contour := range contours { + paths[i] = map[string]any{"path": contour.Path, "evenOdd": contour.EvenOdd} + } + item["contours"] = paths + } if object.Type == "ImageObject" && capability.Update { full, err := object.ImageObject.ImageBounds() if err != nil { @@ -767,6 +786,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { item["imageBounds"] = editorBox(full) } if object.Type == "TextObject" { + item["bounds"] = editorBox(box) value, layout := object.TextObject.TextLayout() item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight @@ -1071,27 +1091,100 @@ func batchPages(args []js.Value) (any, error) { return editorPageInfo{info.(editorInfo), index}, nil } +// editorOutlineInfo 目录操作后的位置 +type editorOutlineInfo struct { + editorInfo + OutlinePath []int `json:"outlinePath"` +} + +// indexesFromJS 读取整数索引列表 +// 入参: value JavaScript数组 +// 返回: []int 索引列表 +func indexesFromJS(value js.Value) []int { + indexes := make([]int, value.Length()) + for i := range indexes { + indexes[i] = value.Index(i).Int() + } + return indexes +} + +// moveOutline 调整目录顺序或层级 +// 入参: args 源路径、目标父路径及插入索引 +// 返回: any 文档及新路径, error 错误信息 +func moveOutline(args []js.Value) (any, error) { + var path []int + info, err := changeObjects(func() error { + var err error + path, err = currentEditor.MoveOutline(indexesFromJS(args[0]), indexesFromJS(args[1]), args[2].Int()) + return err + }) + if err != nil { + return nil, err + } + return editorOutlineInfo{info.(editorInfo), path}, nil +} + +// captureStyle 保存单个对象样式来源,不改写文档 +// 入参: args 页面索引和对象标识 +// 返回: any 空结果, error 错误信息 +func captureStyle(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + object, err := currentEditor.Object(args[0].Int(), args[1].String()) + if err != nil { + return nil, err + } + copiedStyle = &object + return nil, nil +} + +// pasteStyle 将保存的样式应用到同类型选区 +// 入参: args 页面索引和对象标识列表 +// 返回: any 文档信息, error 错误信息 +func pasteStyle(args []js.Value) (any, error) { + return changeObjects(func() error { + if copiedStyle == nil { + return fmt.Errorf("style clipboard is empty") + } + return currentEditor.CopyStyle(args[0].Int(), stringsFromJS(args[1]), *copiedStyle) + }) +} + +// resizeObjects 设置选区位置及尺寸 +// 入参: args 页面索引、对象标识、横纵坐标及宽高 +// 返回: any 文档信息, error 错误信息 +func resizeObjects(args []js.Value) (any, error) { + return changeObjects(func() error { + return currentEditor.ResizeObjects(args[0].Int(), stringsFromJS(args[1]), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + }) +} + // 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() - } + path := indexesFromJS(args[1]) + info, err := changeObjects(func() error { switch args[0].String() { case "add": - _, err := currentEditor.AddOutline(path, args[2].String(), args[3].Int()) + var err error + path, 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) + err := currentEditor.DeleteOutline(path) + path = nil + return err default: return fmt.Errorf("unsupported outline action %q", args[0].String()) } }) + if err != nil { + return nil, err + } + return editorOutlineInfo{info.(editorInfo), path}, nil } // styleObjects 原子更新对象透明度和路径描边属性 @@ -1257,8 +1350,10 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) { _ = currentSession.Close() } currentSession = session + session.editing = true if currentEditor != editor { copiedObjects = nil + copiedStyle = nil } currentEditor = editor return editorSummary(), nil diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 94d1f48..5c99973 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -109,6 +109,7 @@ type Session struct { signatures []SignatureInfo signatureError error signaturesRead bool + editing bool } // DocumentInfo 文档信息 @@ -455,7 +456,11 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { s.textCache[index] = text } } - resources, err := renderer.RenderToSVGWithResources(page, &buf) + renderSVG := renderer.RenderToSVGWithResources + if s.editing { + renderSVG = renderer.RenderToSVGWithObjects + } + resources, err := renderSVG(page, &buf) if err != nil { return PageSVG{}, err } diff --git a/ofdgo_editor_outline.go b/ofdgo_editor_outline.go index a9ea505..2aa4040 100644 --- a/ofdgo_editor_outline.go +++ b/ofdgo_editor_outline.go @@ -187,6 +187,62 @@ func (e *Editor) DeleteOutline(path []int) error { return e.setOutlineXML(editorPatchXML(data, []editorXMLPatch{{node.start, node.end, nil}})) } +// MoveOutline 移动目录及其子树,保留原始动作和扩展字段,一次撤销恢复 +// 入参: path 节点路径, parent 移动前的目标父路径,空路径表示根, index 移除源节点后在目标父节点中的插入索引 +// 返回: []int 移动后的路径, error 错误信息 +func (e *Editor) MoveOutline(path, parent []int, index int) ([]int, error) { + if len(path) == 0 || len(parent) >= len(path) && slices.Equal(path, parent[:len(path)]) { + return nil, fmt.Errorf("cannot move an outline into itself") + } + 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 + } + target, err := editorOutlineNode(root, parent) + if err != nil { + return nil, err + } + children := editorOutlineChildren(target) + same := slices.Equal(path[:len(path)-1], parent) + if same { + children = slices.Delete(children, path[len(path)-1], path[len(path)-1]+1) + } + if index < 0 || index > len(children) { + return nil, fmt.Errorf("outline insertion index %d out of range", index) + } + result := slices.Clone(parent) + if len(result) >= len(path) && slices.Equal(path[:len(path)-1], result[:len(path)-1]) && result[len(path)-1] > path[len(path)-1] { + result[len(path)-1]-- + } + result = append(result, index) + if same && path[len(path)-1] == index { + return result, nil + } + value, err := editorXMLStandalone(data[node.start:node.end], node) + if err != nil { + return nil, err + } + patches := []editorXMLPatch{{node.start, node.end, nil}} + if target.open == target.end { + patches = append(patches, editorXMLContent(data, target, value)) + } else { + at := target.close + if index < len(children) { + at = children[index].start + } + patches = append(patches, editorXMLPatch{at, at, value}) + } + return result, e.setOutlineXML(editorPatchXML(data, patches)) +} + // validateOutline 校验目录标题和目标页面 // 入参: title 标题, page 页面索引 // 返回: error 错误信息 diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 8741e43..bf4f29b 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "math" "strings" ) @@ -149,9 +150,6 @@ func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { 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) @@ -161,10 +159,107 @@ func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { return e.updateObjects(page, objects, true) } +// CopyStyle 将同类型对象的外观复制到选区,不复制内容、位置、资源数据或动作 +// 文字复制字体、字号、颜色及透明度,沿用目标段落设置;字体标识须属于当前文档 +// 路径按页面中的实际线宽和虚线长度复制,保持目标变换 +// 入参: page 目标页面索引, ids 目标对象标识, source 样式来源快照 +// 返回: error 错误信息 +func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) error { + var alpha *int + switch source.Type { + case "TextObject": + alpha = source.TextObject.Alpha + case "PathObject": + alpha = source.PathObject.Alpha + case "ImageObject": + alpha = source.ImageObject.Alpha + default: + return fmt.Errorf("unsupported style source %q", source.Type) + } + if alpha != nil && (*alpha < 0 || *alpha > 255) { + return fmt.Errorf("alpha must be between 0 and 255") + } + objects, _, err := e.selectedObjects(page, ids) + if err != nil { + return err + } + for _, object := range objects { + if object.Type != source.Type { + return fmt.Errorf("style requires objects of the same type") + } + } + if source.Type == "TextObject" { + objects, err = e.styleTextObjects(objects, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}) + if err != nil { + return err + } + for i := range objects { + objects[i].TextObject.FillColor = cloneEditorData(source.TextObject.FillColor) + objects[i].TextObject.Alpha = cloneEditorData(source.TextObject.Alpha) + } + return e.UpdateObjects(page, objects) + } + if source.Type == "PathObject" { + from := source.PathObject + if from.DrawParam != "" { + return fmt.Errorf("style source must have resolved draw parameters") + } + if err := validateEditorStroke(from); err != nil { + return err + } + for _, color := range []*FillColor{from.FillColor, (*FillColor)(from.StrokeColor)} { + if err := creationColor(color); err != nil { + return err + } + } + } + for i, object := range objects { + object = cloneEditorData(object) + switch source.Type { + case "PathObject": + from, to := source.PathObject, &object.PathObject + to.Fill, to.Stroke, to.Alpha = from.Fill, from.Stroke, from.Alpha + to.FillColor, to.StrokeColor = from.FillColor, from.StrokeColor + to.LineWidth, to.Cap, to.Join, to.MiterLimit = from.LineWidth, from.Cap, from.Join, from.MiterLimit + to.DashPattern, to.DashOffset = from.DashPattern, from.DashOffset + scale := editorStrokeScale(from.CTM) / editorStrokeScale(to.CTM) + if scale != 1 { + if to.LineWidth == 0 { + to.LineWidth = defaultPathLineWidth + } + to.LineWidth *= scale + to.DashPattern = scaleTextNumbers(to.DashPattern, scale) + if to.DashOffset != nil { + value := *to.DashOffset * scale + to.DashOffset = &value + } + } + case "ImageObject": + object.ImageObject.Alpha = source.ImageObject.Alpha + } + objects[i] = object + } + return e.updateObjects(page, objects, true) +} + +// editorStrokeScale 获取路径在页面坐标中的描边倍率,与渲染器保持一致 +// 入参: ctm 对象变换 +// 返回: float64 描边倍率 +func editorStrokeScale(ctm string) float64 { + m := NewMatrix(ctm) + if scale := math.Sqrt(math.Abs(m.a*m.d - m.b*m.c)); scale > 0 { + return scale + } + return 1 +} + // validateEditorStroke 校验路径描边尺寸、端点和虚线 // 入参: path 路径对象 // 返回: error 错误信息 func validateEditorStroke(path PathObject) error { + if path.Join != "" && path.Join != "Miter" && path.Join != "Round" && path.Join != "Bevel" { + return fmt.Errorf("invalid line join %q", path.Join) + } if !finite(path.LineWidth) || path.LineWidth < 0 || !finite(path.MiterLimit) || path.MiterLimit < 0 { return fmt.Errorf("invalid path stroke dimensions") } diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 5f549d3..65639cb 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -87,11 +87,22 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { if err != nil { return err } + updates, err := e.styleTextObjects(objects, style) + if err != nil { + return err + } + return e.UpdateObjects(page, updates) +} + +// styleTextObjects 生成文字样式副本,不提交文档或历史 +// 入参: objects 原文字对象, style 样式增量 +// 返回: []GraphicObject 更新对象, error 错误信息 +func (e *Editor) styleTextObjects(objects []GraphicObject, style TextStyle) ([]GraphicObject, error) { updates := make([]GraphicObject, len(objects)) for i, object := range objects { id := editorObjectID(object) if object.Type != "TextObject" { - return fmt.Errorf("object %q is not text", id) + return nil, fmt.Errorf("object %q is not text", id) } updates[i] = cloneEditorData(object) text := &updates[i].TextObject @@ -100,7 +111,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { fontChanged := style.Font != "" && style.Font != text.Font sizeChanged := style.Size != 0 && style.Size != text.Size if sizeChanged && !known { - return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("object %q requires explicit paragraph layout before changing size", id)} + return nil, &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("object %q requires explicit paragraph layout before changing size", id)} } if fontChanged { text.Font = style.Font @@ -110,7 +121,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { } if known && (fontChanged || sizeChanged) { if err := e.LayoutText(text, value, layout); err != nil { - return err + return nil, err } } if style.Color != "" { @@ -120,7 +131,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { text.FillColor.Value = style.Color } } - return e.UpdateObjects(page, updates) + return updates, nil } // textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染 diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 5b16551..2b61206 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -64,6 +64,49 @@ func (e *Editor) FlipObjects(page int, ids []string, axis string) error { return e.orientObjects(page, ids, m) } +// ResizeObjects 将选区可见范围移动并缩放到指定矩形,文字与路径保持比例,图片支持独立宽高 +// 不重排文字或重采样图片,一次撤销恢复全部;基本图形独立调整宽高可使用PathObject.Reshape +// 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的目标范围 +// 返回: error 错误信息 +func (e *Editor) ResizeObjects(page int, ids []string, box Box) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + objects, _, err := e.selectedObjects(page, ids) + if err != nil || len(objects) == 0 { + return err + } + boxes, err := e.objectBounds(page, objects) + if err != nil { + return err + } + var before Box + for _, bounds := range boxes { + before = unionTextBox(before, bounds) + } + if before.W <= 0 || before.H <= 0 { + return fmt.Errorf("selection has no visible bounds") + } + sx, sy := box.W/before.W, box.H/before.H + uniform := math.Abs(sx-sy) <= 1e-9*math.Max(sx, sy) + matrix := Matrix{a: sx, d: sy, e: box.X - before.X*sx, f: box.Y - before.Y*sy} + for i, object := range objects { + object = cloneEditorData(object) + if uniform { + object, err = transformEditorObject(object, matrix.e, matrix.f, sx) + } else if object.Type == "ImageObject" { + object = transformEditorMatrix(object, matrix) + } else { + return fmt.Errorf("nonuniform resizing requires image objects") + } + if err != nil { + return err + } + objects[i] = object + } + return e.updateObjects(page, objects, true) +} + // orientObjects 更新Boundary与CTM,不重排文字或重采样图片 // 入参: page 页面索引, ids 对象标识, matrix 直角旋转或镜像矩阵 // 返回: error 错误信息 @@ -83,33 +126,38 @@ func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error { x, y := box.X+box.W/2, box.Y+box.H/2 matrix = TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y)) for i, object := range objects { - var boundary, ctm *string - switch object.Type { - case "TextObject": - boundary, ctm = &object.TextObject.Boundary, &object.TextObject.CTM - case "PathObject": - boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM - case "ImageObject": - boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM - default: - return fmt.Errorf("unsupported object type %q", object.Type) - } - before, _ := ParseBox(*boundary) - if object.Type == "ImageObject" && *ctm == "" { - *ctm = Matrix{a: before.W, d: before.H}.String() - } - after := matrix.TransformBox(before) - local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y)) - *ctm = local.Multiply(NewMatrix(*ctm)).String() - *boundary = editorBoxString(after) - if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { - *clips = transformObjectClips(*clips, local) - } - objects[i] = object + objects[i] = transformEditorMatrix(object, matrix) } return e.updateObjects(page, objects, true) } +// transformEditorMatrix 在页面坐标中变换基本对象,保留局部绘制数据和裁剪 +// 入参: object 对象, matrix 页面变换 +// 返回: GraphicObject 变换后的独立对象 +func transformEditorMatrix(object GraphicObject, matrix Matrix) GraphicObject { + var boundary, ctm *string + switch object.Type { + case "TextObject": + boundary, ctm = &object.TextObject.Boundary, &object.TextObject.CTM + case "PathObject": + boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM + case "ImageObject": + boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM + } + before, _ := ParseBox(*boundary) + if object.Type == "ImageObject" && *ctm == "" { + *ctm = Matrix{a: before.W, d: before.H}.String() + } + after := matrix.TransformBox(before) + local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y)) + *ctm = local.Multiply(NewMatrix(*ctm)).String() + *boundary = editorBoxString(after) + if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag { + *clips = transformObjectClips(*clips, local) + } + return object +} + // transformObjectClips 变换裁剪区域,不修改原始裁剪数据 // 入参: clips 裁剪集合, matrix 对象边界坐标中的变换 // 返回: *Clips 变换后的裁剪集合 diff --git a/ofdgo_renderer_bounds.go b/ofdgo_renderer_bounds.go index 4c2bf71..a82887d 100644 --- a/ofdgo_renderer_bounds.go +++ b/ofdgo_renderer_bounds.go @@ -28,6 +28,32 @@ import ( // 入参: object 图形对象, drawParam 图层绘制参数标识,无继承时为空 // 返回: Box 毫米坐标范围, error 错误信息 func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, error) { + return r.measureObject(object, drawParam, &boundsRenderer{}) +} + +// ObjectContour 页面坐标中的填充轮廓,Path为SVG路径,EvenOdd表示奇偶填充 +type ObjectContour struct { + Path string `json:"path"` + EvenOdd bool `json:"evenOdd,omitempty"` +} + +// ObjectContours 获取路径或图片的实际绘制区域,包含描边、虚线、变换及对象裁剪 +// 图片不解码像素,底纹不展开图案单元;不包含页面边界或父级变换 +// 入参: object 路径或图片对象, drawParam 图层绘制参数标识 +// 返回: []ObjectContour 可用于点选的轮廓, error 错误信息 +func (r *Renderer) ObjectContours(object GraphicObject, drawParam string) ([]ObjectContour, error) { + if object.Type != "PathObject" && object.Type != "ImageObject" { + return nil, fmt.Errorf("contours require a path or image object") + } + bounds := &boundsRenderer{collect: true} + _, err := r.measureObject(object, drawParam, bounds) + return bounds.contours, err +} + +// measureObject 复用渲染逻辑收集对象范围与可选轮廓 +// 入参: object 对象, drawParam 图层绘制参数标识, bounds 度量目标 +// 返回: Box 毫米范围, error 错误信息 +func (r *Renderer) measureObject(object GraphicObject, drawParam string, bounds *boundsRenderer) (Box, error) { if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "ImageObject" { return Box{}, fmt.Errorf("unsupported object type %q", object.Type) } @@ -44,7 +70,6 @@ func (r *Renderer) ObjectBounds(object GraphicObject, drawParam string) (Box, er renderer.pageText = nil renderer.textOnly = false defaults := renderer.drawParamDefaults(drawParam, nil) - bounds := &boundsRenderer{} ctx := canvas.NewContext(bounds) switch object.Type { case "TextObject": @@ -106,7 +131,9 @@ func boundsColor(color *FillColor) *FillColor { // boundsRenderer 收集绘制范围,不分配页面像素或合并独立图形轮廓 type boundsRenderer struct { - box Box + box Box + collect bool + contours []ObjectContour } // Size 返回不限定边界的度量画布尺寸 @@ -118,18 +145,27 @@ func (r *boundsRenderer) Size() (float64, float64) { // add 合并路径的精确曲线范围并转换为向下的纵轴 // 入参: path 绘制路径 func (r *boundsRenderer) add(path *canvas.Path) { + r.addContour(path, false) +} + +// addContour 合并范围并按需保留独立填充区域 +// 入参: path 绘制路径, evenOdd 是否使用奇偶规则 +func (r *boundsRenderer) addContour(path *canvas.Path, evenOdd bool) { if path.Empty() { return } rect := path.Bounds() r.box = unionTextBox(r.box, Box{X: rect.X0, Y: -rect.Y1, W: rect.W(), H: rect.H()}) + if r.collect { + r.contours = append(r.contours, ObjectContour{Path: path.Copy().Transform(canvas.Matrix{{1, 0, 0}, {0, -1, 0}}).ToSVG(), EvenOdd: evenOdd}) + } } // RenderPath 收集填充与描边范围,保持线帽、连接和虚线语义 // 入参: path 路径, style 绘制样式, m 变换矩阵 func (r *boundsRenderer) RenderPath(path *canvas.Path, style canvas.Style, m canvas.Matrix) { if style.HasFill() { - r.add(path.Copy().Transform(m)) + r.addContour(path.Copy().Transform(m), style.FillRule == canvas.EvenOdd) } if style.HasStroke() { p := path.Dash(style.DashOffset, style.Dashes...).Stroke(style.StrokeWidth, style.StrokeCapper, style.StrokeJoiner, canvas.Tolerance) diff --git a/ofdgo_renderer_layer.go b/ofdgo_renderer_layer.go index ee8a7ae..e54be95 100644 --- a/ofdgo_renderer_layer.go +++ b/ofdgo_renderer_layer.go @@ -169,6 +169,12 @@ func (r *Renderer) renderCompositeGraphicUnit(ctx *canvas.Context, cgu Composite // renderObject 渲染图形对象 // 入参: ctx 画布上下文, obj 图形对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径 func (r *Renderer) renderObject(ctx *canvas.Context, obj *GraphicObject, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) { + if groups, ok := ctx.Renderer.(interface { + beginObject(*GraphicObject) bool + endObject() + }); ok && groups.beginObject(obj) { + defer groups.endObject() + } switch obj.Type { case "TextObject": r.renderText(ctx, obj.TextObject, pageH, defaults, parentCTM, boundaryInCTM, parentClip) diff --git a/ofdgo_renderer_svg.go b/ofdgo_renderer_svg.go index d23d388..24ffcbb 100644 --- a/ofdgo_renderer_svg.go +++ b/ofdgo_renderer_svg.go @@ -19,6 +19,7 @@ import ( "bytes" "crypto/sha256" "fmt" + "html" "image" "image/png" "io" @@ -61,13 +62,14 @@ type svgResourceRenderer struct { seen map[string]bool styles strings.Builder err error + objects map[*GraphicObject]string } // RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体 // 入参: page 页面内容, writer 输出流 // 返回: []SVGFont 字体资源,可按Name复用并按Weight、Style注册,error 错误信息 func (r *Renderer) RenderToSVGWithFonts(page *PageContent, writer io.Writer) ([]SVGFont, error) { - resources, err := r.renderSVGResources(page, writer, false) + resources, err := r.renderSVGResources(page, writer, false, false) return resources.Fonts, err } @@ -75,14 +77,21 @@ func (r *Renderer) RenderToSVGWithFonts(page *PageContent, writer io.Writer) ([] // 入参: page 页面内容, writer 输出流 // 返回: SVGResources 可跨页面复用的资源, error 错误信息 func (r *Renderer) RenderToSVGWithResources(page *PageContent, writer io.Writer) (SVGResources, error) { - return r.renderSVGResources(page, writer, true) + return r.renderSVGResources(page, writer, true, false) +} + +// RenderToSVGWithObjects 渲染SVG并为页面直接对象添加data-ofd-object分组,保持原绘制顺序和分离资源 +// 入参: page 页面内容, writer 输出流 +// 返回: SVGResources 字体和图片资源, error 错误信息 +func (r *Renderer) RenderToSVGWithObjects(page *PageContent, writer io.Writer) (SVGResources, error) { + return r.renderSVGResources(page, writer, true, true) } // renderSVGResources 渲染SVG并收集外部资源 -// 入参: page 页面内容, writer 输出流, images 是否分离图片 +// 入参: page 页面内容, writer 输出流, images 是否分离图片, objects 是否标识页面直接对象 // 返回: SVGResources 外部资源, error 错误信息 -func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, images bool) (SVGResources, error) { - c, err := r.renderPage(page) +func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, images, objects bool) (SVGResources, error) { + box, err := r.GetPageBox(page) if err != nil { return SVGResources{}, err } @@ -90,7 +99,7 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image options.EmbedFonts = false buffer := bufio.NewWriter(writer) s := &svgResourceRenderer{ - SVG: svg.New(buffer, c.W, c.H, &options), + SVG: svg.New(buffer, box.W, box.H, &options), renderer: r, writer: buffer, seen: make(map[string]bool), @@ -98,7 +107,24 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image if images { s.imageNames = make(map[image.Image]string) } - c.RenderTo(s) + if objects { + s.objects = make(map[*GraphicObject]string) + for i := range page.Content.Layer { + for j := range page.Content.Layer[i].Objects { + object := &page.Content.Layer[i].Objects[j] + s.objects[object] = editorObjectID(*object) + } + } + if err := r.renderPageToContext(canvas.NewContext(s), page, true); err != nil { + return SVGResources{}, err + } + } else { + c, err := r.renderPage(page) + if err != nil { + return SVGResources{}, err + } + c.RenderTo(s) + } if s.err != nil { return SVGResources{}, s.err } @@ -109,6 +135,23 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush() } +// beginObject 开始标记当前页面直接对象,模板、注释和内部复合对象不单独标记 +// 入参: object 图形对象 +// 返回: bool 是否写入分组 +func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool { + id := s.objects[object] + if id == "" { + return false + } + fmt.Fprintf(s.writer, ``, html.EscapeString(id)) + return true +} + +// endObject 结束对象分组 +func (s *svgResourceRenderer) endObject() { + fmt.Fprint(s.writer, ``) +} + // RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换 // 入参: img 图片对象, m 变换矩阵 func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) { diff --git a/ofdgo_shape.go b/ofdgo_shape.go index e619eef..ee24702 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -47,9 +47,11 @@ func (p PathObject) Outline() (string, error) { type ShapeKind string const ( - ShapeLine ShapeKind = "line" - ShapeRectangle ShapeKind = "rectangle" - ShapeEllipse ShapeKind = "ellipse" + ShapeLine ShapeKind = "line" + ShapeArrow ShapeKind = "arrow" + ShapeDoubleArrow ShapeKind = "double-arrow" + ShapeRectangle ShapeKind = "rectangle" + ShapeEllipse ShapeKind = "ellipse" ) // NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充 @@ -63,13 +65,38 @@ func NewShape(kind ShapeKind, box Box) (PathObject, error) { } object := PathObject{LineWidth: defaultPathLineWidth} switch kind { - case ShapeLine: + case ShapeLine, ShapeArrow, ShapeDoubleArrow: if box.W == 0 && box.H == 0 { return PathObject{}, fmt.Errorf("line endpoints must differ") } - x, y := math.Min(0, box.W)-defaultPathLineWidth/2, math.Min(0, box.H)-defaultPathLineWidth/2 - object.AbbreviatedData = fmt.Sprintf("M %g %g L %g %g", -x, -y, box.W-x, box.H-y) - box = Box{X: box.X + x, Y: box.Y + y, W: math.Abs(box.W) + defaultPathLineWidth, H: math.Abs(box.H) + defaultPathLineWidth} + points := []Point{{}, {X: box.W, Y: box.H}} + if kind != ShapeLine { + length := math.Hypot(box.W, box.H) + head := math.Min(4, length/3) + x, y := box.W/length*head, box.H/length*head + points = append(points, Point{X: box.W - x - y*0.45, Y: box.H - y + x*0.45}, Point{X: box.W, Y: box.H}, Point{X: box.W - x + y*0.45, Y: box.H - y - x*0.45}) + if kind == ShapeDoubleArrow { + points = append(points, Point{X: x - y*0.45, Y: y + x*0.45}, Point{}, Point{X: x + y*0.45, Y: y - x*0.45}) + } + } + left, top, right, bottom := 0.0, 0.0, 0.0, 0.0 + for _, point := range points { + left, top, right, bottom = math.Min(left, point.X), math.Min(top, point.Y), math.Max(right, point.X), math.Max(bottom, point.Y) + } + left, top, right, bottom = left-defaultPathLineWidth/2, top-defaultPathLineWidth/2, right+defaultPathLineWidth/2, bottom+defaultPathLineWidth/2 + var data strings.Builder + for i, point := range points { + command := "L" + if i == 0 || i == 2 || i == 5 { + command = "M" + } + if i > 0 { + data.WriteByte(' ') + } + fmt.Fprintf(&data, "%s %g %g", command, point.X-left, point.Y-top) + } + object.AbbreviatedData = data.String() + box = Box{X: box.X + left, Y: box.Y + top, W: right - left, H: bottom - top} case ShapeRectangle, ShapeEllipse: if box.W <= 0 || box.H <= 0 { return PathObject{}, fmt.Errorf("shape dimensions must be positive") @@ -108,6 +135,16 @@ func (p PathObject) Shape() (ShapeKind, Box) { var kind ShapeKind var box Box switch { + case (commands == "MLMLL" && len(values) == 10) || (commands == "MLMLLMLL" && len(values) == 16): + kind = ShapeArrow + if commands == "MLMLLMLL" { + kind = ShapeDoubleArrow + } + box = Box{X: values[0], Y: values[1], W: values[2] - values[0], H: values[3] - values[1]} + shape, err := NewShape(kind, Box{W: box.W, H: box.H}) + if err != nil || !sameShapePath(tokens, strings.Fields(shape.AbbreviatedData)) { + return "", Box{} + } case commands == "ML" && len(values) == 4 && len(tokens) == 6 && tokens[0] == "M" && tokens[3] == "L": kind, box = ShapeLine, Box{X: values[0], Y: values[1], W: values[2] - values[0], H: values[3] - values[1]} case commands == "MLLLC" && len(values) == 8: @@ -117,7 +154,8 @@ func (p PathObject) Shape() (ShapeKind, Box) { default: return "", Box{} } - if kind != ShapeLine { + line := kind == ShapeLine || kind == ShapeArrow || kind == ShapeDoubleArrow + if !line { shape, err := NewShape(kind, box) if err != nil || !sameShapePath(tokens, strings.Fields(shape.AbbreviatedData)) { return "", Box{} @@ -135,7 +173,7 @@ func (p PathObject) Shape() (ShapeKind, Box) { if err != nil || !axisAlignedMatrix(m) { return "", Box{} } - if kind == ShapeLine { + if line { x, y := m.Transform(box.X, box.Y) w, h := matrixVector(m, box.W, box.H) box = Box{X: boundary.X + x, Y: boundary.Y + y, W: w, H: h} @@ -161,7 +199,7 @@ func sameShapePath(a, b []string) bool { } x, errX := strconv.ParseFloat(token, 64) y, errY := strconv.ParseFloat(b[i], 64) - if errX != nil || errY != nil || x != y { + if errX != nil || errY != nil || !finite(x) || !finite(y) || math.Abs(x-y) > 1e-10*math.Max(1, math.Max(math.Abs(x), math.Abs(y))) { return false } } @@ -190,7 +228,8 @@ func (p PathObject) Reshape(box Box) (PathObject, error) { } else { w, h = box.H/m.b, box.W/m.c } - if kind != ShapeLine { + line := kind == ShapeLine || kind == ShapeArrow || kind == ShapeDoubleArrow + if !line { w, h = math.Abs(w), math.Abs(h) } shape, err := NewShape(kind, Box{W: w, H: h}) @@ -203,7 +242,7 @@ func (p PathObject) Reshape(box Box) (PathObject, error) { } else { m.e, m.f = 0, 0 var x, y float64 - if kind == ShapeLine { + if line { x, y = m.Transform(-boundary.X, -boundary.Y) } else { bounds := m.TransformBox(boundary)