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]