From e91e146b50778a4d047b8a6e7bde50cc98877607 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Tue, 27 Oct 2026 21:42:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=88=86=E6=95=A3=E5=AF=B9=E9=BD=90=E4=B8=8E?= =?UTF-8?q?=E6=AE=B5=E8=90=BD=E5=8D=B3=E6=97=B6=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 2 +- assets/webui/ofdgo.js | 39 +++++++++-- assets/webui/ofdgo_edit.js | 139 +++++++++++++++++++++++++++++++++---- internal/webui/webui.go | 6 +- ofdgo_editor_text.go | 17 ++++- ofdgo_rich_layout.go | 19 ++++- ofdgo_rich_text.go | 78 +++++++++++++++++++-- ofdgo_text_layout.go | 13 +++- 8 files changed, 277 insertions(+), 36 deletions(-) diff --git a/assets/webui/index.html b/assets/webui/index.html index 0e01433..67c3216 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -140,7 +140,7 @@ - + diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 058fb5d..04445a9 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; +import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, richTextAlignment, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js"; import { PrintDialog } from "./ofdgo_print.js"; @@ -704,7 +704,7 @@ const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Cont el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], + textControls: [el.textAlign, el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog, textClipboardScope: () => `${documentTab().id}:${documentContentKey(state)}:${state.editorInfo?.revision ?? ""}`, getTextClipboard: () => workspace.textClipboard, @@ -867,7 +867,7 @@ el.textColor.addEventListener("change", () => changeTextStyle(true)); for (const button of textFormatButtons) button.addEventListener("click", () => changeRangeFormat(button.dataset.textFormat)); textBackground.addEventListener("change", () => changeRangeFormat("background", textBackground.value)); editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); -el.textAlign.addEventListener("change", () => changeParagraph()); +el.textAlign.addEventListener("change", () => changeTextAlignment()); el.textLineHeight.addEventListener("change", () => changeParagraph()); editorClick(el.paragraphButton, () => { const item = currentTextStyle(); @@ -4253,9 +4253,11 @@ async function validateRichText(item, model, width, fonts = []) { } async function saveRichText(item, model, fonts = [], box = item) { + const tab = documentTab(), openSeq = state.openSeq, wasmSeq = state.wasmSeq; const boundary = { x: box.x, y: box.y, width: box.width, height: box.height }; if (box.frameWidth !== undefined) Object.assign(boundary, { frameWidth: box.frameWidth, frameOffset: box.frameOffset || 0 }); fonts = await validateRichText(item, model, box.frameWidth ?? item.textFrame?.width ?? item.width, fonts); + if (tab.closed || !isForegroundDocument(tab) || tab.state.openSeq !== openSeq || tab.state.wasmSeq !== wasmSeq) return false; return changeDocument("ofdgoSaveRichText", item.draft ? null : item, ...(item.draft ? [item.index, ""] : []), JSON.stringify(model), boundary, fonts); } @@ -4552,6 +4554,25 @@ function inputMillimeters(input, original) { return points === displayPoints(original) ? original : points * 25.4 / 72; } +async function changeTextAlignment() { + const align = el.textAlign.value, editing = canvasEditor.input, tab = documentTab(); + if (!state.ready || state.exporting || document.body.hasAttribute("data-busy") || editing?.composing || editing?.saving || !align) return; + try { + if (editing) { + if (!editing.rich && !editing.item.draft && !confirmTextReflow(editing.item)) return; + if (!await ensureRichInput() || !isForegroundDocument(tab) || canvasEditor.input !== editing) return; + editing.rich.align(align); + } else if (canvasEditor.selected?.richText) { + const item = canvasEditor.selected; + await saveRichText(item, { ...item.richText, paragraphs: undefined, layout: { ...item.richText.layout, align } }); + } else await changeParagraph(); + } catch (error) { + if (isForegroundDocument(tab) && canvasEditor.input === editing) showError(error, false); + } finally { + if (isForegroundDocument(tab)) updateObjectControls(canvasEditor.selected, true); + } +} + async function changeParagraph(wrap) { const item = currentTextStyle(); const explicitWrap = wrap !== undefined; @@ -4577,7 +4598,7 @@ async function changeParagraph(wrap) { updateObjectControls(item, true); return; } - if (item.richText) await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: Boolean(wrap), align: el.textAlign.value, lineHeight: height, letterSpacing: spacing } }); + if (item.richText) await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: Boolean(wrap), lineHeight: height, letterSpacing: spacing } }); else await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); updateObjectControls(canvasEditor.selected, true); } @@ -12466,14 +12487,18 @@ function updateObjectControls(item, reset = false) { el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; } - el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); + el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && (item?.selectionIDs || !canEditObject(item, "layoutKnown")); reflow.hidden = !positioned; el.textWrap.hidden = !!positioned; reflow.disabled = textDisabled || !canEditObject(item, "reflow") || !!canvasEditor.input?.reflow; - el.paragraphButton.disabled = el.textAlign.disabled; + el.paragraphButton.disabled = el.textWrap.disabled; + el.textAlign.disabled = el.textSize.disabled || Boolean(item?.items || item?.selectionIDs); const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); - el.textAlign.value = layoutKnown ? text.align || "left" : ""; + const rich = canvasEditor.input?.rich; + const alignment = rich ? richTextAlignment(rich.model, ...rich.selection()) : item?.richText ? richTextAlignment(item.richText) : layoutKnown ? text.align || "left" : ""; + el.textAlign.options[0].textContent = rich || item?.richText ? "混合" : "原文"; + el.textAlign.value = alignment; el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文"; el.textSpacing.placeholder = layoutKnown ? "0" : "原文"; el.textWrap.setAttribute("data-pressed", String(Boolean(text.wrap))); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 35c376e..278342a 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -42,6 +42,76 @@ export function richTextModel(item) { firstLineIndent: item.firstLineIndent || 0, autoBidi: true } }; } +function paragraphRange(text, start, end) { + const first = text.slice(0, start).split("\n").length - 1; + return [first, end > start ? text.slice(0, end - 1).split("\n").length - 1 : first]; +} + +export function richTextAlignment(model, start = 0, end = model.runs.reduce((n, run) => n + run.text.length, 0)) { + const text = model.runs.map(run => run.text).join(""); + const [first, last] = paragraphRange(text, start, end); + const align = model.paragraphs?.[first]?.align || model.layout.align || "left"; + for (let i = first + 1; i <= last; i++) if ((model.paragraphs?.[i]?.align || model.layout.align || "left") !== align) return ""; + return align; +} + +function replaceParagraphs(model, text, start, end, inserted, paragraphs) { + if (!model.paragraphs?.length && !paragraphs?.length) return; + const first = text.slice(0, start).split("\n").length - 1, last = text.slice(0, end).split("\n").length - 1; + const current = model.paragraphs?.length ? model.paragraphs : text.split("\n").map(() => ({ align: "" })); + model.paragraphs = [...current.slice(0, first), + ...inserted.split("\n").map((_, i) => ({ ...(paragraphs?.[i] || current[first]) })), ...current.slice(last + 1)]; +} + +function distributeParagraph(input, paragraph) { + const probe = input.cloneNode(false), copy = paragraph.cloneNode(true); + Object.assign(probe.style, { position: "fixed", transform: "none", visibility: "hidden", pointerEvents: "none", width: `${input.offsetWidth}px` }); + probe.contentEditable = "false"; + probe.append(copy); + document.body.append(probe); + try { + const lines = [], range = document.createRange(), width = copy.clientWidth; + const spacing = parseFloat(getComputedStyle(probe).letterSpacing) || 0; + const indent = parseFloat(getComputedStyle(copy).textIndent) || 0; + for (const span of copy.children) { + const node = span.firstChild; + if (node?.nodeType !== 3) continue; + for (const part of graphemes.segment(node.textContent)) { + range.setStart(node, part.index); range.setEnd(node, part.index + part.segment.length); + const rect = range.getBoundingClientRect(); + let line = lines.at(-1); + if (!line || rect.top >= line.bottom - 0.5 || rect.bottom <= line.top + 0.5) { + line = { top: rect.top, bottom: rect.bottom, parts: [] }; lines.push(line); + } + line.parts.push({ text: part.segment, width: rect.width, style: span.style.cssText }); + } + } + if (!lines.length) return; + paragraph.replaceChildren(); + for (const [index, line] of lines.entries()) { + if (index) { const wrap = document.createElement("br"); wrap.dataset.textWrap = "true"; paragraph.append(wrap); } + const row = document.createElement("span"); + row.style.whiteSpace = "pre"; + let end = line.parts.length; + while (end && line.parts[end - 1].text === " ") end--; + const length = line.parts.slice(0, end).reduce((sum, part) => sum + part.width, 0) - (end ? spacing : 0); + const extra = end > 1 ? Math.max(0, width - (index ? 0 : indent) - length) / (end - 1) : 0; + let previous; + for (const [i, part] of line.parts.entries()) { + const tracking = i < end - 1 ? spacing + extra : i === end - 1 ? 0 : spacing; + const key = `${part.style}:${tracking}`; + if (previous?.key === key) previous.node.firstChild.appendData(part.text); + else { + const span = document.createElement("span"); span.style.cssText = part.style; + span.style.letterSpacing = `${tracking}px`; span.textContent = part.text; row.append(span); + previous = {key, node:span}; + } + } + paragraph.append(row); + } + } finally { probe.remove(); } +} + function normalizeRuns(runs) { const result = []; for (const run of runs) { @@ -104,6 +174,7 @@ export class RichTextSession { this.model = structuredClone(model); this.model.runs = normalizeRuns(model.runs); this.initial = JSON.stringify(this.model); + this.recordedParagraphs = this.model.paragraphs; this.faces = faces; for (const face of faces.values()) document.fonts.add(face); this.imports = new Map(imports.map(font => [font.key, font.data])); @@ -156,11 +227,19 @@ export class RichTextSession { record(before) { const change = textChange(before, this.model.runs); const entry = { change, typing: { ...this.typing }, caret: [...this.caret] }, old = this.entries[this.index]; + const beforeText = before.map(run => run.text).join(""); + if (beforeText !== this.text && this.model.paragraphs === this.recordedParagraphs) { + const removed = change.before.map(run => run.text).join(""), inserted = change.after.map(run => run.text).join(""); + replaceParagraphs(this.model, beforeText, change.start, change.start + removed.length, inserted); + } + const paragraphsChanged = JSON.stringify(this.recordedParagraphs) !== JSON.stringify(this.model.paragraphs); + if (paragraphsChanged) entry.paragraphs = { before: this.recordedParagraphs, after: this.model.paragraphs }; + this.recordedParagraphs = this.model.paragraphs; const changed = change.before.length || change.after.length; - if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; + if (!changed && !paragraphsChanged && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; if (!changed) entry.change = null; this.entries.splice(++this.index, Infinity, entry); - if (changed) this.preview(); + if (changed || paragraphsChanged) this.preview(); } undo(direction) { if (this.editing.composing || this.editing.saving) return; @@ -172,6 +251,9 @@ export class RichTextSession { const end = change.start + removed.reduce((length, run) => length + run.text.length, 0); this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, change.start), ...added, ...sliceRuns(this.model.runs, end, this.text.length)]); } + const paragraphs = (direction < 0 ? this.entries[this.index] : entry).paragraphs; + if (paragraphs) this.model.paragraphs = direction < 0 ? paragraphs.before : paragraphs.after; + this.recordedParagraphs = this.model.paragraphs; this.index += direction; this.typing = { ...entry.typing }; this.caret = [...entry.caret]; @@ -187,6 +269,7 @@ export class RichTextSession { const editing = this.editing; if (this.editor.input !== editing || editing.composing || editing.saving) return; const current = () => this.editor.input === editing && this.previewSequence === sequence && !editing.composing && !editing.saving; + if (!this.model.runs.length) { this.editor.setTextError(null); return; } try { await this.editor.options.onPreviewRichText(editing.item, this.model, editing.frame.width, this.fonts); if (!current()) return; @@ -213,10 +296,20 @@ export class RichTextSession { this.render(); this.editor.options.onTextChange(); } - replace(runs) { + align(value) { + if (this.editing.composing || this.editing.saving) return; this.remember(); - const [start, end] = this.selection(); + const [start, end] = this.selection(), [first, last] = paragraphRange(this.text, start, end); + this.model.paragraphs = this.text.split("\n").map((_, i) => ({ align: i >= first && i <= last ? value : this.model.paragraphs?.[i]?.align || "" })); + this.record(this.model.runs); + this.render(); + this.editor.options.onTextChange(); + } + replace(runs, paragraphs, range) { + this.remember(); + const [start, end] = range || this.selection(); const before = this.model.runs; + if (paragraphs) replaceParagraphs(this.model, this.text, start, end, runs.map(run => run.text).join(""), paragraphs); this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]); this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)]; this.caret.push(this.caret[0]); @@ -256,7 +349,9 @@ export class RichTextSession { const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join(""); const keys = new Set(runs.map(run => run.format.font)); const fonts = new Map([...keys].map(key => [key, this.imports.get(key) || this.resources.get(key)]).filter(([, data]) => data)); - const clipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(), + const first = this.text.slice(0, start).split("\n").length - 1; + const paragraphs = text.split("\n").map((_, i) => ({ align: this.model.paragraphs?.[first + i]?.align || this.model.layout.align || "left" })); + const clipboard = { token: crypto.randomUUID(), runs, text, paragraphs, scope: this.editor.options.textClipboardScope?.(), fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) }; if (this.editor.options.setTextClipboard) this.editor.options.setTextClipboard(clipboard); else this.editor.richClipboard = clipboard; @@ -278,17 +373,17 @@ export class RichTextSession { const keys = new Map(copy.runs.map(run => [run.format.font, sameScope ? run.format.font : `temp-font-${crypto.randomUUID()}`])); let faces = copy.faces; if (!sameScope) { - const original = this.model.runs, caret = this.selection(); + const original = this.model.runs, paragraphs = this.model.paragraphs, caret = this.selection(); faces = new Map(await Promise.all([...copy.fonts].map(async ([key, data]) => [key, await new FontFace(`ofdgo-paste-${keys.get(key)}`, data, fontFaceStyle(data)).load()]))); - if (this.editor.input !== this.editing || this.editing.composing || this.editing.saving || this.model.runs !== original || this.selection().join() !== caret.join()) return; + if (this.editor.input !== this.editing || this.editing.composing || this.editing.saving || this.model.runs !== original || this.model.paragraphs !== paragraphs || this.selection().join() !== caret.join()) return; } for (const [key, data] of copy.fonts) this.imports.set(keys.get(key), data); for (const [key, face] of faces) if (!this.faces.has(keys.get(key))) { this.faces.set(keys.get(key), face); document.fonts.add(face); } for (const [key, choice] of copy.choices) this.choices.set(keys.get(key), choice); runs = copy.runs.map(run => ({ text: run.text, format: { ...run.format, font: keys.get(run.format.font) } })); } - this.replace(runs || [{ text, format: { ...this.typing } }]); + this.replace(runs || [{ text, format: { ...this.typing } }], internal ? copy.paragraphs : null); } setFont(face, data, key) { key ||= `temp-font-${crypto.randomUUID()}`; @@ -308,10 +403,13 @@ export class RichTextSession { } } if (!lines[0].length) lines[0].push({ text: "", format: this.typing }); + this.editor.fitTextInput(); const previous = this.renderedLines || []; let offset = 0; this.renderedLines = lines.map((parts, index) => { - const key = JSON.stringify(parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family])); + const align = this.model.paragraphs?.[index]?.align || this.model.layout.align || "left"; + const distribute = align === "distributed" && !CSS.supports("text-justify", "inter-character"); + const key = JSON.stringify([parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family]), align, distribute ? input.offsetWidth : null]); let paragraph = previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null; if (!paragraph) { paragraph = document.createElement("div"); @@ -328,12 +426,15 @@ export class RichTextSession { paragraph.style.fontSize = paragraph.firstElementChild.style.fontSize; paragraph.append(document.createElement("br")); } + Object.assign(paragraph.style, { textAlign: distribute ? "left" : align === "distributed" ? "justify" : align, + textAlignLast: !distribute && align === "distributed" ? "justify" : "auto", textJustify: align === "distributed" ? "inter-character" : "auto" }); + if (distribute) distributeParagraph(input, paragraph); } if (input.childNodes[index] !== paragraph) input.insertBefore(paragraph, input.childNodes[index] || null); if (index) offset++; - for (const span of paragraph.children) { - if (span.nodeName !== "SPAN") continue; - const node = span.firstChild; + const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode())) { positions.push({ node, start: offset, end: offset + node.textContent.length }); offset += node.textContent.length; } @@ -342,7 +443,6 @@ export class RichTextSession { while (input.childNodes.length > lines.length) input.lastChild.remove(); input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`; input.style.caretColor = this.typing.color || "#000000"; - this.editor.fitTextInput(); this.editing.range = null; input.focus({ preventScroll: true }); restoreTextSelection(positions, this.caret); @@ -358,6 +458,7 @@ export class RichTextSession { export function editTextValue(input) { let text = "", previous; for (const node of input.childNodes) { + if (node.nodeName === "BR" && node.dataset.textWrap) continue; const block = node.nodeName === "DIV" || node.nodeName === "P"; if (previous && (block || previous.nodeName === "DIV" || previous.nodeName === "P") && previous.nodeName !== "BR") text += "\n"; text += node.nodeType === 3 ? node.textContent : node.nodeName === "BR" ? (node.nextSibling ? "\n" : "") : editTextValue(node); @@ -1743,6 +1844,16 @@ export class CanvasEditor { if (event.inputType === "historyUndo" || event.inputType === "historyRedo") { event.preventDefault(); editing.rich.undo(event.inputType === "historyUndo" ? -1 : 1); return; } + if (!editing.composing && ["deleteContentBackward", "deleteContentForward"].includes(event.inputType) && input.querySelector("br[data-text-wrap]")) { + event.preventDefault(); + const rich = editing.rich, range = rich.selection(); + if (range[0] === range[1]) { + const bounds = [0, ...Array.from(graphemes.segment(rich.text), part => part.index + part.segment.length)]; + if (event.inputType === "deleteContentBackward") range[0] = bounds.findLast(at => at < range[0]) ?? 0; + else range[1] = bounds.find(at => at > range[1]) ?? rich.text.length; + } + rich.replace([], null, range); return; + } if (!editing.composing) { editing.rich.remember(); editing.rich.beforeCaret = editing.rich.selection(); } } if (editing.source && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) { @@ -1816,7 +1927,7 @@ export class CanvasEditor { const { input, frame, item, rich } = editing; const layout = rich?.model.layout || item; const width = Math.max(0, frame.width - (layout.leftIndent || 0) - (layout.rightIndent || 0)) * PX_PER_MM + 4; - const autoWidth = !layout.align || layout.align === "left"; + const autoWidth = (!layout.align || layout.align === "left") && !rich?.model.paragraphs?.some(paragraph => paragraph.align && paragraph.align !== "left"); Object.assign(input.style, { width: autoWidth ? "max-content" : `${width}px`, maxWidth: layout.wrap ? `${width}px` : "none", diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 5d20e70..a71a3d3 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -5061,8 +5061,12 @@ func (e *businessEngine) browserRichText(value ofdgo.RichText) map[string]any { "strikeout": format.Strikeout, "shadow": format.Shadow, "background": background, }} } + paragraphs := make([]any, len(value.Paragraphs)) + for i, paragraph := range value.Paragraphs { + paragraphs[i] = map[string]any{"align": paragraph.Align} + } layout := value.Layout - return map[string]any{"runs": runs, "layout": map[string]any{ + return map[string]any{"runs": runs, "paragraphs": paragraphs, "layout": map[string]any{ "autoBidi": layout.AutoBidi, "shape": layout.Shape, "shaping": map[string]any{ "Bidi": layout.Shaping.Bidi, "Direction": layout.Shaping.Direction, diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 7cb057e..ddba053 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -35,7 +35,7 @@ type MissingGlyphError struct { // TextLayout 本地横向段落选项 // Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位 // Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留 -// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify +// Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed // LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 // LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米 // AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐 @@ -633,9 +633,9 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, } // alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐 -// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距 +// 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点 // 返回: float64 行首X坐标, string 字符间的DeltaX序列 -func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64) (float64, string) { +func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64, clusters map[int]bool) (float64, string) { end := len(runes) for end > 0 && runes[end-1] == ' ' { end-- @@ -665,6 +665,17 @@ func alignTextLine(runes []rune, advances []float64, width float64, alignment st } } } + if alignment == "distributed" && extra > 0 { + var seg segmenter.Segmenter + seg.Init(runes[:end]) + for it := seg.GraphemeIterator(); it.Next(); { + cluster := it.Grapheme() + at := cluster.Offset + len(cluster.Text) + if at < end && (clusters == nil || clusters[at]) { + gaps[at-1] = true + } + } + } deltas := make([]string, max(0, len(runes)-1)) for i := range deltas { advance := advances[i] diff --git a/ofdgo_rich_layout.go b/ofdgo_rich_layout.go index 56f8665..c9d13e1 100644 --- a/ofdgo_rich_layout.go +++ b/ofdgo_rich_layout.go @@ -127,13 +127,17 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro var underlays []GraphicObject var shadows []GraphicObject var y float64 - for start := 0; start <= len(chars); { + for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ { end := start for end < len(chars) && chars[end] != '\n' { end++ } paragraph := chars[start:end] - local := richParagraphOptions(paragraph, options) + local := options + if len(value.Paragraphs) > 0 && value.Paragraphs[paragraphIndex].Align != "" { + local.Align = value.Paragraphs[paragraphIndex].Align + } + local = richParagraphOptions(paragraph, local) if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write())) if err != nil { @@ -194,7 +198,16 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height) } height = max(height, ascent+descent) - x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing) + var clusters map[int]bool + if local.Align == "distributed" { + clusters = make(map[int]bool) + for _, span := range spans { + for _, glyph := range span.glyphs { + clusters[span.start+glyph.Cluster] = true + } + } + } + x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing, clusters) copy(steps, parseFloats(delta)) if local.Shaping.Direction == "rtl" { for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- { diff --git a/ofdgo_rich_text.go b/ofdgo_rich_text.go index f9e7ece..295f4e5 100644 --- a/ofdgo_rich_text.go +++ b/ofdgo_rich_text.go @@ -29,9 +29,16 @@ import ( var ErrNotRichText = errors.New("object is not rich text") // RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节 +// Paragraphs为空时所有段落沿用Layout,否则数量必须与LF分隔的段落一致 type RichText struct { - Runs []RichTextRun `json:"runs"` - Layout TextLayout `json:"layout"` + Runs []RichTextRun `json:"runs"` + Layout TextLayout `json:"layout"` + Paragraphs []RichTextParagraph `json:"paragraphs,omitempty"` +} + +// RichTextParagraph 保存显式段落的对齐方式,空值沿用Layout,按LF分隔的段落顺序排列 +type RichTextParagraph struct { + Align string `json:"align"` } // RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米 @@ -142,6 +149,14 @@ func (v RichText) Validate() error { if err := validateRichLayout(v.Layout); err != nil { return err } + if len(v.Paragraphs) != 0 && len(v.Paragraphs) != strings.Count(v.Text(), "\n")+1 { + return fmt.Errorf("rich text paragraph count does not match text") + } + for _, paragraph := range v.Paragraphs { + if err := validateRichLayout(TextLayout{Align: paragraph.Align}); err != nil { + return err + } + } bounds := richTextBoundaries(v.Text()) position := 0 for _, run := range v.Runs { @@ -189,7 +204,7 @@ func validateRichLayout(v TextLayout) error { return fmt.Errorf("invalid rich text layout dimensions") } switch v.Align { - case "", "left", "center", "right", "justify": + case "", "left", "center", "right", "justify", "distributed": default: return fmt.Errorf("invalid rich text alignment") } @@ -233,6 +248,12 @@ func (v RichText) Slice(start, end int) (RichText, error) { return RichText{}, err } result := RichText{Layout: v.Layout, Runs: v.sliceRuns(start, end)} + if len(v.Paragraphs) > 0 { + chars := []rune(v.Text()) + first := strings.Count(string(chars[:start]), "\n") + count := strings.Count(string(chars[start:end]), "\n") + 1 + result.Paragraphs = slices.Clone(v.Paragraphs[first : first+count]) + } if len(result.Runs) == 0 { position := 0 format := v.Runs[len(v.Runs)-1].Format @@ -282,6 +303,20 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error { caret, _ := v.Slice(start, start) next.Runs = caret.Runs } + if len(v.Paragraphs) > 0 { + chars := []rune(v.Text()) + first := strings.Count(string(chars[:start]), "\n") + last := first + strings.Count(string(chars[start:end]), "\n") + next.Paragraphs = slices.Clone(v.Paragraphs[:first]) + count := 1 + for _, run := range runs { + count += strings.Count(run.Text, "\n") + } + for range count { + next.Paragraphs = append(next.Paragraphs, v.Paragraphs[first]) + } + next.Paragraphs = append(next.Paragraphs, v.Paragraphs[last+1:]...) + } next = normalizeRichText(next) if err := next.Validate(); err != nil { return err @@ -310,7 +345,42 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error { if start == end { return nil } - return v.Replace(start, end, part.Runs) + paragraphs := v.Paragraphs + if err := v.Replace(start, end, part.Runs); err != nil { + return err + } + v.Paragraphs = paragraphs + return nil +} + +// AlignParagraphs 修改光标所在或半开选区涉及的段落,选区末端不包含下一段首 +// 入参: start 起点, end 终点, align 对齐方式 +// 返回: error 无效范围或对齐方式 +func (v *RichText) AlignParagraphs(start, end int, align string) error { + if v == nil { + return fmt.Errorf("nil rich text") + } + if err := v.checkRange(start, end); err != nil { + return err + } + if err := validateRichLayout(TextLayout{Align: align}); err != nil { + return err + } + chars := []rune(v.Text()) + first := strings.Count(string(chars[:start]), "\n") + last := first + if end > start { + last = strings.Count(string(chars[:end-1]), "\n") + } + paragraphs := slices.Clone(v.Paragraphs) + if len(paragraphs) == 0 { + paragraphs = make([]RichTextParagraph, strings.Count(v.Text(), "\n")+1) + } + for i := first; i <= last; i++ { + paragraphs[i].Align = align + } + v.Paragraphs = paragraphs + return nil } // apply 应用显式给出的格式字段 diff --git a/ofdgo_text_layout.go b/ofdgo_text_layout.go index 1ec00f3..018295d 100644 --- a/ofdgo_text_layout.go +++ b/ofdgo_text_layout.go @@ -63,7 +63,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) { return fmt.Errorf("paragraph indents must be finite") } - if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { + if !slices.Contains([]string{"", "left", "center", "right", "justify", "distributed"}, options.Align) { return fmt.Errorf("invalid text alignment %q", options.Align) } var width float64 @@ -129,7 +129,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM if i == 0 { indent = options.FirstLineIndent } - x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing) + x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing, nil) x += options.LeftIndent + indent y := float64(ascender)*unit + float64(len(codes))*lineHeight if !finite(x) || !finite(y) { @@ -220,7 +220,14 @@ func layoutShapedText(obj *TextObject, value string, options TextLayout, metrics } } chars := runes[line[0]:line[1]] - x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing) + var clusters map[int]bool + if options.Align == "distributed" { + clusters = make(map[int]bool) + for _, glyph := range glyphs { + clusters[glyph.Cluster] = true + } + } + x, delta := alignTextLine(chars, lineAdvances, available, options.Align, i+1 < len(lines), options.LetterSpacing, clusters) if options.Shaping.Direction == "rtl" { for j := len(chars) - 1; j >= 0 && chars[j] == ' '; j-- { x -= lineAdvances[j]