mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
1 parent
64997b6c39
commit
896d624c3e
4 files changed
+1231
-29
No files matched your search
+46
-10
@@ -667,14 +667,18 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
||||
onDrawText: beginCanvasText,
|
||||
onDrawAnnotation: placeAnnotation,
|
||||
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
|
||||
onPreviewText: (item, value, font, size, reflow) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size, !!reflow),
|
||||
onPreviewText: (item, value, font, size, reflow, color = null) => item.selectionIDs
|
||||
? callWASM("ofdgoPreviewTextSelection", item.index, item.selectionIDs, value, font || null, size, color, !!reflow)
|
||||
: callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size, !!reflow),
|
||||
onCommitRichText: saveRichText,
|
||||
onPreviewRichText: validateRichText,
|
||||
onTextError: (message, previous) => {
|
||||
if (message) setStatus(message);
|
||||
else if (el.statusText.textContent === previous) setStatus(state.doc ? pageStatus(state.pageIndex, state.doc.pageCount) : STATUS.ready);
|
||||
},
|
||||
onCommitText: (item, value, fontData, color = null, reflow = false) => item.draft
|
||||
onCommitText: (item, value, fontData, color = null, reflow = false) => item.selectionIDs
|
||||
? changeDocument("ofdgoUpdateTextSelection", item, value, fontData || null, canvasEditor.input?.style?.size ?? null, color, reflow)
|
||||
: item.draft
|
||||
? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item))
|
||||
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color, reflow),
|
||||
onTextChange: () => {
|
||||
@@ -2881,7 +2885,30 @@ function updateAnnotationFields() {
|
||||
}
|
||||
|
||||
async function editCanvasObject(item) {
|
||||
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
|
||||
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) return;
|
||||
if (item.items) {
|
||||
if (!item.items.every(member => member.type === "TextObject" && !member.richText)) return;
|
||||
const openSeq = state.openSeq;
|
||||
setBusy(true);
|
||||
try {
|
||||
const ids = item.items.map(member => member.id);
|
||||
const data = await callWASM("ofdgoTextSelection", item.index, ids);
|
||||
const faces = new Map();
|
||||
for (const font of data.fonts) {
|
||||
faces.set(font.id, await new FontFace(`ofdgo-selection-${crypto.randomUUID()}`, font.bytes, fontFaceStyle(font.bytes)).load());
|
||||
}
|
||||
if (openSeq !== state.openSeq || canvasEditor.selected !== item || !item.node.isConnected) return;
|
||||
const face = faces.get(data.item.font);
|
||||
if (!face) throw new Error("字体不可用");
|
||||
Object.assign(item, data.item, { type: "TextObject", selectionIDs: ids, id: ids });
|
||||
canvasEditor.editText(item, face, data.source, null, faces);
|
||||
} catch (err) {
|
||||
if (openSeq === state.openSeq) showError(err, false);
|
||||
} finally {
|
||||
if (openSeq === state.openSeq) setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!item.richText && canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
|
||||
if (item.type === "Annotation") {
|
||||
editAnnotationDetails(item);
|
||||
@@ -3054,6 +3081,7 @@ async function ensureRichInput() {
|
||||
editing = canvasEditor.input;
|
||||
}
|
||||
if (!editing) return false;
|
||||
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);
|
||||
@@ -3603,8 +3631,11 @@ async function changeDocument(name, item, ...args) {
|
||||
const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item);
|
||||
if (scope && inserting && name === "ofdgoSaveRichText") throw new Error("组合内部暂不支持新增富文本");
|
||||
if (scope && inserting) args.push(scope.key);
|
||||
if (scoped && !operation) throw new Error("内部对象暂不支持此操作");
|
||||
const doc = scoped
|
||||
const textSelection = name === "ofdgoUpdateTextSelection";
|
||||
if (scoped && !operation && !textSelection) throw new Error("内部对象暂不支持此操作");
|
||||
const doc = textSelection
|
||||
? await callWASM(name, item.index, item.selectionIDs, ...args)
|
||||
: scoped
|
||||
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
|
||||
: annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args)
|
||||
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
|
||||
@@ -3666,7 +3697,7 @@ async function changeDocument(name, item, ...args) {
|
||||
}
|
||||
const view = restoredView;
|
||||
const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name)
|
||||
|| inserting || name === "ofdgoPasteObjects" && !!args[4]);
|
||||
|| textSelection || inserting || name === "ofdgoPasteObjects" && !!args[4]);
|
||||
const reindexed = restoring ? view?.reindex && view.before : reindex && before;
|
||||
const clipboard = state.objectClipboard;
|
||||
if (reindexed && clipboard?.scope?.startsWith(reindexed.scope + "/") && clipboard.page === reindexed.page) state.objectClipboard = null;
|
||||
@@ -3709,6 +3740,7 @@ async function changeDocument(name, item, ...args) {
|
||||
canvasEditor.pendingSelection = { index: item?.index ?? args[0], ids: name === "ofdgoInsertInk" ? [] : doc.selectedIDs };
|
||||
}
|
||||
if (scoped && name === "ofdgoOrderObjects") canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs };
|
||||
if (textSelection) canvasEditor.pendingSelection = { index: item.index, ids: doc.selectedIDs || [] };
|
||||
openSeq = ++state.openSeq;
|
||||
if (changingFont) {
|
||||
await refreshFontBinding(doc, openSeq);
|
||||
@@ -11737,8 +11769,9 @@ function updateObjectControls(item, reset = false) {
|
||||
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
|
||||
el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
|
||||
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
|
||||
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|
||||
|| !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update"));
|
||||
el.editObjectButton.disabled = disabled || (item.items ? !item.items.every(member => member.type === "TextObject" && !member.richText)
|
||||
: !canEditObject(item, "enter") && (item.type === "PathObject"
|
||||
|| !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update")));
|
||||
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
|
||||
el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
|
||||
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
|
||||
@@ -11747,7 +11780,7 @@ function updateObjectControls(item, reset = false) {
|
||||
const text = selection ? currentTextStyle() : state.textDefaults;
|
||||
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && (!selection || item.items.some(member => member.richText))) || Boolean(canvasEditor.input?.composing || canvasEditor.input?.saving);
|
||||
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
|
||||
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown"));
|
||||
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items && !item.selectionIDs) && !canEditObject(item, "layoutKnown"));
|
||||
el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !item.richText && !canEditObject(item, "paint");
|
||||
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
|
||||
const formatsDisabled = textDisabled || Boolean(item?.items || item?.scoped) || Boolean(item && !item.draft && (!selection || !canEditObject(item, "textContent")));
|
||||
@@ -11764,9 +11797,12 @@ function updateObjectControls(item, reset = false) {
|
||||
if (richHistory) {
|
||||
el.undoButton.disabled = textDisabled || richHistory.index === 0;
|
||||
el.redoButton.disabled = textDisabled || richHistory.index === richHistory.entries.length - 1;
|
||||
} else {
|
||||
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);
|
||||
const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && !canEditObject(item, "layoutKnown");
|
||||
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;
|
||||
|
||||
+44
-19
@@ -484,7 +484,7 @@ function paintShape(node, shape, box) {
|
||||
|
||||
export function canEditObject(item, capability) {
|
||||
if (!item) return false;
|
||||
const objects = item.items || [item];
|
||||
const objects = item.selectionIDs ? [item] : item.items || [item];
|
||||
return objects.every(object => Boolean(object.capabilities?.[capability] || capability === "move" && object.capabilities?.transform));
|
||||
}
|
||||
|
||||
@@ -512,6 +512,7 @@ export function objectEditReason(item) {
|
||||
}
|
||||
|
||||
export function selectedText(item) {
|
||||
if (item?.selectionIDs) return item;
|
||||
if (!item?.items) return item?.type === "TextObject" ? item : null;
|
||||
if (!item.items.every(member => member.type === "TextObject")) return null;
|
||||
const text = { ...item, type: "TextObject" };
|
||||
@@ -654,7 +655,7 @@ export class CanvasEditor {
|
||||
}
|
||||
}
|
||||
surface.append(layer);
|
||||
if (selected.size) this.setSelection(selection);
|
||||
if (pending || selected.size) this.setSelection(selection);
|
||||
if (pending) this.pendingSelection = null;
|
||||
if (crop && selection.length === 1 && selection[0].id === crop.item.id
|
||||
&& ["x", "y", "width", "height"].every(key => selection[0][key] === crop.item[key])) {
|
||||
@@ -1497,10 +1498,11 @@ export class CanvasEditor {
|
||||
}
|
||||
}
|
||||
|
||||
editText(item, face, source = null, rich = null) {
|
||||
editText(item, face, source = null, rich = null, faces = null) {
|
||||
this.cancel();
|
||||
this.closeText();
|
||||
this.select(item);
|
||||
if (!item.selectionIDs) this.select(item);
|
||||
for (const face of faces?.values() || []) document.fonts.add(face);
|
||||
document.fonts.add(face);
|
||||
const input = document.createElement("div");
|
||||
input.className = "edit-text";
|
||||
@@ -1532,13 +1534,17 @@ export class CanvasEditor {
|
||||
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
|
||||
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
|
||||
});
|
||||
this.input = { input, item, face, frame, source: Boolean(source), fontChoice: item.fontChoice };
|
||||
this.input = { input, item, face, faces, frame, source: Boolean(source), fontChoice: item.fontChoice };
|
||||
const editing = this.input;
|
||||
if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts);
|
||||
if (rich && !rich.model.layout.lineHeight) input.style.lineHeight = "normal";
|
||||
if (source) editing.history = { entries: [{ value: item.text, face, fontChoice: item.fontChoice }], index: 0 };
|
||||
item.artwork?.classList.add("edit-text-source");
|
||||
item.node.classList.add("edit-text-source");
|
||||
for (const member of item.selectionIDs ? item.items : []) {
|
||||
member.artwork?.classList.add("edit-text-source");
|
||||
member.node.classList.add("edit-text-source");
|
||||
}
|
||||
item.surface.append(input);
|
||||
this.fitTextInput();
|
||||
if (source) this.paintSourceText(source);
|
||||
@@ -1731,7 +1737,7 @@ export class CanvasEditor {
|
||||
history.index = index;
|
||||
const entry = history.entries[index], fragment = document.createDocumentFragment(), positions = [];
|
||||
if (entry.face) {
|
||||
document.fonts.delete(editing.face);
|
||||
if (![...editing.faces?.values() || []].includes(editing.face)) document.fonts.delete(editing.face);
|
||||
document.fonts.add(entry.face);
|
||||
Object.assign(editing, { face: entry.face, fontData: entry.fontData, fontChoice: entry.fontChoice, style: entry.style, reflow: entry.reflow });
|
||||
editing.input.style.fontFamily = `"${entry.face.family}"`;
|
||||
@@ -1759,15 +1765,17 @@ export class CanvasEditor {
|
||||
if (!editing?.source || editing.saving) return;
|
||||
this.paintSourceDraft();
|
||||
if (editing.composing) return;
|
||||
const value = editTextValue(editing.input), font = editing.fontData, size = editing.style?.size || editing.item.size, reflow = !!editing.reflow;
|
||||
const value = editTextValue(editing.input), font = editing.fontData;
|
||||
const size = editing.style?.size ?? (editing.item.selectionIDs ? null : editing.item.size), color = editing.style?.color ?? null, reflow = !!editing.reflow;
|
||||
if (value === "") {
|
||||
this.setTextError(null);
|
||||
return;
|
||||
}
|
||||
const current = () => this.input === editing && !editing.rich && !editing.composing && !editing.saving && editing.fontData === font
|
||||
&& (editing.style?.size || editing.item.size) === size && !!editing.reflow === reflow && editTextValue(editing.input) === value;
|
||||
&& (editing.style?.size ?? (editing.item.selectionIDs ? null : editing.item.size)) === size
|
||||
&& (editing.style?.color ?? null) === color && !!editing.reflow === reflow && editTextValue(editing.input) === value;
|
||||
try {
|
||||
const source = await this.options.onPreviewText(editing.item, value, font, size, reflow);
|
||||
const source = await this.options.onPreviewText(editing.item, value, font, size, reflow, color);
|
||||
if (!current()) return;
|
||||
this.setTextError(null);
|
||||
this.paintSourceText(source);
|
||||
@@ -1803,16 +1811,23 @@ export class CanvasEditor {
|
||||
const size = (editing.style?.size || item.size) * PX_PER_MM;
|
||||
const context = document.createElement("canvas").getContext("2d");
|
||||
context.font = `${source.italic ? "italic" : "normal"} ${source.weight || 400} ${size}px "${face.family}"`;
|
||||
const metrics = context.measureText("Mg"), baseline = (size + metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
|
||||
Object.assign(input.style, { lineHeight: `${size}px`, fontWeight: String(source.weight || 400), fontStyle: source.italic ? "italic" : "normal" });
|
||||
const fragment = document.createDocumentFragment(), positions = [];
|
||||
let line, lineIndex = -1, naturalX = 0, position = 0, glyphIndex = 0;
|
||||
const angle = (source.rotation || 0) * Math.PI / 180, cos = Math.cos(angle), sin = Math.sin(angle);
|
||||
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
|
||||
for (const run of source.runs) {
|
||||
const runFace = editing.fontData ? face : editing.faces?.get(run.font) || face;
|
||||
const runSize = (editing.style?.size ?? run.size ?? editing.item.size) * PX_PER_MM;
|
||||
const weight = (run.weight ?? source.weight) || 400, italic = run.italic ?? source.italic;
|
||||
const scale = (run.scale ?? source.scale) || 1, vscale = (run.vscale ?? source.vscale) || 1;
|
||||
const rotation = run.rotation ?? source.rotation ?? 0, angle = rotation * Math.PI / 180;
|
||||
const cos = Math.cos(angle), sin = Math.sin(angle);
|
||||
context.font = `${italic ? "italic" : "normal"} ${weight} ${runSize}px "${runFace.family}"`;
|
||||
const metrics = context.measureText("Mg"), baseline = (runSize + metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
|
||||
if (!line || run.line) {
|
||||
if (line) position++;
|
||||
line = document.createElement("div");
|
||||
line.style.height = `${size}px`;
|
||||
fragment.append(line);
|
||||
lineIndex++;
|
||||
naturalX = 0;
|
||||
@@ -1822,7 +1837,7 @@ export class CanvasEditor {
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y - baseline);
|
||||
right = Math.max(right, x);
|
||||
bottom = Math.max(bottom, y + size - baseline);
|
||||
bottom = Math.max(bottom, y + runSize - baseline);
|
||||
}
|
||||
for (const char of run.text) {
|
||||
x = run.positions[index][0] * PX_PER_MM;
|
||||
@@ -1831,14 +1846,17 @@ export class CanvasEditor {
|
||||
span.append(node);
|
||||
const glyph = source.glyphs?.[glyphIndex++];
|
||||
if (glyph) span.dataset.glyph = glyph;
|
||||
Object.assign(span.style, { display: "inline-block", opacity: String((item.alpha ?? 255) / 255), transformOrigin: `left ${baseline}px`, transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) rotate(${source.rotation || 0}deg) scale(${source.scale},${source.vscale || 1})` });
|
||||
Object.assign(span.style, { display: "inline-block", verticalAlign: "top", fontFamily: `"${runFace.family}"`, fontSize: `${runSize}px`, lineHeight: `${runSize}px`,
|
||||
fontWeight: String(weight), fontStyle: italic ? "italic" : "normal", color: editing.style?.color ?? run.color ?? item.color,
|
||||
opacity: String((run.alpha ?? item.alpha ?? 255) / 255), transformOrigin: `left ${baseline}px`,
|
||||
transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) rotate(${rotation}deg) scale(${scale},${vscale})` });
|
||||
line.append(span);
|
||||
positions.push({ node, start: position, end: position + char.length });
|
||||
position += char.length;
|
||||
const advance = context.measureText(glyph || char).width;
|
||||
if (glyph) span.style.width = `${advance}px`;
|
||||
for (const dx of [0, advance * source.scale]) {
|
||||
for (const dy of [-baseline * (source.vscale || 1), (size - baseline) * (source.vscale || 1)]) {
|
||||
for (const dx of [0, advance * scale]) {
|
||||
for (const dy of [-baseline * vscale, (runSize - baseline) * vscale]) {
|
||||
const px = x + dx * cos - dy * sin, py = y + dx * sin + dy * cos;
|
||||
left = Math.min(left, px);
|
||||
top = Math.min(top, py);
|
||||
@@ -1855,6 +1873,7 @@ export class CanvasEditor {
|
||||
positions.push({ node, start: position, end: position });
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(left)) { left = top = 0; right = bottom = size; }
|
||||
for (const line of fragment.children) line.style.transform = `translate(${-left}px,${-top}px)`;
|
||||
input.replaceChildren(fragment);
|
||||
const [a, b, c, d, e, f] = frame.matrix;
|
||||
@@ -1870,7 +1889,7 @@ export class CanvasEditor {
|
||||
const editing = this.input;
|
||||
if (editing.rich) { editing.rich.setFont(face, data, key); return; }
|
||||
document.fonts.add(face);
|
||||
document.fonts.delete(editing.face);
|
||||
if (![...editing.faces?.values() || []].includes(editing.face)) document.fonts.delete(editing.face);
|
||||
editing.face = face;
|
||||
editing.fontData = data;
|
||||
editing.input.style.fontFamily = `"${face.family}"`;
|
||||
@@ -1884,7 +1903,7 @@ export class CanvasEditor {
|
||||
const editing = this.input;
|
||||
if (editing.rich) { editing.rich.apply(values); return; }
|
||||
const style = { ...editing.style, ...values };
|
||||
for (const key of Object.keys(style)) if (style[key] === editing.item[key]) delete style[key];
|
||||
for (const key of Object.keys(style)) if (!editing.item.selectionIDs && style[key] === editing.item[key]) delete style[key];
|
||||
editing.style = Object.keys(style).length ? style : null;
|
||||
const item = { ...editing.item, ...editing.style };
|
||||
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
|
||||
@@ -1892,7 +1911,7 @@ export class CanvasEditor {
|
||||
this.recordSourceText();
|
||||
this.fitTextInput();
|
||||
this.options.onTextChange();
|
||||
if ("size" in values) this.previewSourceText();
|
||||
if ("size" in values || editing.item.selectionIDs) this.previewSourceText();
|
||||
}
|
||||
|
||||
textChanged() {
|
||||
@@ -1922,7 +1941,7 @@ export class CanvasEditor {
|
||||
editing.input.contentEditable = "false";
|
||||
let saved = false;
|
||||
try {
|
||||
saved = value === "" && !editing.item.draft ? await this.options.onDelete(editing.item)
|
||||
saved = value === "" && !editing.item.draft && !editing.item.selectionIDs ? await this.options.onDelete(editing.item)
|
||||
: editing.rich ? await this.options.onCommitRichText(editing.item, editing.rich.model, editing.rich.fonts)
|
||||
: await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null, !!editing.reflow);
|
||||
} catch (error) {
|
||||
@@ -1950,7 +1969,13 @@ export class CanvasEditor {
|
||||
editing.input.remove();
|
||||
editing.item.artwork?.classList.remove("edit-text-source");
|
||||
editing.item.node.classList.remove("edit-text-source");
|
||||
for (const member of editing.item.selectionIDs ? editing.item.items : []) {
|
||||
member.artwork?.classList.remove("edit-text-source");
|
||||
member.node.classList.remove("edit-text-source");
|
||||
}
|
||||
for (const face of editing.faces?.values() || []) document.fonts.delete(face);
|
||||
document.fonts.delete(editing.face);
|
||||
if (editing.item.selectionIDs && this.selected === editing.item) this.setSelection(editing.item.items);
|
||||
if (editing.item.draft && this.selected === editing.item) this.select(null);
|
||||
this.options.onTextChange();
|
||||
}
|
||||
|
||||
@@ -305,6 +305,9 @@ func RunWASM() {
|
||||
registerCallback("ofdgoInsertText", insertText)
|
||||
registerCallback("ofdgoUpdateText", updateText)
|
||||
registerCallback("ofdgoPreviewText", previewText)
|
||||
registerCallback("ofdgoTextSelection", textSelection)
|
||||
registerCallback("ofdgoPreviewTextSelection", previewTextSelection)
|
||||
registerCallback("ofdgoUpdateTextSelection", updateTextSelection)
|
||||
registerCallback("ofdgoStyleText", styleText)
|
||||
registerCallback("ofdgoGetRichText", getRichText)
|
||||
registerCallback("ofdgoSaveRichText", saveRichText)
|
||||
@@ -3198,6 +3201,303 @@ func previewText(args []js.Value) (any, error) {
|
||||
return map[string]any{"runs": runs, "glyphs": glyphs, "scale": scale, "vscale": text.VScale, "rotation": text.CharDirection, "weight": text.Weight, "italic": text.Italic}, nil
|
||||
}
|
||||
|
||||
// readTextSelection 将画布路径转换为同一容器的库层文字选区
|
||||
// 入参: ids 图元路径列表
|
||||
// 返回: ofdgo.TextSelection 文字选区, error 路径错误
|
||||
func readTextSelection(ids []string) (ofdgo.TextSelection, error) {
|
||||
selection := ofdgo.TextSelection{IDs: ids}
|
||||
if currentEditor == nil {
|
||||
return selection, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return selection, fmt.Errorf("text selection is empty")
|
||||
}
|
||||
if index := strings.LastIndex(ids[0], "/"); index >= 0 {
|
||||
key := ids[0][:index]
|
||||
path, err := compositePath(key)
|
||||
if err != nil {
|
||||
return selection, err
|
||||
}
|
||||
indexes, err := compositeIndexes(key, ids)
|
||||
return ofdgo.TextSelection{Root: &path, Indexes: indexes}, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if strings.Contains(id, "/") {
|
||||
return selection, fmt.Errorf("text selection crosses containers")
|
||||
}
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
// textSelection 分析联合文字并加载预览所需字体,不改变文档资源
|
||||
// 入参: args 页码和图元路径列表
|
||||
// 返回: any 文字选区、定位预览和字体, error 分析错误
|
||||
func textSelection(args []js.Value) (any, error) {
|
||||
selection, err := readTextSelection(stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
analysis, err := currentEditor.AnalyzeTextSelection(args[0].Int(), selection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := textSelectionSource(analysis.Parts, analysis.Bounds, analysis.Text, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first := analysis.Parts[0].TextObject
|
||||
_, origin := analysis.Parts[0].Matrix.Transform(0, 0)
|
||||
_, unit := analysis.Parts[0].Matrix.Transform(0, 1)
|
||||
item := editorBox(analysis.Bounds)
|
||||
item["text"], item["font"], item["size"] = analysis.Text, first.Font, first.Size*(unit-origin)
|
||||
item["color"] = richTextColorHex(analysis.Parts[0].Color)
|
||||
item["alpha"] = 255
|
||||
if first.Alpha != nil {
|
||||
item["alpha"] = *first.Alpha
|
||||
}
|
||||
item["capabilities"] = map[string]any{"textContent": true, "rewriteText": analysis.Replace, "replaceFont": true, "paint": true, "reflow": analysis.Reflow, "layoutKnown": false}
|
||||
item["textFrame"] = map[string]any{"width": analysis.Bounds.W, "height": analysis.Bounds.H, "matrix": editorMatrix(ofdgo.TranslationMatrix(analysis.Bounds.X, analysis.Bounds.Y))}
|
||||
available, err := currentSession.Reader.Fonts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, font := range available {
|
||||
if font.ID == first.Font {
|
||||
item["fontName"] = font.FontName
|
||||
break
|
||||
}
|
||||
}
|
||||
data, err := json.Marshal(map[string]any{"item": item, "source": source})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := js.Global().Get("JSON").Call("parse", string(data))
|
||||
fonts := js.Global().Get("Array").New()
|
||||
seen := make(map[string]bool)
|
||||
for _, part := range analysis.Parts {
|
||||
id := part.TextObject.Font
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
data, err := currentEditor.FontData(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
font := js.Global().Get("Object").New()
|
||||
font.Set("id", id)
|
||||
font.Set("bytes", bytesToJS(data))
|
||||
fonts.Call("push", font)
|
||||
}
|
||||
result.Set("fonts", fonts)
|
||||
return successResult(result), nil
|
||||
}
|
||||
|
||||
// textSelectionSource 将库层逐字定位转换为选区局部坐标及分段字体样式
|
||||
// 入参: parts 预览文字图元, box 原始选区边界, value 完整原文, mapped 是否使用原字形映射
|
||||
// 返回: map[string]any 定位预览, error 字形映射错误
|
||||
func textSelectionSource(parts []ofdgo.TextSelectionPart, box ofdgo.Box, value string, mapped bool) (map[string]any, error) {
|
||||
runs := make([]any, 0, len(parts))
|
||||
glyphs := make(map[int]string)
|
||||
offset := 0
|
||||
logical, cursor := []rune(value), 0
|
||||
for _, part := range parts {
|
||||
if part.Deleted {
|
||||
continue
|
||||
}
|
||||
text := part.TextObject
|
||||
originX, originY := part.Matrix.Transform(0, 0)
|
||||
xAxis, _ := part.Matrix.Transform(1, 0)
|
||||
_, yAxis := part.Matrix.Transform(0, 1)
|
||||
sx, sy := xAxis-originX, yAxis-originY
|
||||
scale := text.HScale
|
||||
if scale == 0 {
|
||||
scale = 1
|
||||
}
|
||||
for _, transform := range text.CGTransform {
|
||||
if !mapped {
|
||||
break
|
||||
}
|
||||
if transform.CodeCount != transform.GlyphCount {
|
||||
return nil, fmt.Errorf("text selection contains ambiguous glyph mapping")
|
||||
}
|
||||
for i, value := range strings.Fields(transform.Glyphs) {
|
||||
glyph, err := strconv.ParseUint(value, 10, 16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
glyphs[offset+transform.CodePosition+i] = string(rune(0xF0000 + glyph))
|
||||
}
|
||||
}
|
||||
alpha := 255
|
||||
if text.Alpha != nil {
|
||||
alpha = int(*text.Alpha)
|
||||
}
|
||||
chars := []rune(strings.ReplaceAll(text.Text(), "\n", ""))
|
||||
if len(chars) != len(part.Points) {
|
||||
return nil, fmt.Errorf("text selection positions do not match characters")
|
||||
}
|
||||
for i, char := range chars {
|
||||
x, y := part.Matrix.Transform(part.Points[i].X, part.Points[i].Y)
|
||||
line := false
|
||||
for cursor < len(logical) && logical[cursor] == '\n' {
|
||||
if line || len(runs) == 0 {
|
||||
runs = append(runs, map[string]any{"text": "", "x": x - box.X, "y": y - box.Y - text.Size*sy,
|
||||
"positions": [][2]float64{}, "line": line, "font": text.Font, "size": text.Size * sy})
|
||||
}
|
||||
line = true
|
||||
cursor++
|
||||
}
|
||||
if cursor >= len(logical) || logical[cursor] != char {
|
||||
return nil, fmt.Errorf("text selection preview does not match content")
|
||||
}
|
||||
cursor++
|
||||
runs = append(runs, map[string]any{"text": string(char), "x": x - box.X, "y": y - box.Y,
|
||||
"positions": [][2]float64{{x - box.X, y - box.Y}}, "line": line, "font": text.Font,
|
||||
"size": text.Size * sy, "scale": scale * sx / sy, "vscale": 1, "rotation": 0,
|
||||
"weight": text.Weight, "italic": text.Italic, "alpha": alpha, "color": richTextColorHex(part.Color)})
|
||||
}
|
||||
offset += len(chars)
|
||||
}
|
||||
for cursor < len(logical) && logical[cursor] == '\n' && len(runs) > 0 {
|
||||
last := runs[len(runs)-1].(map[string]any)
|
||||
runs = append(runs, map[string]any{"text": "", "x": float64(0), "y": last["y"].(float64) + last["size"].(float64),
|
||||
"positions": [][2]float64{}, "line": true, "font": last["font"], "size": last["size"]})
|
||||
cursor++
|
||||
}
|
||||
if cursor != len(logical) {
|
||||
return nil, fmt.Errorf("text selection preview is incomplete")
|
||||
}
|
||||
return map[string]any{"runs": runs, "glyphs": glyphs}, nil
|
||||
}
|
||||
|
||||
// readTextSelectionEdit 将完整草稿转为码点范围修改或显式富文本重排
|
||||
// 入参: args 编辑参数, analysis 原始选区
|
||||
// 返回: ofdgo.TextSelectionEdit 修改内容, error 参数错误
|
||||
func readTextSelectionEdit(args []js.Value, analysis ofdgo.TextSelectionAnalysis) (ofdgo.TextSelectionEdit, error) {
|
||||
old, next := []rune(analysis.Text), []rune(args[2].String())
|
||||
start, end, tail := 0, len(old), len(next)
|
||||
for start < end && start < tail && old[start] == next[start] {
|
||||
start++
|
||||
}
|
||||
for end > start && tail > start && old[end-1] == next[tail-1] {
|
||||
end--
|
||||
tail--
|
||||
}
|
||||
edit := ofdgo.TextSelectionEdit{Start: start, End: end, Text: string(next[start:tail])}
|
||||
if !args[3].IsNull() {
|
||||
var err error
|
||||
edit.FontData, err = bytesFromJS(args[3])
|
||||
if err != nil {
|
||||
return edit, err
|
||||
}
|
||||
}
|
||||
if !args[4].IsNull() {
|
||||
edit.Size = args[4].Float()
|
||||
}
|
||||
if !args[5].IsNull() {
|
||||
value := args[5].String()
|
||||
if len(value) != 7 || value[0] != '#' {
|
||||
return edit, fmt.Errorf("invalid RGB color %q", value)
|
||||
}
|
||||
rgb, err := hex.DecodeString(value[1:])
|
||||
if err != nil {
|
||||
return edit, err
|
||||
}
|
||||
edit.Color = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2])
|
||||
}
|
||||
if args[6].Bool() && len(next) > 0 {
|
||||
value := analysis.RichText
|
||||
var replacement []ofdgo.RichTextRun
|
||||
if tail > start {
|
||||
part, err := value.Slice(start, start)
|
||||
if tail-start == end-start {
|
||||
part, err = value.Slice(start, end)
|
||||
}
|
||||
if err != nil {
|
||||
return edit, err
|
||||
}
|
||||
if tail-start == end-start {
|
||||
offset := start
|
||||
for _, run := range part.Runs {
|
||||
count := len([]rune(run.Text))
|
||||
replacement = append(replacement, ofdgo.RichTextRun{Text: string(next[offset : offset+count]), Format: run.Format})
|
||||
offset += count
|
||||
}
|
||||
} else {
|
||||
replacement = []ofdgo.RichTextRun{{Text: edit.Text, Format: part.Runs[0].Format}}
|
||||
}
|
||||
}
|
||||
if err := value.Replace(start, end, replacement); err != nil {
|
||||
return edit, err
|
||||
}
|
||||
value.Layout.AutoBidi, value.Layout.Wrap = true, true
|
||||
edit.Reflow = &value
|
||||
}
|
||||
return edit, nil
|
||||
}
|
||||
|
||||
// previewTextSelection 预览联合修改,不改变文档资源及撤销记录
|
||||
// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记
|
||||
// 返回: any 定位预览, error 编辑错误
|
||||
func previewTextSelection(args []js.Value) (any, error) {
|
||||
selection, err := readTextSelection(stringsFromJS(args[1]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
analysis, err := currentEditor.AnalyzeTextSelection(args[0].Int(), selection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edit, err := readTextSelectionEdit(args, analysis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview, err := currentEditor.PreviewTextSelection(args[0].Int(), analysis.Selection, edit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return textSelectionSource(preview.Parts, analysis.Bounds, preview.Text, len(edit.FontData) == 0)
|
||||
}
|
||||
|
||||
// updateTextSelection 原子提交联合修改并恢复删除或重排后的选区
|
||||
// 入参: args 页码、图元路径、内容、字体数据、字号、颜色和重排标记
|
||||
// 返回: any 文档及选区信息, error 编辑错误
|
||||
func updateTextSelection(args []js.Value) (any, error) {
|
||||
ids := stringsFromJS(args[1])
|
||||
selection, err := readTextSelection(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := args[0].Int()
|
||||
analysis, err := currentEditor.AnalyzeTextSelection(page, selection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edit, err := readTextSelectionEdit(args, analysis)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview, err := currentEditor.PreviewTextSelection(page, analysis.Selection, edit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := changeObjects(func() error { return currentEditor.UpdateTextSelection(page, analysis.Selection, edit) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected := append([]string{}, preview.Selection.IDs...)
|
||||
for _, index := range preview.Selection.Indexes {
|
||||
key := ids[0][:strings.LastIndex(ids[0], "/")]
|
||||
selected = append(selected, fmt.Sprintf("%s/%d", key, index))
|
||||
}
|
||||
return editorSelectionInfo{editorInfo: result.(editorInfo), SelectedIDs: selected}, nil
|
||||
}
|
||||
|
||||
// previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录
|
||||
// 入参: args 页面、对象路径和内容
|
||||
// 返回: any SVG预览, error 错误信息
|
||||
|
||||
@@ -0,0 +1,841 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TextSelection 指定同页同一直接容器的文字,Root为空使用IDs,否则使用Indexes
|
||||
// 分析返回的选区绑定文档和修订,结构或内容改变后需重新分析
|
||||
type TextSelection struct {
|
||||
Root *ObjectPath
|
||||
IDs []string
|
||||
Indexes []int
|
||||
editor *Editor
|
||||
document editorDocumentIdentity
|
||||
revision uint64
|
||||
page int
|
||||
}
|
||||
|
||||
// TextSelectionEdit 指定码点范围替换,空Text表示删除,Font覆盖所有剩余文字
|
||||
// Reflow非空时使用完整富文本显式重排并忽略范围和Text,Width为可选页面段落宽度,单位为毫米
|
||||
// Size为页面毫米字号,零值保持原字号,Color为空保持原颜色,FontData优先于Font
|
||||
type TextSelectionEdit struct {
|
||||
Start, End int
|
||||
Text string
|
||||
Font string
|
||||
FontData []byte
|
||||
Size float64
|
||||
Color string
|
||||
Reflow *RichText
|
||||
Width float64
|
||||
}
|
||||
|
||||
// TextSelectionPart 保存源图元及合并文本码点范围,Matrix将TextObject局部坐标映射至页面
|
||||
// Deleted表示提交时删除源图元,重排生成项以Sources标识全部来源
|
||||
// Points为不含换行的局部码点原点,Color为解析后的RGB分量,Start和End使用原合并文本范围
|
||||
type TextSelectionPart struct {
|
||||
ID string
|
||||
Index int
|
||||
Start, End int
|
||||
TextObject TextObject
|
||||
Matrix Matrix
|
||||
Deleted bool
|
||||
Sources []int
|
||||
Points []Point
|
||||
Color string
|
||||
points []Point
|
||||
position ObjectPosition
|
||||
raw TextObject
|
||||
container *editorXML
|
||||
}
|
||||
|
||||
// TextSelectionAnalysis 保存阅读顺序、格式和可执行能力,不插入推测空格
|
||||
type TextSelectionAnalysis struct {
|
||||
Selection TextSelection
|
||||
Text string
|
||||
RichText RichText
|
||||
Parts []TextSelectionPart
|
||||
Bounds Box
|
||||
Replace, Delete, Reflow bool
|
||||
}
|
||||
|
||||
// TextSelectionPreview 保存实际更新后的文字和源映射,删除项保留原范围
|
||||
type TextSelectionPreview struct {
|
||||
Selection TextSelection
|
||||
Font string
|
||||
Text string
|
||||
Parts []TextSelectionPart
|
||||
Bounds Box
|
||||
analysis TextSelectionAnalysis
|
||||
}
|
||||
|
||||
// AnalyzeTextSelection 分析同一容器内的横排文字,拒绝重叠及无法可靠映射的布局
|
||||
// 入参: page 页面索引, selection 文字选区
|
||||
// 返回: TextSelectionAnalysis 阅读顺序与能力, error 选择或布局错误
|
||||
func (e *Editor) AnalyzeTextSelection(page int, selection TextSelection) (TextSelectionAnalysis, error) {
|
||||
if selection.editor != nil && (selection.editor != e || selection.page != page || !e.documentIdentity().matches(selection.document) || selection.revision != e.revision) {
|
||||
return TextSelectionAnalysis{}, fmt.Errorf("stale text selection")
|
||||
}
|
||||
copy := e.transactionSnapshot()
|
||||
result, err := copy.analyzeTextSelection(page, selection)
|
||||
if err == nil {
|
||||
result.Selection.editor, result.Selection.document, result.Selection.revision = e, e.documentIdentity(), e.revision
|
||||
result.Selection.page = page
|
||||
}
|
||||
return cloneEditorData(result), err
|
||||
}
|
||||
|
||||
// analyzeTextSelection 在隔离资源视图中计算选区和逐字坐标
|
||||
// 入参: page 页面索引, selection 文字选区
|
||||
// 返回: TextSelectionAnalysis 分析快照, error 选择或布局错误
|
||||
func (e *Editor) analyzeTextSelection(page int, selection TextSelection) (TextSelectionAnalysis, error) {
|
||||
result := TextSelectionAnalysis{Selection: selection, Replace: true, Delete: true, Reflow: true}
|
||||
result.Selection.IDs = slices.Clone(selection.IDs)
|
||||
result.Selection.Indexes = slices.Clone(selection.Indexes)
|
||||
if selection.Root != nil {
|
||||
root := cloneEditorData(*selection.Root)
|
||||
result.Selection.Root = &root
|
||||
if len(selection.IDs) != 0 || len(selection.Indexes) == 0 {
|
||||
return result, fmt.Errorf("composite selection requires indexes only")
|
||||
}
|
||||
members, err := e.CompositeObjectsAt(page, root, selection.Indexes)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
reader, _, _, nodes, err := e.compositeScope(page, root)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer reader.Close()
|
||||
for i, member := range members {
|
||||
if member.Object.Type != "TextObject" || !member.Capabilities.RewriteText {
|
||||
return result, fmt.Errorf("selection member is not editable text")
|
||||
}
|
||||
node := nodes[selection.Indexes[i]]
|
||||
result.Parts = append(result.Parts, TextSelectionPart{ID: member.Object.TextObject.ID, Index: selection.Indexes[i], TextObject: member.Object.TextObject, raw: node.object.TextObject, Matrix: member.Matrix, position: member.Position, container: node.span.parent})
|
||||
}
|
||||
} else {
|
||||
if len(selection.Indexes) != 0 || len(selection.IDs) == 0 {
|
||||
return result, fmt.Errorf("page selection requires IDs only")
|
||||
}
|
||||
objects, _, err := e.selectedObjects(page, selection.IDs)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, object := range objects {
|
||||
if object.Type != "TextObject" {
|
||||
return result, fmt.Errorf("selection member is not text")
|
||||
}
|
||||
t := object.TextObject
|
||||
capability, err := e.ObjectCapabilities(page, t.ID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !capability.RewriteText {
|
||||
return result, capability.editError()
|
||||
}
|
||||
position, err := e.ObjectPosition(page, t.ID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
layer, _, err := e.findObject(page, t.ID)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
box, err := ParseBox(t.Boundary)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
container, _ := e.objectOrderIndexes(page, layer, t.ID)
|
||||
result.Parts = append(result.Parts, TextSelectionPart{ID: t.ID, Index: position.Index, TextObject: resolved.TextObject, raw: t, Matrix: TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(t.CTM)), position: position, container: container})
|
||||
}
|
||||
}
|
||||
positions := make([]int, len(result.Parts))
|
||||
for i, part := range result.Parts {
|
||||
positions[i] = part.position.Index
|
||||
}
|
||||
slices.Sort(positions)
|
||||
for i := 1; i < len(positions); i++ {
|
||||
if positions[i] != positions[i-1]+1 {
|
||||
return result, fmt.Errorf("text selection skips unselected container members")
|
||||
}
|
||||
}
|
||||
for i := range result.Parts {
|
||||
p := &result.Parts[i]
|
||||
if p.container != result.Parts[0].container || p.position.Container != result.Parts[0].position.Container || p.position.Layer != result.Parts[0].position.Layer {
|
||||
return result, fmt.Errorf("text selection crosses containers")
|
||||
}
|
||||
t := p.TextObject
|
||||
m := p.Matrix
|
||||
if math.Abs(m.a-m.d) > 1e-9*math.Max(m.a, m.d) {
|
||||
result.Reflow = false
|
||||
}
|
||||
if t.ReadDirection != 0 || t.CharDirection != 0 || t.VScale != 0 || t.Decoration != "" || math.Abs(m.b) > 1e-9 || math.Abs(m.c) > 1e-9 || m.a <= 0 || m.d <= 0 {
|
||||
return result, fmt.Errorf("text selection requires unambiguous horizontal character mapping")
|
||||
}
|
||||
if err := validateTextGlyphs(t, 1<<16); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, transform := range t.CGTransform {
|
||||
if transform.CodeCount != transform.GlyphCount {
|
||||
return result, fmt.Errorf("combined glyphs require independent explicit layout")
|
||||
}
|
||||
}
|
||||
points, err := e.selectionTextPoints(t)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
p.points = points
|
||||
chars := []rune(t.Text())
|
||||
var previous Point
|
||||
hasPrevious := false
|
||||
for j, point := range points {
|
||||
if chars[j] == '\n' {
|
||||
continue
|
||||
}
|
||||
p.Points = append(p.Points, point)
|
||||
x, y := m.Transform(point.X, point.Y)
|
||||
if !finite(x) || !finite(y) || hasPrevious && (point.Y < previous.Y-1e-7 || point.Y > previous.Y+1e-7 && chars[j-1] != '\n' || math.Abs(point.Y-previous.Y) < 1e-7 && point.X <= previous.X) {
|
||||
return result, fmt.Errorf("ambiguous text reading order")
|
||||
}
|
||||
previous, hasPrevious = point, true
|
||||
result.Bounds = unionTextBox(result.Bounds, m.TransformBox(Box{X: point.X, Y: point.Y - t.Size, W: t.Size, H: t.Size}))
|
||||
}
|
||||
if t.Clips != nil || len(t.Actions) != 0 || t.DrawParam != "" || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.HScale != 0 && t.HScale != 1 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
||||
result.Reflow = false
|
||||
}
|
||||
}
|
||||
slices.SortFunc(result.Parts, func(a, b TextSelectionPart) int {
|
||||
ax, ay := a.Matrix.Transform(a.points[0].X, a.points[0].Y)
|
||||
bx, by := b.Matrix.Transform(b.points[0].X, b.points[0].Y)
|
||||
if ay < by {
|
||||
return -1
|
||||
}
|
||||
if ay > by {
|
||||
return 1
|
||||
}
|
||||
if ax < bx {
|
||||
return -1
|
||||
}
|
||||
if ax > bx {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
for start := 0; start < len(result.Parts); {
|
||||
first := result.Parts[start]
|
||||
_, anchor := first.Matrix.Transform(first.points[0].X, first.points[0].Y)
|
||||
end := start + 1
|
||||
for end < len(result.Parts) {
|
||||
part := result.Parts[end]
|
||||
_, y := part.Matrix.Transform(part.points[0].X, part.points[0].Y)
|
||||
if y-anchor > 1e-6 {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
slices.SortStableFunc(result.Parts[start:end], func(a, b TextSelectionPart) int {
|
||||
ax, _ := a.Matrix.Transform(a.points[0].X, a.points[0].Y)
|
||||
bx, _ := b.Matrix.Transform(b.points[0].X, b.points[0].Y)
|
||||
if ax < bx {
|
||||
return -1
|
||||
}
|
||||
if ax > bx {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
start = end
|
||||
}
|
||||
var baseline, lastEnd, lastOrigin, previousSize, rowStart float64
|
||||
var text strings.Builder
|
||||
offset := 0
|
||||
for i := range result.Parts {
|
||||
p := &result.Parts[i]
|
||||
x, y := p.Matrix.Transform(p.points[0].X, p.points[0].Y)
|
||||
if i > 0 && (y < baseline-1e-6 || math.Abs(y-baseline) < 1e-6 && (x <= lastOrigin+1e-7 || x < lastEnd-1e-7)) {
|
||||
return result, fmt.Errorf("text objects overlap or interleave")
|
||||
}
|
||||
size := p.TextObject.Size * p.Matrix.d
|
||||
if i > 0 {
|
||||
limit := math.Max(size, previousSize)
|
||||
if math.Abs(y-baseline) < 1e-6 && x-lastEnd > limit*8 || y > baseline+1e-6 && (y-baseline > limit*3 || math.Abs(x-rowStart) > limit*2) {
|
||||
return result, fmt.Errorf("text selection has ambiguous column or paragraph gaps")
|
||||
}
|
||||
}
|
||||
if i == 0 || y > baseline+1e-6 {
|
||||
rowStart = x
|
||||
}
|
||||
if i > 0 && y > baseline+1e-6 {
|
||||
text.WriteByte('\n')
|
||||
offset++
|
||||
result.RichText.Runs[len(result.RichText.Runs)-1].Text += "\n"
|
||||
}
|
||||
lastOrigin, baseline = p.Matrix.Transform(p.points[len(p.points)-1].X, p.points[len(p.points)-1].Y)
|
||||
font, err := e.editorFont(p.TextObject.Font)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
chars := []rune(strings.ReplaceAll(p.TextObject.Text(), "\n", ""))
|
||||
glyph := font.GlyphIndex(chars[len(chars)-1])
|
||||
for _, mapping := range p.TextObject.CGTransform {
|
||||
if len(chars)-1 >= mapping.CodePosition && len(chars)-1 < mapping.CodePosition+mapping.CodeCount {
|
||||
glyph = uint16(parseInts(mapping.Glyphs)[len(chars)-1-mapping.CodePosition])
|
||||
}
|
||||
}
|
||||
scale := p.TextObject.HScale
|
||||
if scale == 0 {
|
||||
scale = 1
|
||||
}
|
||||
lastEnd = lastOrigin + float64(font.GlyphAdvance(glyph))*p.TextObject.Size/float64(font.UnitsPerEm())*scale*p.Matrix.a
|
||||
previousSize = size
|
||||
value := p.TextObject.Text()
|
||||
p.Start = offset
|
||||
text.WriteString(value)
|
||||
offset += utf8.RuneCountInString(value)
|
||||
p.End = offset
|
||||
format := TextFormat{Font: p.TextObject.Font, Size: p.TextObject.Size * p.Matrix.d, Color: "0 0 0", Bold: p.TextObject.Weight == 700, Italic: p.TextObject.Italic}
|
||||
if p.TextObject.FillColor != nil {
|
||||
c := p.TextObject.FillColor
|
||||
if c.Pattern != nil || c.AxialShd != nil || c.RadialShd != nil || c.GouraudShd != nil || c.LaGouraudShd != nil || c.unsupported {
|
||||
result.Reflow = false
|
||||
}
|
||||
color, err := e.Color(p.TextObject.FillColor)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
r, g, b, a := color.RGBA()
|
||||
if a > 0 && a < 65535 {
|
||||
r = r * 65535 / a
|
||||
g = g * 65535 / a
|
||||
b = b * 65535 / a
|
||||
}
|
||||
format.Color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
|
||||
if a != 65535 {
|
||||
result.Reflow = false
|
||||
}
|
||||
}
|
||||
result.RichText.Runs = append(result.RichText.Runs, RichTextRun{Text: value, Format: format})
|
||||
p.Color = format.Color
|
||||
}
|
||||
result.Text = text.String()
|
||||
if result.Reflow && e.source != nil && (e.source.document.CustomTags.Path != "" || len(e.source.document.CustomTags.CustomTag) != 0) {
|
||||
structures, err := e.Structure()
|
||||
if err != nil {
|
||||
result.Reflow = false
|
||||
} else {
|
||||
ids := make(map[string]bool, len(result.Parts)+1)
|
||||
for _, part := range result.Parts {
|
||||
ids[editorResourceID(part.ID)] = true
|
||||
}
|
||||
if selection.Root != nil {
|
||||
ids[editorResourceID(selection.Root.ID)] = true
|
||||
for i, index := range selection.Root.Children {
|
||||
parent := *selection.Root
|
||||
parent.Children = parent.Children[:i]
|
||||
member, err := e.CompositeObject(page, parent, index)
|
||||
if err != nil {
|
||||
result.Reflow = false
|
||||
break
|
||||
}
|
||||
ids[editorResourceID(editorObjectID(member.Object))] = true
|
||||
}
|
||||
}
|
||||
pageID := editorResourceID(e.pages[page].ID)
|
||||
for _, structure := range structures {
|
||||
if structure.Error != "" || structure.Root == nil || !structure.CanBind {
|
||||
result.Reflow = false
|
||||
break
|
||||
}
|
||||
pending := []*StructureNode{structure.Root}
|
||||
for len(pending) != 0 && result.Reflow {
|
||||
node := pending[len(pending)-1]
|
||||
pending = pending[:len(pending)-1]
|
||||
for _, ref := range node.References {
|
||||
if editorResourceID(ref.PageID) != pageID {
|
||||
continue
|
||||
}
|
||||
for _, id := range ref.ObjectIDs {
|
||||
if ids[editorResourceID(id)] {
|
||||
result.Reflow = false
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range node.Children {
|
||||
pending = append(pending, &node.Children[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// selectionTextPoints 读取标准逐字原点,不推断缺失的字符映射
|
||||
// 入参: t 文字对象
|
||||
// 返回: []Point 含换行占位的码点原点, error 字体或映射错误
|
||||
func (e *Editor) selectionTextPoints(t TextObject) ([]Point, error) {
|
||||
font, err := e.editorFont(t.Font)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ascent, _, _ := font.VerticalMetrics()
|
||||
var points []Point
|
||||
lines := newTextLineScanner(t)
|
||||
mappings := make(map[int]CGTransform, len(t.CGTransform))
|
||||
for _, mapping := range t.CGTransform {
|
||||
mappings[mapping.CodePosition] = mapping
|
||||
}
|
||||
offset := 0
|
||||
for _, code := range textCodeOrigins(t.TextCode, float64(ascent)*t.Size/float64(font.UnitsPerEm())) {
|
||||
if lines.next(code) {
|
||||
points = append(points, Point{})
|
||||
}
|
||||
if code.Index != "" {
|
||||
return nil, fmt.Errorf("glyph-index text has no character mapping")
|
||||
}
|
||||
position := NewTextPositioner(code)
|
||||
chars := textCodeRunes(code.Value)
|
||||
for i := 0; i < len(chars); {
|
||||
mapping, ok := mappings[offset+i]
|
||||
if !ok {
|
||||
points = append(points, position.Next())
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if mapping.CodeCount <= 0 || mapping.GlyphCount <= 0 || i+mapping.CodeCount > len(chars) {
|
||||
return nil, fmt.Errorf("invalid text character mapping")
|
||||
}
|
||||
glyphPoints := make([]Point, mapping.GlyphCount)
|
||||
for j := range glyphPoints {
|
||||
glyphPoints[j] = position.Next()
|
||||
}
|
||||
for j := 0; j < mapping.CodeCount; j++ {
|
||||
points = append(points, glyphPoints[min(j, len(glyphPoints)-1)])
|
||||
}
|
||||
i += mapping.CodeCount
|
||||
}
|
||||
offset += len(chars)
|
||||
}
|
||||
if len(points) == 0 || len(points) != utf8.RuneCountInString(t.Text()) {
|
||||
return nil, fmt.Errorf("text selection requires a nonempty character mapping")
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
// PreviewTextSelection 在隔离快照生成与提交相同的结果,不分配文档资源或修改历史
|
||||
// 入参: page 页面索引, selection 文字选区, edit 编辑范围与样式
|
||||
// 返回: TextSelectionPreview 更新文字与来源映射, error 校验错误
|
||||
func (e *Editor) PreviewTextSelection(page int, selection TextSelection, edit TextSelectionEdit) (TextSelectionPreview, error) {
|
||||
analysis, err := e.AnalyzeTextSelection(page, selection)
|
||||
if err != nil {
|
||||
return TextSelectionPreview{}, err
|
||||
}
|
||||
copy := e.transactionSnapshot()
|
||||
if err := copy.prepareTextSelectionEdit(&edit); err != nil {
|
||||
return TextSelectionPreview{}, err
|
||||
}
|
||||
result, err := copy.previewTextSelection(analysis, edit)
|
||||
return cloneEditorData(result), err
|
||||
}
|
||||
|
||||
// previewTextSelection 统一生成定位更新或显式富文本布局
|
||||
// 入参: analysis 选区快照, edit 编辑参数
|
||||
// 返回: TextSelectionPreview 更新预览, error 编辑错误
|
||||
func (e *Editor) previewTextSelection(analysis TextSelectionAnalysis, edit TextSelectionEdit) (TextSelectionPreview, error) {
|
||||
result := TextSelectionPreview{analysis: analysis, Bounds: analysis.Bounds, Font: edit.Font}
|
||||
if edit.Reflow != nil {
|
||||
if !analysis.Reflow {
|
||||
return result, fmt.Errorf("selection appearance cannot be safely reflowed")
|
||||
}
|
||||
value := cloneEditorData(*edit.Reflow)
|
||||
for i := range value.Runs {
|
||||
if edit.Font != "" {
|
||||
value.Runs[i].Format.Font = edit.Font
|
||||
}
|
||||
if edit.Size != 0 {
|
||||
value.Runs[i].Format.Size = edit.Size
|
||||
}
|
||||
if edit.Color != "" {
|
||||
value.Runs[i].Format.Color = edit.Color
|
||||
}
|
||||
}
|
||||
width := edit.Width
|
||||
if width == 0 {
|
||||
width = analysis.Bounds.W
|
||||
}
|
||||
layout, err := e.layoutRichText(value, width)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Text = value.Text()
|
||||
for _, object := range layout.objects {
|
||||
if object.Type != "TextObject" {
|
||||
continue
|
||||
}
|
||||
box, err := ParseBox(object.TextObject.Boundary)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
part := TextSelectionPart{Index: -1, TextObject: object.TextObject, Matrix: TranslationMatrix(analysis.Bounds.X+box.X, analysis.Bounds.Y+box.Y).Multiply(NewMatrix(object.TextObject.CTM))}
|
||||
part.Color = "0 0 0"
|
||||
if object.TextObject.FillColor != nil {
|
||||
part.Color = object.TextObject.FillColor.Value
|
||||
}
|
||||
points, err := e.selectionTextPoints(object.TextObject)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for i, char := range []rune(object.TextObject.Text()) {
|
||||
if char != '\n' {
|
||||
part.Points = append(part.Points, points[i])
|
||||
}
|
||||
}
|
||||
for i := range analysis.Parts {
|
||||
part.Sources = append(part.Sources, i)
|
||||
}
|
||||
result.Parts = append(result.Parts, part)
|
||||
}
|
||||
result.Selection = selectionAfterTextEdit(analysis, nil, true)
|
||||
return result, nil
|
||||
}
|
||||
if !utf8.ValidString(edit.Text) || strings.ContainsAny(edit.Text, "\r\t") || edit.Start < 0 || edit.End < edit.Start || edit.End > utf8.RuneCountInString(analysis.Text) {
|
||||
return result, fmt.Errorf("invalid text selection range")
|
||||
}
|
||||
boundaries := richTextBoundaries(analysis.Text)
|
||||
if !boundaries[edit.Start] || !boundaries[edit.End] {
|
||||
return result, fmt.Errorf("text range splits a grapheme")
|
||||
}
|
||||
replacement := []rune(edit.Text)
|
||||
originalText := []rune(analysis.Text)
|
||||
wantedText := string(originalText[:edit.Start]) + edit.Text + string(originalText[edit.End:])
|
||||
if len(replacement) != 0 && len(replacement) != edit.End-edit.Start {
|
||||
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("positioned replacement must have equal length or be a deletion")}
|
||||
}
|
||||
for _, source := range analysis.Parts {
|
||||
p := source
|
||||
t := cloneEditorData(source.TextObject)
|
||||
old := []rune(t.Text())
|
||||
var next []rune
|
||||
var codes []TextCode
|
||||
var transforms []CGTransform
|
||||
glyphs := make(map[int]string)
|
||||
for _, mapping := range t.CGTransform {
|
||||
for j, glyph := range strings.Fields(mapping.Glyphs) {
|
||||
glyphs[mapping.CodePosition+j] = glyph
|
||||
}
|
||||
}
|
||||
glyphPosition, retained := 0, 0
|
||||
p.Points = nil
|
||||
for j, char := range old {
|
||||
at := source.Start + j
|
||||
original := char
|
||||
if char != '\n' {
|
||||
glyphPosition++
|
||||
}
|
||||
if at >= edit.Start && at < edit.End {
|
||||
if len(replacement) == 0 {
|
||||
continue
|
||||
}
|
||||
char = replacement[at-edit.Start]
|
||||
}
|
||||
if (original == '\n') != (char == '\n') {
|
||||
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing line breaks requires reflow")}
|
||||
}
|
||||
next = append(next, char)
|
||||
if char == '\n' {
|
||||
continue
|
||||
}
|
||||
point := source.points[j]
|
||||
p.Points = append(p.Points, point)
|
||||
codes = append(codes, TextCode{X: ofdNumber(point.X), Y: ofdNumber(point.Y), Value: escapeOFDText(string(char))})
|
||||
if glyph, ok := glyphs[glyphPosition-1]; ok {
|
||||
transforms = append(transforms, CGTransform{CodePosition: retained, CodeCount: 1, GlyphCount: 1, Glyphs: glyph})
|
||||
}
|
||||
retained++
|
||||
}
|
||||
p.Deleted = len(codes) == 0
|
||||
if !p.Deleted {
|
||||
if len(next) == len(old) && (string(next) != string(old) || edit.Font != "" && edit.Font != t.Font) {
|
||||
if err := e.RewriteTextWithFont(&t, string(next), edit.Font); err != nil {
|
||||
return result, err
|
||||
}
|
||||
} else if len(next) != len(old) {
|
||||
t.TextCode, t.CGTransform, t.layout = codes, transforms, nil
|
||||
if err := e.RewriteTextWithFont(&t, t.Text(), edit.Font); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if edit.Size != 0 {
|
||||
t.Size = edit.Size / source.Matrix.d
|
||||
}
|
||||
if edit.Color != "" {
|
||||
p.Color = edit.Color
|
||||
var r, g, b float64
|
||||
fmt.Sscan(edit.Color, &r, &g, &b)
|
||||
fill, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
t.FillColor = editorStyleColor(fill, t.FillColor)
|
||||
}
|
||||
if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: t}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.Text != "" && source.Start > 0 && []rune(analysis.Text)[source.Start-1] == '\n' {
|
||||
result.Text += "\n"
|
||||
}
|
||||
result.Text += t.Text()
|
||||
}
|
||||
p.TextObject = t
|
||||
result.Parts = append(result.Parts, p)
|
||||
}
|
||||
result.Selection = selectionAfterTextEdit(analysis, result.Parts, false)
|
||||
if result.Text != wantedText {
|
||||
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned line breaks requires explicit reflow")}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// prepareTextSelectionEdit 在当前隔离视图校验覆盖样式并注册替换字体
|
||||
// 入参: edit 编辑参数
|
||||
// 返回: error 字体或样式错误
|
||||
func (e *Editor) prepareTextSelectionEdit(edit *TextSelectionEdit) error {
|
||||
if !finite(edit.Size) || edit.Size < 0 || !finite(edit.Width) || edit.Width < 0 {
|
||||
return fmt.Errorf("invalid text selection dimensions")
|
||||
}
|
||||
if edit.Color != "" {
|
||||
if err := creationColor(&FillColor{Value: edit.Color}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(edit.FontData) > 0 {
|
||||
id, err := e.AddFont(FontFile{Data: edit.FontData}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
edit.Font = id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectionAfterTextEdit 计算删除及重排后的选中位置,不保留旧修订绑定
|
||||
// 入参: analysis 原选区, parts 更新项, reflow 是否合并为富文本
|
||||
// 返回: TextSelection 提交后可用于重新枚举的选择
|
||||
func selectionAfterTextEdit(analysis TextSelectionAnalysis, parts []TextSelectionPart, reflow bool) TextSelection {
|
||||
result := TextSelection{Root: analysis.Selection.Root}
|
||||
deleted := make(map[int]bool)
|
||||
if reflow {
|
||||
for _, part := range analysis.Parts[1:] {
|
||||
deleted[part.Index] = true
|
||||
}
|
||||
parts = analysis.Parts[:1]
|
||||
} else {
|
||||
for _, part := range parts {
|
||||
if part.Deleted {
|
||||
deleted[part.Index] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, part := range parts {
|
||||
if part.Deleted {
|
||||
continue
|
||||
}
|
||||
if result.Root == nil {
|
||||
result.IDs = append(result.IDs, part.ID)
|
||||
continue
|
||||
}
|
||||
index := part.Index
|
||||
for removed := range deleted {
|
||||
if removed < part.Index {
|
||||
index--
|
||||
}
|
||||
}
|
||||
result.Indexes = append(result.Indexes, index)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// UpdateTextSelection 原子提交联合文字修改,失败不改变资源、ID或撤销重做
|
||||
// 入参: page 页面索引, selection 文字选区, edit 编辑范围与样式
|
||||
// 返回: error 校验或提交错误
|
||||
func (e *Editor) UpdateTextSelection(page int, selection TextSelection, edit TextSelectionEdit) error {
|
||||
analysis, err := e.AnalyzeTextSelection(page, selection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.Transaction(func(e *Editor) error {
|
||||
if err := e.prepareTextSelectionEdit(&edit); err != nil {
|
||||
return err
|
||||
}
|
||||
preview, err := e.previewTextSelection(analysis, edit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if edit.Reflow != nil {
|
||||
return e.reflowTextSelection(page, selection, edit, preview)
|
||||
}
|
||||
if selection.Root != nil {
|
||||
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
||||
for i, node := range nodes {
|
||||
for _, part := range preview.Parts {
|
||||
if part.Index != selection.Indexes[i] {
|
||||
continue
|
||||
}
|
||||
if part.Deleted {
|
||||
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
||||
continue
|
||||
}
|
||||
object := cloneEditorData(node.object)
|
||||
object.TextObject.TextCode, object.TextObject.CGTransform = part.TextObject.TextCode, part.TextObject.CGTransform
|
||||
if edit.Font != "" {
|
||||
object.TextObject.Font = part.TextObject.Font
|
||||
}
|
||||
if edit.Size != 0 {
|
||||
object.TextObject.Size = part.TextObject.Size
|
||||
}
|
||||
if edit.Color != "" {
|
||||
object.TextObject.FillColor = part.TextObject.FillColor
|
||||
}
|
||||
if reflect.DeepEqual(object, node.object) {
|
||||
continue
|
||||
}
|
||||
if err := node.update(object); err != nil {
|
||||
return err
|
||||
}
|
||||
node.setState(editorCompositeState{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
var updates []GraphicObject
|
||||
var deleted []string
|
||||
for _, part := range preview.Parts {
|
||||
if part.Deleted {
|
||||
deleted = append(deleted, part.ID)
|
||||
continue
|
||||
}
|
||||
next := selectionUpdatedText(part, edit)
|
||||
if !reflect.DeepEqual(next, part.raw) {
|
||||
updates = append(updates, GraphicObject{Type: "TextObject", TextObject: next})
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := e.updateObjects(page, updates, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
return e.DeleteObjects(page, deleted)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// selectionUpdatedText 仅更新请求字段,保留源对象继承样式与排版状态
|
||||
// 入参: part 预览项, edit 编辑参数
|
||||
// 返回: TextObject 可提交的原始对象
|
||||
func selectionUpdatedText(part TextSelectionPart, edit TextSelectionEdit) TextObject {
|
||||
next := cloneEditorData(part.raw)
|
||||
next.TextCode, next.CGTransform, next.layout = part.TextObject.TextCode, part.TextObject.CGTransform, part.TextObject.layout
|
||||
if edit.Font != "" {
|
||||
next.Font = part.TextObject.Font
|
||||
}
|
||||
if edit.Size != 0 {
|
||||
next.Size = part.TextObject.Size
|
||||
}
|
||||
if edit.Color != "" {
|
||||
next.FillColor = part.TextObject.FillColor
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// reflowTextSelection 将富文本排版结果替换至首个源位置,其他源成员一并删除
|
||||
// 入参: page 页面索引, selection 原选择, edit 编辑参数, preview 布局预览
|
||||
// 返回: error 排版或提交错误
|
||||
func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit TextSelectionEdit, preview TextSelectionPreview) error {
|
||||
value := cloneEditorData(*edit.Reflow)
|
||||
for i := range value.Runs {
|
||||
if edit.Font != "" {
|
||||
value.Runs[i].Format.Font = edit.Font
|
||||
}
|
||||
if edit.Size != 0 {
|
||||
value.Runs[i].Format.Size = edit.Size
|
||||
}
|
||||
if edit.Color != "" {
|
||||
value.Runs[i].Format.Color = edit.Color
|
||||
}
|
||||
}
|
||||
box := preview.Bounds
|
||||
if edit.Width != 0 {
|
||||
box.W = edit.Width
|
||||
}
|
||||
object, meta, err := e.prepareRichText(box, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
first := preview.analysis.Parts[0]
|
||||
if selection.Root == nil {
|
||||
object.CompositeGraphicUnit.ID = first.ID
|
||||
if err := e.updateObjects(page, []GraphicObject{object}, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var deleted []string
|
||||
for _, part := range preview.analysis.Parts[1:] {
|
||||
deleted = append(deleted, part.ID)
|
||||
}
|
||||
if len(deleted) > 0 {
|
||||
if err := e.DeleteObjects(page, deleted); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
||||
}
|
||||
return 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})
|
||||
continue
|
||||
}
|
||||
inverse, ok := node.parent.Invert()
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid composite parent transform")
|
||||
}
|
||||
local := transformEditorMatrix(object, inverse)
|
||||
local.CompositeGraphicUnit.ID = node.object.TextObject.ID
|
||||
if !node.boundaryInCTM {
|
||||
local = compositeBoundary(local, node.parent, false)
|
||||
}
|
||||
if err := node.update(local); err != nil {
|
||||
return err
|
||||
}
|
||||
node.setState(editorCompositeState{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user