diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 313329f..5952d93 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -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); diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 513ac66..b0da31f 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -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); diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 26e9e4a..42d9697 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -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() { diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 5cee3e5..2e0617c 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -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 { diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 1e6593e..c785a34 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -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 } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 7dc15d0..e9a2cc5 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -549,9 +549,12 @@ 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 - capability.LayoutKnown = text.layout != nil + } 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 { diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 4c4da78..44b534d 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -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 } diff --git a/ofdgo_editor_error.go b/ofdgo_editor_error.go index f3fe5aa..a4d152e 100644 --- a/ofdgo_editor_error.go +++ b/ofdgo_editor_error.go @@ -32,6 +32,8 @@ const ( EditFontUnavailable EditReason = "fontUnavailable" // EditMissingGlyphs 字体缺少文字所需字形 EditMissingGlyphs EditReason = "missingGlyphs" + // EditLayoutRequired 内容变化需要显式选择段落排版 + EditLayoutRequired EditReason = "layoutRequired" // EditInvalidObject 对象数据未通过校验 EditInvalidObject EditReason = "invalidObject" ) diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index e7ef72f..08789e0 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -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 } } diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 95aee4b..0755b92 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -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 错误信息