diff --git a/assets/webui/index.html b/assets/webui/index.html index df95d50..a6cd89f 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -89,6 +89,18 @@ 插入 +
+ + + + 绘制 +
+
+
+
+
pt
+ 图形 +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index e7b64d3..a75a152 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -87,6 +87,8 @@ a:focus-visible { .editor-toolbar { display: flex; flex-wrap: wrap; + max-height: 40dvh; + overflow: auto; gap: 12px; padding: 8px 12px 6px; border-bottom: 1px solid var(--line); @@ -120,6 +122,19 @@ a:focus-visible { min-width: 0; } +.shape-paint, +.shape-paint label { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.shape-paint input[type="checkbox"] { + margin: 0; + accent-color: var(--accent); +} + .editor-group:has(#addPageButton) { grid-template-columns: repeat(6, max-content); } @@ -1162,6 +1177,30 @@ button.outline-link:hover { pointer-events: none; } +.editing-draw .page-surface { + cursor: crosshair; + touch-action: none; + user-select: none; +} + +.editing-draw .edit-layer, +.editing-draw .page-surface > :not(.edit-preview) { + pointer-events: none; +} + +.editing-draw .edit-object { + pointer-events: none; +} + +.edit-preview { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; +} + .edit-object { position: absolute; pointer-events: auto; @@ -1175,6 +1214,12 @@ button.outline-link:hover { outline: calc(1px / var(--surface-scale)) solid var(--accent); } +.edit-line::before { + position: absolute; + inset: calc(-4px / var(--surface-scale)); + content: ""; +} + .edit-object.selected { background: rgba(15, 123, 104, 0.04); } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 20d6efd..c8f2904 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -94,6 +94,14 @@ const el = { textFont: document.querySelector("#textFont"), textSize: document.querySelector("#textSize"), textColor: document.querySelector("#textColor"), + drawLineButton: document.querySelector("#drawLineButton"), + drawRectangleButton: document.querySelector("#drawRectangleButton"), + drawEllipseButton: document.querySelector("#drawEllipseButton"), + shapeFill: document.querySelector("#shapeFill"), + shapeFillColor: document.querySelector("#shapeFillColor"), + shapeStroke: document.querySelector("#shapeStroke"), + shapeStrokeColor: document.querySelector("#shapeStrokeColor"), + shapeWidth: document.querySelector("#shapeWidth"), undoButton: document.querySelector("#undoButton"), redoButton: document.querySelector("#redoButton"), insertTextButton: document.querySelector("#insertTextButton"), @@ -231,6 +239,10 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onDelete: (item) => changeDocument("ofdgoDeleteObject", item), onEdit: editCanvasObject, + drawStyle: shapeStyle, + onTool: updateDrawingControls, + onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape, + box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null), onTextChange: () => { const editing = canvasEditor.input; @@ -246,7 +258,8 @@ el.selectObjectButton.addEventListener("click", () => { if (document.body.hasAttribute("aria-busy")) { return; } - state.selectObjects = !state.selectObjects || state.panMode; + state.selectObjects = !state.selectObjects || state.panMode || Boolean(canvasEditor.tool); + canvasEditor.setTool(""); if (state.selectObjects && state.panMode) { togglePan(); } @@ -259,7 +272,7 @@ el.deleteObjectButton.addEventListener("click", () => { }); el.editObjectButton.addEventListener("click", () => { if (canvasEditor.selected) { - openInsertPanel(!canvasEditor.selected.image, canvasEditor.selected); + openInsertPanel(canvasEditor.selected.type === "TextObject", canvasEditor.selected); } }); el.textSize.addEventListener("change", () => changeTextStyle(false)); @@ -267,19 +280,25 @@ el.textColor.addEventListener("change", () => changeTextStyle(true)); el.textFont.addEventListener("change", () => { const item = canvasEditor.selected; const font = state.textFonts[Number(el.textFont.value)]; - if (item && !item.image && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) { + if (item?.type === "TextObject" && state.ready && !document.body.hasAttribute("aria-busy") && font && !font.embedded) { return changeDocument("ofdgoUpdateText", item, item.text, fontManager.read(font), item.size, null); } }); -el.textSize.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === "Escape") { - event.preventDefault(); - if (event.key === "Escape") { - updateObjectControls(canvasEditor.selected, true); +el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); +for (const input of [el.textSize, el.shapeWidth]) { + input.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === "Escape") { + event.preventDefault(); + if (event.key === "Escape") { + updateObjectControls(canvasEditor.selected, true); + if (input === el.shapeWidth && canvasEditor.selected?.type !== "PathObject") { + input.value = input.defaultValue; + } + } + input.blur(); } - el.textSize.blur(); - } -}); + }); +} el.copyObjectButton.addEventListener("click", () => { const item = canvasEditor.selected; if (!item) { @@ -310,6 +329,21 @@ el.objectAlign.addEventListener("change", () => { }); el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo")); el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo")); +for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { + button.addEventListener("click", () => { + if (!state.editing || document.body.hasAttribute("aria-busy")) { + return; + } + const next = canvasEditor.tool === tool ? "" : tool; + state.selectObjects = true; + setPan(false); + canvasEditor.setTool(next); + el.viewerPanel.focus({ preventScroll: true }); + }); +} +for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) { + input.addEventListener("change", changeShapeStyle); +} el.addPageButton.addEventListener("click", () => { const page = currentPageInfo(); return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); @@ -651,7 +685,11 @@ async function createDocument(event) { } async function editCanvasObject(item) { - if (item.image) { + if (item.type === "PathObject") { + el.shapeWidth.focus(); + return; + } + if (item.type === "ImageObject") { return openInsertPanel(false, item); } if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) { @@ -709,7 +747,7 @@ async function openInsertPanel(text, item = null) { function updateInsertFonts() { const selected = state.insertFonts[Number(el.insertFont.value)]; - state.insertFonts = editorFonts(state.insertObject?.image ? null : state.insertObject); + state.insertFonts = editorFonts(state.insertObject?.type === "TextObject" ? state.insertObject : null); setFontOptions(el.insertFont, state.insertFonts, selected); el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length; el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : ""; @@ -736,7 +774,7 @@ function setFontOptions(select, fonts, selected) { } function updateTextFonts(item, refresh = false) { - const text = item && !item.image; + const text = item?.type === "TextObject"; if (refresh || state.textFonts[0]?.id !== (text ? `embedded:${item.font}` : undefined)) { state.textFonts = text ? editorFonts(item) : []; setFontOptions(el.textFont, state.textFonts, state.textFonts[0]); @@ -815,7 +853,7 @@ function textPoints(size) { async function changeTextStyle(color) { const item = canvasEditor.selected; - if (!item || item.image || !state.ready || state.exporting) { + if (item?.type !== "TextObject" || !state.ready || state.exporting) { return; } if (!color && !el.textSize.checkValidity()) { @@ -831,6 +869,55 @@ async function changeTextStyle(color) { updateObjectControls(canvasEditor.selected, true); } +function shapeStyle() { + return { fill: el.shapeFill.checked, fillColor: el.shapeFillColor.value, + stroke: el.shapeStroke.checked, strokeColor: el.shapeStrokeColor.value, + lineWidth: Number(el.shapeWidth.value) * 25.4 / 72 }; +} + +function strokePoints(width) { + return Number((width * 72 / 25.4).toPrecision(6)); +} + +async function changeShapeStyle() { + const item = canvasEditor.selected; + if (!state.editing || document.body.hasAttribute("aria-busy")) { + return; + } + if (!el.shapeWidth.checkValidity() || Number(el.shapeWidth.value) <= 0) { + el.shapeWidth.value = String(item?.type === "PathObject" ? strokePoints(item.lineWidth) : 1); + return; + } + if (!el.shapeFill.checked && !el.shapeStroke.checked) { + el.shapeStroke.checked = true; + } + const style = shapeStyle(); + if (item?.type === "PathObject") { + await changeDocument("ofdgoUpdatePathStyle", item, style.fill, style.fillColor, style.stroke, style.strokeColor, + Number(el.shapeWidth.value) === strokePoints(item.lineWidth) ? item.lineWidth : style.lineWidth); + updateObjectControls(canvasEditor.selected, true); + } + updateDrawingControls(); +} + +function updateDrawingControls() { + const tool = canvasEditor.tool; + const line = tool === "line" || !tool && canvasEditor.selected?.line; + if (line) { + el.shapeFill.checked = false; + el.shapeStroke.checked = true; + } + const disabled = !state.editing || !state.ready || state.exporting; + for (const [button, name] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { + button.disabled = disabled; + button.setAttribute("aria-pressed", String(tool === name)); + } + el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool)); + el.shapeFill.disabled = el.shapeStroke.disabled = disabled || line; + el.shapeFillColor.disabled = disabled || line || !el.shapeFill.checked; + el.shapeStrokeColor.disabled = el.shapeWidth.disabled = disabled || !line && !el.shapeStroke.checked; +} + async function changeDocument(name, item, ...args) { if (!state.editing || document.body.hasAttribute("aria-busy")) { return; @@ -855,12 +942,12 @@ async function changeDocument(name, item, ...args) { if (!item || name === "ofdgoDeleteObject") { canvasEditor.clear(); } - if (name === "ofdgoCopyObject") { + if (name === "ofdgoCopyObject" || name === "ofdgoInsertShape") { canvasEditor.selectLast = true; } openSeq = ++state.openSeq; - if (item) { - await refreshEditorPage(doc, item.index, openSeq); + if (item || name === "ofdgoInsertShape") { + await refreshEditorPage(doc, item ? item.index : args[0], openSeq); } else { const samePage = doc.pages.findIndex((page) => page.id === previous.id); const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage); @@ -2700,6 +2787,7 @@ function layoutPages() { if (!state.doc) { return; } + canvasEditor.cancel(); for (const page of state.doc.pages || []) { const shell = pageShell(page.index); if (shell) { @@ -3657,7 +3745,6 @@ function rotatePages() { if (document.body.hasAttribute("aria-busy")) { return; } - canvasEditor.cancel(); state.rotation = (state.rotation + 90) % 360; clearRegionHighlights(); el.viewerPanel.classList.remove("single-page-fits-height"); @@ -3938,7 +4025,6 @@ function updateEditorTools() { el.movePageNextButton.disabled = pagesDisabled || state.pageIndex === state.doc?.pageCount - 1; const enabled = state.editing && state.selectObjects && !state.panMode; canvasEditor.setEnabled(enabled); - el.selectObjectButton.setAttribute("aria-pressed", String(enabled)); updateObjectControls(canvasEditor.selected); el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; @@ -3946,15 +4032,26 @@ function updateEditorTools() { function updateObjectControls(item, reset = false) { const disabled = !item || !state.ready || state.exporting; - el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.editObjectButton.disabled = el.objectAlign.disabled = disabled; - el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.image; + el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.objectAlign.disabled = disabled; + el.editObjectButton.disabled = disabled || item.type === "PathObject"; + el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.type !== "TextObject"; updateTextFonts(item); if (reset || el.textSize.disabled || document.activeElement !== el.textSize) { - el.textSize.value = item && !item.image ? textPoints(item.size) : ""; + el.textSize.value = item?.type === "TextObject" ? textPoints(item.size) : ""; } if (reset || el.textColor.disabled || document.activeElement !== el.textColor) { - el.textColor.value = item && !item.image ? item.color : "#000000"; + el.textColor.value = item?.type === "TextObject" ? item.color : "#000000"; } + if (item?.type === "PathObject") { + el.shapeFill.checked = item.fill; + el.shapeStroke.checked = item.stroke; + el.shapeFillColor.value = item.fillColor; + el.shapeStrokeColor.value = item.strokeColor; + if (reset || document.activeElement !== el.shapeWidth) { + el.shapeWidth.value = strokePoints(item.lineWidth); + } + } + updateDrawingControls(); el.objectOrder.disabled = disabled || item.count <= 1; for (const option of el.objectOrder.options) { option.disabled = disabled || (["up", "top"].includes(option.value) ? item.order === item.count - 1 : item.order === 0); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index f414c12..bdeb99f 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -24,7 +24,8 @@ export function objectTransform(box, page, dx, dy, corner = "") { (left ? box.x + box.width : page.width - box.x) / box.width, (top ? box.y + box.height : page.height - box.y) / box.height, ); - const scale = Math.max(Math.min(1 / Math.min(box.width, box.height), limit), Math.min(limit, + const minimum = Math.min(1, 1 / (box.line ? Math.max(box.width, box.height) : Math.min(box.width, box.height))); + const scale = Math.max(Math.min(minimum, limit), Math.min(limit, 1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2))); return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale }; } @@ -36,6 +37,8 @@ export class CanvasEditor { this.enabled = false; this.selected = null; this.nodes = new WeakMap(); + this.pages = new WeakMap(); + this.tool = ""; viewer.addEventListener("pointerdown", (event) => this.start(event)); viewer.addEventListener("pointermove", (event) => this.move(event)); viewer.addEventListener("pointerup", (event) => this.end(event)); @@ -57,19 +60,34 @@ export class CanvasEditor { this.enabled = enabled; this.viewer.classList.toggle("editing-select", enabled); if (!enabled) { + this.setTool(""); this.clear(); } } + setTool(tool) { + this.cancel(); + this.tool = tool; + this.viewer.classList.toggle("editing-draw", Boolean(tool)); + if (tool) { + this.select(null); + } + this.options.onTool?.(tool); + } + mount(index, page, surface) { + 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 node = document.createElement("div"); node.className = "edit-object"; + if (object.line) { + node.classList.add("edit-line"); + } node.tabIndex = 0; node.setAttribute("role", "button"); - node.setAttribute("aria-label", object.image ? "图片对象" : "文字对象"); + node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象" }[object.type]); const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface }; this.nodes.set(node, item); node.addEventListener("focus", () => this.select(item)); @@ -120,6 +138,10 @@ export class CanvasEditor { if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) { return; } + if (this.tool) { + this.startShape(event); + return; + } const node = event.target.closest(".edit-object"); if (node) { event.preventDefault(); @@ -144,6 +166,10 @@ export class CanvasEditor { return; } event.preventDefault(); + if (drag.shape) { + this.moveShape(event, drag); + return; + } if (!drag.change && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) { return; } @@ -159,6 +185,18 @@ export class CanvasEditor { return; } this.move(event); + if (drag.shape) { + this.drag = null; + this.viewer.releasePointerCapture(drag.pointerID); + if (!drag.box) { + drag.preview.remove(); + return; + } + this.setTool(""); + Promise.resolve(this.options.onDraw(drag.page.index, drag.shape, drag.box, drag.style)) + .finally(() => drag.preview.remove()); + return; + } this.cancel(); if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) { this.options.onTransform(drag.item, drag.change); @@ -169,14 +207,75 @@ export class CanvasEditor { const drag = this.drag; this.drag = null; if (drag) { - this.place(drag.item); + if (drag.shape) { + drag.preview.remove(); + } else { + this.place(drag.item); + } if (this.viewer.hasPointerCapture(drag.pointerID)) { this.viewer.releasePointerCapture(drag.pointerID); } } } + startShape(event) { + const surface = event.target.closest(".page-surface"); + const page = this.pages.get(surface); + if (!page) { + return; + } + event.preventDefault(); + this.viewer.focus({ preventScroll: true }); + 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 shape = this.tool; + const node = document.createElementNS("http://www.w3.org/2000/svg", shape === "rectangle" ? "rect" : shape); + const style = this.options.drawStyle(); + if (shape === "line") { + style.fill = false; + style.stroke = true; + } + node.setAttribute("fill", style.fill ? style.fillColor : "none"); + node.setAttribute("stroke", style.stroke ? style.strokeColor : "none"); + node.setAttribute("stroke-width", style.lineWidth); + preview.append(node); + surface.append(preview); + this.drag = { shape, page, preview, node, style, pointerID: event.pointerId, + rect: surface.getBoundingClientRect(), rotation: this.options.rotation(), + clientX: event.clientX, clientY: event.clientY }; + this.viewer.setPointerCapture(event.pointerId); + } + + moveShape(event, drag) { + const point = (x, y) => { + const p = pagePoint(x, y, drag.rect, drag.page, drag.rotation); + return { x: Math.max(0, Math.min(p.x, drag.page.width)), y: Math.max(0, Math.min(p.y, drag.page.height)) }; + }; + const from = point(drag.clientX, drag.clientY); + const to = point(event.clientX, event.clientY); + const x = Math.min(from.x, to.x), y = Math.min(from.y, to.y); + const width = Math.abs(to.x - from.x), height = 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" + ? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } + : { x, y, width, height } : null; + const attributes = drag.shape === "line" ? { x1: from.x, y1: from.y, x2: to.x, y2: to.y } + : drag.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)) { + drag.node.setAttribute(key, value); + } + } + keyDown(event) { + if (event.key === "Escape" && this.tool && !this.options.busy()) { + event.preventDefault(); + event.stopPropagation(); + this.setTool(""); + return; + } if (this.input || !this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) { return; } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 8de5656..a20e6d1 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -27,6 +27,7 @@ import ( "io" "math" "strconv" + "strings" "syscall/js" "github.com/xiaoqidun/ofdgo" @@ -102,6 +103,8 @@ func RunWASM() { registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoInsertImage", insertImage) + registerCallback("ofdgoInsertShape", insertShape) + registerCallback("ofdgoUpdatePathStyle", updatePathStyle) registerCallback("ofdgoReplaceImage", replaceImage) registerCallback("ofdgoEditorFont", editorFont) registerCallback("ofdgoAlignObject", alignObject) @@ -609,11 +612,25 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { if err != nil { return nil, err } + case "PathObject": + id = object.PathObject.ID + box, err = ofdgo.ParseBox(object.PathObject.Boundary) + if err != nil { + return nil, err + } default: continue } if box.W > 0 && box.H > 0 { - item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)} + item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)} + if object.Type == "PathObject" { + path := object.PathObject + item["line"] = editorLine(path) + 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" { item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size if codes := object.TextObject.TextCode; len(codes) > 1 { @@ -733,6 +750,8 @@ func copyObject(args []js.Value) (any, error) { boundary = &object.TextObject.Boundary case "ImageObject": boundary = &object.ImageObject.Boundary + case "PathObject": + boundary = &object.PathObject.Boundary default: return nil, fmt.Errorf("unsupported object type %q", object.Type) } @@ -903,6 +922,16 @@ func updateText(args []js.Value) (any, error) { // 入参: object 文字对象, value 十六进制色值 // 返回: error 错误信息 func setTextColor(object *ofdgo.TextObject, value string) error { + if object.FillColor == nil { + object.FillColor = &ofdgo.FillColor{} + } + return setEditorColor(object.FillColor, value) +} + +// setEditorColor 写入浏览器RGB色值,保留其他颜色属性 +// 入参: color 颜色, value 十六进制色值 +// 返回: error 错误信息 +func setEditorColor(color *ofdgo.FillColor, value string) error { if len(value) != 7 || value[0] != '#' { return fmt.Errorf("invalid RGB color %q", value) } @@ -910,11 +939,116 @@ func setTextColor(object *ofdgo.TextObject, value string) error { if err != nil { return err } + color.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) + return nil +} + +// editorColorHex 将创作对象的RGB颜色转换为浏览器色值 +// 入参: color 颜色 +// 返回: string 十六进制色值 +func editorColorHex(color *ofdgo.FillColor) string { + var r, g, b int + if color != nil { + fmt.Sscan(color.Value, &r, &g, &b) + } + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// editorPathScale 获取路径线宽的页面缩放比例 +// 入参: object 路径对象 +// 返回: float64 缩放比例 +func editorPathScale(object ofdgo.PathObject) float64 { + m := ofdgo.NewMatrix(object.CTM) + x, y := m.Transform(0, 0) + ax, ay := m.Transform(1, 0) + bx, by := m.Transform(0, 1) + return math.Sqrt(math.Abs((ax-x)*(by-y) - (ay-y)*(bx-x))) +} + +// editorLine 判断路径是否为单一直线 +// 入参: object 路径对象 +// 返回: bool 是否为单一直线 +func editorLine(object ofdgo.PathObject) bool { + tokens := strings.Fields(object.AbbreviatedData) + return len(tokens) == 6 && tokens[0] == "M" && tokens[3] == "L" +} + +// setPathStyle 应用图形填充、描边和线宽 +// 入参: object 路径对象, args 填充开关及色值、描边开关及色值、线宽 +// 返回: error 错误信息 +func setPathStyle(object *ofdgo.PathObject, args []js.Value) error { + fill, stroke, width := args[0].Bool(), args[2].Bool(), args[4].Float() + if editorLine(*object) { + fill, stroke = false, true + } + if !fill && !stroke { + return fmt.Errorf("fill or stroke must be enabled") + } + if width <= 0 || math.IsNaN(width) || math.IsInf(width, 0) { + return fmt.Errorf("line width must be positive and finite") + } + object.Fill, object.Stroke = &fill, &stroke + if scale := editorPathScale(*object); width != object.LineWidth*scale { + object.LineWidth = width / scale + } if object.FillColor == nil { object.FillColor = &ofdgo.FillColor{} } - object.FillColor.Value = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) - return nil + if object.StrokeColor == nil { + object.StrokeColor = &ofdgo.StrokeColor{} + } + if err := setEditorColor(object.FillColor, args[1].String()); err != nil { + return err + } + return setEditorColor((*ofdgo.FillColor)(object.StrokeColor), args[3].String()) +} + +// insertShape 创建标准路径图形 +// 入参: args 页码、图形类型、范围和绘制样式 +// 返回: any 文档信息, error 错误信息 +func insertShape(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()}) + if err != nil { + return nil, err + } + if err := setPathStyle(&path, args[6:]); err != nil { + return nil, err + } + if _, err := currentEditor.AddObject(args[0].Int(), ofdgo.GraphicObject{Type: "PathObject", PathObject: path}); err != nil { + return nil, err + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) +} + +// updatePathStyle 更新选中路径的绘制样式 +// 入参: args 页码、对象标识和绘制样式 +// 返回: any 文档信息, error 错误信息 +func updatePathStyle(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + page, id := args[0].Int(), args[1].String() + object, err := currentEditor.Object(page, id) + if err != nil { + return nil, err + } + if object.Type != "PathObject" { + return nil, fmt.Errorf("object is not a path") + } + if err := setPathStyle(&object.PathObject, args[2:]); err != nil { + return nil, err + } + revision := currentEditor.Revision() + if err := currentEditor.UpdateObject(page, id, object); err != nil { + return nil, err + } + if currentEditor.Revision() == revision { + return editorSummary(), nil + } + return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } // insertText 使用选定字体创建文字对象 diff --git a/ofdgo_shape.go b/ofdgo_shape.go new file mode 100644 index 0000000..a63b857 --- /dev/null +++ b/ofdgo_shape.go @@ -0,0 +1,64 @@ +// 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 ( + "fmt" + "math" +) + +// ShapeKind 基本图形类型 +type ShapeKind string + +const ( + ShapeLine ShapeKind = "line" + ShapeRectangle ShapeKind = "rectangle" + ShapeEllipse ShapeKind = "ellipse" +) + +// NewShape 创建使用标准紧缩路径的图形,默认黑色描边、不填充。 +// 可调整返回对象的颜色、线宽等属性后通过 Editor.AddObject 写入。 +// 入参: kind 图形类型, box 图形范围;直线从 (X,Y) 到 (X+W,Y+H),W、H 可为负或零 +// 返回: PathObject 路径对象, error 错误信息 +func NewShape(kind ShapeKind, box Box) (PathObject, error) { + if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) { + return PathObject{}, fmt.Errorf("shape coordinates must be finite") + } + object := PathObject{LineWidth: defaultPathLineWidth} + switch kind { + case ShapeLine: + 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} + case ShapeRectangle, ShapeEllipse: + if box.W <= 0 || box.H <= 0 { + return PathObject{}, fmt.Errorf("shape dimensions must be positive") + } + if kind == ShapeRectangle { + object.AbbreviatedData = fmt.Sprintf("M 0 0 L %g 0 L %g %g L 0 %g C", box.W, box.W, box.H, box.H) + } else { + rx, ry := box.W/2, box.H/2 + object.AbbreviatedData = fmt.Sprintf("M 0 %g A %g %g 0 0 1 %g %g A %g %g 0 0 1 0 %g C", ry, rx, ry, box.W, ry, rx, ry, ry) + } + default: + return PathObject{}, fmt.Errorf("unsupported shape %q", kind) + } + object.Boundary = fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H) + return object, nil +}