mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f246b0f543 | ||
|
|
dfea3d60fd | ||
|
|
e91e146b50 |
No files matched your search
@@ -140,7 +140,7 @@
|
||||
<option value="left">左齐</option>
|
||||
<option value="center">居中</option>
|
||||
<option value="right">右齐</option>
|
||||
<option value="justify">两端</option>
|
||||
<option value="justify">两端</option><option value="distributed">分散</option>
|
||||
</select>
|
||||
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" title="行距,0为自动" disabled><span>pt</span></span></label>
|
||||
<button id="textWrap" class="button" type="button" data-pressed="false" disabled>折行</button>
|
||||
|
||||
+41
-13
@@ -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();
|
||||
@@ -4136,13 +4136,14 @@ function currentTextStyle() {
|
||||
return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text;
|
||||
}
|
||||
|
||||
async function loadRichText(item, firstFace, tab = documentTab(), firstData = null) {
|
||||
async function loadRichText(item, firstFace, tab = documentTab(), firstData = null, reflow = false) {
|
||||
const wasmSeq = tab.state.wasmSeq;
|
||||
const callWASM = (name, ...args) => {
|
||||
if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改");
|
||||
return callTabWASM(tab, name, ...args);
|
||||
};
|
||||
const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id);
|
||||
const prepared = item.richText ? { model: item.richText, frame: item.textFrame } : await callWASM("ofdgoGetRichText", item.index, item.id, reflow);
|
||||
const model = prepared.model;
|
||||
const remaining = firstData ? { ...model, runs: model.runs.filter(run => run.format.font !== item.font) } : model;
|
||||
const fontData = remaining.runs.length ? await callWASM("ofdgoRichTextFonts", JSON.stringify(remaining)) : [];
|
||||
const resources = new Map(fontData.map(font => [font.key, font.data]));
|
||||
@@ -4154,10 +4155,10 @@ async function loadRichText(item, firstFace, tab = documentTab(), firstData = nu
|
||||
if (tab.closed || wasmSeq !== tab.state.wasmSeq) throw new Error("文档已更改");
|
||||
faces.set(id, face);
|
||||
}
|
||||
return { model, faces, resources };
|
||||
return { model, faces, resources, frame: prepared.frame, reflow };
|
||||
}
|
||||
|
||||
async function ensureRichInput() {
|
||||
async function ensureRichInput(reflow = false) {
|
||||
let editing = canvasEditor.input;
|
||||
if (!editing) {
|
||||
const item = canvasEditor.selected;
|
||||
@@ -4169,9 +4170,9 @@ async function ensureRichInput() {
|
||||
if (editing.item.selectionIDs) return false;
|
||||
if (editing.rich) return true;
|
||||
if (editing.composing || editing.saving) return false;
|
||||
const rich = await loadRichText(editing.item, editing.face);
|
||||
const rich = await loadRichText(editing.item, editing.face, documentTab(), null, reflow);
|
||||
if (canvasEditor.input !== editing) return false;
|
||||
return canvasEditor.promoteText(rich);
|
||||
return Boolean(editing.rich) || canvasEditor.promoteText(rich);
|
||||
}
|
||||
|
||||
async function reflowCanvasText() {
|
||||
@@ -4253,9 +4254,12 @@ 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 (item.richReflow) boundary.reflow = true;
|
||||
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 +4556,26 @@ 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;
|
||||
const sequence = editing ? editing.alignSequence = (editing.alignSequence || 0) + 1 : 0;
|
||||
try {
|
||||
if (editing) {
|
||||
if (!editing.rich && !editing.item.draft && !confirmTextReflow(editing.item)) return;
|
||||
if (!await ensureRichInput(!canEditObject(editing.item, "layoutKnown")) || !isForegroundDocument(tab) || canvasEditor.input !== editing || editing.alignSequence !== sequence) 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 && (!editing || editing.alignSequence === sequence)) showError(error, false);
|
||||
} finally {
|
||||
if (isForegroundDocument(tab)) updateObjectControls(canvasEditor.selected, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeParagraph(wrap) {
|
||||
const item = currentTextStyle();
|
||||
const explicitWrap = wrap !== undefined;
|
||||
@@ -4577,7 +4601,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 +12490,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)));
|
||||
|
||||
+148
-32
@@ -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,14 @@ 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 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 +246,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 +264,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 +291,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;
|
||||
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]);
|
||||
@@ -242,6 +330,7 @@ export class RichTextSession {
|
||||
if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; }
|
||||
}
|
||||
const runs = this.model.runs;
|
||||
replaceParagraphs(this.model, previous, start, end, value.slice(start, nextEnd));
|
||||
this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start),
|
||||
{ text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]);
|
||||
this.caret = caret;
|
||||
@@ -256,7 +345,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 +369,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 +399,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 +422,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 +439,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 +454,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,7 +1840,21 @@ export class CanvasEditor {
|
||||
if (event.inputType === "historyUndo" || event.inputType === "historyRedo") {
|
||||
event.preventDefault(); editing.rich.undo(event.inputType === "historyUndo" ? -1 : 1); return;
|
||||
}
|
||||
if (!editing.composing) { editing.rich.remember(); editing.rich.beforeCaret = editing.rich.selection(); }
|
||||
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();
|
||||
const range = event.getTargetRanges()[0];
|
||||
editing.rich.beforeCaret = range ? editTextSelection(input, range) : editing.rich.selection();
|
||||
}
|
||||
}
|
||||
if (editing.source && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) {
|
||||
event.preventDefault();
|
||||
@@ -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",
|
||||
@@ -1846,33 +1957,38 @@ export class CanvasEditor {
|
||||
const caret = editTextSelection(editing.input, editing.range) || [0, 0];
|
||||
const value = editTextValue(editing.input);
|
||||
const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts, rich.resources);
|
||||
const format = { ...session.typing, ...editing.style };
|
||||
if (editing.fontData) {
|
||||
const key = `temp-font-${crypto.randomUUID()}`;
|
||||
format.font = key;
|
||||
session.imports.set(key, editing.fontData);
|
||||
session.faces.set(key, editing.face);
|
||||
if (editing.fontChoice) session.choices.set(key, editing.fontChoice);
|
||||
}
|
||||
const history = editing.history;
|
||||
const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }];
|
||||
for (const entry of values) {
|
||||
const base = { ...session.typing }, fonts = new Map();
|
||||
const current = { value, style: editing.style, face: editing.face, fontData: editing.fontData, fontChoice: editing.fontChoice, caret };
|
||||
const values = editing.history ? editing.history.entries.slice(0, editing.history.index + 1) : [{ value: editing.item.text }];
|
||||
for (const entry of [...values, current]) {
|
||||
const format = { ...base, ...entry.style };
|
||||
if (entry.fontData) {
|
||||
let key = fonts.get(entry.fontData);
|
||||
if (!key) {
|
||||
key = `temp-font-${crypto.randomUUID()}`; fonts.set(entry.fontData, key);
|
||||
session.imports.set(key, entry.fontData); session.faces.set(key, entry.face);
|
||||
document.fonts.add(entry.face);
|
||||
if (entry.fontChoice) session.choices.set(key, entry.fontChoice);
|
||||
}
|
||||
format.font = key;
|
||||
}
|
||||
const before = session.model.runs;
|
||||
session.model.runs = normalizeRuns([{ text: entry.value, format }]);
|
||||
session.typing = { ...format };
|
||||
session.typing = format;
|
||||
session.caret = entry.caret || caret;
|
||||
session.record(before);
|
||||
}
|
||||
const before = session.model.runs;
|
||||
session.model.runs = normalizeRuns([{ text: value, format }]);
|
||||
session.caret = caret;
|
||||
session.record(before);
|
||||
if (rich.frame) {
|
||||
editing.frame = rich.frame;
|
||||
editing.item = { ...editing.item, richReflow: rich.reflow, textFrame: rich.frame };
|
||||
Object.assign(editing.input.style, { whiteSpace: rich.model.layout.wrap ? "pre-wrap" : "pre", textIndent: "0px", letterSpacing: "0px" });
|
||||
}
|
||||
editing.rich = session;
|
||||
editing.source = false;
|
||||
document.removeEventListener("selectionchange", editing.selectionListener);
|
||||
editing.history = null;
|
||||
editing.range = null;
|
||||
const [a, b, c, d, e, f] = editing.frame.matrix, left = editing.item.leftIndent || 0;
|
||||
const [a, b, c, d, e, f] = editing.frame.matrix, left = rich.model.layout.leftIndent || 0;
|
||||
editing.input.style.transform = `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`;
|
||||
if (!rich.model.layout.lineHeight) editing.input.style.lineHeight = "normal";
|
||||
session.render();
|
||||
|
||||
+23
-9
@@ -4686,7 +4686,7 @@ func (e *businessEngine) readTextSelectionEdit(args []platformValue, analysis of
|
||||
if err := value.Replace(start, end, replacement); err != nil {
|
||||
return edit, err
|
||||
}
|
||||
value.Layout.AutoBidi, value.Layout.Wrap = true, true
|
||||
value.Layout.AutoBidi = true
|
||||
edit.Reflow = &value
|
||||
}
|
||||
return edit, nil
|
||||
@@ -5001,8 +5001,8 @@ func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.Graphi
|
||||
}
|
||||
|
||||
// getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式
|
||||
// 入参: args 页码和对象标识
|
||||
// 返回: any 富文本快照, error 错误信息
|
||||
// 入参: args 页码、对象标识和可选显式重排标记
|
||||
// 返回: any 富文本快照,携带重排标记时附带局部编辑框及页面变换, error 错误信息
|
||||
func (e *businessEngine) getRichText(args []platformValue) (any, error) {
|
||||
if e.currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
@@ -5011,10 +5011,15 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := e.currentEditor.TargetRichText(args[0].Int(), path)
|
||||
value, frame, matrix, err := e.currentEditor.PrepareRichText(args[0].Int(), path, len(args) > 2 && args[2].Bool())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(args) > 2 {
|
||||
return map[string]any{"model": e.browserRichText(value), "frame": map[string]any{
|
||||
"width": frame.W, "height": frame.H, "matrix": e.editorMatrix(matrix),
|
||||
}}, nil
|
||||
}
|
||||
return e.browserRichText(value), nil
|
||||
}
|
||||
|
||||
@@ -5061,8 +5066,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,
|
||||
@@ -5111,7 +5120,7 @@ func (e *businessEngine) readRichText(value platformValue) (ofdgo.RichText, erro
|
||||
}
|
||||
|
||||
// saveRichText 原子保存文字、样式和新增字体,空标识创建文本框
|
||||
// 入参: args 页码、标识、富文本JSON、新建边界、可选字体集合和父路径
|
||||
// 入参: args 页码、标识、富文本JSON、边界及重排标记、可选字体集合和父路径
|
||||
// 返回: any 文档信息及文字选区, error 错误信息
|
||||
func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
value, err := e.readRichText(args[2])
|
||||
@@ -5125,13 +5134,20 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
box := args[3]
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("reflow").Type() == platformTypeBoolean && box.Get("reflow").Bool() {
|
||||
path, err := e.compositePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.currentEditor.ReflowRichText(page, path, value)
|
||||
}
|
||||
if len(args) > 5 && args[5].String() != "" {
|
||||
key := args[5].String()
|
||||
path, err := e.compositePath(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
box := args[3]
|
||||
if id != "" {
|
||||
indexes, err := e.compositeIndexes(key, []string{id})
|
||||
if err != nil {
|
||||
@@ -5154,7 +5170,6 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
if err := e.currentEditor.UpdateRichText(page, id, value); err != nil {
|
||||
return err
|
||||
}
|
||||
box := args[3]
|
||||
if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber {
|
||||
offset := 0.0
|
||||
if box.Get("frameOffset").Type() == platformTypeNumber {
|
||||
@@ -5164,7 +5179,6 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
box := args[3]
|
||||
id, err = e.currentEditor.AddRichText(page, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value)
|
||||
return err
|
||||
})
|
||||
|
||||
+130
-83
@@ -101,7 +101,7 @@ func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error)
|
||||
// 入参: page 页面索引, id 对象标识, value 完整富文本
|
||||
// 返回: error 识别、排版或提交错误
|
||||
func (e *Editor) UpdateRichText(page int, id string, value RichText) error {
|
||||
return e.updateRichTextFrame(page, id, value, 0, 0)
|
||||
return e.updateRichTextFrame(page, id, value, 0, 0, false)
|
||||
}
|
||||
|
||||
// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放
|
||||
@@ -115,23 +115,19 @@ func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.updateRichTextFrame(page, id, value, offset, width)
|
||||
return e.updateRichTextFrame(page, id, value, offset, width, false)
|
||||
}
|
||||
|
||||
// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度
|
||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度
|
||||
// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排原文
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error {
|
||||
func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64, reflow bool) error {
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
old, err := e.Object(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := e.RichText(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
frame, matrix, err := e.RichTextFrame(page, id)
|
||||
current, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, reflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -262,19 +258,8 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
if object.Type == "TextObject" {
|
||||
if _, err := e.promoteRichText(page, object); err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
frame, err := object.TextObject.TextFrame()
|
||||
if err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
box, _ := creationBox(object.TextObject.Boundary)
|
||||
dx, dy, err := e.richPlainOffset(object.TextObject)
|
||||
if err != nil {
|
||||
return Box{}, Matrix{}, err
|
||||
}
|
||||
return Box{W: frame.W, H: frame.H}, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(frame.X+dx, frame.Y+dy)), nil
|
||||
_, frame, matrix, err := e.PrepareRichText(page, ObjectPath{ID: id}, false)
|
||||
return frame, matrix, err
|
||||
}
|
||||
if object.Type != "CompositeObject" {
|
||||
return Box{}, Matrix{}, ErrNotRichText
|
||||
@@ -341,6 +326,13 @@ func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText,
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值保留
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error {
|
||||
return e.updateCompositeRichText(page, path, index, value, offset, width, false)
|
||||
}
|
||||
|
||||
// updateCompositeRichText 统一处理内部文字的安全提升与显式重排
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
|
||||
if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 {
|
||||
return fmt.Errorf("invalid rich text frame")
|
||||
}
|
||||
@@ -350,7 +342,7 @@ func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, v
|
||||
return err
|
||||
}
|
||||
if member.Object.Type == "TextObject" {
|
||||
return e.updateCompositePlainRichText(page, path, index, value, offset, width)
|
||||
return e.updateCompositePlainRichText(page, path, index, value, offset, width, reflow)
|
||||
}
|
||||
var metadata richTextMetadata
|
||||
entryIndex := -1
|
||||
@@ -677,9 +669,13 @@ func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, erro
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
if origin := e.objectOrigin(object.TextObject.ID); layer.DrawParam != "" || origin != nil && !editorXMLSupported(origin.node) {
|
||||
if origin := e.objectOrigin(object.TextObject.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||
return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")}
|
||||
}
|
||||
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
return e.promoteTextValue(object.TextObject)
|
||||
}
|
||||
|
||||
@@ -693,11 +689,7 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
if t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
||||
return unsupported()
|
||||
}
|
||||
frame, err := t.TextFrame()
|
||||
if err != nil {
|
||||
return unsupported()
|
||||
}
|
||||
if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) {
|
||||
if _, err := t.TextFrame(); err != nil {
|
||||
return unsupported()
|
||||
}
|
||||
value, layout := t.TextLayout()
|
||||
@@ -713,7 +705,7 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) {
|
||||
return unsupported()
|
||||
}
|
||||
if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||
if !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) {
|
||||
return unsupported()
|
||||
}
|
||||
for _, dy := range parseFloatsWithG(actual.DeltaY) {
|
||||
@@ -745,18 +737,16 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) {
|
||||
return result, result.Validate()
|
||||
}
|
||||
|
||||
// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局
|
||||
// richPlainOffset 计算原首行相对标准排版的位移,保留既有段落和组合内基线
|
||||
// 入参: text 已校验的普通文字
|
||||
// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标
|
||||
func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||
if text.layout != nil {
|
||||
return 0, 0, nil
|
||||
}
|
||||
if len(text.TextCode) != 1 {
|
||||
return 0, 0, fmt.Errorf("positioned text requires explicit layout")
|
||||
if len(text.TextCode) == 0 {
|
||||
return 0, 0, fmt.Errorf("text codes are empty")
|
||||
}
|
||||
value, layout := text.TextLayout()
|
||||
check := text
|
||||
if err := e.LayoutText(&check, text.Text(), TextLayout{}); err != nil {
|
||||
if err := e.LayoutText(&check, value, layout); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
x, err := strconv.ParseFloat(text.TextCode[0].X, 64)
|
||||
@@ -771,7 +761,11 @@ func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) {
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return x, y - baseline, nil
|
||||
origin, err := strconv.ParseFloat(check.TextCode[0].X, 64)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return x - origin, y - baseline, nil
|
||||
}
|
||||
|
||||
// copyRichTextMetadata 迁移选区及其组合、注解内部的编辑标引,复用标准内容中的字体引用
|
||||
@@ -1078,49 +1072,34 @@ func (e *Editor) formatCompositeText(page int, path ObjectPath, index int, patch
|
||||
}
|
||||
|
||||
// updateCompositePlainRichText 将可验证布局的内部文字提升为富文本并保留实例坐标
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度
|
||||
// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本内容, offset 局部偏移, width 可选宽度, reflow 是否显式重排
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error {
|
||||
func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index int, value RichText, offset, width float64, reflow bool) error {
|
||||
target := ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: append(slices.Clone(path.Children), index)}
|
||||
_, frame, matrix, err := e.PrepareRichText(page, target, reflow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matrix = matrix.Multiply(TranslationMatrix(offset, 0))
|
||||
if width > 0 {
|
||||
frame.W = width
|
||||
}
|
||||
var metadata richTextMetadata
|
||||
if err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error {
|
||||
node, text := nodes[0], members[0].Object.TextObject
|
||||
node := nodes[0]
|
||||
if node.object.Type != "TextObject" || !editorXMLSupported(node.span) {
|
||||
return ErrNotRichText
|
||||
}
|
||||
text.CTM = ""
|
||||
_, err := e.promoteTextValue(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := value.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
frame, err := text.TextFrame()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dx, dy, err := e.richPlainOffset(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object := node.object
|
||||
if !node.boundaryInCTM {
|
||||
object = compositeBoundary(object, node.parent, true)
|
||||
}
|
||||
box, err := creationBox(object.TextObject.Boundary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(dx+offset, dy))
|
||||
if width > 0 {
|
||||
frame.W = width
|
||||
inverse, ok := node.parent.Invert()
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid composite parent transform")
|
||||
}
|
||||
next, meta, err := e.prepareRichText(Box{W: frame.W, H: frame.H}, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unit := richTextPlacement(meta, matrix)
|
||||
unit.ID, unit.ResourceID = object.TextObject.ID, next.CompositeGraphicUnit.ResourceID
|
||||
unit := richTextPlacement(meta, inverse.Multiply(matrix))
|
||||
unit.ID, unit.ResourceID = node.object.TextObject.ID, next.CompositeGraphicUnit.ResourceID
|
||||
next.CompositeGraphicUnit = unit
|
||||
if !node.boundaryInCTM {
|
||||
next = compositeBoundary(next, node.parent, false)
|
||||
@@ -1141,23 +1120,91 @@ func (e *Editor) updateCompositePlainRichText(page int, path ObjectPath, index i
|
||||
// 入参: page 页面索引, path 完整对象路径
|
||||
// 返回: RichText 独立模型, error 路径或不安全提升错误
|
||||
func (e *Editor) TargetRichText(page int, path ObjectPath) (RichText, error) {
|
||||
value, _, _, err := e.PrepareRichText(page, path, false)
|
||||
return value, err
|
||||
}
|
||||
|
||||
// PrepareRichText 读取独立富文本模型和局部编辑框,显式重排时保留原首行起点及页面变换
|
||||
// 入参: page 页面索引, path 完整对象路径, reflow 是否允许重排普通文字
|
||||
// 返回: RichText 内容, Box 局部编辑框, Matrix 页面变换, error 路径、外观或排版错误
|
||||
func (e *Editor) PrepareRichText(page int, path ObjectPath, reflow bool) (RichText, Box, Matrix, error) {
|
||||
var text TextObject
|
||||
var matrix Matrix
|
||||
if len(path.Children) == 0 {
|
||||
if path.Annotation != "" {
|
||||
return RichText{}, ErrNotRichText
|
||||
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||
}
|
||||
return e.RichText(page, path.ID)
|
||||
object, err := e.Object(page, path.ID)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
value, err := e.RichText(page, path.ID)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
frame, matrix, err := e.RichTextFrame(page, path.ID)
|
||||
return value, frame, matrix, err
|
||||
}
|
||||
if origin := e.objectOrigin(path.ID); origin != nil && !editorXMLSupported(origin.node) {
|
||||
return RichText{}, Box{}, Matrix{}, ErrNotRichText
|
||||
}
|
||||
layer, _, err := e.findObject(page, path.ID)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
object, err = e.resolveEditorStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
text = object.TextObject
|
||||
box, err := creationBox(text.Boundary)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
matrix = TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(text.CTM))
|
||||
} else {
|
||||
index := path.Children[len(path.Children)-1]
|
||||
path.Children = path.Children[:len(path.Children)-1]
|
||||
member, err := e.CompositeObject(page, path, index)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
if member.Object.Type != "TextObject" {
|
||||
return e.CompositeRichText(page, member)
|
||||
}
|
||||
text, matrix = member.Object.TextObject, member.Matrix
|
||||
}
|
||||
frame, err := text.TextFrame()
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
dx, dy, err := e.richPlainOffset(text)
|
||||
if err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
if reflow {
|
||||
value, layout := text.TextLayout()
|
||||
layout.AutoBidi = true
|
||||
if err := e.LayoutText(&text, value, layout); err != nil {
|
||||
return RichText{}, Box{}, Matrix{}, err
|
||||
}
|
||||
}
|
||||
value, err := e.promoteTextValue(text)
|
||||
return value, Box{W: frame.W, H: frame.H}, matrix.Multiply(TranslationMatrix(dx, dy)), err
|
||||
}
|
||||
|
||||
// ReflowRichText 原子重排完整路径文字,保留局部宽度、页面变换及共享实例隔离
|
||||
// 入参: page 页面索引, path 完整对象路径, value 新的富文本内容
|
||||
// 返回: error 路径、排版或提交错误
|
||||
func (e *Editor) ReflowRichText(page int, path ObjectPath, value RichText) error {
|
||||
if len(path.Children) == 0 {
|
||||
if path.Annotation != "" {
|
||||
return ErrNotRichText
|
||||
}
|
||||
return e.updateRichTextFrame(page, path.ID, value, 0, 0, true)
|
||||
}
|
||||
index := path.Children[len(path.Children)-1]
|
||||
path.Children = path.Children[:len(path.Children)-1]
|
||||
member, err := e.CompositeObject(page, path, index)
|
||||
if err != nil {
|
||||
return RichText{}, err
|
||||
}
|
||||
if member.Object.Type == "TextObject" {
|
||||
text := member.Object.TextObject
|
||||
text.CTM = ""
|
||||
return e.promoteTextValue(text)
|
||||
}
|
||||
value, _, _, err := e.CompositeRichText(page, member)
|
||||
return value, err
|
||||
return e.updateCompositeRichText(page, path, index, value, 0, 0, true)
|
||||
}
|
||||
+14
-3
@@ -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]
|
||||
|
||||
@@ -817,7 +817,7 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
||||
}
|
||||
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
||||
}
|
||||
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||
if err := e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||
for i, node := range nodes {
|
||||
if selection.Indexes[i] != first.Index {
|
||||
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
||||
@@ -838,5 +838,18 @@ func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit Tex
|
||||
node.setState(editorCompositeState{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
index := first.Index
|
||||
for _, part := range preview.analysis.Parts[1:] {
|
||||
if part.Index < first.Index {
|
||||
index--
|
||||
}
|
||||
}
|
||||
member, err := e.CompositeObject(page, *selection.Root, index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.putCompositeRichTextMetadata(page, member.Object, meta, -1)
|
||||
}
|
||||
+16
-3
@@ -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-- {
|
||||
|
||||
+74
-4
@@ -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 应用显式给出的格式字段
|
||||
|
||||
+10
-3
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user