diff --git a/assets/webui/index.html b/assets/webui/index.html index 976cedb..8810275 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -132,6 +132,7 @@ 行距pt 折行 + 重排 字距pt 段落 @@ -482,14 +483,6 @@ 关闭 - - - 保留排版 - 内容 - - 取消确定 - - diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 3b5c07a..18e634c 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -1092,26 +1092,6 @@ button:not(:disabled):hover { } } -#sourceTextPanel { - width: 440px; -} - -#sourceTextValue { - min-height: 80px; -} - -#sourceTextPreview { - height: 160px; - margin-top: 12px; - background: #fff; - overflow: hidden; -} - -#sourceTextPreview svg { - display: block; - width: 100%; - height: 100%; -} .seal-position-options, .seal-position-options label { @@ -2871,6 +2851,21 @@ button.outline-link:hover { text-indent: inherit; } +.edit-text span[data-glyph] { + position: relative; + -webkit-text-fill-color: transparent; +} + +.edit-text span[data-glyph]::after { + content: attr(data-glyph); + position: absolute; + left: 0; + top: 0; + -webkit-text-fill-color: currentColor; + pointer-events: none; + user-select: none; +} + .edit-text:focus-visible { outline: none; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index b7f523d..e13e78f 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -356,12 +356,6 @@ const el = { objectHeight: document.querySelector("#objectHeight"), objectAspect: document.querySelector("#objectAspect"), objectStylePanel: document.querySelector("#objectStylePanel"), - sourceTextPanel: document.querySelector("#sourceTextPanel"), - sourceTextForm: document.querySelector("#sourceTextForm"), - sourceTextValue: document.querySelector("#sourceTextValue"), - sourceTextPreview: document.querySelector("#sourceTextPreview"), - sourceTextStatus: document.querySelector("#sourceTextStatus"), - sourceTextCancel: document.querySelector("#sourceTextCancel"), objectStyleForm: document.querySelector("#objectStyleForm"), groupObjectsButton: document.querySelector("#groupObjectsButton"), ungroupObjectButton: document.querySelector("#ungroupObjectButton"), @@ -603,7 +597,7 @@ const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Cont el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput], + textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput, document.querySelector("#textReflow")], keepTextFocus: () => fontPicker.open || canvasEditor.input?.fontDialog, textClipboardScope: () => `${state.wasmSeq}:${state.fileName}:${state.doc?.documents?.[state.doc.documentIndex || 0]?.root}:${state.doc?.documentIndex || 0}:${state.doc?.documentVersion || ""}`, busy: () => document.body.hasAttribute("aria-busy"), @@ -673,20 +667,21 @@ 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), + onPreviewText: (item, value, font, size, reflow) => callWASM("ofdgoPreviewText", item.index, item.id, value, font || null, size, !!reflow), onCommitRichText: saveRichText, onPreviewRichText: validateRichText, onTextError: (message, previous) => { if (message) setStatus(message); else if (el.statusText.textContent === previous) setStatus(state.doc ? pageStatus(state.pageIndex, state.doc.pageCount) : STATUS.ready); }, - onCommitText: (item, value, fontData, color = null) => item.draft + onCommitText: (item, value, fontData, color = null, reflow = false) => 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), + : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, color, reflow), onTextChange: () => { const editing = canvasEditor.input; updatePendingChanges(); - if (editing?.rich) updateObjectControls(canvasEditor.selected); + if (editing?.rich || editing?.source) updateObjectControls(canvasEditor.selected); + if (editing?.source) updateTextFonts(canvasEditor.selected, true); if (!editing) { updateObjectControls(canvasEditor.selected); updateTextFonts(canvasEditor.selected, true); @@ -1120,7 +1115,7 @@ el.resetCropButton.addEventListener("click", () => { }); for (const [button, direction, command] of [[el.undoButton, -1, "ofdgoUndo"], [el.redoButton, 1, "ofdgoRedo"]]) { button.addEventListener("click", async () => { - if (canvasEditor.input?.rich) { canvasEditor.textUndo(direction); return; } + if (canvasEditor.input?.rich || canvasEditor.input?.source) { canvasEditor.textUndo(direction); return; } if (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; await changeDocument(command); }); @@ -1279,22 +1274,7 @@ el.objectBoundsForm.addEventListener("submit", async event => { if (saved) el.objectBoundsPanel.close(); }); el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close()); -el.sourceTextCancel.addEventListener("click", () => el.sourceTextPanel.close()); -el.sourceTextValue.addEventListener("input", () => { - clearTimeout(state.sourceTextTimer); - state.sourceTextTimer = setTimeout(previewPositionedText, 150); -}); -el.sourceTextPanel.addEventListener("close", () => { - clearTimeout(state.sourceTextTimer); - state.sourceText = null; - el.sourceTextPreview.replaceChildren(); -}); -el.sourceTextForm.addEventListener("submit", async event => { - event.preventDefault(); - const item = state.sourceText?.item; - if (!item) return; - if (await changeDocument("ofdgoUpdateText", item, el.sourceTextValue.value, null, item.size, null)) el.sourceTextPanel.close(); -}); +document.querySelector("#textReflow").addEventListener("click", reflowCanvasText); el.objectLinkKind.addEventListener("change", updateObjectLinkFields); el.gradientKind.addEventListener("change", () => { el.gradientControls.hidden = el.gradientKind.value === "keep"; @@ -1831,7 +1811,7 @@ function handleKeyDown(event) { function formDialogOpen() { return Boolean(sealPreview || resourceManager.pick) || settingsElements.Panel.open || fontBindingElements.Panel.open || el.signPanel.open || el.verifyPanel.open || el.credentialsPanel.open || el.encryptionPanel.open || batchElements.Panel.open || el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open || el.documentPanel.open - || resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; + || resourceElements.Panel.open || el.structureDialog.open || el.versionPanel.open || el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open; } function setObjectDimension(input, value) { @@ -2908,18 +2888,7 @@ async function editCanvasObject(item) { return; } if (item.type === "TextObject") { - if (!canEditObject(item, "textContent")) { - if (canEditObject(item, "rewriteText")) { - state.sourceText = { item, openSeq: state.openSeq }; - el.sourceTextValue.value = item.text; - el.sourceTextStatus.textContent = ""; - el.sourceTextPreview.replaceChildren(); - el.sourceTextPanel.showModal(); - el.sourceTextValue.focus(); - await previewPositionedText(); - } - return; - } + if (!canEditObject(item, "textContent") && !canEditObject(item, "rewriteText")) return; } else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return; if (item.type === "PathObject") { el.shapeWidth.focus(); @@ -2931,7 +2900,7 @@ async function editCanvasObject(item) { const openSeq = state.openSeq; setBusy(true); try { - const data = await callWASM("ofdgoEditorFont", item.font); + const data = await callWASM("ofdgoEditorFont", item.font, !canEditObject(item, "layoutKnown")); const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes, fontFaceStyle(data.bytes)); await face.load(); let rich = null; @@ -2954,22 +2923,6 @@ async function editCanvasObject(item) { } } -async function previewPositionedText() { - const request = state.sourceText, value = el.sourceTextValue.value; - if (!request || !el.sourceTextPanel.open) return; - try { - const svg = await callWASM("ofdgoPreviewPositionedText", request.item.index, request.item.id, value); - if (state.sourceText !== request || request.openSeq !== state.openSeq || el.sourceTextValue.value !== value) return; - el.sourceTextPreview.replaceChildren(parseSVG(svg)); - el.sourceTextStatus.textContent = ""; - } catch (error) { - if (state.sourceText === request && request.openSeq === state.openSeq && el.sourceTextValue.value === value) { - el.sourceTextPreview.replaceChildren(); - el.sourceTextStatus.textContent = error.message; - } - } -} - function mountEditorObjects(index, page, surface) { const scope = state.composite?.index === index ? state.composite : null; canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface); @@ -3108,6 +3061,16 @@ async function ensureRichInput() { return canvasEditor.promoteText(rich); } +async function reflowCanvasText() { + const editing = canvasEditor.input; + if (document.body.hasAttribute("aria-busy") || !editing?.source || editing.composing || editing.saving || !canEditObject(editing.item, "reflow")) return; + if (!window.confirm("将重新排版原文,是否继续?")) return; + editing.reflow = true; + canvasEditor.recordSourceText(); + await canvasEditor.previewSourceText(); + updateObjectControls(canvasEditor.selected, true); +} + async function changeRangeFormat(key, value) { if (!state.ready || document.body.hasAttribute("aria-busy") || canvasEditor.input?.composing) return; try { @@ -3480,6 +3443,7 @@ async function changeTextStyle(color) { try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; } } if (canvasEditor.input) { + if (!color && canvasEditor.input.source && !canvasEditor.input.reflow && size !== item.size && !confirmTextReflow(item)) return; canvasEditor.setTextStyle(color ? { color: selectedColor } : { size }); updateObjectControls(canvasEditor.selected, true); return; @@ -3782,8 +3746,6 @@ async function changeDocument(name, item, ...args) { el.batchPagesStatus.textContent = err.message; } else if (el.objectStylePanel.open) { el.objectStyleStatus.textContent = err.message; - } else if (el.sourceTextPanel.open) { - el.sourceTextStatus.textContent = err.message; } else if (el.objectBoundsPanel.open) { el.objectBoundsStatus.textContent = err.message; } else if (el.outlinePanel.open) { @@ -4583,7 +4545,6 @@ async function openOFD(file, remote = null) { el.importPanel.close(); el.batchPagesPanel.close(); el.objectStylePanel.close(); - el.sourceTextPanel.close(); el.objectBoundsPanel.close(); el.outlinePanel.close(); el.structureDialog.close(); @@ -11799,12 +11760,16 @@ function updateObjectControls(item, reset = false) { textBackground.classList.toggle("mixed-color", Boolean((canvasEditor.input?.rich || item?.richText) && text.background === undefined)); if (text.background) textBackground.value = text.background; document.querySelector("#textHighlight").style.setProperty("--highlight-color", textBackground.value); - const richHistory = canvasEditor.input?.rich; + const richHistory = canvasEditor.input?.rich || canvasEditor.input?.history; if (richHistory) { el.undoButton.disabled = textDisabled || richHistory.index === 0; el.redoButton.disabled = textDisabled || richHistory.index === richHistory.entries.length - 1; } el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); + const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && !canEditObject(item, "layoutKnown"); + reflow.hidden = !positioned; + el.textWrap.hidden = !!positioned; + reflow.disabled = textDisabled || !canEditObject(item, "reflow") || !!canvasEditor.input?.reflow; el.paragraphButton.disabled = el.textAlign.disabled; const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); el.textAlign.value = layoutKnown ? text.align || "left" : ""; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index 2c43b97..e0b4b40 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -1536,7 +1536,7 @@ export class CanvasEditor { const editing = this.input; if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts); if (rich && !rich.model.layout.lineHeight) input.style.lineHeight = "normal"; - if (source) editing.history = { entries: [{ value: item.text }], index: 0 }; + if (source) editing.history = { entries: [{ value: item.text, face, fontChoice: item.fontChoice }], index: 0 }; item.artwork?.classList.add("edit-text-source"); item.node.classList.add("edit-text-source"); item.surface.append(input); @@ -1631,7 +1631,8 @@ export class CanvasEditor { if (editing.rich) editing.rich.render(); input.focus({ preventScroll: true }); if (!item.draft) document.getSelection().selectAllChildren(input); - if (editing.rich) { editing.rich.selection(); this.options.onTextChange(); } + if (editing.rich) editing.rich.selection(); + this.options.onTextChange(); } textUndo(direction) { @@ -1715,8 +1716,10 @@ export class CanvasEditor { 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) }); + const previous = history.entries[history.index]; + if (previous.value === value && previous.fontData === editing.fontData && previous.style === editing.style && !!previous.reflow === !!editing.reflow) return; + history.entries.splice(++history.index, Infinity, { value, caret: editTextSelection(input), face: editing.face, + fontData: editing.fontData, fontChoice: editing.fontChoice, style: editing.style, reflow: editing.reflow }); } restoreSourceText(direction) { @@ -1726,6 +1729,14 @@ export class CanvasEditor { if (index < 0 || index >= history.entries.length) return; history.index = index; const entry = history.entries[index], fragment = document.createDocumentFragment(), positions = []; + if (entry.face) { + document.fonts.delete(editing.face); + document.fonts.add(entry.face); + Object.assign(editing, { face: entry.face, fontData: entry.fontData, fontChoice: entry.fontChoice, style: entry.style, reflow: entry.reflow }); + editing.input.style.fontFamily = `"${entry.face.family}"`; + editing.input.style.fontSize = `${(entry.style?.size || editing.item.size) * PX_PER_MM}px`; + editing.input.style.color = entry.style?.color || editing.item.color; + } let position = 0; for (const text of entry.value.split("\n")) { const paragraph = document.createElement("div"); @@ -1745,11 +1756,11 @@ export class CanvasEditor { 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 value = editTextValue(editing.input), font = editing.fontData, size = editing.style?.size || editing.item.size, reflow = !!editing.reflow; const current = () => this.input === editing && !editing.rich && !editing.composing && !editing.saving && editing.fontData === font - && (editing.style?.size || editing.item.size) === size && editTextValue(editing.input) === value; + && (editing.style?.size || editing.item.size) === size && !!editing.reflow === reflow && editTextValue(editing.input) === value; try { - const source = await this.options.onPreviewText(editing.item, value, font, size); + const source = await this.options.onPreviewText(editing.item, value, font, size, reflow); if (!current()) return; this.setTextError(null); this.paintSourceText(source); @@ -1768,7 +1779,8 @@ export class CanvasEditor { 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; + let line, lineIndex = -1, naturalX = 0, position = 0, glyphIndex = 0; + const angle = (source.rotation || 0) * Math.PI / 180, cos = Math.cos(angle), sin = Math.sin(angle); let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; for (const run of source.runs) { if (!line || run.line) { @@ -1790,15 +1802,23 @@ export class CanvasEditor { y = run.positions[index][1] * PX_PER_MM; 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})` }); + const glyph = source.glyphs?.[glyphIndex++]; + if (glyph) span.dataset.glyph = glyph; + Object.assign(span.style, { display: "inline-block", opacity: String((item.alpha ?? 255) / 255), transformOrigin: `left ${baseline}px`, transform: `translate(${x - naturalX}px,${y - baseline - lineIndex * size}px) rotate(${source.rotation || 0}deg) scale(${source.scale},${source.vscale || 1})` }); line.append(span); positions.push({ node, start: position, end: position + char.length }); position += char.length; - const advance = context.measureText(char).width; - left = Math.min(left, x); - top = Math.min(top, y - baseline); - right = Math.max(right, x + advance * source.scale); - bottom = Math.max(bottom, y + size - baseline); + const advance = context.measureText(glyph || char).width; + if (glyph) span.style.width = `${advance}px`; + for (const dx of [0, advance * source.scale]) { + for (const dy of [-baseline * (source.vscale || 1), (size - baseline) * (source.vscale || 1)]) { + const px = x + dx * cos - dy * sin, py = y + dx * sin + dy * cos; + left = Math.min(left, px); + top = Math.min(top, py); + right = Math.max(right, px); + bottom = Math.max(bottom, py); + } + } naturalX += advance; index++; } @@ -1827,6 +1847,7 @@ export class CanvasEditor { editing.face = face; editing.fontData = data; editing.input.style.fontFamily = `"${face.family}"`; + this.recordSourceText(); this.options.onTextChange(); editing.input.focus({ preventScroll: true }); this.previewSourceText(); @@ -1841,6 +1862,7 @@ export class CanvasEditor { const item = { ...editing.item, ...editing.style }; editing.input.style.fontSize = `${item.size * PX_PER_MM}px`; editing.input.style.color = item.color; + this.recordSourceText(); this.fitTextInput(); this.options.onTextChange(); if ("size" in values) this.previewSourceText(); @@ -1852,7 +1874,7 @@ export class CanvasEditor { if (editing.rich) return Boolean(editing.refreshPending) || (!editing.item.draft || Boolean(editing.rich.text.trim())) && editing.rich.changed; const value = editTextValue(editing.input); return (!editing.item.draft || Boolean(value.trim())) - && (value !== editing.item.text || Boolean(editing.fontData || editing.style)); + && (value !== editing.item.text || Boolean(editing.fontData || editing.style || editing.reflow)); } async commitText() { @@ -1874,7 +1896,7 @@ export class CanvasEditor { let saved = false; try { saved = editing.rich ? await this.options.onCommitRichText(editing.item, editing.rich.model, editing.rich.fonts) - : await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null); + : await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null, !!editing.reflow); } catch (error) { if (this.input === editing) this.setTextError(error); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index ac85cb8..e61d217 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -1428,7 +1428,7 @@ func resourceLocations(args []js.Value) (any, error) { } // fontPreview 使用当前文档和版本的字形轮廓生成独立预览 -// 入参: args 字体资源标识 +// 入参: args 字体资源标识及可选字形映射标记 // 返回: any SVG数据, error 字体或绘制错误 func fontPreview(args []js.Value) (any, error) { if currentSession == nil || len(args) != 1 { @@ -3009,6 +3009,9 @@ func changeCompositeText(page int, path ofdgo.ObjectPath, indexes []int, operati args = args[1:] } style := ofdgo.TextStyle{Size: args[1].Float()} + if len(args) > 3 { + style.Reflow = args[3].Bool() + } if !args[0].IsNull() { data, err := bytesFromJS(args[0]) if err != nil { @@ -3061,6 +3064,12 @@ func editorFont(args []js.Value) (any, error) { if err != nil { return nil, err } + if len(args) > 1 && args[1].Bool() { + _, data, _, _, err = ofdgo.FixFontDataAggressive(data, true, false) + if err != nil { + return nil, err + } + } return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } @@ -3090,7 +3099,7 @@ func editorObjectAt(page int, key string) (ofdgo.GraphicObject, error) { } // previewText 生成保留原定位的输入预览,不修改文档或历史 -// 入参: args 页码、对象路径、新文字、可选字体数据和字号 +// 入参: args 页码、对象路径、新文字、可选字体数据、字号及重排标记 // 返回: any 定位段及字体样式, error 错误信息 func previewText(args []js.Value) (any, error) { key := args[1].String() @@ -3103,18 +3112,23 @@ func previewText(args []js.Value) (any, error) { return nil, fmt.Errorf("object is not text") } editor := currentEditor + fontID := "" 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) + fontID, err = editor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err } + text.Font = "" } - if len(args) > 4 && args[4].Float() != text.Size { + if len(args) > 4 && args[4].Float() != text.Size || len(args) > 5 && args[5].Bool() { + if fontID != "" { + text.Font = fontID + } var x, y float64 if strings.Contains(key, "/") { x, _ = strconv.ParseFloat(text.TextCode[0].X, 64) @@ -3134,7 +3148,7 @@ func previewText(args []js.Value) (any, error) { code.X, code.Y = fmt.Sprint(cx+x), fmt.Sprint(cy+y-baseline) } } - } else if err := editor.RewriteText(&text, args[2].String()); err != nil { + } else if err := editor.RewriteTextWithFont(&text, args[2].String(), fontID); err != nil { return nil, err } var runs []any @@ -3166,7 +3180,22 @@ func previewText(args []js.Value) (any, error) { if scale == 0 { scale = 1 } - return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, nil + glyphs := make(map[int]string) + if fontID == "" { + for _, transform := range text.CGTransform { + if transform.CodeCount != transform.GlyphCount { + continue + } + for i, value := range strings.Fields(transform.Glyphs) { + glyph, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return nil, err + } + glyphs[transform.CodePosition+i] = string(rune(0xF0000 + glyph)) + } + } + } + return map[string]any{"runs": runs, "glyphs": glyphs, "scale": scale, "vscale": text.VScale, "rotation": text.CharDirection, "weight": text.Weight, "italic": text.Italic}, nil } // previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录 @@ -3204,78 +3233,71 @@ func previewPositionedText(args []js.Value) (any, error) { } // updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性 -// 入参: args 页码、对象标识、内容、字体数据、字号和颜色 +// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 // 返回: any 文档信息, error 错误信息 func updateText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } + return changeObjects(func() error { return applyTextUpdate(args) }) +} + +// applyTextUpdate 在当前事务中修改文字及字体,不刷新会话 +// 入参: args 页码、对象标识、内容、字体数据、字号、颜色及可选重排标记 +// 返回: error 字体、排版或提交错误 +func applyTextUpdate(args []js.Value) error { page, id := args[0].Int(), args[1].String() object, err := currentEditor.Object(page, id) if err != nil { - return nil, err + return err } if object.Type != "TextObject" { - return nil, fmt.Errorf("object %q is not text", id) + return fmt.Errorf("object %q is not text", id) } if !args[3].IsNull() { data, err := bytesFromJS(args[3]) if err != nil { - return nil, err + return err } value := object.TextObject.Text() if !args[2].IsNull() { value = args[2].String() } if err := checkFontGlyphs(data, value); err != nil { - return nil, err + return err } object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { - return nil, err + return err } } if !args[2].IsNull() { - if args[4].Float() == object.TextObject.Size { - var style ofdgo.TextStyle + if args[4].Float() == object.TextObject.Size || len(args) > 6 && args[6].Bool() { + style := ofdgo.TextStyle{Reflow: len(args) > 6 && args[6].Bool()} + if style.Reflow { + style.Size = args[4].Float() + } if !args[3].IsNull() { style.Font = object.TextObject.Font } if !args[5].IsNull() { var fill ofdgo.FillColor if err := setEditorColor(&fill, args[5].String()); err != nil { - return nil, err + return 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) + return currentEditor.UpdateTextContent(page, id, args[2].String(), style) } object.TextObject.Size = args[4].Float() _, layout := object.TextObject.TextLayout() if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil { - return nil, err + return err } } if !args[5].IsNull() { if err := setTextColor(&object.TextObject, args[5].String()); err != nil { - return nil, err + return err } } - revision := currentEditor.Revision() - if err := currentEditor.UpdateObject(page, id, object); err != nil { - return nil, err - } - if currentEditor.Revision() == revision { - return editorSummary(), nil - } - return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + return currentEditor.UpdateObject(page, id, object) } // checkFontGlyphs 只检查候选字体,不注册资源或改变文档 diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 17aa6cb..5c31163 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -70,6 +70,9 @@ func (e *Editor) changeCompositeText(page int, path ObjectPath, indexes []int, v // 入参: node 原文节点, member 有效快照, value 新内容, style 样式增量, options 段落选项, frame 文字框调整 // 返回: error 错误信息 func (e *Editor) updateCompositeText(node *editorCompositeNode, member CompositeMember, value *string, style TextStyle, options *TextLayout, frame *Box) error { + if style.Reflow { + options = &TextLayout{AutoBidi: true} + } rewriteOnly := value != nil && options == nil && frame == nil && style.Color == "" && (style.Font == "" || style.Font == member.Object.TextObject.Font) && (style.Size == 0 || style.Size == member.Object.TextObject.Size) if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont && !(rewriteOnly && member.Capabilities.RewriteText) { return fmt.Errorf("composite text style is not supported") @@ -81,7 +84,11 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite original := content if style.Font != "" { if state.layout == nil && options == nil && (style.Size == 0 || style.Size == text.Size) { - if err := e.replaceTextFont(&text, style.Font); err != nil { + replacement := content + if value != nil { + replacement = *value + } + if err := e.RewriteTextWithFont(&text, replacement, style.Font); err != nil { return err } } else { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 974c301..9f43e0b 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -628,7 +628,6 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX } if object.Type == "TextObject" { all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0 && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0) - all.Reflow = all.Reflow && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0) all.RewriteText = all.TextContent if !all.RewriteText { text := object.TextObject diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index f3593a9..d5dd8ac 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -55,10 +55,12 @@ type TextLayout struct { // TextStyle 文字样式增量 // Font为空时保留原字体,Size单位为毫米且为0时保留原字号 // Color为OFD的RGB分量字符串,空值保留原颜色,修改时保留原透明度 +// Reflow显式重新排版,默认保留原文定位 type TextStyle struct { - Font string - Size float64 - Color string + Font string + Size float64 + Color string + Reflow bool } // textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染 @@ -140,11 +142,37 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) } } }() + reflow := style.Reflow + if reflow { + if object.TextObject.ReadDirection != 0 || object.TextObject.CharDirection != 0 { + return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("paragraph layout requires horizontal text")} + } + if style.Font != "" { + object.TextObject.Font = style.Font + } + if style.Size != 0 { + object.TextObject.Size = style.Size + } + if err := e.LayoutText(&object.TextObject, value, TextLayout{AutoBidi: true}); err != nil { + return err + } + style.Font, style.Size = "", 0 + style.Reflow = false + } + if !capability.LayoutKnown && !reflow && style.Font != "" { + if err := e.RewriteTextWithFont(&object.TextObject, value, style.Font); err != nil { + return err + } + style.Font = "" + } updates, err := e.styleTextObjects([]GraphicObject{object}, style) if err != nil { return err } text := &updates[0].TextObject + if reflow { + return e.updateObjects(page, updates, true) + } if capability.LayoutKnown { _, layout := text.TextLayout() err = e.LayoutText(text, value, layout) @@ -283,10 +311,12 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { } if mapping < len(transforms) && position+j >= transforms[mapping].CodePosition { transform := &transforms[mapping] - if transform.CodeCount != 1 || transform.GlyphCount != 1 { + if transform.CodeCount != transform.GlyphCount { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing a combined glyph requires explicit layout")} } - transform.Glyphs = strconv.Itoa(int(sfnt.GlyphIndex(char))) + glyphs := strings.Fields(transform.Glyphs) + glyphs[position+j-transform.CodePosition] = strconv.Itoa(int(sfnt.GlyphIndex(char))) + transform.Glyphs = strings.Join(glyphs, " ") } } } @@ -300,6 +330,52 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { return nil } +// RewriteTextWithFont 更换字体并修改定位文字,保留原始坐标及逐字映射,失败时不改变对象 +// 入参: obj 文字对象, value 新内容, fontID 目标字体,空值沿用原字体 +// 返回: error 字体、定位或字形错误 +func (e *Editor) RewriteTextWithFont(obj *TextObject, value, fontID string) error { + value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") + if fontID == "" || fontID == obj.Font { + return e.RewriteText(obj, value) + } + text := cloneEditorData(*obj) + if err := validateTextGlyphs(text, 1<<16); err != nil { + return err + } + for _, transform := range text.CGTransform { + if transform.CodeCount != transform.GlyphCount { + return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing the font of combined glyphs requires explicit layout")} + } + } + if len(text.CGTransform) != 0 && len([]rune(text.Text())) != len([]rune(value)) { + return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")} + } + transforms := text.CGTransform + text.Font, text.CGTransform = fontID, nil + if err := e.RewriteText(&text, value); err != nil { + return err + } + font, err := e.editorFont(fontID) + if err != nil { + return err + } + var missing []rune + for _, char := range text.Text() { + if char != '\n' && font.GlyphIndex(char) == 0 { + missing = append(missing, char) + } + } + if err := missingGlyphError(fontID, missing); err != nil { + return err + } + text.Font, text.CGTransform = obj.Font, transforms + if err := e.replaceTextFont(&text, fontID); err != nil { + return err + } + *obj = text + return nil +} + // TextLayout 获取最近一次排版的原文与选项,区分软换行和显式换行 // 信息仅在当前编辑过程保留,读取OFD时返回定位后的文字和默认选项 // 返回: string 原文, TextLayout 排版选项 @@ -393,6 +469,13 @@ func (e *Editor) styleTextObjects(objects []GraphicObject, style TextStyle) ([]G text := &updates[i].TextObject value, layout := text.TextLayout() known := e.objectOrigin(id) == nil || text.layout != nil + if style.Reflow { + if text.ReadDirection != 0 || text.CharDirection != 0 { + return nil, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("paragraph layout requires horizontal text")} + } + known = true + layout.AutoBidi = true + } fontChanged := style.Font != "" && style.Font != text.Font sizeChanged := style.Size != 0 && style.Size != text.Size if sizeChanged && !known { @@ -408,7 +491,7 @@ func (e *Editor) styleTextObjects(objects []GraphicObject, style TextStyle) ([]G if sizeChanged { text.Size = style.Size } - if known && (fontChanged || sizeChanged) { + if known && (fontChanged || sizeChanged || style.Reflow) { if err := e.LayoutText(text, value, layout); err != nil { return nil, err } @@ -458,15 +541,19 @@ func (e *Editor) replaceTextFont(text *TextObject, id string) error { var missing []rune for i := range transforms { transform := &transforms[i] - if transform.CodeCount != 1 || transform.GlyphCount != 1 { + if transform.CodeCount != transform.GlyphCount { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing the font of combined glyphs requires explicit layout")} } - char := chars[transform.CodePosition] - glyph := font.GlyphIndex(char) - if glyph == 0 { - missing = append(missing, char) + glyphs := make([]string, transform.CodeCount) + for j := range glyphs { + char := chars[transform.CodePosition+j] + glyph := font.GlyphIndex(char) + if glyph == 0 { + missing = append(missing, char) + } + glyphs[j] = strconv.Itoa(int(glyph)) } - transform.Glyphs = strconv.Itoa(int(glyph)) + transform.Glyphs = strings.Join(glyphs, " ") } if err := missingGlyphError(id, missing); err != nil { return err