mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 16:59:27 +08:00
This commit is contained in:
1 parent
ce05fe96af
commit
87457c4994
10 files changed
+529
-38
No files matched your search
@@ -1655,6 +1655,7 @@ button.outline-link:hover {
|
|||||||
.edit-handle {
|
.edit-handle {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
width: calc(9px / var(--surface-scale));
|
width: calc(9px / var(--surface-scale));
|
||||||
height: calc(9px / var(--surface-scale));
|
height: calc(9px / var(--surface-scale));
|
||||||
border: calc(1px / var(--surface-scale)) solid var(--accent);
|
border: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
onDrawText: beginCanvasText,
|
onDrawText: beginCanvasText,
|
||||||
onDrawAnnotation: placeAnnotation,
|
onDrawAnnotation: placeAnnotation,
|
||||||
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
|
onDrawInk: (index, points, style, pressure) => changeDocument("ofdgoInsertInk", null, index, JSON.stringify(points), style.lineWidth, pressure, style.strokeColor),
|
||||||
|
onPreviewText: (item, value, font, size) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size),
|
||||||
onCommitText: (item, value, fontData, color = null) => item.draft
|
onCommitText: (item, value, fontData, color = null) => 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("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),
|
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color),
|
||||||
@@ -1637,7 +1638,7 @@ async function editCanvasObject(item) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item.type === "TextObject") {
|
if (item.type === "TextObject") {
|
||||||
if (!confirmTextReflow(item)) return;
|
if (!canEditObject(item, "textContent")) return;
|
||||||
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
|
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
|
||||||
if (item.type === "PathObject") {
|
if (item.type === "PathObject") {
|
||||||
el.shapeWidth.focus();
|
el.shapeWidth.focus();
|
||||||
@@ -1652,8 +1653,9 @@ async function editCanvasObject(item) {
|
|||||||
const data = await callWASM("ofdgoEditorFont", item.font);
|
const data = await callWASM("ofdgoEditorFont", item.font);
|
||||||
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
|
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
|
||||||
await face.load();
|
await face.load();
|
||||||
|
const source = canEditObject(item, "layoutKnown") ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text);
|
||||||
if (openSeq === state.openSeq && item.node.isConnected) {
|
if (openSeq === state.openSeq && item.node.isConnected) {
|
||||||
canvasEditor.editText(item, face);
|
canvasEditor.editText(item, face, source);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
@@ -1828,7 +1830,7 @@ async function beginCanvasText(index, box, surface, page) {
|
|||||||
function editorFonts(item) {
|
function editorFonts(item) {
|
||||||
const fonts = fontManager.editorFonts();
|
const fonts = fontManager.editorFonts();
|
||||||
if (item) {
|
if (item) {
|
||||||
fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "update") });
|
fonts.unshift({ id: `embedded:${item.font}`, name: item.fontName || item.font, embedded: true, disabled: !canEditObject(item, "textContent") });
|
||||||
}
|
}
|
||||||
return fonts;
|
return fonts;
|
||||||
}
|
}
|
||||||
@@ -2712,7 +2714,7 @@ async function loadWASM() {
|
|||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
request.resolve(data.data);
|
request.resolve(data.data);
|
||||||
} else {
|
} else {
|
||||||
const err = new Error(missingGlyphMessage(data.missingGlyphs) || data.error);
|
const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "请先设置段落排版,再跨段或换行修改" : data.error));
|
||||||
if (data.canceled) {
|
if (data.canceled) {
|
||||||
err.name = "AbortError";
|
err.name = "AbortError";
|
||||||
}
|
}
|
||||||
@@ -4710,7 +4712,7 @@ async function selectSearchMatch(index) {
|
|||||||
if (state.editing) {
|
if (state.editing) {
|
||||||
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
|
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
|
||||||
const item = node && canvasEditor.nodes.get(node);
|
const item = node && canvasEditor.nodes.get(node);
|
||||||
if (item?.type === "TextObject" && canEditObject(item, "update")) {
|
if (item?.type === "TextObject") {
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
setPan(false);
|
setPan(false);
|
||||||
canvasEditor.setTool("");
|
canvasEditor.setTool("");
|
||||||
@@ -6036,7 +6038,7 @@ function updateObjectControls(item, reset = false) {
|
|||||||
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.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.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
|
||||||
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|
||||||
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : item.type === "ImageObject" ? "replaceImage" : "update"));
|
|| !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update"));
|
||||||
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
|
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.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
|
||||||
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
|
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
|
||||||
|
|||||||
+153
-5
@@ -11,6 +11,31 @@ export function editTextValue(input) {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editTextSelection(input, saved) {
|
||||||
|
const selection = document.getSelection();
|
||||||
|
const points = saved ? [[saved.startContainer, saved.startOffset], [saved.endContainer, saved.endOffset]]
|
||||||
|
: selection?.rangeCount ? [[selection.anchorNode, selection.anchorOffset], [selection.focusNode, selection.focusOffset]] : null;
|
||||||
|
if (!points || points.some(([node]) => !input.contains(node))) return null;
|
||||||
|
return points.map(([node, position]) => {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(input);
|
||||||
|
range.setEnd(node, position);
|
||||||
|
return editTextValue(range.cloneContents()).length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreTextSelection(positions, caret, saved) {
|
||||||
|
if (!caret || !positions.length) return;
|
||||||
|
const endpoints = caret.map(point => {
|
||||||
|
const entry = positions.find(entry => entry.end >= point) || positions.at(-1);
|
||||||
|
return [entry.node, Math.max(0, Math.min(point - entry.start, entry.node.length))];
|
||||||
|
});
|
||||||
|
if (saved) {
|
||||||
|
saved.setStart(...endpoints[0]);
|
||||||
|
saved.setEnd(...endpoints[1]);
|
||||||
|
} else document.getSelection().setBaseAndExtent(...endpoints[0], ...endpoints[1]);
|
||||||
|
}
|
||||||
|
|
||||||
export function pagePoint(x, y, rect, page, rotation) {
|
export function pagePoint(x, y, rect, page, rotation) {
|
||||||
const u = (x - rect.left) / rect.width;
|
const u = (x - rect.left) / rect.width;
|
||||||
const v = (y - rect.top) / rect.height;
|
const v = (y - rect.top) / rect.height;
|
||||||
@@ -1145,7 +1170,7 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editText(item, face) {
|
editText(item, face, source = null) {
|
||||||
this.cancel();
|
this.cancel();
|
||||||
this.closeText();
|
this.closeText();
|
||||||
this.select(item);
|
this.select(item);
|
||||||
@@ -1178,17 +1203,28 @@ export class CanvasEditor {
|
|||||||
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
|
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
|
||||||
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
|
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
|
||||||
});
|
});
|
||||||
this.input = { input, item, face, fontChoice: item.fontChoice };
|
this.input = { input, item, face, source: Boolean(source), fontChoice: item.fontChoice };
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
|
if (source) editing.history = { entries: [{ value: item.text }], index: 0 };
|
||||||
item.artwork?.classList.add("edit-text-source");
|
item.artwork?.classList.add("edit-text-source");
|
||||||
item.node.classList.add("edit-text-source");
|
item.node.classList.add("edit-text-source");
|
||||||
item.surface.append(input);
|
item.surface.append(input);
|
||||||
input.addEventListener("input", () => this.options.onTextChange());
|
if (source) this.paintSourceText(source);
|
||||||
input.addEventListener("compositionstart", () => { editing.composing = true; });
|
input.addEventListener("input", () => {
|
||||||
|
this.recordSourceText();
|
||||||
|
this.options.onTextChange();
|
||||||
|
this.previewSourceText();
|
||||||
|
});
|
||||||
|
input.addEventListener("compositionstart", () => {
|
||||||
|
this.rememberSourceSelection();
|
||||||
|
editing.composing = true;
|
||||||
|
});
|
||||||
input.addEventListener("compositionend", () => {
|
input.addEventListener("compositionend", () => {
|
||||||
editing.composing = false;
|
editing.composing = false;
|
||||||
|
this.recordSourceText();
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
if (this.input === editing && editing.commitAfterComposition) this.commitText();
|
if (this.input === editing && editing.commitAfterComposition) this.commitText();
|
||||||
|
else this.previewSourceText();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
input.addEventListener("paste", event => {
|
input.addEventListener("paste", event => {
|
||||||
@@ -1196,6 +1232,12 @@ export class CanvasEditor {
|
|||||||
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
||||||
});
|
});
|
||||||
input.addEventListener("beforeinput", event => {
|
input.addEventListener("beforeinput", event => {
|
||||||
|
if (editing.source && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.restoreSourceText(event.inputType === "historyUndo" ? -1 : 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.rememberSourceSelection();
|
||||||
if (event.inputType.startsWith("format")) event.preventDefault();
|
if (event.inputType.startsWith("format")) event.preventDefault();
|
||||||
if (event.inputType === "insertLineBreak") {
|
if (event.inputType === "insertLineBreak") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1221,7 +1263,10 @@ export class CanvasEditor {
|
|||||||
if (event.isComposing || editing.composing || editing.saving) {
|
if (event.isComposing || editing.composing || editing.saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (editing.source && (event.ctrlKey || event.metaKey) && !event.altKey && ["z", "y"].includes(event.key.toLowerCase())) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.restoreSourceText(event.key.toLowerCase() === "y" || event.shiftKey ? 1 : -1);
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.closeText();
|
this.closeText();
|
||||||
this.focus();
|
this.focus();
|
||||||
@@ -1234,6 +1279,107 @@ export class CanvasEditor {
|
|||||||
if (!item.draft) document.getSelection().selectAllChildren(input);
|
if (!item.draft) document.getSelection().selectAllChildren(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rememberSourceSelection() {
|
||||||
|
const editing = this.input;
|
||||||
|
if (editing?.history && !editing.composing) editing.history.entries[editing.history.index].caret = editTextSelection(editing.input);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSourceText() {
|
||||||
|
const editing = this.input;
|
||||||
|
if (!editing?.history || editing.composing) return;
|
||||||
|
const { history, input } = editing, value = editTextValue(input);
|
||||||
|
if (history.entries[history.index].value === value) return;
|
||||||
|
history.entries.splice(++history.index, Infinity, { value, caret: editTextSelection(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreSourceText(direction) {
|
||||||
|
const editing = this.input, history = editing?.history;
|
||||||
|
if (!history || editing.composing || editing.saving) return;
|
||||||
|
const index = history.index + direction;
|
||||||
|
if (index < 0 || index >= history.entries.length) return;
|
||||||
|
history.index = index;
|
||||||
|
const entry = history.entries[index], fragment = document.createDocumentFragment(), positions = [];
|
||||||
|
let position = 0;
|
||||||
|
for (const text of entry.value.split("\n")) {
|
||||||
|
const paragraph = document.createElement("div");
|
||||||
|
const node = document.createTextNode(text);
|
||||||
|
paragraph.append(node);
|
||||||
|
positions.push({ node, start: position, end: position + text.length });
|
||||||
|
if (!text) paragraph.append(document.createElement("br"));
|
||||||
|
fragment.append(paragraph);
|
||||||
|
position += text.length + 1;
|
||||||
|
}
|
||||||
|
editing.input.replaceChildren(fragment);
|
||||||
|
restoreTextSelection(positions, entry.caret);
|
||||||
|
this.options.onTextChange();
|
||||||
|
this.previewSourceText();
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewSourceText() {
|
||||||
|
const editing = this.input;
|
||||||
|
if (!editing?.source || editing.composing || editing.saving) return;
|
||||||
|
const value = editTextValue(editing.input), font = editing.fontData, size = editing.style?.size || editing.item.size;
|
||||||
|
const current = () => this.input === editing && !editing.composing && !editing.saving && editing.fontData === font
|
||||||
|
&& (editing.style?.size || editing.item.size) === size && editTextValue(editing.input) === value;
|
||||||
|
try {
|
||||||
|
const source = await this.options.onPreviewText(editing.item, value, font, size);
|
||||||
|
if (!current()) return;
|
||||||
|
editing.input.removeAttribute("aria-invalid");
|
||||||
|
editing.input.removeAttribute("title");
|
||||||
|
this.paintSourceText(source);
|
||||||
|
} catch (err) {
|
||||||
|
if (!current()) return;
|
||||||
|
editing.input.setAttribute("aria-invalid", "true");
|
||||||
|
editing.input.title = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paintSourceText(source) {
|
||||||
|
const editing = this.input, { input, item, face } = editing;
|
||||||
|
const caret = editTextSelection(input, editing.range);
|
||||||
|
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, width = 0, height = 0;
|
||||||
|
for (const run of source.runs) {
|
||||||
|
if (!line || run.line) {
|
||||||
|
if (line) position++;
|
||||||
|
line = document.createElement("div");
|
||||||
|
fragment.append(line);
|
||||||
|
lineIndex++;
|
||||||
|
naturalX = 0;
|
||||||
|
}
|
||||||
|
let x = run.x * PX_PER_MM, y = run.y * PX_PER_MM, index = 0;
|
||||||
|
for (const char of run.text) {
|
||||||
|
const span = document.createElement("span"), node = document.createTextNode(char);
|
||||||
|
span.append(node);
|
||||||
|
Object.assign(span.style, { display: "inline-block", opacity: String((item.alpha ?? 255) / 255), transformOrigin: "left top", transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) scaleX(${source.scale})` });
|
||||||
|
line.append(span);
|
||||||
|
positions.push({ node, start: position, end: position + char.length });
|
||||||
|
position += char.length;
|
||||||
|
const advance = context.measureText(char).width;
|
||||||
|
width = Math.max(width, x + advance * source.scale);
|
||||||
|
height = Math.max(height, y + size - baseline);
|
||||||
|
naturalX += advance;
|
||||||
|
x += run.dx?.length ? run.dx[Math.min(index, run.dx.length - 1)] * PX_PER_MM : run.dy?.length ? 0 : advance * source.scale;
|
||||||
|
y += run.dy?.length ? run.dy[Math.min(index, run.dy.length - 1)] * PX_PER_MM : 0;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
if (!line.hasChildNodes()) {
|
||||||
|
const node = document.createTextNode("");
|
||||||
|
line.append(node, document.createElement("br"));
|
||||||
|
positions.push({ node, start: position, end: position });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input.replaceChildren(fragment);
|
||||||
|
input.style.width = `${Math.max(parseFloat(input.style.width), width + 4)}px`;
|
||||||
|
input.style.minHeight = `${Math.max(parseFloat(input.style.minHeight), height + 4)}px`;
|
||||||
|
restoreTextSelection(positions, caret, editing.range);
|
||||||
|
}
|
||||||
|
|
||||||
setTextFont(face, data) {
|
setTextFont(face, data) {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
@@ -1243,6 +1389,7 @@ export class CanvasEditor {
|
|||||||
editing.input.style.fontFamily = `"${face.family}"`;
|
editing.input.style.fontFamily = `"${face.family}"`;
|
||||||
this.options.onTextChange();
|
this.options.onTextChange();
|
||||||
editing.input.focus({ preventScroll: true });
|
editing.input.focus({ preventScroll: true });
|
||||||
|
this.previewSourceText();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextStyle(values) {
|
setTextStyle(values) {
|
||||||
@@ -1254,6 +1401,7 @@ export class CanvasEditor {
|
|||||||
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
|
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
|
||||||
editing.input.style.color = item.color;
|
editing.input.style.color = item.color;
|
||||||
this.options.onTextChange();
|
this.options.onTextChange();
|
||||||
|
if ("size" in values) this.previewSourceText();
|
||||||
}
|
}
|
||||||
|
|
||||||
textChanged() {
|
textChanged() {
|
||||||
|
|||||||
+116
-2
@@ -134,6 +134,7 @@ func RunWASM() {
|
|||||||
registerAsyncCallback("ofdgoImportPages", importPages)
|
registerAsyncCallback("ofdgoImportPages", importPages)
|
||||||
registerCallback("ofdgoInsertText", insertText)
|
registerCallback("ofdgoInsertText", insertText)
|
||||||
registerCallback("ofdgoUpdateText", updateText)
|
registerCallback("ofdgoUpdateText", updateText)
|
||||||
|
registerCallback("ofdgoPreviewText", previewText)
|
||||||
registerCallback("ofdgoStyleText", styleText)
|
registerCallback("ofdgoStyleText", styleText)
|
||||||
registerCallback("ofdgoCheckTextFont", checkTextFont)
|
registerCallback("ofdgoCheckTextFont", checkTextFont)
|
||||||
registerCallback("ofdgoInsertImage", insertImage)
|
registerCallback("ofdgoInsertImage", insertImage)
|
||||||
@@ -1615,6 +1616,7 @@ func pasteEditorSelection(page int, clipboard *editorClipboard, dx, dy float64)
|
|||||||
func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any {
|
func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[string]any {
|
||||||
result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode),
|
result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode),
|
||||||
"replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")}
|
"replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")}
|
||||||
|
result["textContent"] = capability.TextContent
|
||||||
if missing := capability.MissingGlyphs; missing != nil {
|
if missing := capability.MissingGlyphs; missing != nil {
|
||||||
result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
|
result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
|
||||||
}
|
}
|
||||||
@@ -1985,20 +1987,114 @@ func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operati
|
|||||||
return currentEditor.StyleCompositeText(page, path, indexes, style)
|
return currentEditor.StyleCompositeText(page, path, indexes, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorFont 读取对象实际使用的内嵌字体,供画布输入使用
|
// editorFont 读取对象编辑使用的字体,匹配的外部字体不嵌入文档
|
||||||
// 入参: args 字体资源标识
|
// 入参: args 字体资源标识
|
||||||
// 返回: any 字体数据, error 错误信息
|
// 返回: any 字体数据, error 错误信息
|
||||||
func editorFont(args []js.Value) (any, error) {
|
func editorFont(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
return nil, fmt.Errorf("no document is being edited")
|
return nil, fmt.Errorf("no document is being edited")
|
||||||
}
|
}
|
||||||
data, err := currentSession.Reader.FontData(args[0].String())
|
data, err := currentEditor.FontData(args[0].String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return successResult(map[string]any{"bytes": bytesToJS(data)}), nil
|
return successResult(map[string]any{"bytes": bytesToJS(data)}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// previewText 生成保留原定位的输入预览,不修改文档或历史
|
||||||
|
// 入参: args 页码、对象路径、新文字、可选字体数据和字号
|
||||||
|
// 返回: any 定位段及字体样式, error 错误信息
|
||||||
|
func previewText(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being edited")
|
||||||
|
}
|
||||||
|
page, key := args[0].Int(), args[1].String()
|
||||||
|
var object ofdgo.GraphicObject
|
||||||
|
if separator := strings.LastIndex(key, "/"); separator >= 0 {
|
||||||
|
path, err := compositePath(key[:separator])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member, err := strconv.Atoi(key[separator+1:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
members, err := currentEditor.CompositeObjectsAt(page, path, []int{member})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
object = members[0].Object
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
object, err = currentEditor.Object(page, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := object.TextObject
|
||||||
|
if object.Type != "TextObject" {
|
||||||
|
return nil, fmt.Errorf("object is not text")
|
||||||
|
}
|
||||||
|
editor := currentEditor
|
||||||
|
if len(args) > 3 && !args[3].IsNull() {
|
||||||
|
data, err := bytesFromJS(args[3])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
editor = ofdgo.NewEditor()
|
||||||
|
text.Font, err = editor.AddFont(FontFile{Data: data}, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(args) > 4 && args[4].Float() != text.Size {
|
||||||
|
var x, y float64
|
||||||
|
if strings.Contains(key, "/") {
|
||||||
|
x, _ = strconv.ParseFloat(text.TextCode[0].X, 64)
|
||||||
|
y, _ = strconv.ParseFloat(text.TextCode[0].Y, 64)
|
||||||
|
}
|
||||||
|
text.Size = args[4].Float()
|
||||||
|
_, layout := text.TextLayout()
|
||||||
|
if err := editor.LayoutText(&text, args[2].String(), layout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.Contains(key, "/") {
|
||||||
|
baseline, _ := strconv.ParseFloat(text.TextCode[0].Y, 64)
|
||||||
|
for i := range text.TextCode {
|
||||||
|
code := &text.TextCode[i]
|
||||||
|
cx, _ := strconv.ParseFloat(code.X, 64)
|
||||||
|
cy, _ := strconv.ParseFloat(code.Y, 64)
|
||||||
|
code.X, code.Y = fmt.Sprint(cx+x), fmt.Sprint(cy+y-baseline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err := editor.RewriteText(&text, args[2].String()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var runs []any
|
||||||
|
var x, y float64
|
||||||
|
value, offset := []rune(text.Text()), 0
|
||||||
|
for _, code := range text.TextCode {
|
||||||
|
if code.X != "" {
|
||||||
|
x, _ = strconv.ParseFloat(code.X, 64)
|
||||||
|
}
|
||||||
|
if code.Y != "" {
|
||||||
|
y, _ = strconv.ParseFloat(code.Y, 64)
|
||||||
|
}
|
||||||
|
line := offset < len(value) && value[offset] == '\n'
|
||||||
|
if line {
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
content := (ofdgo.TextObject{TextCode: []ofdgo.TextCode{code}}).Text()
|
||||||
|
runs = append(runs, map[string]any{"text": content, "x": x, "y": y, "dx": code.GetDeltaX(), "dy": code.GetDeltaY(), "line": line})
|
||||||
|
offset += len([]rune(content))
|
||||||
|
}
|
||||||
|
scale := text.HScale
|
||||||
|
if scale == 0 {
|
||||||
|
scale = 1
|
||||||
|
}
|
||||||
|
return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// replaceImage 替换图片资源并更新预览
|
// replaceImage 替换图片资源并更新预览
|
||||||
// 入参: args 页码、对象标识、图片数据和适应方式
|
// 入参: args 页码、对象标识、图片数据和适应方式
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
@@ -2704,6 +2800,24 @@ func updateText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !args[2].IsNull() {
|
if !args[2].IsNull() {
|
||||||
|
if args[4].Float() == object.TextObject.Size {
|
||||||
|
style := ofdgo.TextStyle{Font: object.TextObject.Font}
|
||||||
|
if !args[5].IsNull() {
|
||||||
|
var fill ofdgo.FillColor
|
||||||
|
if err := setEditorColor(&fill, args[5].String()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
style.Color = fill.Value
|
||||||
|
}
|
||||||
|
revision := currentEditor.Revision()
|
||||||
|
if err := currentEditor.UpdateTextContent(page, id, args[2].String(), style); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentEditor.Revision() == revision {
|
||||||
|
return editorSummary(), nil
|
||||||
|
}
|
||||||
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
|
}
|
||||||
object.TextObject.Size = args[4].Float()
|
object.TextObject.Size = args[4].Float()
|
||||||
_, layout := object.TextObject.TextLayout()
|
_, layout := object.TextObject.TextLayout()
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
|
||||||
|
|||||||
+8
-16
@@ -47,6 +47,7 @@ type Editor struct {
|
|||||||
resources []editorResource
|
resources []editorResource
|
||||||
fonts map[string]*font.SFNT
|
fonts map[string]*font.SFNT
|
||||||
fontFS []fs.FS
|
fontFS []fs.FS
|
||||||
|
fontRenderer *Renderer
|
||||||
images map[string]image.Point
|
images map[string]image.Point
|
||||||
resourceID map[editorResourceKey]string
|
resourceID map[editorResourceKey]string
|
||||||
maxID int
|
maxID int
|
||||||
@@ -60,10 +61,11 @@ type Editor struct {
|
|||||||
outlines []byte
|
outlines []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFontFS 设置几何度量使用的外部字体来源,与预览渲染器保持一致,不嵌入或替换文档字体
|
// SetFontFS 设置编辑与几何度量使用的外部字体来源,不嵌入或替换文档字体
|
||||||
// 入参: fsys 字体文件系统,空参数恢复默认来源
|
// 入参: fsys 字体文件系统,空参数恢复默认来源
|
||||||
func (e *Editor) SetFontFS(fsys ...fs.FS) {
|
func (e *Editor) SetFontFS(fsys ...fs.FS) {
|
||||||
e.fontFS = slices.Clone(fsys)
|
e.fontFS = slices.Clone(fsys)
|
||||||
|
e.fontRenderer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorResource 文档内嵌资源
|
// editorResource 文档内嵌资源
|
||||||
@@ -1150,7 +1152,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !finite(obj.Size) || obj.Size <= 0 {
|
if !finite(obj.Size) || obj.Size <= 0 {
|
||||||
return fmt.Errorf("text requires an embedded font and a positive finite size")
|
return fmt.Errorf("text requires a positive finite size")
|
||||||
}
|
}
|
||||||
if len(obj.TextCode) == 0 {
|
if len(obj.TextCode) == 0 {
|
||||||
return fmt.Errorf("text codes are empty")
|
return fmt.Errorf("text codes are empty")
|
||||||
@@ -1197,7 +1199,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
}
|
}
|
||||||
for _, delta := range []string{code.DeltaX, code.DeltaY} {
|
for _, delta := range []string{code.DeltaX, code.DeltaY} {
|
||||||
if delta != "" {
|
if delta != "" {
|
||||||
if err := creationDeltas(delta, len(runes)-1); err != nil {
|
if err := creationDeltas(delta); err != nil {
|
||||||
return fmt.Errorf("invalid text delta: %w", err)
|
return fmt.Errorf("invalid text delta: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1214,19 +1216,16 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// creationDeltas 校验文字位移数组及g重复编码
|
// creationDeltas 校验文字位移数组及g重复编码
|
||||||
// 入参: value 位移数组, count 所需位移数量
|
// 入参: value 位移数组
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func creationDeltas(value string, count int) error {
|
func creationDeltas(value string) error {
|
||||||
fields := strings.Fields(value)
|
fields := strings.Fields(value)
|
||||||
numbers := 0
|
|
||||||
for i := 0; i < len(fields); i++ {
|
for i := 0; i < len(fields); i++ {
|
||||||
repeat := 1
|
|
||||||
if fields[i] == "g" {
|
if fields[i] == "g" {
|
||||||
if i+2 >= len(fields) {
|
if i+2 >= len(fields) {
|
||||||
return fmt.Errorf("incomplete delta repetition")
|
return fmt.Errorf("incomplete delta repetition")
|
||||||
}
|
}
|
||||||
var err error
|
repeat, err := strconv.Atoi(fields[i+1])
|
||||||
repeat, err = strconv.Atoi(fields[i+1])
|
|
||||||
if err != nil || repeat <= 0 {
|
if err != nil || repeat <= 0 {
|
||||||
return fmt.Errorf("invalid delta repetition")
|
return fmt.Errorf("invalid delta repetition")
|
||||||
}
|
}
|
||||||
@@ -1235,13 +1234,6 @@ func creationDeltas(value string, count int) error {
|
|||||||
if _, err := creationNumbers(fields[i], 1); err != nil {
|
if _, err := creationNumbers(fields[i], 1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if repeat > count-numbers {
|
|
||||||
return fmt.Errorf("too many text deltas")
|
|
||||||
}
|
|
||||||
numbers += repeat
|
|
||||||
}
|
|
||||||
if numbers != count {
|
|
||||||
return fmt.Errorf("expected %d text deltas", count)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -549,9 +549,12 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp
|
|||||||
if err := e.prepareText(&text); err != nil {
|
if err := e.prepareText(&text); err != nil {
|
||||||
capability.ReasonCode, capability.Reason = editReason(err), err.Error()
|
capability.ReasonCode, capability.Reason = editReason(err), err.Error()
|
||||||
errors.As(err, &capability.MissingGlyphs)
|
errors.As(err, &capability.MissingGlyphs)
|
||||||
} else if _, err := text.TextFrame(); err == nil {
|
} else {
|
||||||
capability.Reflow = text.ReadDirection == 0 && text.CharDirection == 0
|
capability.TextContent = text.ReadDirection == 0 && text.CharDirection == 0
|
||||||
capability.LayoutKnown = text.layout != nil
|
if _, err := text.TextFrame(); err == nil {
|
||||||
|
capability.Reflow = capability.TextContent
|
||||||
|
capability.LayoutKnown = text.layout != nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if transform && node.object.Type == "ImageObject" && bounds.box.W > 0 && bounds.box.H > 0 {
|
if transform && node.object.Type == "ImageObject" && bounds.box.W > 0 && bounds.box.H > 0 {
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateCompositeText 修改内部普通横向文字,保留首行起点、变换、裁剪及未修改属性
|
// UpdateCompositeText 修改内部普通横向文字,原文保留定位,已知段落按会话选项重排
|
||||||
// 复杂字形定位不自动重排,空样式保留原字体、字号和颜色
|
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
|
||||||
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value string, style TextStyle) error {
|
func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value string, style TextStyle) error {
|
||||||
@@ -100,7 +100,12 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite
|
|||||||
}
|
}
|
||||||
object.TextObject.Boundary, object.TextObject.CTM = text.Boundary, text.CTM
|
object.TextObject.Boundary, object.TextObject.CTM = text.Boundary, text.CTM
|
||||||
}
|
}
|
||||||
if options != nil || content != original || style.Size != 0 && style.Size != text.Size {
|
if options == nil && frame == nil && state.layout == nil && (style.Size == 0 || style.Size == text.Size) && content != original {
|
||||||
|
if err := e.RewriteText(&text, content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
object.TextObject.TextCode = text.TextCode
|
||||||
|
} else if options != nil || content != original || style.Size != 0 && style.Size != text.Size {
|
||||||
if style.Size != 0 {
|
if style.Size != 0 {
|
||||||
text.Size = style.Size
|
text.Size = style.Size
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const (
|
|||||||
EditFontUnavailable EditReason = "fontUnavailable"
|
EditFontUnavailable EditReason = "fontUnavailable"
|
||||||
// EditMissingGlyphs 字体缺少文字所需字形
|
// EditMissingGlyphs 字体缺少文字所需字形
|
||||||
EditMissingGlyphs EditReason = "missingGlyphs"
|
EditMissingGlyphs EditReason = "missingGlyphs"
|
||||||
|
// EditLayoutRequired 内容变化需要显式选择段落排版
|
||||||
|
EditLayoutRequired EditReason = "layoutRequired"
|
||||||
// EditInvalidObject 对象数据未通过校验
|
// EditInvalidObject 对象数据未通过校验
|
||||||
EditInvalidObject EditReason = "invalidObject"
|
EditInvalidObject EditReason = "invalidObject"
|
||||||
)
|
)
|
||||||
|
|||||||
+45
-3
@@ -26,6 +26,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tdewolff/canvas"
|
||||||
"github.com/tdewolff/font"
|
"github.com/tdewolff/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ type editorSourcePage struct {
|
|||||||
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
|
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
|
||||||
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
|
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
|
||||||
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
|
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
|
||||||
|
// TextContent表示可修改文字内容,未知段落保持原定位,不自动重排
|
||||||
// FitImage表示可按原始图片比例适应或填充现有边界
|
// FitImage表示可按原始图片比例适应或填充现有边界
|
||||||
// ResetCrop表示有可单独还原的会话裁剪
|
// ResetCrop表示有可单独还原的会话裁剪
|
||||||
// Paint表示可独立修改纯色填充和描边,不要求重新排版文字
|
// Paint表示可独立修改纯色填充和描边,不要求重新排版文字
|
||||||
@@ -72,6 +74,7 @@ type editorSourcePage struct {
|
|||||||
// Ungroup表示可移除组合容器并保留内部内容,Stretch表示图片及纯图片组合可独立调整宽高
|
// Ungroup表示可移除组合容器并保留内部内容,Stretch表示图片及纯图片组合可独立调整宽高
|
||||||
type ObjectCapabilities struct {
|
type ObjectCapabilities struct {
|
||||||
Update bool
|
Update bool
|
||||||
|
TextContent bool
|
||||||
Paint bool
|
Paint bool
|
||||||
ReplaceFont bool
|
ReplaceFont bool
|
||||||
ReplaceImage bool
|
ReplaceImage bool
|
||||||
@@ -369,6 +372,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
|
|||||||
all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil
|
all.ResetCrop = object.Type == "ImageObject" && object.state.crop != nil
|
||||||
origin := e.objectOrigin(id)
|
origin := e.objectOrigin(id)
|
||||||
if origin == nil {
|
if origin == nil {
|
||||||
|
all.TextContent = all.Reflow
|
||||||
all.Stretch = object.Type == "ImageObject"
|
all.Stretch = object.Type == "ImageObject"
|
||||||
all.ReplaceImage = object.Type == "ImageObject"
|
all.ReplaceImage = object.Type == "ImageObject"
|
||||||
all.CropImage = all.ReplaceImage
|
all.CropImage = all.ReplaceImage
|
||||||
@@ -411,6 +415,7 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
|
|||||||
all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject
|
all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
|
all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0
|
||||||
all.Arrange = all.Update || e.editorTextMeasurable(object.TextObject)
|
all.Arrange = all.Update || e.editorTextMeasurable(object.TextObject)
|
||||||
if _, err := object.TextObject.TextFrame(); err != nil {
|
if _, err := object.TextObject.TextFrame(); err != nil {
|
||||||
all.Reflow = false
|
all.Reflow = false
|
||||||
@@ -611,7 +616,7 @@ func editorGeometry(object GraphicObject) (string, string) {
|
|||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorFont 按需解析原文档内嵌字体,不用系统回退替换原字体
|
// editorFont 按需解析内嵌字体或名称匹配的外部字体,不使用无关回退字体
|
||||||
// 入参: id 字体资源标识
|
// 入参: id 字体资源标识
|
||||||
// 返回: *font.SFNT 字体, error 错误信息
|
// 返回: *font.SFNT 字体, error 错误信息
|
||||||
func (e *Editor) editorFont(id string) (*font.SFNT, error) {
|
func (e *Editor) editorFont(id string) (*font.SFNT, error) {
|
||||||
@@ -619,6 +624,22 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) {
|
|||||||
return sfnt, nil
|
return sfnt, nil
|
||||||
}
|
}
|
||||||
if e.source != nil {
|
if e.source != nil {
|
||||||
|
definition := e.source.reader.fontCache[id]
|
||||||
|
if definition != nil && definition.FontFile == "" {
|
||||||
|
if e.fontRenderer == nil {
|
||||||
|
e.fontRenderer = NewRenderer(e.source.reader, WithFontFS(e.fontFS...))
|
||||||
|
}
|
||||||
|
style := canvasFontStyle(definition)
|
||||||
|
for _, source := range e.fontRenderer.fontSources(id, definition, style) {
|
||||||
|
if !source.exact {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if family := e.fontRenderer.loadFontSource(canvas.NewFontFamily(definition.FontName), source, style); family != nil {
|
||||||
|
return family.Face(12, style).Font.SFNT, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("font %q is unavailable", definition.FontName)}
|
||||||
|
}
|
||||||
data, err := e.source.reader.FontData(id)
|
data, err := e.source.reader.FontData(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &EditError{Code: EditFontUnavailable, Err: err}
|
return nil, &EditError{Code: EditFontUnavailable, Err: err}
|
||||||
@@ -630,7 +651,28 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) {
|
|||||||
e.fonts[id] = sfnt
|
e.fonts[id] = sfnt
|
||||||
return sfnt, nil
|
return sfnt, nil
|
||||||
}
|
}
|
||||||
return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("embedded font %q not found", id)}
|
return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("font %q not found", id)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontData 获取编辑使用的字体数据,外部字体不写入文档资源
|
||||||
|
// 入参: id 字体资源标识
|
||||||
|
// 返回: []byte 独立字体数据, error 错误信息
|
||||||
|
func (e *Editor) FontData(id string) ([]byte, error) {
|
||||||
|
for _, resource := range e.resources {
|
||||||
|
if resource.font != nil && resource.font.ID == id {
|
||||||
|
return bytes.Clone(resource.data), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.source != nil {
|
||||||
|
if definition := e.source.reader.fontCache[id]; definition != nil && definition.FontFile != "" {
|
||||||
|
return e.source.reader.FontData(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sfnt, err := e.editorFont(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sfnt.Write(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorTextMeasurable 判断原字形能否在不使用回退字体的情况下度量
|
// editorTextMeasurable 判断原字形能否在不使用回退字体的情况下度量
|
||||||
@@ -787,7 +829,7 @@ func validateEditorGeometry(object GraphicObject) error {
|
|||||||
}
|
}
|
||||||
for _, value := range []string{code.DeltaX, code.DeltaY} {
|
for _, value := range []string{code.DeltaX, code.DeltaY} {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
if err := creationDeltas(value, len(textCodeRunes(code.Value))-1); err != nil {
|
if err := creationDeltas(value); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
package ofdgo
|
package ofdgo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-text/typesetting/segmenter"
|
"github.com/go-text/typesetting/segmenter"
|
||||||
)
|
)
|
||||||
@@ -95,6 +97,186 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error {
|
|||||||
return e.UpdateObjects(page, updates)
|
return e.UpdateObjects(page, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排
|
||||||
|
// 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排
|
||||||
|
// 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) error {
|
||||||
|
capability, err := e.ObjectCapabilities(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !capability.ReplaceFont {
|
||||||
|
return capability.editError()
|
||||||
|
}
|
||||||
|
object, err := e.Object(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates, err := e.styleTextObjects([]GraphicObject{object}, style)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
text := &updates[0].TextObject
|
||||||
|
if capability.LayoutKnown {
|
||||||
|
_, layout := text.TextLayout()
|
||||||
|
err = e.LayoutText(text, value, layout)
|
||||||
|
} else {
|
||||||
|
err = e.RewriteText(text, value)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.updateObjects(page, updates, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RewriteText 保留普通横向原文的字形定位,不修改对象样式或文档
|
||||||
|
// 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距
|
||||||
|
// 不跨定位段增删或自动生成新行,复杂字形映射需单独处理
|
||||||
|
// 入参: obj 原文字对象, value 新内容
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) RewriteText(obj *TextObject, value string) error {
|
||||||
|
if obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != "" {
|
||||||
|
return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text positioning is not supported for content editing")}
|
||||||
|
}
|
||||||
|
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
|
||||||
|
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
|
||||||
|
return fmt.Errorf("text must contain UTF-8 characters without tabs")
|
||||||
|
}
|
||||||
|
check := cloneEditorData(*obj)
|
||||||
|
if err := e.prepareText(&check); err != nil {
|
||||||
|
var missing *MissingGlyphError
|
||||||
|
if !errors.As(err, &missing) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sfnt, err := e.editorFont(obj.Font)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var missing []rune
|
||||||
|
for _, char := range value {
|
||||||
|
if char != '\n' && sfnt.GlyphIndex(char) == 0 {
|
||||||
|
missing = append(missing, char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := missingGlyphError(obj.Font, missing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
old, next := []rune(obj.Text()), []rune(value)
|
||||||
|
codes := append([]TextCode(nil), obj.TextCode...)
|
||||||
|
if len(old) == len(next) {
|
||||||
|
offset := 0
|
||||||
|
for i := range codes {
|
||||||
|
if obj.textCodeLineBreak(i) {
|
||||||
|
if next[offset] != '\n' {
|
||||||
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")}
|
||||||
|
}
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
end := offset + len(textCodeRunes(codes[i].Value))
|
||||||
|
content := string(next[offset:end])
|
||||||
|
if strings.Contains(content, "\n") {
|
||||||
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")}
|
||||||
|
}
|
||||||
|
if content != string(textCodeRunes(codes[i].Value)) {
|
||||||
|
codes[i].Value = escapeOFDText(content)
|
||||||
|
}
|
||||||
|
offset = end
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start := 0
|
||||||
|
for start < min(len(old), len(next)) && old[start] == next[start] {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end, nextEnd := len(old), len(next)
|
||||||
|
for end > start && nextEnd > start && old[end-1] == next[nextEnd-1] {
|
||||||
|
end--
|
||||||
|
nextEnd--
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(string(old[start:end])+string(next[start:nextEnd]), "\n") {
|
||||||
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")}
|
||||||
|
}
|
||||||
|
offset, changed := 0, false
|
||||||
|
for i := range codes {
|
||||||
|
if obj.textCodeLineBreak(i) {
|
||||||
|
offset++
|
||||||
|
}
|
||||||
|
runes := textCodeRunes(codes[i].Value)
|
||||||
|
limit := offset + len(runes)
|
||||||
|
if start >= offset && end <= limit {
|
||||||
|
from, to := start-offset, end-offset
|
||||||
|
replacement := next[start:nextEnd]
|
||||||
|
content := append(append(append([]rune(nil), runes[:from]...), replacement...), runes[to:]...)
|
||||||
|
if len(content) == 0 {
|
||||||
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("removing a positioned text run requires explicit layout")}
|
||||||
|
}
|
||||||
|
unit := obj.Size / float64(sfnt.UnitsPerEm())
|
||||||
|
if obj.HScale != 0 {
|
||||||
|
unit *= obj.HScale
|
||||||
|
}
|
||||||
|
advance := func(char rune) float64 { return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit }
|
||||||
|
if codes[i].DeltaX != "" || codes[i].DeltaY == "" {
|
||||||
|
codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance)
|
||||||
|
}
|
||||||
|
if codes[i].DeltaY != "" {
|
||||||
|
codes[i].DeltaY = rewriteTextDeltas(codes[i].DeltaY, runes, replacement, from, to, func(rune) float64 { return 0 })
|
||||||
|
}
|
||||||
|
codes[i].Value = escapeOFDText(string(content))
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
offset = limit
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("editing across positioned text runs requires explicit layout")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check = *obj
|
||||||
|
check.TextCode = codes
|
||||||
|
if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: check}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
obj.TextCode, obj.layout = codes, nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteTextDeltas 局部替换字符步进,保留未修改字符及末尾附加位移
|
||||||
|
// 入参: value 原位移, old 原字符, replacement 替换字符, start 起点, end 终点, advance 字体步进
|
||||||
|
// 返回: string 更新后的位移数组
|
||||||
|
func rewriteTextDeltas(value string, old, replacement []rune, start, end int, advance func(rune) float64) string {
|
||||||
|
deltas := parseFloatsWithG(value)
|
||||||
|
steps := make([]float64, len(old))
|
||||||
|
for i, char := range old {
|
||||||
|
steps[i] = advance(char)
|
||||||
|
if i < len(old)-1 || len(deltas) >= len(old) {
|
||||||
|
if delta, ok := textDelta(deltas, i); ok {
|
||||||
|
steps[i] = delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracking := 0.0
|
||||||
|
if len(old) > 0 && (len(old) > 1 || len(deltas) != 0) {
|
||||||
|
index := min(start, max(0, len(old)-2))
|
||||||
|
tracking = steps[index] - advance(old[index])
|
||||||
|
}
|
||||||
|
result := append([]float64(nil), steps[:start]...)
|
||||||
|
for _, char := range replacement {
|
||||||
|
result = append(result, advance(char)+tracking)
|
||||||
|
}
|
||||||
|
result = append(result, steps[end:]...)
|
||||||
|
if len(deltas) < len(old) {
|
||||||
|
result = result[:len(result)-1]
|
||||||
|
} else {
|
||||||
|
result = append(result, deltas[len(old):]...)
|
||||||
|
}
|
||||||
|
numbers := make([]string, len(result))
|
||||||
|
for i, delta := range result {
|
||||||
|
numbers[i] = ofdNumber(delta)
|
||||||
|
}
|
||||||
|
return strings.Join(numbers, " ")
|
||||||
|
}
|
||||||
|
|
||||||
// styleTextObjects 生成文字样式副本,不提交文档或历史
|
// styleTextObjects 生成文字样式副本,不提交文档或历史
|
||||||
// 入参: objects 原文字对象, style 样式增量
|
// 入参: objects 原文字对象, style 样式增量
|
||||||
// 返回: []GraphicObject 更新对象, error 错误信息
|
// 返回: []GraphicObject 更新对象, error 错误信息
|
||||||
|
|||||||
Reference in new issue
Block a user