From 89797af8d64c4a5cd8c28b7b655c6a4e443f6e0f Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 00:52:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=88=9B=E5=BB=BA=E7=BC=96=E8=BE=91):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=9B=BE=E7=89=87=E9=80=82=E5=BA=94=E3=80=81?= =?UTF-8?q?=E8=A3=81=E5=89=AA=E6=81=A2=E5=A4=8D=E5=92=8C=E5=AD=97=E8=B7=9D?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=EF=BC=8C=E7=BB=9F=E4=B8=80=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=A0=8F=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 17 +++++++- assets/webui/ofdgo.css | 26 ++++++++++-- assets/webui/ofdgo.js | 87 +++++++++++++++++++++++++++++--------- assets/webui/ofdgo_edit.js | 51 +++++++++------------- internal/webui/wasm.go | 74 +++++++++++++++++++++++++++----- ofdgo_editor.go | 32 +++++++++----- ofdgo_editor_text.go | 43 ++++++++++++++----- ofdgo_editor_transform.go | 68 +++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 88 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index f6e2b3b..b2b715f 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -101,9 +101,17 @@ + 变换 + +
+ - 变换 + 图片
@@ -118,8 +126,9 @@ - + + 段落
@@ -201,6 +210,10 @@
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 7425bae..8a83712 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -113,12 +113,24 @@ a:focus-visible { .editor-file, .editor-paragraph, -.editor-transform, +.editor-image, .editor-group:has(#textFont), .editor-group:has(#shapeFill) { grid-template-columns: repeat(2, max-content); } +.editor-transform { + grid-template-columns: max-content; +} + +.editor-image > select { + grid-column: 1 / -1; +} + +.editor-paragraph > .editor-field { + grid-column: auto; +} + .editor-objects { grid-template-columns: repeat(4, max-content); } @@ -178,8 +190,8 @@ a:focus-visible { } .editor-paragraph { - width: 172px; - grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 218px; + grid-template-columns: 54px minmax(0, 1fr); } .editor-file #saveButton { @@ -196,6 +208,14 @@ a:focus-visible { min-width: 0; } +#textColor { + width: 100%; +} + +.shape-paint { + justify-content: space-between; +} + .shape-paint, .shape-paint label { display: flex; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index c847fa2..eb4b592 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -39,7 +39,7 @@ const state = { insertObject: null, textFonts: [], textFontID: null, - textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0 }, + textDefaults: { type: "TextObject", size: 12 * 25.4 / 72, color: "#000000", wrap: true, align: "left", paragraphHeight: 0, letterSpacing: 0 }, ofdBytes: null, fileName: "ofdgo.ofd", openSeq: 0, @@ -95,12 +95,14 @@ const el = { objectDistribute: document.querySelector("#objectDistribute"), objectRotate: document.querySelector("#objectRotate"), objectFlip: document.querySelector("#objectFlip"), + imageFit: document.querySelector("#imageFit"), cropImageButton: document.querySelector("#cropImageButton"), resetCropButton: document.querySelector("#resetCropButton"), multiSelectButton: document.querySelector("#multiSelectButton"), textAlign: document.querySelector("#textAlign"), textWrap: document.querySelector("#textWrap"), textLineHeight: document.querySelector("#textLineHeight"), + textSpacing: document.querySelector("#textSpacing"), textFont: document.querySelector("#textFont"), textFontAdd: document.querySelector("#textFontAdd"), textSize: document.querySelector("#textSize"), @@ -142,6 +144,8 @@ const el = { insertPanel: document.querySelector("#insertPanel"), insertForm: document.querySelector("#insertForm"), insertImage: document.querySelector("#insertImage"), + insertFitRow: document.querySelector("#insertFitRow"), + insertFit: document.querySelector("#insertFit"), insertStatus: document.querySelector("#insertStatus"), insertCancel: document.querySelector("#insertCancel"), insertSubmit: document.querySelector("#insertSubmit"), @@ -244,7 +248,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height), - onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0), + onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0), onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height), onCropChange: () => { updatePendingChanges(); @@ -259,7 +263,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onDrawText: beginCanvasText, onCommitText: (item, value, fontData) => item.draft - ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight) + ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing) : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null), onTextChange: () => { const editing = canvasEditor.input; @@ -271,7 +275,6 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { }, }); -// updatePendingChanges 将未提交的文字和裁剪计入修改状态,不生成历史记录。 function updatePendingChanges() { const editing = canvasEditor.input; setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision @@ -282,7 +285,6 @@ el.editorTools.addEventListener("pointerdown", (event) => { if (canvasEditor.input && event.target.closest("button")) event.preventDefault(); }); -// editorClick 提交画布中的文字和裁剪后,再执行工具栏操作。 function editorClick(button, action) { button.addEventListener("click", async () => { if (canvasEditor.input && !await canvasEditor.commitText()) return; @@ -323,9 +325,10 @@ el.textColor.addEventListener("change", () => changeTextStyle(true)); editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); el.textAlign.addEventListener("change", () => changeParagraph()); el.textLineHeight.addEventListener("change", () => changeParagraph()); +el.textSpacing.addEventListener("change", () => changeParagraph()); el.textFont.addEventListener("change", changeTextFont); el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); -for (const input of [el.textSize, el.shapeWidth, el.textLineHeight]) { +for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) { input.addEventListener("keydown", (event) => { if (event.isComposing) return; if (event.key === "Enter" || event.key === "Escape") { @@ -387,7 +390,12 @@ el.cropImageButton.addEventListener("click", () => { if (document.body.hasAttribute("aria-busy")) return; if (canvasEditor.crop) return canvasEditor.commitCrop(); const item = canvasEditor.selected; - if (item?.type === "ImageObject") canvasEditor.startCrop(item); + if (item?.type === "ImageObject") return startImageCrop(item); +}); +el.imageFit.addEventListener("change", () => { + const mode = el.imageFit.value, item = canvasEditor.selected; + el.imageFit.value = ""; + if (mode && item?.type === "ImageObject" && !canvasEditor.crop) return changeDocument("ofdgoFitImage", item, mode); }); el.resetCropButton.addEventListener("click", () => { if (document.body.hasAttribute("aria-busy")) return; @@ -788,12 +796,46 @@ async function editCanvasObject(item) { } } +async function startImageCrop(item) { + const openSeq = state.openSeq; + const urls = []; + let mounted = false; + setBusy(true); + try { + const page = await callWASM("ofdgoPreviewImage", item.index, item.id); + if (openSeq !== state.openSeq || !item.surface.isConnected) return; + const images = new Map(state.svgImages); + for (const image of page.images) { + if (images.has(image.name)) continue; + const url = URL.createObjectURL(new Blob([image.bytes], { type: image.mime })); + urls.push(url); + images.set(image.name, { url }); + } + await Promise.all(urls.map(url => { + const image = new Image(); + image.src = url; + return image.decode(); + })); + if (openSeq !== state.openSeq || canvasEditor.selected !== item) return; + const svg = parseSVG(page.svg, `crop${openSeq}-${item.id}`, images); + svg.classList.add("ofd-svg"); + canvasEditor.startCrop(item, { svg, urls }); + mounted = true; + } catch (err) { + if (openSeq === state.openSeq) showError(err, false); + } finally { + if (!mounted) urls.forEach(url => URL.revokeObjectURL(url)); + if (openSeq === state.openSeq) setBusy(false); + } +} + function openInsertPanel(item = null) { if (!state.editing || document.body.hasAttribute("aria-busy")) { return; } el.insertForm.reset(); state.insertObject = item; + el.insertFitRow.hidden = !item; el.insertPanel.setAttribute("aria-label", item ? "替换图片" : "添加图片"); el.insertSubmit.textContent = item ? "替换" : "添加"; el.insertStatus.textContent = ""; @@ -801,12 +843,10 @@ function openInsertPanel(item = null) { el.insertImage.focus(); } -// currentTextStyle 获取所选文字样式,未选中文字时使用新文字的默认样式。 function currentTextStyle() { return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults; } -// toggleTextTool 切换画布文字工具,不提前创建空文字对象。 async function toggleTextTool() { if (!state.editing || document.body.hasAttribute("aria-busy")) return; if (canvasEditor.tool === "text") { @@ -821,7 +861,7 @@ async function toggleTextTool() { if (openSeq !== state.openSeq) return; const style = currentTextStyle(); state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap, - align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, + align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0, fontChoice: state.textFonts[Number(el.textFont.value)] || state.textDefaults.fontChoice }; state.selectObjects = true; setPan(false); @@ -829,7 +869,6 @@ async function toggleTextTool() { el.viewerPanel.focus({ preventScroll: true }); } -// beginCanvasText 加载所选本地字体,并在画布上创建待提交的文字输入框。 async function beginCanvasText(index, box, surface, page) { const font = state.textDefaults.fontChoice || state.textFonts[Number(el.textFont.value)]; if (!font) { @@ -889,7 +928,6 @@ function updateTextFonts(item, refresh = false) { } } -// changeTextFont 更换字体时保留尚未提交的文字。 async function changeTextFont() { const item = canvasEditor.selected; const font = state.textFonts[Number(el.textFont.value)]; @@ -943,7 +981,7 @@ async function insertObject(event) { return; } const doc = item - ? await callWASM("ofdgoReplaceImage", item.index, item.id, data) + ? await callWASM("ofdgoReplaceImage", item.index, item.id, data, el.insertFit.value) : await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2); if (openSeq !== state.openSeq) { return; @@ -981,7 +1019,6 @@ function textPoints(size) { return Number((size * 72 / 25.4).toFixed(2)); } -// changeParagraph 仅提交变更的段落选项,具体排版由库完成。 async function changeParagraph(wrap = currentTextStyle().wrap) { const item = currentTextStyle(); if (document.body.hasAttribute("aria-busy") || item.draft) return; @@ -991,12 +1028,18 @@ async function changeParagraph(wrap = currentTextStyle().wrap) { } const points = Number(el.textLineHeight.value); const height = points === textPoints(item.paragraphHeight || 0) ? item.paragraphHeight || 0 : points * 25.4 / 72; - if (item === state.textDefaults) { - Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height }); + if (!el.textSpacing.checkValidity()) { updateObjectControls(canvasEditor.selected, true); return; } - await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height); + const spacingPoints = Number(el.textSpacing.value); + const spacing = spacingPoints === textPoints(item.letterSpacing || 0) ? item.letterSpacing || 0 : spacingPoints * 25.4 / 72; + if (item === state.textDefaults) { + Object.assign(item, { wrap: Boolean(wrap), align: el.textAlign.value, paragraphHeight: height, letterSpacing: spacing }); + updateObjectControls(canvasEditor.selected, true); + return; + } + await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing); updateObjectControls(canvasEditor.selected, true); } @@ -2984,9 +3027,9 @@ function layoutPageShell(shell, page) { } } -function parseSVG(svgText, prefix = "") { +function parseSVG(svgText, prefix = "", images = state.svgImages) { const template = document.createElement("template"); - template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${state.svgImages.get(name).url}"`); + template.innerHTML = svgText.replace(/xlink:href="(ofdgo-image-[a-f0-9]{64})"/g, (_, name) => `xlink:href="${images.get(name).url}"`); const svg = document.adoptNode(template.content.firstElementChild); prefixSVGIds(svg, prefix); return svg; @@ -4206,6 +4249,7 @@ function updateObjectControls(item, reset = false) { el.objectAlign.disabled = disabled || cropping; el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping; el.cropImageButton.disabled = disabled || item.type !== "ImageObject"; + el.imageFit.disabled = el.cropImageButton.disabled || cropping; el.cropImageButton.textContent = cropping ? "完成" : "裁剪"; el.cropImageButton.setAttribute("aria-label", cropping ? "完成裁剪" : "裁剪图片"); el.cropImageButton.setAttribute("aria-pressed", String(cropping)); @@ -4219,12 +4263,15 @@ function updateObjectControls(item, reset = false) { el.textFont.disabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items); el.textSize.disabled = el.textColor.disabled = el.textFont.disabled || Boolean(item?.draft); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; - el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSize.disabled; + el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled; el.textAlign.value = text.align || "left"; el.textWrap.setAttribute("aria-pressed", String(Boolean(text.wrap))); if (reset || document.activeElement !== el.textLineHeight) { el.textLineHeight.value = text.paragraphHeight ? String(textPoints(text.paragraphHeight)) : ""; } + if (reset || document.activeElement !== el.textSpacing) { + el.textSpacing.value = text.letterSpacing ? String(textPoints(text.letterSpacing)) : ""; + } updateTextFonts(item); if (reset || el.textSize.disabled || document.activeElement !== el.textSize) { el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size)); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index a3d0031..a408f0c 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -21,14 +21,12 @@ export function objectTransform(box, dx, dy, corner = "") { return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale }; } -// selectionBounds 计算同页对象的联合边界,不改变对象位置。 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, height: Math.max(...items.map(item => item.y + item.height)) - y }; } -// alignmentGuides 查找最近的边缘或中心辅助线,不改变对象坐标。 function alignmentGuides(box, targets, tolerance) { const lines = []; for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) { @@ -51,7 +49,6 @@ function alignmentGuides(box, targets, tolerance) { return lines.join(""); } -// transformedBox 将本地矩形映射到页面,用于变换后的文字框预览。 function transformedBox(box, matrix) { const [a, b, c, d, e, f] = matrix; const points = [[box.x, box.y], [box.x + box.width, box.y], [box.x, box.y + box.height], [box.x + box.width, box.y + box.height]] @@ -60,7 +57,6 @@ function transformedBox(box, matrix) { return { x, y, width: Math.max(...points.map(p => p[0])) - x, height: Math.max(...points.map(p => p[1])) - y }; } -// cropBox 将保留区域限制在当前图片范围内,不改变图片比例。 function cropBox(box, bounds, dx, dy, corner) { if (!corner) return { ...box, x: Math.max(bounds.x, Math.min(box.x + dx, bounds.x + bounds.width - box.width)), y: Math.max(bounds.y, Math.min(box.y + dy, bounds.y + bounds.height - box.height)) }; @@ -70,7 +66,6 @@ function cropBox(box, bounds, dx, dy, corner) { height: Math.min(bounds.y + bounds.height, next.y + next.height) - y }; } -// constrainedPoint 按修饰键约束图形端点,允许超出页面边界。 export function constrainedPoint(from, to, shape, shift) { let dx = to.x - from.x, dy = to.y - from.y; if (shift && shape === "line") { @@ -87,7 +82,6 @@ export function constrainedPoint(from, to, shape, shift) { return { x: from.x + dx, y: from.y + dy }; } -// reshapeBox 移动线段端点或调整基本图形范围,不缩放描边。 export function reshapeBox(item, dx, dy, handle, shift) { const box = item.geometry; if (!dx && !dy && !shift) return { ...box }; @@ -112,7 +106,6 @@ export function reshapeBox(item, dx, dy, handle, shift) { return { x, y, width: right - x, height: bottom - y }; } -// paintShape 使用统一的页面坐标绘制图形及端点调整预览。 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 } @@ -172,7 +165,7 @@ export class CanvasEditor { mount(index, page, surface) { const crop = this.crop?.item.index === index ? this.crop : null; - if (crop) this.closeCrop(); + if (crop) this.closeCrop(true); const pending = this.pendingSelection?.index === index; const selected = pending ? this.pendingSelection.ids || page.objects.slice(-1).map(item => item.id) : this.items().filter(item => item.index === index).map(item => item.id); @@ -212,9 +205,11 @@ export class CanvasEditor { if (pending) this.pendingSelection = null; if (crop && selection.length === 1 && selection[0].id === crop.item.id && ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) { - this.startCrop(selection[0]); + this.startCrop(selection[0], crop.pagePreview); this.crop.box = { ...crop.box }; this.paintCrop(); + } else if (crop) { + crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url)); } } @@ -247,7 +242,6 @@ export class CanvasEditor { } } - // placeShape 按图形几何范围放置手柄,不包含边界留量。 placeShape(item, box) { const x = Math.min(box.x, box.x + box.width), y = Math.min(box.y, box.y + box.height); Object.assign(item.node.style, { @@ -263,12 +257,10 @@ export class CanvasEditor { } } - // items 获取选中的真实对象,临时多选框不作为文档对象。 items() { return this.selected?.items || (this.selected ? [this.selected] : []); } - // setSelection 更新同页对象选区,并同步工具栏。 setSelection(items) { if (this.crop && (items.length !== 1 || items[0] !== this.crop.item)) this.closeCrop(); if (this.selected?.items) this.selected.node.remove(); @@ -391,7 +383,7 @@ export class CanvasEditor { if (drag.crop) { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); - this.crop.box = cropBox(drag.box, this.crop.item, to.x - from.x, to.y - from.y, drag.corner); + this.crop.box = cropBox(drag.box, this.crop.item.imageBounds, to.x - from.x, to.y - from.y, drag.corner); this.paintCrop(); return; } @@ -434,7 +426,6 @@ export class CanvasEditor { if (!drag.corner) this.showGuides(drag); } - // showGuides 在页面缩放和旋转时保持辅助线的屏幕判定距离一致。 showGuides(drag) { const { item, rect, rotation, change } = drag; const path = alignmentGuides({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, { @@ -528,7 +519,6 @@ export class CanvasEditor { } } - // startMarquee 在页面坐标中开始框选,统一处理缩放和旋转。 startMarquee(event, additive) { const surface = event.target.closest(".page-surface"), page = this.pages.get(surface); if (!page) { @@ -546,7 +536,6 @@ export class CanvasEditor { this.viewer.setPointerCapture(event.pointerId); } - // moveMarquee 选中与框选区域相交的对象,支持追加到已有选区。 moveMarquee(event, drag) { const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.page, drag.rotation); const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.page, drag.rotation); @@ -583,7 +572,6 @@ export class CanvasEditor { this.viewer.setPointerCapture(event.pointerId); } - // nearestSurface 为页面外开始的手势确定一个已加载页面,拖动期间不切换页面。 nearestSurface(event) { let nearest = null, distance = Infinity; for (const surface of this.viewer.querySelectorAll(".page-surface")) { @@ -599,7 +587,6 @@ export class CanvasEditor { return nearest; } - // createPreview 创建临时SVG预览层,拖动期间不重新渲染页面。 createPreview(surface, page, shape, style) { const preview = document.createElementNS("http://www.w3.org/2000/svg", "svg"); preview.classList.add("edit-preview"); @@ -631,7 +618,6 @@ export class CanvasEditor { ? { x: from.x, y: from.y, width: to.x - from.x, height: to.y - from.y } : { x, y, width, height }); } - // modifierChange 在指针静止时响应Shift变化并刷新预览。 modifierChange(event) { const pointer = this.drag?.pointer; if (event.key === "Shift" && pointer) { @@ -707,6 +693,7 @@ export class CanvasEditor { fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`, lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color, textAlign: item.align || "left", + letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`, }); if (item.textFrame) { const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame; @@ -741,14 +728,12 @@ export class CanvasEditor { if (!item.draft) input.select(); } - // resizeText 按当前字体和内容调整输入框,不修改文档中的几何范围。 resizeText() { const { input } = this.input; input.style.height = "0px"; input.style.height = `${input.scrollHeight + 2}px`; } - // setTextFont 暂存输入框字体,后续与文字内容一起提交。 setTextFont(face, data) { const editing = this.input; document.fonts.add(face); @@ -794,12 +779,16 @@ export class CanvasEditor { } } - // startCrop 开始调整图片保留区域,确认前不修改文档。 - startCrop(item) { + startCrop(item, pagePreview) { this.cancel(); this.closeCrop(); this.setTool(""); - const box = { x: item.x, y: item.y, width: item.width, height: item.height }; + const bounds = item.imageBounds; + const x = Math.max(item.x, bounds.x), y = Math.max(item.y, bounds.y); + const box = { x, y, width: Math.min(item.x + item.width, bounds.x + bounds.width) - x, + height: Math.min(item.y + item.height, bounds.y + bounds.height) - y }; + const original = item.surface.querySelector(".ofd-svg"); + original.replaceWith(pagePreview.svg); const { preview, node: mask } = this.createPreview(item.surface, item.page, "path", { fill: true, fillColor: "rgba(255,255,255,0.72)" }); mask.setAttribute("fill-rule", "evenodd"); const node = document.createElement("div"); @@ -812,26 +801,23 @@ export class CanvasEditor { } item.surface.append(node); item.surface.classList.add("cropping"); - this.crop = { item, box, node, mask, preview }; + this.crop = { item, box, initialBox: { ...box }, node, mask, preview, original, pagePreview }; this.paintCrop(); this.viewer.focus({ preventScroll: true }); } - // paintCrop 更新遮罩及手柄,不重新渲染或复制图片数据。 paintCrop() { const { item, box, node, mask } = this.crop; const rectangle = b => `M${b.x} ${b.y}h${b.width}v${b.height}h${-b.width}Z`; - mask.setAttribute("d", rectangle(item) + rectangle(box)); + mask.setAttribute("d", rectangle(item.imageBounds) + rectangle(box)); Object.assign(node.style, { left: `${box.x * PX_PER_MM}px`, top: `${box.y * PX_PER_MM}px`, width: `${box.width * PX_PER_MM}px`, height: `${box.height * PX_PER_MM}px` }); this.options.onCropChange(); } - // cropChanged 判断裁剪范围是否有待提交的变更。 cropChanged() { - return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.item[key])); + return Boolean(this.crop && ["x", "y", "width", "height"].some(key => this.crop.box[key] !== this.crop.initialBox[key])); } - // commitCrop 将裁剪作为一次可撤销操作提交,失败时保留预览。 async commitCrop() { if (!this.crop) return true; if (this.options.busy()) return false; @@ -845,14 +831,15 @@ export class CanvasEditor { return Boolean(saved); } - // closeCrop 移除临时裁剪区域,不修改文档内容。 - closeCrop() { + closeCrop(keepPreview = false) { if (!this.crop) return; if (this.drag?.crop) this.cancel(); const crop = this.crop; this.crop = null; crop.node.remove(); crop.preview.remove(); + crop.pagePreview.svg.replaceWith(crop.original); + if (!keepPreview) crop.pagePreview.urls.forEach(url => URL.revokeObjectURL(url)); crop.item.surface.classList.remove("cropping"); this.options.onCropChange(); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 6c827c2..4bc5986 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -119,6 +119,8 @@ func RunWASM() { registerCallback("ofdgoRotateObjects", rotateObjects) registerCallback("ofdgoFlipObjects", flipObjects) registerCallback("ofdgoCropImage", cropImage) + registerCallback("ofdgoFitImage", fitImage) + registerCallback("ofdgoPreviewImage", previewImage) registerCallback("ofdgoDeleteObject", deleteObject) registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) }) registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) }) @@ -318,10 +320,6 @@ func renderPage(args []js.Value) (any, error) { for i, font := range page.Fonts { fonts[i] = map[string]any{"name": font.Name, "weight": font.Weight, "style": font.Style} } - images := make([]any, len(page.Images)) - for i, image := range page.Images { - images[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} - } return successResult(map[string]any{ "index": page.Index, "number": page.Number, @@ -331,7 +329,7 @@ func renderPage(args []js.Value) (any, error) { "svg": page.SVG, "links": links, "fonts": fonts, - "images": images, + "images": svgImagesToJS(page.Images), "text": string(textData), "objects": objects, }), nil @@ -662,6 +660,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { value, layout := source.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 + item["letterSpacing"] = layout.LetterSpacing frame, err := object.TextObject.TextFrame() if err != nil { return nil, err @@ -724,7 +723,7 @@ func editorFont(args []js.Value) (any, error) { } // replaceImage 替换图片资源并更新预览 -// 入参: args 页码、对象标识和图片数据 +// 入参: args 页码、对象标识、图片数据和适应方式 // 返回: any 文档信息, error 错误信息 func replaceImage(args []js.Value) (any, error) { if currentEditor == nil { @@ -735,7 +734,7 @@ func replaceImage(args []js.Value) (any, error) { return nil, err } revision := currentEditor.Revision() - if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data); err != nil { + if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data, args[3].String()); err != nil { return nil, err } if currentEditor.Revision() == revision { @@ -1106,7 +1105,7 @@ func updatePathStyle(args []js.Value) (any, error) { } // insertText 使用选定字体创建文字对象 -// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐和行距 +// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距和字距 // 返回: any 文档信息, error 错误信息 func insertText(args []js.Value) (any, error) { if currentEditor == nil { @@ -1139,7 +1138,7 @@ func insertText(args []js.Value) (any, error) { Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H), Font: fontID, Size: args[5].Float(), }} - if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float()}); err != nil { + if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float()}); err != nil { return nil, err } if err := setTextColor(&object.TextObject, args[6].String()); err != nil { @@ -1152,7 +1151,7 @@ func insertText(args []js.Value) (any, error) { } // layoutText 调整文字框和段落排版,保留软换行前的原文。 -// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐和行距 +// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距和字距 // 返回: any 文档信息, error 错误信息 func layoutText(args []js.Value) (any, error) { if currentEditor == nil { @@ -1173,7 +1172,7 @@ func layoutText(args []js.Value) (any, error) { } } value, _ := object.TextObject.TextLayout() - if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float()}); err != nil { + if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float()}); err != nil { return nil, err } revision := currentEditor.Revision() @@ -1230,6 +1229,59 @@ func cropImage(args []js.Value) (any, error) { }) } +// fitImage 按原始比例适应或填充图片框。 +// 入参: args 页面索引、对象标识和适应方式 +// 返回: any 文档信息, error 错误信息 +func fitImage(args []js.Value) (any, error) { + return changeObjects(func() error { + return currentEditor.FitImage(args[0].Int(), args[1].String(), args[2].String()) + }) +} + +// previewImage 在页面副本中显示指定图片的完整内容,不改变正文、历史或缓存。 +// 入参: args 页面索引和图片对象标识 +// 返回: any 页面SVG和分离的图片资源, error 错误信息 +func previewImage(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being created") + } + page, err := currentEditor.Page(args[0].Int()) + if err != nil { + return nil, err + } + id := args[1].String() + found := false + for i := range page.Content.Layer { + for j := range page.Content.Layer[i].Objects { + object := &page.Content.Layer[i].Objects[j] + if object.Type == "ImageObject" && object.ImageObject.ID == id { + object.ImageObject.Clips = nil + found = true + } + } + } + if !found { + return nil, fmt.Errorf("image object %q not found", id) + } + var out bytes.Buffer + resources, err := currentSession.Renderer.RenderToSVGWithResources(page, &out) + if err != nil { + return nil, err + } + return successResult(map[string]any{"svg": out.String(), "images": svgImagesToJS(resources.Images)}), nil +} + +// svgImagesToJS 将图片资源以二进制数组传给前端,不使用base64。 +// 入参: images SVG引用的图片资源 +// 返回: []any 图片标识、类型和数据 +func svgImagesToJS(images []ofdgo.SVGImage) []any { + items := make([]any, len(images)) + for i, image := range images { + items[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)} + } + return items +} + // transformObjects 统一移动或缩放选区,生成一次撤销记录。 // 入参: args 页面索引、对象标识数组、横纵位移和正缩放比例 // 返回: any 文档信息, error 错误信息 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 1146d3c..b029100 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -40,7 +40,7 @@ type Editor struct { pages []PageContent resources []editorResource fonts map[string]*font.SFNT - images map[string]bool + images map[string]image.Point resourceID map[editorResourceKey]string maxID int history []editorChange @@ -77,7 +77,7 @@ func NewEditor() *Editor { CreationDate: time.Now().Format("2006-01-02"), }, fonts: make(map[string]*font.SFNT), - images: make(map[string]bool), + images: make(map[string]image.Point), resourceID: make(map[editorResourceKey]string), } } @@ -346,7 +346,7 @@ func (e *Editor) AddImage(data []byte) (string, error) { data: bytes.Clone(data), image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name}, }) - e.images[id] = true + e.images[id] = image.Pt(config.Width, config.Height) e.resourceID[key] = id return id, nil } @@ -554,7 +554,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor) case "ImageObject": obj := &object.ImageObject - if !e.images[obj.ResourceID] || (obj.ImageMask != "" && !e.images[obj.ImageMask]) { + if e.images[obj.ResourceID].X == 0 || (obj.ImageMask != "" && e.images[obj.ImageMask].X == 0) { return GraphicObject{}, fmt.Errorf("image resource not found") } if obj.Border != nil { @@ -720,6 +720,7 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic if obj.layout != nil { layout := *obj.layout layout.options.LineHeight *= scale + layout.options.LetterSpacing *= scale obj.layout = &layout } if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke { @@ -756,10 +757,10 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic return object, nil } -// ReplaceImage 替换图片资源,保留对象位置、尺寸、变换和绘制顺序 -// 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据 +// ReplaceImage 替换图片资源,保留对象标识和绘制顺序,一次撤销恢复资源及布局。 +// 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据, fit 为contain、cover或空,空值保留原变换和裁剪 // 返回: error 错误信息 -func (e *Editor) ReplaceImage(page int, id string, data []byte) error { +func (e *Editor) ReplaceImage(page int, id string, data []byte, fit string) error { object, err := e.Object(page, id) if err != nil { return err @@ -771,6 +772,11 @@ func (e *Editor) ReplaceImage(page int, id string, data []byte) error { if err != nil { return err } + if fit != "" { + if err := e.fitImage(&object.ImageObject, fit); err != nil { + return err + } + } return e.UpdateObject(page, id, object) } @@ -819,6 +825,9 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e if !finite(lineHeight) || lineHeight < 0 { return fmt.Errorf("line height must be finite and nonnegative") } + if !finite(options.LetterSpacing) { + return fmt.Errorf("letter spacing must be finite") + } if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { return fmt.Errorf("invalid text alignment %q", options.Align) } @@ -849,13 +858,16 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e return fmt.Errorf("font does not contain U+%04X", char) } advances[i] = float64(sfnt.GlyphAdvance(glyph)) * unit * hScale - if !finite(advances[i]) { + } + spaceTextAdvances(runes, advances, options.LetterSpacing) + for _, advance := range advances { + if !finite(advance) { return fmt.Errorf("text advance exceeds finite range") } } - lines := breakTextLines(runes, advances, width, options.Wrap) + lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing) for i, line := range lines { - x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines)) + x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines), options.LetterSpacing) y := float64(ascender)*unit + float64(len(codes))*lineHeight if !finite(x) || !finite(y) { return fmt.Errorf("text position exceeds finite range") diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index afcf162..3a1b37b 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -21,11 +21,13 @@ import ( ) // TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify。 -// LineHeight为毫米单位的基线间距,0使用字体度量;零值保持显式换行和左对齐。 +// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负。 +// 零值保持显式换行和左对齐。 type TextLayout struct { - Wrap bool - Align string - LineHeight float64 + Wrap bool + Align string + LineHeight float64 + LetterSpacing float64 } // textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染。 @@ -45,9 +47,9 @@ func (obj TextObject) TextLayout() (string, TextLayout) { } // breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白。 -// 入参: runes 段落字符, advances 各字符步进, width 本地排版宽度, wrap 是否自动折行 +// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距 // 返回: [][2]int 各行的字符起止索引,左闭右开 -func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) [][2]int { +func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing float64) [][2]int { if !wrap || len(runes) == 0 { return [][2]int{{0, len(runes)}} } @@ -76,7 +78,11 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) for visible > start && runes[visible-1] == ' ' { visible-- } - if sums[visible]-sums[start] > width && end > start { + length := sums[visible] - sums[start] + if visible > start { + length -= spacing + } + if length > width && end > start { break } end = candidate @@ -84,7 +90,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) if breaks[end] { legal = end } - if sums[visible]-sums[start] > width { + if length > width { break } } @@ -101,9 +107,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool) } // alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐。 -// 入参: runes 行内字符, advances 各字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐 +// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距 // 返回: float64 行首X坐标, string 字符间的DeltaX序列 -func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool) (float64, string) { +func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) { end := len(runes) for end > 0 && runes[end-1] == ' ' { end-- @@ -112,6 +118,9 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st for _, advance := range advances[:end] { length += advance } + if end > 0 { + length -= spacing + } x, extra := 0.0, max(0, width-length) if alignment == "center" { x = extra / 2 @@ -140,3 +149,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st } return x, strings.Join(deltas, " ") } + +// spaceTextAdvances 将字距加入各字素末尾,不拆散组合字符。 +// 入参: runes 段落字符, advances 待更新的字符步进, spacing 附加毫米字距 +func spaceTextAdvances(runes []rune, advances []float64, spacing float64) { + if spacing == 0 { + return + } + var seg segmenter.Segmenter + seg.Init(runes) + for it := seg.GraphemeIterator(); it.Next(); { + cluster := it.Grapheme() + advances[cluster.Offset+len(cluster.Text)-1] += spacing + } +} diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 9c4fd86..5ef06b7 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -16,6 +16,7 @@ package ofdgo import ( "fmt" + "math" "slices" ) @@ -152,6 +153,16 @@ func (e *Editor) CropImage(page int, id string, box Box) error { return fmt.Errorf("object %q is not an image", id) } image := &object.ImageObject + if err := cropImageObject(image, box); err != nil { + return err + } + return e.UpdateObject(page, id, object) +} + +// cropImageObject 更新图片副本的裁剪和局部坐标,不修改资源。 +// 入参: image 图片对象副本, box 页面毫米坐标中的保留范围 +// 返回: error 错误信息 +func cropImageObject(image *ImageObject, box Box) error { full, err := image.ImageBounds() if err != nil { return err @@ -181,9 +192,66 @@ func (e *Editor) CropImage(page int, id string, box Box) error { path.Fill, path.Stroke = &fill, &stroke image.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{CTM: inverse.String(), Path: []PathObject{path}}}}}} } + return nil +} + +// FitImage 按原始像素比例居中适应或填充当前Boundary,保留直角旋转及镜像方向。 +// 保留原始图片,不重采样;重设裁剪,一次撤销恢复原布局。 +// 入参: page 页面索引, id 图片对象标识, mode 为contain(完整显示)或cover(填满裁剪) +// 返回: error 错误信息 +func (e *Editor) FitImage(page int, id, mode string) error { + object, err := e.Object(page, id) + if err != nil { + return err + } + if object.Type != "ImageObject" { + return fmt.Errorf("object %q is not an image", id) + } + if err := e.fitImage(&object.ImageObject, mode); err != nil { + return err + } return e.UpdateObject(page, id, object) } +// fitImage 将原始像素比例应用到当前图片框,适应时保留空白,填充时裁掉超出部分。 +// 入参: obj 图片对象副本, mode 为contain或cover +// 返回: error 错误信息 +func (e *Editor) fitImage(obj *ImageObject, mode string) error { + if mode != "contain" && mode != "cover" { + return fmt.Errorf("invalid image fit %q", mode) + } + box, err := creationBox(obj.Boundary) + if err != nil { + return err + } + m := NewMatrix(obj.CTM) + if !axisAlignedMatrix(m) { + return fmt.Errorf("image fitting requires an axis-aligned transform") + } + size := e.images[obj.ResourceID] + w, h := float64(size.X), float64(size.Y) + if m.a == 0 { + w, h = h, w + } + scale := math.Min(box.W/w, box.H/h) + if mode == "cover" { + scale = math.Max(box.W/w, box.H/h) + } + if m.a == 0 { + m.b, m.c = math.Copysign(float64(size.X)*scale, m.b), math.Copysign(float64(size.Y)*scale, m.c) + } else { + m.a, m.d = math.Copysign(w*scale, m.a), math.Copysign(h*scale, m.d) + } + m.e, m.f = 0, 0 + full := m.TransformBox(Box{W: 1, H: 1}) + m.e, m.f = (box.W-full.W)/2-full.X, (box.H-full.H)/2-full.Y + obj.CTM, obj.Clips = m.String(), nil + if mode == "cover" { + return cropImageObject(obj, box) + } + return nil +} + // TextFrame 将对象Boundary逆变换到本地坐标并返回轴对齐边界。 // 直角旋转与翻转不改变本地段落宽度。 // 返回: Box 对象本地坐标中的边界, error 错误信息