feat(编辑能力): 完善原格式文字编辑,修复字体预览与文字定位问题
build.yaml / build (push) Successful in 37s

This commit is contained in:
xiaoqidun committed 2026-09-18 16:16:58 +08:00
1 parent ce05fe96af
commit 87457c4994
10 files changed
+528 -37

No files matched your search

+1
View File
@@ -1655,6 +1655,7 @@ button.outline-link:hover {
.edit-handle {
display: none;
position: absolute;
z-index: 1;
width: calc(9px / var(--surface-scale));
height: calc(9px / var(--surface-scale));
border: calc(1px / var(--surface-scale)) solid var(--accent);
+8 -6
View File
@@ -433,6 +433,7 @@ 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) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size),
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("ofdgoUpdateText", item, value, fontData || null, item.size, color),
@@ -1637,7 +1638,7 @@ async function editCanvasObject(item) {
return;
}
if (item.type === "TextObject") {
if (!confirmTextReflow(item)) return;
if (!canEditObject(item, "textContent")) return;
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
if (item.type === "PathObject") {
el.shapeWidth.focus();
@@ -1652,8 +1653,9 @@ async function editCanvasObject(item) {
const data = await callWASM("ofdgoEditorFont", item.font);
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
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) {
canvasEditor.editText(item, face);
canvasEditor.editText(item, face, source);
}
} catch (err) {
if (openSeq === state.openSeq) {
@@ -1828,7 +1830,7 @@ async function beginCanvasText(index, box, surface, page) {
function editorFonts(item) {
const fonts = fontManager.editorFonts();
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;
}
@@ -2712,7 +2714,7 @@ async function loadWASM() {
if (data.ok) {
request.resolve(data.data);
} 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) {
err.name = "AbortError";
}
@@ -4710,7 +4712,7 @@ async function selectSearchMatch(index) {
if (state.editing) {
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
const item = node && canvasEditor.nodes.get(node);
if (item?.type === "TextObject" && canEditObject(item, "update")) {
if (item?.type === "TextObject") {
state.selectObjects = true;
setPan(false);
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.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
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.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
+153 -5
View File
@@ -11,6 +11,31 @@ export function editTextValue(input) {
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) {
const u = (x - rect.left) / rect.width;
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.closeText();
this.select(item);
@@ -1178,17 +1203,28 @@ export class CanvasEditor {
letterSpacing: `${(item.letterSpacing || 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;
if (source) editing.history = { entries: [{ value: item.text }], index: 0 };
item.artwork?.classList.add("edit-text-source");
item.node.classList.add("edit-text-source");
item.surface.append(input);
input.addEventListener("input", () => this.options.onTextChange());
input.addEventListener("compositionstart", () => { editing.composing = true; });
if (source) this.paintSourceText(source);
input.addEventListener("input", () => {
this.recordSourceText();
this.options.onTextChange();
this.previewSourceText();
});
input.addEventListener("compositionstart", () => {
this.rememberSourceSelection();
editing.composing = true;
});
input.addEventListener("compositionend", () => {
editing.composing = false;
this.recordSourceText();
Promise.resolve().then(() => {
if (this.input === editing && editing.commitAfterComposition) this.commitText();
else this.previewSourceText();
});
});
input.addEventListener("paste", event => {
@@ -1196,6 +1232,12 @@ export class CanvasEditor {
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
});
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 === "insertLineBreak") {
event.preventDefault();
@@ -1221,7 +1263,10 @@ export class CanvasEditor {
if (event.isComposing || editing.composing || editing.saving) {
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();
this.closeText();
this.focus();
@@ -1234,6 +1279,107 @@ export class CanvasEditor {
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) {
const editing = this.input;
document.fonts.add(face);
@@ -1243,6 +1389,7 @@ export class CanvasEditor {
editing.input.style.fontFamily = `"${face.family}"`;
this.options.onTextChange();
editing.input.focus({ preventScroll: true });
this.previewSourceText();
}
setTextStyle(values) {
@@ -1254,6 +1401,7 @@ export class CanvasEditor {
editing.input.style.fontSize = `${item.size * PX_PER_MM}px`;
editing.input.style.color = item.color;
this.options.onTextChange();
if ("size" in values) this.previewSourceText();
}
textChanged() {
+116 -2
View File
@@ -134,6 +134,7 @@ func RunWASM() {
registerAsyncCallback("ofdgoImportPages", importPages)
registerCallback("ofdgoInsertText", insertText)
registerCallback("ofdgoUpdateText", updateText)
registerCallback("ofdgoPreviewText", previewText)
registerCallback("ofdgoStyleText", styleText)
registerCallback("ofdgoCheckTextFont", checkTextFont)
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 {
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")}
result["textContent"] = capability.TextContent
if missing := capability.MissingGlyphs; missing != nil {
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)
}
// editorFont 读取对象实际使用的内嵌字体,供画布输入使用
// editorFont 读取对象编辑使用的字体,匹配的外部字体不嵌入文档
// 入参: args 字体资源标识
// 返回: any 字体数据, error 错误信息
func editorFont(args []js.Value) (any, error) {
if currentEditor == nil {
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 {
return nil, err
}
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 替换图片资源并更新预览
// 入参: args 页码、对象标识、图片数据和适应方式
// 返回: any 文档信息, error 错误信息
@@ -2704,6 +2800,24 @@ func updateText(args []js.Value) (any, error) {
}
}
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()
_, layout := object.TextObject.TextLayout()
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
+8 -16
View File
@@ -47,6 +47,7 @@ type Editor struct {
resources []editorResource
fonts map[string]*font.SFNT
fontFS []fs.FS
fontRenderer *Renderer
images map[string]image.Point
resourceID map[editorResourceKey]string
maxID int
@@ -60,10 +61,11 @@ type Editor struct {
outlines []byte
}
// SetFontFS 设置几何度量使用的外部字体来源,与预览渲染器保持一致,不嵌入或替换文档字体
// SetFontFS 设置编辑与几何度量使用的外部字体来源,不嵌入或替换文档字体
// 入参: fsys 字体文件系统,空参数恢复默认来源
func (e *Editor) SetFontFS(fsys ...fs.FS) {
e.fontFS = slices.Clone(fsys)
e.fontRenderer = nil
}
// editorResource 文档内嵌资源
@@ -1150,7 +1152,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
return err
}
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 {
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} {
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)
}
}
@@ -1214,19 +1216,16 @@ func (e *Editor) prepareText(obj *TextObject) error {
}
// creationDeltas 校验文字位移数组及g重复编码
// 入参: value 位移数组, count 所需位移数量
// 入参: value 位移数组
// 返回: error 错误信息
func creationDeltas(value string, count int) error {
func creationDeltas(value string) error {
fields := strings.Fields(value)
numbers := 0
for i := 0; i < len(fields); i++ {
repeat := 1
if fields[i] == "g" {
if i+2 >= len(fields) {
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 {
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 {
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
}
+5 -2
View File
@@ -549,11 +549,14 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp
if err := e.prepareText(&text); err != nil {
capability.ReasonCode, capability.Reason = editReason(err), err.Error()
errors.As(err, &capability.MissingGlyphs)
} else if _, err := text.TextFrame(); err == nil {
capability.Reflow = text.ReadDirection == 0 && text.CharDirection == 0
} else {
capability.TextContent = text.ReadDirection == 0 && text.CharDirection == 0
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 {
clips := node.object.ImageObject.Clips
_, capability.CropImage = node.matrix(clips == nil || clips.TransFlag == nil || *clips.TransFlag).Invert()
+8 -3
View File
@@ -20,8 +20,8 @@ import (
"strconv"
)
// UpdateCompositeText 修改内部普通横向文字,保留首行起点、变换、裁剪及未修改属性
// 复杂字形定位不自动重排,空样式保留原字体、字号和颜色
// UpdateCompositeText 修改内部普通横向文字,原文保留定位,已知段落按会话选项重排
// 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线
// 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量
// 返回: 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
}
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 {
text.Size = style.Size
}
+2
View File
@@ -32,6 +32,8 @@ const (
EditFontUnavailable EditReason = "fontUnavailable"
// EditMissingGlyphs 字体缺少文字所需字形
EditMissingGlyphs EditReason = "missingGlyphs"
// EditLayoutRequired 内容变化需要显式选择段落排版
EditLayoutRequired EditReason = "layoutRequired"
// EditInvalidObject 对象数据未通过校验
EditInvalidObject EditReason = "invalidObject"
)
+45 -3
View File
@@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"github.com/tdewolff/canvas"
"github.com/tdewolff/font"
)
@@ -64,6 +65,7 @@ type editorSourcePage struct {
// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因
// Update表示完整替换内容,ReplaceFont表示可显式替换文字字体,Transform表示保留文字定位的几何变换,Order仅限同一图层或末级页块
// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复
// TextContent表示可修改文字内容,未知段落保持原定位,不自动重排
// FitImage表示可按原始图片比例适应或填充现有边界
// ResetCrop表示有可单独还原的会话裁剪
// Paint表示可独立修改纯色填充和描边,不要求重新排版文字
@@ -72,6 +74,7 @@ type editorSourcePage struct {
// Ungroup表示可移除组合容器并保留内部内容,Stretch表示图片及纯图片组合可独立调整宽高
type ObjectCapabilities struct {
Update bool
TextContent bool
Paint bool
ReplaceFont 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
origin := e.objectOrigin(id)
if origin == nil {
all.TextContent = all.Reflow
all.Stretch = object.Type == "ImageObject"
all.ReplaceImage = object.Type == "ImageObject"
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
}
if object.Type == "TextObject" {
all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0
all.Arrange = all.Update || e.editorTextMeasurable(object.TextObject)
if _, err := object.TextObject.TextFrame(); err != nil {
all.Reflow = false
@@ -611,7 +616,7 @@ func editorGeometry(object GraphicObject) (string, string) {
return "", ""
}
// editorFont 按需解析原文档内嵌字体,不用系统回退替换原字体
// editorFont 按需解析内嵌字体或名称匹配的外部字体,不使用无关回退字体
// 入参: id 字体资源标识
// 返回: *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
}
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)
if err != nil {
return nil, &EditError{Code: EditFontUnavailable, Err: err}
@@ -630,7 +651,28 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) {
e.fonts[id] = sfnt
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 判断原字形能否在不使用回退字体的情况下度量
@@ -787,7 +829,7 @@ func validateEditorGeometry(object GraphicObject) error {
}
for _, value := range []string{code.DeltaX, code.DeltaY} {
if value != "" {
if err := creationDeltas(value, len(textCodeRunes(code.Value))-1); err != nil {
if err := creationDeltas(value); err != nil {
return err
}
}
+182
View File
@@ -15,9 +15,11 @@
package ofdgo
import (
"errors"
"fmt"
"image/color"
"strings"
"unicode/utf8"
"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)
}
// 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 生成文字样式副本,不提交文档或历史
// 入参: objects 原文字对象, style 样式增量
// 返回: []GraphicObject 更新对象, error 错误信息