From 61cfb79f25e038abd9ed66df817579436f9e6926 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 14 Oct 2026 14:27:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AF=8C=E6=96=87=E6=9C=AC=E9=80=89=E5=8C=BA?= =?UTF-8?q?=E4=B8=8E=E5=85=AD=E7=A7=8D=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 6 + assets/webui/ofdgo.css | 42 ++- assets/webui/ofdgo.js | 300 +++++++++++++-- assets/webui/ofdgo_edit.js | 374 ++++++++++++++++++- assets/webui/ofdgo_font.js | 2 +- internal/webui/wasm.go | 250 +++++++++++++ ofdgo_editor.go | 2 + ofdgo_editor_batch.go | 77 ++++ ofdgo_editor_font_scan.go | 9 + ofdgo_editor_import.go | 12 +- ofdgo_editor_rich_text.go | 736 +++++++++++++++++++++++++++++++++++++ ofdgo_page.go | 1 + ofdgo_rich_layout.go | 613 ++++++++++++++++++++++++++++++ ofdgo_rich_text.go | 312 ++++++++++++++++ ofdgo_structure.go | 13 +- 15 files changed, 2694 insertions(+), 55 deletions(-) create mode 100644 ofdgo_editor_rich_text.go create mode 100644 ofdgo_rich_layout.go create mode 100644 ofdgo_rich_text.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 2821f81..424bfca 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -113,6 +113,12 @@
+ + + + + +
文字 diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 9a1d733..198d03f 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -222,13 +222,48 @@ a:focus-visible { } .editor-group:has(#textFont) { - grid-template-columns: 160px 32px; + grid-template-columns: repeat(7, 44px); } +.editor-group:has(#textFont) > .text-font { + grid-column: 1 / 6; + grid-template-columns: minmax(0, 1fr) 32px; + column-gap: 4px; +} + +.editor-group:has(#textFont) > .editor-field { grid-column: 6 / 8; } + .editor-paragraph { grid-template-columns: var(--editor-control-width) 120px; } +.editor-group:has(#textFont) .button { + padding: 0; + font-family: Arial, sans-serif; + font-size: 16px; +} + +.text-format-bold { font-weight: 700; } +.text-format-italic { font-style: italic; } +.text-format-underline { text-decoration: underline; } +.text-format-strikeout { text-decoration: line-through; } +.text-format-shadow { text-shadow: 2px 2px 1px #888; } + +.editor-group:has(#textFont) [aria-pressed="mixed"] { + background: #edf1f4; + box-shadow: inset 0 -3px var(--text-muted); +} + +.text-highlight { + display: grid; + grid-template-columns: minmax(0, 1fr) 12px; + height: var(--control-height); +} + +.text-highlight .button { min-width: 0; } +.text-highlight .button span { border-bottom: 3px solid var(--highlight-color, #ffff00); } +.text-highlight .color-input { min-width: 0; width: 12px; height: 100%; padding: 0; } + .editor-group:has(#shapeFill) { grid-template-columns: repeat(2, 84px); } @@ -254,8 +289,9 @@ a:focus-visible { } .font-picker:focus-within { + border-color: var(--focus); outline: 2px solid var(--focus); - outline-offset: 2px; + outline-offset: -2px; } #textFontToggle { @@ -3785,7 +3821,7 @@ audio.page-media { } .editor-group:has(#textFont) { - grid-template-columns: 128px 32px; + grid-template-columns: repeat(7, 44px); } .editor-paragraph { diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index c3229f6..3e010ea 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, canEditObject, editTextValue, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; +import { CanvasEditor, canEditObject, editTextValue, richTextModel, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -594,11 +594,15 @@ const fontManager = new FontManager({ onPermissionChange: updateFontPermissionHint, }); const fontPicker = new FontPicker(el.textFont, el.textFontToggle, el.textFontList, changeTextFont, loadEditorFonts); +const textFormatButtons = [...document.querySelectorAll("[data-text-format]")]; +const textBackground = document.querySelector("#textBackground"); const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Content", "Old", "Font", "Text", "TextRow", "Image", "ImageRow", "Author", "LinkKind", "Address", "AddressRow", "Target", "TargetRow", "X", "Y", "Width", "Height", "Angle", "Opacity", "Tile", "Placement", "PlacementLabel", "Pick", "Scope", "Pages"].map(key => [key, document.querySelector(`#annotation${key}`)])); el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textSize, el.textColor], + textControls: [el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.fontInput], + 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"), rotation: () => state.rotation, canInsert: (index) => pageCan("insert", index), @@ -618,7 +622,9 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { }, onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height, Boolean(item.oriented)), onResize: (item, box) => changeDocument("ofdgoResizeObjects", { ...item, id: [item.id] }, box.x, box.y, box.width, box.height), - onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)), + onTextWidth: (item, box) => item.richText + ? saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: true } }, [], { ...item, frameWidth: box.width, frameOffset: box.x - item.x }) + : changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)), onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height), onCropChange: () => { updatePendingChanges(); @@ -665,12 +671,15 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { 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), + onCommitRichText: saveRichText, + onPreviewRichText: validateRichText, 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), onTextChange: () => { const editing = canvasEditor.input; updatePendingChanges(); + if (editing?.rich) updateObjectControls(canvasEditor.selected); if (!editing) { updateObjectControls(canvasEditor.selected); updateTextFonts(canvasEditor.selected, true); @@ -728,6 +737,8 @@ editorClick(el.editObjectButton, () => { }); el.textSize.addEventListener("change", () => changeTextStyle(false)); el.textColor.addEventListener("change", () => changeTextStyle(true)); +for (const button of textFormatButtons) button.addEventListener("click", () => changeRangeFormat(button.dataset.textFormat)); +textBackground.addEventListener("change", () => changeRangeFormat("background", textBackground.value)); editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); el.textAlign.addEventListener("change", () => changeParagraph()); el.textLineHeight.addEventListener("change", () => changeParagraph()); @@ -1007,8 +1018,9 @@ el.paragraphForm.addEventListener("submit", async event => { Object.assign(item, indents); } else { if (!confirmTextReflow(item)) return; - const saved = await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left", - item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents)); + const saved = item.richText ? await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, ...indents } }) + : await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left", + item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents)); if (!saved) return; } el.paragraphPanel.close(); @@ -1095,8 +1107,13 @@ el.resetCropButton.addEventListener("click", () => { if (canEditObject(item, "resetCrop")) return changeDocument("ofdgoResetImageCrop", item); if (item?.imageBounds) return canvasEditor.options.onCrop(item, item.imageBounds); }); -editorClick(el.undoButton, () => changeDocument("ofdgoUndo")); -editorClick(el.redoButton, () => changeDocument("ofdgoRedo")); +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 (!await canvasEditor.commitNudge() || !await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return; + await changeDocument(command); + }); +} for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButton, "rectangle"], [el.drawEllipseButton, "ellipse"]]) { editorClick(button, () => toggleDrawingTool(tool)); } @@ -1190,6 +1207,7 @@ editorClick(el.objectBoundsButton, openObjectBounds); for (const [button, ungroup] of [[el.groupObjectsButton, false], [el.ungroupObjectButton, true]]) { button.addEventListener("click", async () => { const item = canvasEditor.selected; + if (ungroup && canvasEditor.items().some(member => member.richText)) return; if (await changeDocument("ofdgoGroupObjects", { ...item, id: canvasEditor.items().map(member => member.id) }, ungroup)) el.objectStylePanel.close(); }); } @@ -1211,6 +1229,7 @@ el.arrowTool.addEventListener("change", async () => { }); el.copyStyleButton.addEventListener("click", async () => { const item = canvasEditor.selected, seq = state.openSeq; + if (!canCopyStyle(item)) return; setBusy(true); try { await callWASM("ofdgoCaptureStyle", item.index, item.id, state.composite?.key || ""); @@ -1220,6 +1239,7 @@ el.copyStyleButton.addEventListener("click", async () => { }); el.pasteStyleButton.addEventListener("click", async () => { const item = canvasEditor.selected; + if (canvasEditor.items().some(member => member.richText)) return; if (await changeDocument("ofdgoPasteStyle", { ...item, id: canvasEditor.items().map(member => member.id) })) el.objectStylePanel.close(); }); el.objectBoundsCancel.addEventListener("click", () => el.objectBoundsPanel.close()); @@ -1311,6 +1331,7 @@ el.structureForm.addEventListener("submit", async event => { event.preventDefault(); if (!state.editing || document.body.hasAttribute("aria-busy")) return; const index = Number(el.structureEntry.value), path = JSON.parse(el.structureNodeSelect.value || "[]"); + if (state.doc.structures?.[index]?.internal) return; const action = index < 0 ? "create" : el.structureAction.value; let original = state.doc.structures?.[index]?.root; for (const part of path) original = original?.children?.[part]; @@ -1830,7 +1851,7 @@ function openObjectBounds() { } function canCopyStyle(item) { - if (item?.type === "Annotation") return false; + if (item?.type === "Annotation" || item?.richText) return false; if (!item?.scoped) return canEditObject(item, "update"); if (item.type === "TextObject") return canEditObject(item, "replaceFont") && canEditObject(item, "paint"); if (item.type === "PathObject") return canEditObject(item, "transform") && canEditObject(item, "paint"); @@ -1854,7 +1875,7 @@ function openObjectStyle() { if (!items.length) return; const ordered = [...items].sort((a, b) => a.position - b.position); el.groupObjectsButton.disabled = items.length < 2 || !ordered.every((item, index) => canEditObject(item, "copy") && canEditObject(item, "order") && item.container === ordered[0].container && item.position === ordered[0].position + index); - el.ungroupObjectButton.disabled = items.length !== 1 || !canEditObject(items[0], "ungroup"); + el.ungroupObjectButton.disabled = items.length !== 1 || Boolean(items[0].richText) || !canEditObject(items[0], "ungroup"); el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]); el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item)); const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null; @@ -1893,7 +1914,7 @@ function openObjectStyle() { loadGradientForm("fill"); state.patternForms = {}; for (const target of ["fill", "stroke"]) { - const pattern = items.length === 1 && !items[0].scoped ? items[0][`${target}Pattern`] : null; + const pattern = items.length === 1 && !items[0].scoped && !items[0].richText ? items[0][`${target}Pattern`] : null; if (pattern) state.patternForms[target] = Object.fromEntries(Object.entries(pattern).map(([key, value]) => [key, String(value)])); } state.patternOriginal = JSON.stringify(state.patternForms); @@ -2330,6 +2351,7 @@ async function switchReadingVersion(id) { const doc = await callWASM("ofdgoSelectVersion", id); if (openSeq !== state.openSeq) return; state.objectClipboard = state.styleClipboard = null; + canvasEditor.richClipboard = null; resetCompositeScope(); canvasEditor.clear(); canvasEditor.setTool(""); @@ -2511,6 +2533,7 @@ async function switchDocument(index) { state.savedRevision = doc.revision; state.editorViews.clear(); state.objectClipboard = state.styleClipboard = null; + canvasEditor.richClipboard = null; resetCompositeScope(); canvasEditor.clear(); canvasEditor.setTool(""); @@ -2600,6 +2623,7 @@ async function toggleEditor() { state.conversionWarnings = []; state.objectClipboard = null; state.styleClipboard = null; + canvasEditor.richClipboard = null; canvasEditor.clear(); setPan(false); await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode, scale, @@ -2704,6 +2728,7 @@ async function createDocument(event) { state.objectClipboard = null; state.editorViews.clear(); state.styleClipboard = null; + canvasEditor.richClipboard = null; state.fontRenderPending = false; state.annotationCreate = null; canvasEditor.setTool(""); @@ -2865,7 +2890,7 @@ function updateAnnotationFields() { async function editCanvasObject(item) { if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return; - if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); + if (!item.richText && canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id); if (item.type === "Annotation") { editAnnotationDetails(item); return; @@ -2895,11 +2920,16 @@ async function editCanvasObject(item) { setBusy(true); try { const data = await callWASM("ofdgoEditorFont", item.font); - const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes); + const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes, fontFaceStyle(data.bytes)); await face.load(); - const source = canEditObject(item, "layoutKnown") ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); + let rich = null; + if (item.richText) rich = await loadRichText(item, face); + else if (!item.scoped && canEditObject(item, "layoutKnown")) { + try { rich = await loadRichText(item, face); } catch {} + } + const source = rich || canEditObject(item, "layoutKnown") ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); if (openSeq === state.openSeq && item.node.isConnected) { - canvasEditor.editText(item, face, source); + canvasEditor.editText(item, face, source, rich); } } catch (err) { if (openSeq === state.openSeq) { @@ -3030,9 +3060,109 @@ function openInsertPanel(item = null) { function currentTextStyle() { const text = selectedText(canvasEditor.selected) || state.textDefaults; + if (canvasEditor.input?.rich) return { ...text, ...canvasEditor.input.rich.state() }; + if (text.richText) { + const formats = text.richText.runs.map(run => run.format); + return { ...text, ...Object.fromEntries(["font", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"] + .map(key => [key, formats.every(format => format[key] === formats[0]?.[key]) ? formats[0]?.[key] : undefined])) }; + } return canvasEditor.input?.style ? { ...text, ...canvasEditor.input.style } : text; } +async function loadRichText(item, firstFace) { + const model = item.richText || await callWASM("ofdgoGetRichText", item.index, item.id); + const faces = new Map(); + for (const id of new Set(model.runs.map(run => run.format.font))) { + const data = id === item.font && firstFace ? null : (await callWASM("ofdgoEditorFont", id)).bytes; + const face = data ? await new FontFace(`ofdgo-rich-${id}`, data, fontFaceStyle(data)).load() : firstFace; + faces.set(id, face); + } + return { model, faces }; +} + +async function ensureRichInput() { + let editing = canvasEditor.input; + if (!editing) { + const item = canvasEditor.selected; + if (!item || item.items || item.scoped || item.type !== "TextObject") return false; + await editCanvasObject(item); + editing = canvasEditor.input; + } + if (!editing) return false; + if (editing.rich) return true; + if (editing.composing || editing.saving) return false; + const rich = await loadRichText(editing.item, editing.face); + if (canvasEditor.input !== editing) return false; + return canvasEditor.promoteText(rich); +} + +async function changeRangeFormat(key, value) { + if (!state.ready || document.body.hasAttribute("aria-busy") || canvasEditor.input?.composing) return; + try { + if (!canvasEditor.selected && !canvasEditor.input) { + state.textDefaults[key] = value ?? (key === "background" ? state.textDefaults.background ? "" : textBackground.value : !state.textDefaults[key]); + updateObjectControls(null); return; + } + if (!await ensureRichInput()) return; + const rich = canvasEditor.input.rich, current = rich.state()[key]; + rich.apply({ [key]: value ?? (key === "background" ? current ? "" : textBackground.value : current !== true) }); + } catch (error) { showError(error, false); } +} + +async function validateRichText(item, model, width, fonts = []) { + const openSeq = state.openSeq, editing = canvasEditor.input; + model = structuredClone(model); + const prepared = [...fonts], attempted = new Set(); + while (true) { + try { + await callWASM("ofdgoPreviewRichText", item.index, item.draft ? "" : item.id, JSON.stringify(model), width, prepared); + if (openSeq !== state.openSeq) throw new Error("文档已更改"); + return prepared; + } catch (error) { + const id = error.missingGlyphs?.fontID; + if (!id || attempted.has(id) || prepared.some(font => font.key === id) || openSeq !== state.openSeq) throw error; + attempted.add(id); + const original = (await callWASM("ofdgoEditorFont", id)).bytes; + const [source] = await callWASM("ofdgoFontFaces", original); + if (!source?.postscriptName) throw error; + await fontManager.loadFaces(data => callWASM("ofdgoFontFaces", data)); + const candidates = fontManager.editorFonts().filter(font => !font.disabled && font.postscriptName === source.postscriptName); + let recovered; + for (const choice of candidates) { + try { + const data = await readTextFont(choice); + const [face] = await callWASM("ofdgoFontFaces", data); + if (face?.postscriptName !== source.postscriptName) continue; + await callWASM("ofdgoCheckTextFont", data, model.runs.filter(run => run.format.font === id).map(run => run.text).join("")); + recovered = { key: id, data, choice }; + break; + } catch {} + } + if (!recovered || openSeq !== state.openSeq) throw error; + prepared.push({ key: id, data: recovered.data }); + if (editing?.rich && canvasEditor.input === editing) { + const rich = editing.rich; + const face = await new FontFace(`ofdgo-recovered-${crypto.randomUUID()}`, recovered.data, fontFaceStyle(recovered.data)).load(); + if (openSeq !== state.openSeq || canvasEditor.input !== editing) throw error; + rich.imports.set(id, recovered.data); + rich.choices.set(id, recovered.choice); + const previous = rich.faces.get(id); + if (previous) document.fonts.delete(previous); + rich.faces.set(id, face); document.fonts.add(face); + if (!editing.composing && !editing.saving) { rich.selection(); rich.render(); updateObjectControls(canvasEditor.selected); } + } + } + } +} + +async function saveRichText(item, model, fonts = [], box = item) { + const boundary = { x: box.x, y: box.y, width: box.width, height: box.height }; + if (box.frameWidth !== undefined) Object.assign(boundary, { frameWidth: box.frameWidth, frameOffset: box.frameOffset || 0 }); + fonts = await validateRichText(item, model, box.frameWidth ?? item.textFrame?.width ?? item.width, fonts); + return changeDocument("ofdgoSaveRichText", item.draft ? null : item, + ...(item.draft ? [item.index, ""] : []), JSON.stringify(model), boundary, fonts); +} + function textIndents(item) { return [item.leftIndent || 0, item.rightIndent || 0, item.firstLineIndent || 0]; } @@ -3052,6 +3182,7 @@ async function toggleTextTool() { const style = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults; const font = state.textFonts[Number(fontPicker.value)]; state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap, + bold: Boolean(style.bold), italic: Boolean(style.italic), underline: Boolean(style.underline), strikeout: Boolean(style.strikeout), shadow: Boolean(style.shadow), background: style.background || "", wrapOverride: style === state.textDefaults ? style.wrapOverride : style.wrap, align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0, leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0, @@ -3073,13 +3204,16 @@ async function beginCanvasText(index, box, surface, page) { setBusy(true); try { const data = await readTextFont(font); - const face = new FontFace("ofdgo-draft", data); + const face = new FontFace("ofdgo-draft", data, fontFaceStyle(data)); await face.load(); if (openSeq !== state.openSeq || !surface.isConnected) return; const node = document.createElement("div"); const item = { ...state.textDefaults, ...box, wrap: state.textDefaults.wrapOverride ?? box.wrap ?? state.textDefaults.wrap, index, page, surface, node, text: "", draft: true, fontData: data }; - canvasEditor.editText(item, face); + const model = richTextModel(item); + const key = `temp-font-${crypto.randomUUID()}`; + model.runs[0].format.font = key; + canvasEditor.editText(item, face, null, state.composite ? null : { model, faces: new Map([[key, face]]), fonts: [{ key, data }] }); } catch (err) { if (openSeq === state.openSeq) showError(err, false); } finally { @@ -3139,6 +3273,24 @@ async function readTextFont(font) { function updateTextFonts(item, refresh = false) { item = selectedText(item); + const rich = canvasEditor.input?.rich; + if (rich) { + const format = rich.state(), id = format.font; + const key = `rich:${id ?? "mixed"}:${rich.faces.size}`; + if (refresh || state.textFontID !== key) { + if (document.activeElement === el.textFont && fontPicker.open) return; + state.textFontID = key; + state.textFonts = editorFonts(null); + for (const [font, face] of rich.faces) { + const choice = rich.choices?.get(font); + state.textFonts.unshift(choice || { id: `embedded:${font}`, name: font === rich.editing.item.font ? rich.editing.item.fontName || font : face.family, embedded: !rich.imports.has(font), richKey: font }); + } + fontPicker.setFonts(state.textFonts, id === undefined ? null : state.textFonts.find(font => font.richKey === id || rich.choices?.get(id) === font)); + el.textFont.placeholder = id === undefined ? "混合" : ""; + } + return; + } + if (item?.richText) item = { ...item, font: currentTextStyle().font }; const id = item?.type === "TextObject" && !item.draft ? item.font : null; const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id; if (refresh || state.textFontID !== key) { @@ -3149,9 +3301,10 @@ function updateTextFonts(item, refresh = false) { && !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) { state.textFonts.unshift(selected); } - fontPicker.setFonts(state.textFonts, item?.items && !id ? null : selected || state.textFonts[0]); - el.textFont.placeholder = item?.items && !id ? "混合" : ""; - if (!id && !item?.items) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)]; + const mixed = (item?.items || item?.richText) && !id; + fontPicker.setFonts(state.textFonts, mixed ? null : selected || state.textFonts[0]); + el.textFont.placeholder = mixed ? "混合" : ""; + if (!id && !item?.items && !item?.richText) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)]; } } @@ -3159,19 +3312,30 @@ async function changeTextFont() { const item = selectedText(canvasEditor.selected); const font = state.textFonts[Number(fontPicker.value)]; if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return; + if (item?.richText && !canvasEditor.input) { + try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; } + } const editing = canvasEditor.input; if (editing) { + if (editing.composing) return; + editing.rich?.remember(); const openSeq = state.openSeq; setBusy(true); try { - const data = await readTextFont(font); - await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input)); - const face = new FontFace("ofdgo-edit-font", data); + const data = font.richKey && editing.rich?.imports.has(font.richKey) ? editing.rich.imports.get(font.richKey) : await readTextFont(font); + const [start, end] = editing.rich?.selection() || [0, editTextValue(editing.input).length]; + await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input).slice(start, end)); + const face = new FontFace(`ofdgo-edit-font-${crypto.randomUUID()}`, data, fontFaceStyle(data)); await face.load(); if (openSeq !== state.openSeq || canvasEditor.input !== editing) return; editing.fontChoice = font; if (item.draft) state.textDefaults.fontChoice = font; - canvasEditor.setTextFont(face, data); + if (editing.rich) { + const key = font.richKey || (font.embedded ? font.id.slice(9) : `temp-font-${crypto.randomUUID()}`); + editing.rich.choices ||= new Map(); + editing.rich.choices.set(key, font); + canvasEditor.setTextFont(face, font.embedded ? null : data, key); + } else canvasEditor.setTextFont(face, data); } catch (err) { if (openSeq === state.openSeq) { updateTextFonts(canvasEditor.selected, true); @@ -3181,6 +3345,7 @@ async function changeTextFont() { if (openSeq === state.openSeq) setBusy(false); } } else if (item?.type === "TextObject") { + if (item.items?.some(member => member.richText)) return; if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) { await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font), 0, null); updateTextFonts(canvasEditor.selected, true); @@ -3281,7 +3446,8 @@ async function changeParagraph(wrap) { updateObjectControls(item, true); return; } - await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); + if (item.richText) await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: Boolean(wrap), align: el.textAlign.value, lineHeight: height, letterSpacing: spacing } }); + else await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); updateObjectControls(canvasEditor.selected, true); } @@ -3297,6 +3463,9 @@ async function changeTextStyle(color) { } const size = color ? item.size : inputMillimeters(el.textSize, item.size); const fill = color && el.textColor.value !== item.color ? el.textColor.value : null; + if (item.richText && !canvasEditor.input) { + try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; } + } if (canvasEditor.input) { canvasEditor.setTextStyle(color ? { color: el.textColor.value } : { size }); updateObjectControls(canvasEditor.selected, true); @@ -3310,6 +3479,7 @@ async function changeTextStyle(color) { return; } if (item.items) { + if (item.items.some(member => member.richText)) return; if (color ? !canEditObject(item, "paint") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; if (!color && !el.textSize.value) return; if (size !== item.size || fill !== null) { @@ -3423,6 +3593,7 @@ async function changeDocument(name, item, ...args) { const previous = currentPageInfo(); const before = editorLocation(), revision = state.editorInfo.revision; const restoring = name === "ofdgoUndo" || name === "ofdgoRedo"; + if (restoring) canvasEditor.richClipboard = null; const { scrollLeft, scrollTop } = el.viewerPanel; const importing = name === "ofdgoImportPages" || name === "ofdgoImportImages"; const changingFont = name === "ofdgoChangeFontBinding"; @@ -3451,8 +3622,9 @@ async function changeDocument(name, item, ...args) { const scoped = scope && members.length && members.every(member => member.scoped); const ids = item ? Array.isArray(item.id) ? item.id : members.map(member => member.id) : []; const annotated = !scoped && ids.some(id => id.startsWith("annotation:")); - const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name); + const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name) || name === "ofdgoSaveRichText" && !item; const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item); + if (scope && inserting && name === "ofdgoSaveRichText") throw new Error("组合内部暂不支持新增富文本"); if (scope && inserting) args.push(scope.key); if (scoped && !operation) throw new Error("内部对象暂不支持此操作"); const doc = scoped @@ -3462,9 +3634,18 @@ async function changeDocument(name, item, ...args) { if (openSeq !== state.openSeq) { return; } - if (doc.revision === state.editorInfo?.revision) { + if (doc.revision === state.editorInfo?.revision && !(name === "ofdgoSaveRichText" && canvasEditor.input?.refreshPending)) { return true; } + const richEditing = name === "ofdgoSaveRichText" && canvasEditor.input?.rich && canvasEditor.input; + if (richEditing?.saving) { + richEditing.rich.initial = JSON.stringify(richEditing.rich.model); + richEditing.refreshPending = true; + } + if (richEditing?.saving && richEditing.item.draft && doc.selectedIDs?.length) { + richEditing.item.id = doc.selectedIDs[0]; + richEditing.item.draft = false; + } const restoredView = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null; if (restoredView?.reading && state.savedRevision === revision) state.savedRevision = doc.revision; refreshManagement = el.documentPanel.open && (name === "ofdgoChangeDocument" || name === "ofdgoUpdateInfo" || restoring); @@ -3482,6 +3663,7 @@ async function changeDocument(name, item, ...args) { } resetCompositeScope(); state.objectClipboard = state.styleClipboard = null; + canvasEditor.richClipboard = null; state.pageSelection.clear(); state.pageSelectionAnchor = null; state.pageMultiSelect = false; @@ -3700,13 +3882,17 @@ async function refreshEditorPage(doc, index, openSeq, clearSelection = false) { setStatus(pageStatus(state.pageIndex, doc.pageCount)); } catch (err) { if (openSeq === state.openSeq) { - state.pageCache.delete(index); - canvasEditor.clear(); - const shell = pageShell(index); - shell.querySelector(".page-surface").replaceChildren(); - shell.classList.remove("rendered"); - markFlowPageError(index, err); - markThumbnailError(index); + if (canvasEditor.input?.saving && canvasEditor.input.rich && canvasEditor.input.item.index === index) { + canvasEditor.select(canvasEditor.input.item); + } else { + state.pageCache.delete(index); + canvasEditor.clear(); + const shell = pageShell(index); + shell.querySelector(".page-surface").replaceChildren(); + shell.classList.remove("rendered"); + markFlowPageError(index, err); + markThumbnailError(index); + } } throw err; } finally { @@ -3780,6 +3966,19 @@ function openFontFile(input) { if (document.body.hasAttribute("aria-busy")) { return; } + const editing = canvasEditor.input; + if (editing) { + editing.rich?.remember(); + editing.fontDialog = true; + const finish = () => { + editing.fontDialog = false; + input.removeEventListener("change", finish); + input.removeEventListener("cancel", finish); + if (canvasEditor.input === editing) editing.input.focus({ preventScroll: true }); + }; + input.addEventListener("change", finish); + input.addEventListener("cancel", finish); + } input.value = ""; input.click(); } @@ -4146,6 +4345,7 @@ async function loadWASM() { } else { const err = new Error(missingGlyphMessage(data.missingGlyphs) || (data.reasonCode === "layoutRequired" ? "跨段或换行修改需先设置段落排版" : data.error)); err.code = data.code; + err.missingGlyphs = data.missingGlyphs; if (data.canceled) { err.name = "AbortError"; } @@ -4343,6 +4543,7 @@ async function openOFD(file, remote = null) { state.pageMultiSelect = false; state.objectClipboard = null; state.styleClipboard = null; + canvasEditor.richClipboard = null; state.editorInfo = null; state.editorViews.clear(); state.fontRenderPending = false; @@ -9538,9 +9739,11 @@ function openStructureDialog() { if (!state.editing || !state.doc || document.body.hasAttribute("aria-busy")) return; el.structureEntry.replaceChildren(); for (const [index, structure] of (state.doc.structures || []).entries()) { + if (structure.internal) continue; el.structureEntry.add(new Option(`${index + 1} · ${structure.root?.name || structure.typeId}`, index)); } el.structureEntry.add(new Option("新建标引", -1)); + el.structureEntry.value = String((state.doc.structures || []).findIndex(structure => !structure.internal)); el.structureAction.value = "update"; refreshStructureNodes(); el.structureDialog.showModal(); @@ -9677,12 +9880,14 @@ function addStructureReference(reference = {}) { function renderStructures() { const structures = state.doc?.structures || []; - el.structurePanel.hidden = !structures.length && !state.doc?.structureError && !(state.doc && state.editing); + const count = structures.reduce((total, structure) => total + !structure.internal, 0); + el.structurePanel.hidden = !count && !state.doc?.structureError && !(state.doc && state.editing); el.structureManage.hidden = !state.editing; - el.structureSummary.textContent = structures.length; + el.structureSummary.textContent = count; el.structureList.replaceChildren(); if (state.doc?.structureError) appendInfoLine(el.structureList, "错误", state.doc.structureError, "fail"); for (const [index, structure] of structures.entries()) { + if (structure.internal) continue; if (structure.root) el.structureList.append(structureNode(structure.root, index, [])); if (structure.error) appendInfoLine(el.structureList, "错误", structure.error, "fail"); } @@ -10594,9 +10799,9 @@ function updateEditorTools() { el.movePageNextButton.disabled = moveDisabled || selectedPages.at(-1) === state.doc?.pageCount - 1; const enabled = state.editing && state.selectObjects && !state.panMode && !sealPreview && !resourceManager.pick; canvasEditor.setEnabled(enabled); - updateObjectControls(canvasEditor.selected); el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; + updateObjectControls(canvasEditor.selected); } function updateObjectControls(item, reset = false) { @@ -10630,11 +10835,26 @@ function updateObjectControls(item, reset = false) { el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; const selection = selectedText(item); const text = selection ? currentTextStyle() : state.textDefaults; - const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection); + const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && (!selection || item.items.some(member => member.richText))) || Boolean(canvasEditor.input?.composing || canvasEditor.input?.saving); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); - el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint"); + el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !item.richText && !canEditObject(item, "paint"); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; + const formatsDisabled = textDisabled || Boolean(item?.items || item?.scoped) || Boolean(item && !item.draft && (!selection || !canEditObject(item, "textContent"))); + for (const button of textFormatButtons) { + button.disabled = formatsDisabled; + const key = button.dataset.textFormat; + button.setAttribute("aria-pressed", text[key] === undefined && (canvasEditor.input?.rich || item?.richText) ? "mixed" : String(Boolean(text[key]))); + } + textBackground.disabled = formatsDisabled; + 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; + 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); el.paragraphButton.disabled = el.textAlign.disabled; const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); @@ -10654,8 +10874,8 @@ function updateObjectControls(item, reset = false) { } el.textSize.placeholder = text.size === undefined ? "混合" : ""; el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`; - el.textColor.classList.toggle("mixed-color", Boolean(selection?.items && !text.color)); - el.textColor.title = selection?.items && !text.color ? "文字颜色不同" : "文字颜色"; + el.textColor.classList.toggle("mixed-color", Boolean(selection && !text.color)); + el.textColor.title = selection && !text.color ? "文字颜色不同" : "文字颜色"; if (reset || el.textColor.disabled || document.activeElement !== el.textColor) { el.textColor.value = text.color || "#000000"; } diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index b03ea6c..a1da3e4 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -1,5 +1,288 @@ const PX_PER_MM = 96 / 25.4; +const FORMAT_KEYS = ["font", "size", "color", "bold", "italic", "underline", "strikeout", "shadow", "background"]; +const RICH_CLIPBOARD = "application/x-ofdgo-rich-text"; +const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +export function fontFaceStyle(data) { + const style = { weight: "400", style: "normal" }; + const bytes = ArrayBuffer.isView(data) ? new DataView(data.buffer, data.byteOffset, data.byteLength) : new DataView(data); + if (bytes.byteLength < 12) return style; + const count = bytes.getUint16(4); + for (let i = 0; i < count && 12 + (i + 1) * 16 <= bytes.byteLength; i++) { + const record = 12 + i * 16; + if (bytes.getUint32(record) !== 0x68656164) continue; + const offset = bytes.getUint32(record + 8), length = bytes.getUint32(record + 12); + if (length < 46 || offset + length > bytes.byteLength) return style; + const flags = bytes.getUint16(offset + 44); + return { weight: flags & 1 ? "700" : "400", style: flags & 2 ? "italic" : "normal" }; + } + return style; +} + +function graphemeRange(text, start, end) { + const boundaries = [0, ...Array.from(graphemes.segment(text), segment => segment.index + segment.segment.length)]; + const from = boundaries.findLast(offset => offset <= start) ?? 0; + return start === end ? [from, from] : [from, boundaries.find(offset => offset >= end) ?? text.length]; +} + +export function richTextModel(item) { + return { runs: [{ text: item.text || "", format: { font: item.font || "", size: item.size, color: item.color || "#000000", + bold: Boolean(item.bold), italic: Boolean(item.italic), underline: Boolean(item.underline), strikeout: Boolean(item.strikeout), + shadow: Boolean(item.shadow), background: item.background || "" } }], + layout: { wrap: Boolean(item.wrap), align: item.align || "left", lineHeight: item.paragraphHeight || 0, + letterSpacing: item.letterSpacing || 0, leftIndent: item.leftIndent || 0, rightIndent: item.rightIndent || 0, + firstLineIndent: item.firstLineIndent || 0, autoBidi: true } }; +} + +function normalizeRuns(runs) { + const result = []; + for (const run of runs) { + if (!run.text) continue; + const format = Object.fromEntries(FORMAT_KEYS.map(key => [key, run.format[key] ?? (["font", "color", "background"].includes(key) ? "" : key === "size" ? 0 : false)])); + const previous = result.at(-1); + if (previous && FORMAT_KEYS.every(key => previous.format[key] === format[key])) previous.text += run.text; + else result.push({ text: run.text, format }); + } + const text = result.map(run => run.text).join(""), whole = []; + let index = 0, end = result[0]?.text.length || 0; + for (const part of graphemes.segment(text)) { + while (part.index >= end && index < result.length - 1) end += result[++index].text.length; + const format = result[index].format, previous = whole.at(-1); + if (previous && FORMAT_KEYS.every(key => previous.format[key] === format[key])) previous.text += part.segment; + else whole.push({ text: part.segment, format: { ...format } }); + } + return whole; +} + +function sliceRuns(runs, start, end) { + let offset = 0; + return runs.flatMap(run => { + const text = run.text.slice(Math.max(0, start - offset), Math.max(0, end - offset)); + offset += run.text.length; + return text ? [{ text, format: { ...run.format } }] : []; + }); +} + +export class RichTextSession { + constructor(editor, model, faces = new Map(), imports = []) { + this.editor = editor; + this.editing = editor.input; + this.model = structuredClone(model); + this.model.runs = normalizeRuns(model.runs); + this.initial = JSON.stringify(this.model); + this.faces = faces; + for (const face of faces.values()) document.fonts.add(face); + this.imports = new Map(imports.map(font => [font.key, font.data])); + this.choices = new Map(); + if (this.editing.item.fontChoice && imports.length === 1) this.choices.set(imports[0].key, this.editing.item.fontChoice); + this.typing = { ...(model.runs[0]?.format || richTextModel(this.editing.item).runs[0].format) }; + this.caret = [0, 0]; + this.entries = [this.snapshot()]; + this.index = 0; + this.selectionListener = () => { + if (editor.input !== this.editing || this.rendering || this.editing.composing) return; + const caret = editTextSelection(this.editing.input); + if (!caret) return; + if (caret.join() !== this.caret.join()) { + this.caret = caret; + this.typing = { ...this.formatAt(Math.min(...caret)) }; + } + editor.options.onTextChange(); + }; + document.addEventListener("selectionchange", this.selectionListener); + } + + get text() { return this.model.runs.map(run => run.text).join(""); } + get changed() { return JSON.stringify(this.model) !== this.initial; } + get fonts() { return [...this.imports].filter(([key]) => this.model.runs.some(run => run.format.font === key)).map(([key, data]) => ({ key, data })); } + selection() { + const caret = editTextSelection(this.editing.input); + if (caret) this.caret = caret; + return graphemeRange(this.text, ...[...this.caret].sort((a, b) => a - b)); + } + formatAt(position) { + let offset = 0; + for (const run of this.model.runs) { + offset += run.text.length; + if (position <= offset) return run.format; + } + return this.model.runs.at(-1)?.format || this.typing; + } + state() { + const [start, end] = this.selection(); + if (start === end) return { ...this.typing }; + const runs = sliceRuns(this.model.runs, start, end); + return Object.fromEntries(FORMAT_KEYS.map(key => [key, runs.every(run => run.format[key] === runs[0]?.format[key]) ? runs[0]?.format[key] : undefined])); + } + snapshot() { return { model: structuredClone(this.model), typing: { ...this.typing }, caret: [...this.caret] }; } + remember() { + this.selection(); + this.entries[this.index].caret = [...this.caret]; + } + record() { + const entry = this.snapshot(), old = this.entries[this.index]; + const changed = JSON.stringify(entry.model) !== JSON.stringify(old.model); + if (!changed && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; + this.entries.splice(++this.index, Infinity, entry); + if (changed) this.preview(); + } + undo(direction) { + if (this.editing.composing || this.editing.saving) return; + const entry = this.entries[this.index + direction]; + if (!entry) return; + this.index += direction; + this.model = structuredClone(entry.model); + this.typing = { ...entry.typing }; + this.caret = [...entry.caret]; + this.render(); + this.editor.options.onTextChange(); + this.preview(); + } + preview() { + if (!this.editor.options.onPreviewRichText) return; + clearTimeout(this.previewTimer); + const sequence = this.previewSequence = (this.previewSequence || 0) + 1; + this.previewTimer = setTimeout(async () => { + const editing = this.editing; + if (this.editor.input !== editing || editing.composing || editing.saving) return; + const current = () => this.editor.input === editing && this.previewSequence === sequence && !editing.composing && !editing.saving; + try { + await this.editor.options.onPreviewRichText(editing.item, this.model, editing.frame.width, this.fonts); + if (!current()) return; + editing.input.removeAttribute("aria-invalid"); + editing.input.removeAttribute("title"); + } catch (error) { + if (!current()) return; + editing.input.setAttribute("aria-invalid", "true"); + editing.input.title = error.message; + } + }, 300); + } + apply(values) { + if (this.editing.composing || this.editing.saving) return; + this.remember(); + const [start, end] = this.selection(); + this.caret = this.caret[0] > this.caret[1] ? [end, start] : [start, end]; + if (start !== end) this.model.runs = normalizeRuns([ + ...sliceRuns(this.model.runs, 0, start), + ...sliceRuns(this.model.runs, start, end).map(run => ({ ...run, format: { ...run.format, ...values } })), + ...sliceRuns(this.model.runs, end, this.text.length), + ]); + this.typing = { ...this.typing, ...values }; + this.record(); + this.render(); + this.editor.options.onTextChange(); + } + replace(runs) { + this.remember(); + const [start, end] = this.selection(); + this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), ...runs, ...sliceRuns(this.model.runs, end, this.text.length)]); + this.caret = [start + runs.reduce((length, run) => length + run.text.length, 0)]; + this.caret.push(this.caret[0]); + if (runs.length) this.typing = { ...runs.at(-1).format }; + this.record(); + this.render(); + this.editor.options.onTextChange(); + } + readInput() { + if (this.editing.composing) return; + const value = editTextValue(this.editing.input), previous = this.text; + if (value === previous) return; + const caret = editTextSelection(this.editing.input) || this.caret; + let start = 0, end = previous.length, nextEnd = value.length; + const before = this.beforeCaret; + if (before && value.startsWith(previous.slice(0, before[0])) && value.endsWith(previous.slice(before[1])) + && value.length >= previous.length - before[1] + before[0]) { + start = before[0]; end = before[1]; nextEnd = value.length - (previous.length - end); + } else { + while (start < end && start < nextEnd && previous[start] === value[start]) start++; + if (start && /[\uD800-\uDBFF]/.test(previous[start - 1])) start--; + while (end > start && nextEnd > start && previous[end - 1] === value[nextEnd - 1]) { end--; nextEnd--; } + if (end < previous.length && /[\uDC00-\uDFFF]/.test(previous[end])) { end++; nextEnd++; } + } + this.model.runs = normalizeRuns([...sliceRuns(this.model.runs, 0, start), + { text: value.slice(start, nextEnd), format: { ...this.typing } }, ...sliceRuns(this.model.runs, end, previous.length)]); + this.caret = caret; + this.beforeCaret = null; + this.record(); + this.render(); + } + copy(event, cut = false) { + if (this.editing.composing || this.editing.saving) return; + const [start, end] = this.selection(); + if (start === end || !event.clipboardData) return; + const runs = sliceRuns(this.model.runs, start, end), text = runs.map(run => run.text).join(""); + this.editor.richClipboard = { token: crypto.randomUUID(), runs, text, scope: this.editor.options.textClipboardScope?.(), + fonts: new Map(this.imports), faces: new Map(this.faces), choices: new Map(this.choices) }; + event.preventDefault(); + event.clipboardData.setData("text/plain", text); + event.clipboardData.setData(RICH_CLIPBOARD, this.editor.richClipboard.token); + if (cut) this.replace([]); + } + paste(event) { + event.preventDefault(); + if (this.editing.composing || this.editing.saving) return; + const text = event.clipboardData.getData("text/plain").replace(/\r\n?/g, "\n"); + const copy = this.editor.richClipboard; + const internal = copy && copy.scope === this.editor.options.textClipboardScope?.() && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && text === copy.text; + if (internal) { + for (const [key, data] of copy.fonts) this.imports.set(key, data); + for (const [key, face] of copy.faces) { this.faces.set(key, face); document.fonts.add(face); } + this.choices = new Map([...copy.choices, ...(this.choices || [])]); + } + this.replace(internal ? structuredClone(copy.runs) : [{ text, format: { ...this.typing } }]); + } + setFont(face, data, key) { + key ||= `temp-font-${crypto.randomUUID()}`; + this.faces.set(key, face); + if (data) this.imports.set(key, data); + document.fonts.add(face); + this.apply({ font: key }); + } + render() { + this.rendering = true; + const input = this.editing.input, fragment = document.createDocumentFragment(), positions = []; + let paragraph = document.createElement("div"), offset = 0; + fragment.append(paragraph); + for (const run of this.model.runs) { + const pieces = run.text.split("\n"), format = run.format; + for (let i = 0; i < pieces.length; i++) { + if (i) { paragraph = document.createElement("div"); fragment.append(paragraph); offset++; } + const span = document.createElement("span"), node = document.createTextNode(pieces[i]); + const face = this.faces.get(format.font) || this.editing.face; + Object.assign(span.style, { fontFamily: `"${face.family}"`, fontSize: `${format.size * PX_PER_MM}px`, color: format.color, + fontWeight: format.bold ? "700" : face.weight || "400", fontStyle: format.italic ? "italic" : face.style || "normal", + textDecoration: [format.underline && "underline", format.strikeout && "line-through"].filter(Boolean).join(" ") || "none", + textShadow: format.shadow ? "0.06em 0.06em 0 #808080" : "none", backgroundColor: format.background || "transparent" }); + span.append(node); paragraph.append(span); + positions.push({ node, start: offset, end: offset + pieces[i].length }); + offset += pieces[i].length; + } + } + if (!positions.length) { + const node = document.createTextNode(""); paragraph.append(node); + positions.push({ node, start: 0, end: 0 }); + } + input.style.fontSize = this.model.runs.length ? "0px" : `${this.typing.size * PX_PER_MM}px`; + input.style.caretColor = this.typing.color || "#000000"; + for (const line of fragment.children) if (!line.textContent) { + line.style.fontSize = line.querySelector("span")?.style.fontSize || `${this.typing.size * PX_PER_MM}px`; + line.append(document.createElement("br")); + } + input.replaceChildren(fragment); + this.editing.range = null; + input.focus({ preventScroll: true }); + restoreTextSelection(positions, this.caret); + this.rendering = false; + } + destroy() { + clearTimeout(this.previewTimer); + document.removeEventListener("selectionchange", this.selectionListener); + for (const face of this.faces.values()) document.fonts.delete(face); + } +} + export function editTextValue(input) { let text = "", previous; for (const node of input.childNodes) { @@ -1182,7 +1465,7 @@ export class CanvasEditor { } } - editText(item, face, source = null) { + editText(item, face, source = null, rich = null) { this.cancel(); this.closeText(); this.select(item); @@ -1223,33 +1506,47 @@ export class CanvasEditor { }); this.input = { input, item, face, frame, source: Boolean(source), fontChoice: item.fontChoice }; const editing = this.input; + if (rich) editing.rich = new RichTextSession(this, rich.model, rich.faces, rich.fonts); + if (rich && !rich.model.layout.lineHeight) input.style.lineHeight = "normal"; if (source) editing.history = { entries: [{ value: item.text }], index: 0 }; item.artwork?.classList.add("edit-text-source"); item.node.classList.add("edit-text-source"); item.surface.append(input); if (source) this.paintSourceText(source); input.addEventListener("input", () => { + editing.rich?.readInput(); this.recordSourceText(); this.options.onTextChange(); this.previewSourceText(); }); input.addEventListener("compositionstart", () => { + editing.rich?.remember(); + if (editing.rich) editing.rich.beforeCaret = editing.rich.selection(); this.rememberSourceSelection(); editing.composing = true; }); input.addEventListener("compositionend", () => { editing.composing = false; + editing.rich?.readInput(); this.recordSourceText(); + this.options.onTextChange(); Promise.resolve().then(() => { if (this.input === editing && editing.commitAfterComposition) this.commitText(); else this.previewSourceText(); }); }); input.addEventListener("paste", event => { + if (editing.rich) { editing.rich.paste(event); return; } event.preventDefault(); document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); }); input.addEventListener("beforeinput", event => { + if (editing.rich) { + if (event.inputType === "historyUndo" || event.inputType === "historyRedo") { + event.preventDefault(); editing.rich.undo(event.inputType === "historyUndo" ? -1 : 1); return; + } + if (!editing.composing) { editing.rich.remember(); editing.rich.beforeCaret = editing.rich.selection(); } + } if (editing.source && (event.inputType === "historyUndo" || event.inputType === "historyRedo")) { event.preventDefault(); this.restoreSourceText(event.inputType === "historyUndo" ? -1 : 1); @@ -1263,9 +1560,11 @@ export class CanvasEditor { } }); input.addEventListener("blur", (event) => { + editing.rich?.remember(); const selection = document.getSelection(); if (selection?.rangeCount && input.contains(selection.anchorNode)) editing.range = selection.getRangeAt(0).cloneRange(); - if (!this.options.textControls?.includes(event.relatedTarget)) this.commitText(); + if (!this.options.textControls?.some(control => control === event.relatedTarget || control.contains(event.relatedTarget)) + && !this.options.keepTextFocus?.() && !this.options.busy()) this.commitText(); }); input.addEventListener("focus", () => { if (editing.range) { @@ -1281,9 +1580,13 @@ export class CanvasEditor { if (event.isComposing || editing.composing || editing.saving) { return; } - if (editing.source && (event.ctrlKey || event.metaKey) && !event.altKey && ["z", "y"].includes(event.key.toLowerCase())) { + if (editing.rich && (event.ctrlKey || event.metaKey) && !event.altKey && ["b", "i", "u"].includes(event.key.toLowerCase())) { event.preventDefault(); - this.restoreSourceText(event.key.toLowerCase() === "y" || event.shiftKey ? 1 : -1); + const key = { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()]; + editing.rich.apply({ [key]: editing.rich.state()[key] !== true }); + } else if ((editing.source || editing.rich) && (event.ctrlKey || event.metaKey) && !event.altKey && ["z", "y"].includes(event.key.toLowerCase())) { + event.preventDefault(); + this.textUndo(event.key.toLowerCase() === "y" || event.shiftKey ? 1 : -1); } else if (event.key === "Escape") { event.preventDefault(); this.closeText(); @@ -1293,8 +1596,54 @@ export class CanvasEditor { this.commitText(); } }); + input.addEventListener("copy", event => editing.rich?.copy(event)); + input.addEventListener("cut", event => editing.rich?.copy(event, true)); + 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(); } + } + + textUndo(direction) { + if (this.input?.rich) this.input.rich.undo(direction); + else this.restoreSourceText(direction); + } + + promoteText(rich) { + const editing = this.input; + if (!editing || editing.rich || editing.composing || editing.saving) return false; + const caret = editTextSelection(editing.input, editing.range) || [0, 0]; + const value = editTextValue(editing.input); + const session = new RichTextSession(this, rich.model, rich.faces, rich.fonts); + const original = session.snapshot(); + const format = { ...session.typing, ...editing.style }; + if (editing.fontData) { + const key = `temp-font-${crypto.randomUUID()}`; + format.font = key; + session.imports.set(key, editing.fontData); + session.faces.set(key, editing.face); + if (editing.fontChoice) session.choices.set(key, editing.fontChoice); + } + const history = editing.history; + const values = history ? history.entries.slice(0, history.index + 1) : [{ value: editing.item.text }, { value }]; + const entries = [original]; + for (const entry of values) { + const snapshot = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: entry.value, format }]) }, + typing: { ...format }, caret: entry.caret || caret }; + if (JSON.stringify(snapshot.model) !== JSON.stringify(entries.at(-1).model)) entries.push(snapshot); + } + const current = { model: { ...structuredClone(rich.model), runs: normalizeRuns([{ text: value, format }]) }, typing: { ...format }, caret }; + if (JSON.stringify(current.model) !== JSON.stringify(entries.at(-1).model)) entries.push(current); + Object.assign(session, { entries, index: entries.length - 1, model: current.model, typing: format, caret }); + editing.rich = session; + editing.source = false; + editing.history = null; + editing.range = null; + if (!rich.model.layout.lineHeight) editing.input.style.lineHeight = "normal"; + session.render(); + session.preview(); + this.options.onTextChange(); + return true; } rememberSourceSelection() { @@ -1337,7 +1686,7 @@ export class CanvasEditor { 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 + 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; try { const source = await this.options.onPreviewText(editing.item, value, font, size); @@ -1412,8 +1761,9 @@ export class CanvasEditor { restoreTextSelection(positions, caret, editing.range); } - setTextFont(face, data) { + setTextFont(face, data, key) { const editing = this.input; + if (editing.rich) { editing.rich.setFont(face, data, key); return; } document.fonts.add(face); document.fonts.delete(editing.face); editing.face = face; @@ -1426,6 +1776,7 @@ export class CanvasEditor { setTextStyle(values) { const editing = this.input; + if (editing.rich) { editing.rich.apply(values); return; } const style = { ...editing.style, ...values }; for (const key of Object.keys(style)) if (style[key] === editing.item[key]) delete style[key]; editing.style = Object.keys(style).length ? style : null; @@ -1439,6 +1790,7 @@ export class CanvasEditor { textChanged() { const editing = this.input; if (!editing) return false; + 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)); @@ -1460,7 +1812,14 @@ export class CanvasEditor { } editing.saving = true; editing.input.contentEditable = "false"; - const saved = await this.options.onCommitText(editing.style ? { ...editing.item, ...editing.style } : editing.item, value, editing.fontData, editing.style?.color ?? null); + 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); + } catch (error) { + editing.input.setAttribute("aria-invalid", "true"); + editing.input.title = error.message; + } if (this.input !== editing) { return Boolean(saved); } @@ -1478,6 +1837,7 @@ export class CanvasEditor { const editing = this.input; this.input = null; if (editing) { + editing.rich?.destroy(); editing.input.remove(); editing.item.artwork?.classList.remove("edit-text-source"); editing.item.node.classList.remove("edit-text-source"); diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index d13a73e..e5262b7 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -103,7 +103,7 @@ export class FontPicker { if (!this.open) return; const rect = this.input.parentElement.getBoundingClientRect(); if (rect.right <= 0 || rect.left >= window.innerWidth) { this.close(); return; } - const width = Math.min(rect.width, window.innerWidth - 16); + const width = Math.min(Math.max(rect.width, 300), window.innerWidth - 16); Object.assign(this.list.style, { left: `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`, top: `${rect.bottom + 4}px`, width: `${width}px`, maxHeight: `${Math.max(40, Math.min(280, window.innerHeight - rect.bottom - 12))}px` }); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index d6d6262..5e9d969 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -271,6 +271,9 @@ func RunWASM() { registerCallback("ofdgoUpdateText", updateText) registerCallback("ofdgoPreviewText", previewText) registerCallback("ofdgoStyleText", styleText) + registerCallback("ofdgoGetRichText", getRichText) + registerCallback("ofdgoSaveRichText", saveRichText) + registerCallback("ofdgoPreviewRichText", previewRichText) registerCallback("ofdgoCheckTextFont", checkTextFont) registerCallback("ofdgoInsertImage", insertImage) registerCallback("ofdgoInsertShape", insertShape) @@ -1069,6 +1072,15 @@ func changeStructure(args []js.Value) (any, error) { return nil, err } return changeObjects(func() error { + if request.Action != "create" { + structures, err := currentEditor.Structure() + if err != nil { + return err + } + if request.Index >= 0 && request.Index < len(structures) && structures[request.Index].Internal { + return fmt.Errorf("internal text metadata must be edited through the text editor") + } + } switch request.Action { case "create": _, err := currentEditor.AddStructure(ofdgo.CustomTag{TypeID: request.TypeID, NameSpace: request.Node.Namespace}, request.Node) @@ -1625,6 +1637,11 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { item["lineHeight"] = second - first } } + if object.Type == "CompositeObject" { + if value, richErr := currentEditor.RichText(index, id); richErr == nil { + editorRichText(index, item, object, value, fontNames) + } + } objects = append(objects, item) } } @@ -2746,6 +2763,239 @@ func styleText(args []js.Value) (any, error) { }) } +// editorRichText 将富文本复合图元作为一个文本框提供给编辑器 +// 入参: page 页面索引, item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称 +func editorRichText(page int, item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string) { + if len(value.Runs) == 0 { + return + } + local, matrix, err := currentEditor.RichTextFrame(page, object.CompositeGraphicUnit.ID) + if err != nil { + return + } + width, height := local.W, local.H + frame := matrix.TransformBox(local) + format, layout := value.Runs[0].Format, value.Layout + var content strings.Builder + for _, run := range value.Runs { + content.WriteString(run.Text) + } + item["actualType"], item["type"], item["richText"] = object.Type, "TextObject", browserRichText(value) + item["text"], item["font"], item["fontName"], item["size"] = content.String(), format.Font, fonts[format.Font], format.Size + item["color"] = richTextColorHex(format.Color) + item["bold"], item["italic"], item["underline"], item["strikeout"], item["shadow"] = format.Bold, format.Italic, format.Underline, format.Strikeout, format.Shadow + item["background"] = "" + if format.Background != "" { + item["background"] = richTextColorHex(format.Background) + } + item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight + item["letterSpacing"], item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LetterSpacing, layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent + item["paragraphWidth"], item["bounds"] = width, editorBox(frame) + item["x"], item["y"], item["width"], item["height"] = frame.X, frame.Y, frame.W, frame.H + item["textFrame"] = map[string]any{"width": width, "height": height, "matrix": editorMatrix(matrix)} + capabilities := item["capabilities"].(map[string]any) + for _, key := range []string{"textContent", "rewriteText", "replaceFont", "reflow", "layoutKnown", "paint"} { + capabilities[key] = true + } + capabilities["enter"], capabilities["ungroup"] = false, false + capabilities["stretch"] = false +} + +// getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式 +// 入参: args 页码和对象标识 +// 返回: any 富文本快照, error 错误信息 +func getRichText(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + value, err := currentEditor.RichText(args[0].Int(), args[1].String()) + if err != nil { + return nil, err + } + return browserRichText(value), nil +} + +// browserRichText 提供独立富文本快照和浏览器颜色,不改变库层样式 +// 入参: value 库层富文本 +// 返回: map[string]any 文字分段和段落选项 +func browserRichText(value ofdgo.RichText) map[string]any { + runs := make([]any, len(value.Runs)) + for i, run := range value.Runs { + format := run.Format + background := "" + if format.Background != "" { + background = richTextColorHex(format.Background) + } + runs[i] = map[string]any{"text": run.Text, "format": map[string]any{ + "font": format.Font, "size": format.Size, "color": richTextColorHex(format.Color), + "bold": format.Bold, "italic": format.Italic, "underline": format.Underline, + "strikeout": format.Strikeout, "shadow": format.Shadow, "background": background, + }} + } + layout := value.Layout + return map[string]any{"runs": runs, "layout": map[string]any{ + "autoBidi": layout.AutoBidi, "shape": layout.Shape, + "shaping": map[string]any{ + "Bidi": layout.Shaping.Bidi, "Direction": layout.Shaping.Direction, + "Script": layout.Shaping.Script, "Language": layout.Shaping.Language, "Features": layout.Shaping.Features, + }, + "wrap": layout.Wrap, "align": layout.Align, "lineHeight": layout.LineHeight, + "letterSpacing": layout.LetterSpacing, "leftIndent": layout.LeftIndent, + "rightIndent": layout.RightIndent, "firstLineIndent": layout.FirstLineIndent, + }} +} + +// richTextColorHex 将富文本的RGB分量转换为浏览器颜色,不套用文档颜色空间 +// 入参: value RGB分量 +// 返回: string 十六进制颜色 +func richTextColorHex(value string) string { + var r, g, b int + fmt.Sscan(value, &r, &g, &b) + return fmt.Sprintf("#%02x%02x%02x", r, g, b) +} + +// readRichText 读取浏览器富文本并转换显式RGB颜色 +// 入参: value 富文本JSON +// 返回: ofdgo.RichText 库层快照, error 数据错误 +func readRichText(value js.Value) (ofdgo.RichText, error) { + var text ofdgo.RichText + if err := json.Unmarshal([]byte(value.String()), &text); err != nil { + return text, err + } + for i := range text.Runs { + format := &text.Runs[i].Format + for _, field := range []*string{&format.Color, &format.Background} { + if *field == "" { + continue + } + if len(*field) != 7 || (*field)[0] != '#' { + return text, fmt.Errorf("invalid RGB color %q", *field) + } + rgb, err := hex.DecodeString((*field)[1:]) + if err != nil { + return text, err + } + *field = fmt.Sprintf("%d %d %d", rgb[0], rgb[1], rgb[2]) + } + } + return text, nil +} + +// saveRichText 原子保存文字、样式和新增字体,空标识创建文本框 +// 入参: args 页码、标识、富文本JSON、新建边界和可选字体集合 +// 返回: any 文档信息及文字选区, error 错误信息 +func saveRichText(args []js.Value) (any, error) { + value, err := readRichText(args[2]) + if err != nil { + return nil, err + } + page, id := args[0].Int(), args[1].String() + result, err := changeObjects(func() error { + if len(args) > 4 { + if err := importRichTextFonts(&value, args[4]); err != nil { + return err + } + } + if id != "" { + if err := currentEditor.UpdateRichText(page, id, value); err != nil { + return err + } + box := args[3] + if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == js.TypeNumber { + offset := 0.0 + if box.Get("frameOffset").Type() == js.TypeNumber { + offset = box.Get("frameOffset").Float() + } + return currentEditor.ResizeRichTextFrame(page, id, offset, box.Get("frameWidth").Float()) + } + return nil + } + box := args[3] + id, err = currentEditor.AddRichText(page, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value) + return err + }) + if err != nil { + return nil, err + } + info, ok := result.(editorInfo) + if !ok { + return result, nil + } + return editorSelectionInfo{editorInfo: info, SelectedIDs: []string{id}}, nil +} + +// importRichTextFonts 在当前事务内注册实际使用的新字体并替换临时标识 +// 入参: value 富文本快照, fonts 浏览器字体集合 +// 返回: error 数据或字体错误 +func importRichTextFonts(value *ofdgo.RichText, fonts js.Value) error { + if fonts.IsNull() || fonts.IsUndefined() { + return nil + } + registered := make(map[string]string) + for i := 0; i < fonts.Length(); i++ { + entry := fonts.Index(i) + key := entry.Get("key").String() + if key == "" || registered[key] != "" { + return fmt.Errorf("invalid rich text font key") + } + used := false + for _, run := range value.Runs { + used = used || run.Format.Font == key + } + if !used { + continue + } + data, err := bytesFromJS(entry.Get("data")) + if err != nil { + return err + } + font, err := currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + registered[key] = font + } + for i := range value.Runs { + if font := registered[value.Runs[i].Format.Font]; font != "" { + value.Runs[i].Format.Font = font + } + } + return nil +} + +// previewRichText 使用库层排版校验富文本并测量边界,临时资源及历史始终回滚 +// 入参: args 页码、对象标识、富文本JSON、局部宽度和可选字体集合 +// 返回: any 局部边界, error 排版错误 +func previewRichText(args []js.Value) (any, error) { + if currentEditor == nil { + return nil, fmt.Errorf("no document is being edited") + } + value, err := readRichText(args[2]) + if err != nil { + return nil, err + } + var result map[string]any + finished := errors.New("rich text preview complete") + err = currentEditor.Transaction(func(editor *ofdgo.Editor) error { + if len(args) > 4 { + if err := importRichTextFonts(&value, args[4]); err != nil { + return err + } + } + width := args[3].Float() + _, bounds, err := editor.LayoutRichText(value, width) + if err != nil { + return err + } + result = map[string]any{"x": bounds.X, "y": bounds.Y, "width": bounds.W, "height": bounds.H} + return finished + }) + if !errors.Is(err, finished) { + return nil, err + } + return result, nil +} + // setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度 // 入参: object 文字对象, value 十六进制色值 // 返回: error 错误信息 diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 44b0c29..4ee88a2 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -74,6 +74,7 @@ type Editor struct { encryption *encryptionState output *outputOptimization validatedPaths *renderCache[string, bool] + richIndex *richTextIndex } // editorResource 文档内嵌资源 @@ -588,6 +589,7 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) { } object, err := cloneEditorObject(layer.Objects[index]) object.origin = e.objectOrigin(id) + e.attachRichTextSnapshot(page, &object) return object, err } diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go index 98f1f1c..5037077 100644 --- a/ofdgo_editor_batch.go +++ b/ofdgo_editor_batch.go @@ -66,6 +66,7 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) { return nil, err } objects[i].origin = e.objectOrigin(editorObjectID(object)) + e.attachRichTextSnapshot(page, &objects[i]) } return objects, nil } @@ -82,6 +83,45 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64) // 入参: ctx 取消上下文, page 目标页, objects 对象快照, dx 横向位移,单位为毫米, dy 纵向位移,单位为毫米 // 返回: []string 新对象标识, error 校验或取消错误 func (e *Editor) CopyObjectsContext(ctx context.Context, page int, objects []GraphicObject, dx, dy float64) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + metadata, err := e.copiedRichTextMetadata(objects) + if err != nil { + return nil, err + } + if len(metadata) == 0 { + return e.copyObjectsContext(ctx, page, objects, dx, dy) + } + var ids []string + err = e.Transaction(func(e *Editor) error { + var err error + ids, err = e.copyObjectsContext(ctx, page, objects, dx, dy) + if err != nil { + return err + } + for i := range objects { + if meta, ok := metadata[i]; ok { + if err := ctx.Err(); err != nil { + return err + } + if err := e.putRichTextMetadata(page, ids[i], meta, -1); err != nil { + return err + } + } + } + return ctx.Err() + }) + if err != nil { + return nil, err + } + return ids, nil +} + +// copyObjectsContext 复制标准对象及资源来源,由上层按需同步富文本标引。 +// 入参: ctx 取消上下文, page 目标页, objects 对象快照, dx 横向位移, dy 纵向位移 +// 返回: []string 新对象标识, error 校验或取消错误 +func (e *Editor) copyObjectsContext(ctx context.Context, page int, objects []GraphicObject, dx, dy float64) ([]string, error) { if err := ctx.Err(); err != nil { return nil, err } @@ -456,6 +496,43 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error { // 入参: page 页面索引, ids 对象标识 // 返回: error 错误信息 func (e *Editor) DeleteObjects(page int, ids []string) error { + if _, _, err := e.selectedObjects(page, ids); err != nil { + return err + } + entries := make(map[int]bool) + if e.source != nil { + index := e.richTextMetadataIndex() + for _, id := range ids { + if entry, ok := index.entries[editorResourceID(e.pages[page].ID)+"/"+editorResourceID(id)]; ok { + entries[entry.index] = true + } + } + } + if len(entries) == 0 { + return e.deleteObjects(page, ids) + } + order := make([]int, 0, len(entries)) + for index := range entries { + order = append(order, index) + } + slices.Sort(order) + return e.Transaction(func(e *Editor) error { + if err := e.deleteObjects(page, ids); err != nil { + return err + } + for _, index := range slices.Backward(order) { + if err := e.DeleteStructure(index); err != nil { + return err + } + } + return nil + }) +} + +// deleteObjects 删除已选择图元,由上层同步必要的富文本标引。 +// 入参: page 页面索引, ids 对象标识 +// 返回: error 校验或删除错误 +func (e *Editor) deleteObjects(page int, ids []string) error { objects, indexes, err := e.selectedObjects(page, ids) if err != nil || len(indexes) == 0 { return err diff --git a/ofdgo_editor_font_scan.go b/ofdgo_editor_font_scan.go index ff85b5f..487638f 100644 --- a/ofdgo_editor_font_scan.go +++ b/ofdgo_editor_font_scan.go @@ -109,6 +109,15 @@ func scanEditorFontFiles(ctx context.Context, reader *Reader, index int, inspect return nil } if slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) { + if node.matchesOFD("CustomTags") { + safe, err := richTextTagsFontSafe(ctx, reader, index) + if err != nil { + return err + } + if safe { + return nil + } + } scan.unsafe = true return nil } diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index 71c6da1..03ad18d 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -246,8 +246,18 @@ func (e *Editor) ImportObjectsContext(ctx context.Context, source *Editor, sourc imported[i].origin = &editorObjectOrigin{editor: editor, document: editor.documentIdentity(), data: data, node: root.child("Layer").children[0], object: object} } copied, err = editor.CopyObjectsContext(ctx, page, imported, options.DX, options.DY) - return err + if err != nil { + return err + } + orderedIDs := make([]string, len(objects)) + for i, object := range objects { + orderedIDs[i] = editorObjectID(object) + } + return editor.copyRichTextMetadata(source, sourcePage, orderedIDs, page, copied) }) + if err != nil { + return nil, err + } return copied, err } diff --git a/ofdgo_editor_rich_text.go b/ofdgo_editor_rich_text.go new file mode 100644 index 0000000..83d4bed --- /dev/null +++ b/ofdgo_editor_rich_text.go @@ -0,0 +1,736 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image/color" + "io" + "reflect" + "strconv" +) + +// richTextTagType 标识富文本编辑标引,正文使用标准复合资源和图元。 +const richTextTagType = "OFDGo.RichText" + +// richTextNamespace 标识富文本编辑语义的命名空间,版本仅随不兼容的语义变更升级。 +const richTextNamespace = "https://ofdgo.aite.me/ns/rich-text/1" + +// richTextMetadata 不保存字体编号,以标准主文字对象序号恢复当前文档字体。 +type richTextMetadata struct { + Version int `json:"version"` + Value RichText `json:"value"` + Frame Box `json:"frame"` + Extent Box `json:"extent"` + Offset [2]float64 `json:"offset"` + Anchors []int `json:"anchors"` + Digest string `json:"digest"` +} + +// richTextEntry 保存标引入口及延迟报告的损坏诊断。 +type richTextEntry struct { + index int + metadata richTextMetadata + err error +} + +// richTextIndex 缓存当前文档修订的标引,不创建页面预览快照。 +type richTextIndex struct { + source *editorSource + revision uint64 + entries map[string]richTextEntry + err error +} + +// AddRichText 原子添加标准组合及持久化编辑标引,成功时只产生一条历史。 +// 入参: page 页面索引, box 页面编辑框, value 富文本内容 +// 返回: string 组合对象标识, error 排版、资源或持久化错误 +func (e *Editor) AddRichText(page int, box Box, value RichText) (string, error) { + var id string + err := e.Transaction(func(e *Editor) error { + if _, err := e.page(page); err != nil { + return err + } + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + object, meta, err := e.prepareRichText(box, value) + if err != nil { + return err + } + id, err = e.AddObject(page, object) + if err != nil { + return err + } + return e.putRichTextMetadata(page, id, meta, -1) + }) + if err != nil { + return "", err + } + return id, nil +} + +// UpdateRichText 原子替换富文本内容,保留对象标识、局部编辑框及页面变换。 +// 安全普通文字显式重排后提升为组合;不修改共享的旧组合资源。 +// 入参: page 页面索引, id 对象标识, value 完整富文本 +// 返回: error 识别、排版或提交错误 +func (e *Editor) UpdateRichText(page int, id string, value RichText) error { + return e.updateRichTextFrame(page, id, value, 0, 0) +} + +// ResizeRichTextFrame 调整局部左边界与段落宽度并原子重排,保留页面方向和缩放。 +// 入参: page 页面索引, id 对象标识, offset 局部横向偏移, width 新的局部段落宽度 +// 返回: error 尺寸、排版或提交错误 +func (e *Editor) ResizeRichTextFrame(page int, id string, offset, width float64) error { + if !finite(offset) || !finite(width) || width <= 0 { + return fmt.Errorf("rich text width must be positive and finite") + } + value, err := e.RichText(page, id) + if err != nil { + return err + } + return e.updateRichTextFrame(page, id, value, offset, width) +} + +// updateRichTextFrame 统一提交正文或编辑框变化,width为零时保持原始宽度。 +// 入参: page 页面索引, id 对象标识, value 新内容, offset 局部偏移, width 可选宽度 +// 返回: error 校验或提交错误 +func (e *Editor) updateRichTextFrame(page int, id string, value RichText, offset, width float64) error { + return e.Transaction(func(e *Editor) error { + old, err := e.Object(page, id) + if err != nil { + return err + } + current, err := e.RichText(page, id) + if err != nil { + return err + } + frame, matrix, err := e.RichTextFrame(page, id) + if err != nil { + return err + } + if err := value.Validate(); err != nil { + return err + } + if old.Type == "CompositeObject" && offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) { + return nil + } + if width > 0 { + frame.W = width + matrix = matrix.Multiply(TranslationMatrix(offset, 0)) + } + object, meta, err := e.prepareRichText(Box{W: frame.W, H: frame.H}, value) + if err != nil { + return err + } + object.CompositeGraphicUnit.ID = id + placement := richTextPlacement(meta, matrix) + object.CompositeGraphicUnit.Boundary = placement.Boundary + object.CompositeGraphicUnit.CTM = placement.CTM + if old.Type == "CompositeObject" { + unit := old.CompositeGraphicUnit + if unit.Clips != nil { + return fmt.Errorf("clipped rich text requires explicit clip removal before reflow") + } + unit.ResourceID = object.CompositeGraphicUnit.ResourceID + unit.Boundary = placement.Boundary + unit.CTM = placement.CTM + object.CompositeGraphicUnit = unit + } + index := -1 + if old.Type == "CompositeObject" { + entry, err := e.richTextEntry(page, id) + if err != nil { + return err + } + index = entry.index + } + if err := e.updateObjects(page, []GraphicObject{object}, true); err != nil { + return err + } + return e.putRichTextMetadata(page, id, meta, index) + }) +} + +// DeleteRichText 原子删除富文本及其标引,不删除其他实例引用的共享资源。 +// 入参: page 页面索引, id 对象标识 +// 返回: error 识别或删除错误 +func (e *Editor) DeleteRichText(page int, id string) error { + return e.Transaction(func(e *Editor) error { + object, err := e.Object(page, id) + if err != nil { + return err + } + if _, err = e.RichText(page, id); err != nil { + return err + } + if object.Type == "CompositeObject" { + entry, err := e.richTextEntry(page, id) + if err != nil { + return err + } + if err := e.DeleteStructure(entry.index); err != nil { + return err + } + } + return e.DeleteObject(page, id) + }) +} + +// RichText 返回独立的富文本编辑值,未标引组合返回ErrNotRichText,损坏标引返回诊断。 +// 普通文字仅在定位和外观可以安全提升时返回单片段,否则明确拒绝。 +// 入参: page 页面索引, id 对象标识 +// 返回: RichText 编辑内容, error 识别或持久化错误 +func (e *Editor) RichText(page int, id string) (RichText, error) { + object, err := e.Object(page, id) + if err != nil { + return RichText{}, err + } + if object.Type == "TextObject" { + return e.promoteRichText(page, object) + } + if object.Type != "CompositeObject" { + return RichText{}, ErrNotRichText + } + entry, err := e.richTextEntry(page, id) + if err != nil { + return RichText{}, err + } + unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID) + if err != nil { + return RichText{}, fmt.Errorf("rich text resource: %w", err) + } + objects := unit.object.CompositeGraphicUnit.Objects + if richTextDigest(objects) != entry.metadata.Digest { + return RichText{}, fmt.Errorf("rich text content no longer matches its editing metadata") + } + value := cloneEditorData(entry.metadata.Value) + if len(entry.metadata.Anchors) != len(value.Runs) { + return RichText{}, fmt.Errorf("invalid rich text font anchors") + } + for i, anchor := range entry.metadata.Anchors { + if anchor < 0 || anchor >= len(objects) || objects[anchor].Type != "TextObject" { + return RichText{}, fmt.Errorf("invalid rich text font anchor %d", i) + } + value.Runs[i].Format.Font = objects[anchor].TextObject.Font + } + if err := value.Validate(); err != nil { + return RichText{}, fmt.Errorf("invalid rich text metadata: %w", err) + } + return value, nil +} + +// RichTextFrame 返回原始局部编辑框及局部到页面的完整变换,不按组合缩放重新解释宽度。 +// 入参: page 页面索引, id 富文本或安全普通文字对象标识 +// 返回: Box 局部编辑框, Matrix 页面变换, error 识别或几何错误 +func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) { + object, err := e.Object(page, id) + if err != nil { + return Box{}, Matrix{}, err + } + if object.Type == "TextObject" { + if _, err := e.promoteRichText(page, object); err != nil { + return Box{}, Matrix{}, err + } + frame, err := object.TextObject.TextFrame() + if err != nil { + return Box{}, Matrix{}, err + } + box, _ := creationBox(object.TextObject.Boundary) + dx, dy, err := e.richPlainOffset(object.TextObject) + if err != nil { + return Box{}, Matrix{}, err + } + return Box{W: frame.W, H: frame.H}, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.TextObject.CTM)).Multiply(TranslationMatrix(frame.X+dx, frame.Y+dy)), nil + } + if object.Type != "CompositeObject" { + return Box{}, Matrix{}, ErrNotRichText + } + entry, err := e.richTextEntry(page, id) + if err != nil { + return Box{}, Matrix{}, err + } + box, err := creationBox(object.CompositeGraphicUnit.Boundary) + if err != nil { + return Box{}, Matrix{}, err + } + return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil +} + +// prepareRichText 构造独立组合资源和不含字体编号的编辑标引。 +// 入参: box 页面或局部编辑框, value 富文本 +// 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误 +func (e *Editor) prepareRichText(box Box, value RichText) (GraphicObject, richTextMetadata, error) { + if err := value.Validate(); err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + if box.W <= 0 || box.H <= 0 { + return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text frame must be positive") + } + result, err := e.layoutRichText(value, box.W) + if err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + padX, padY := -min(0, result.bounds.X), -min(0, result.bounds.Y) + extent := Box{W: max(box.W, result.bounds.X+result.bounds.W) + padX, H: max(box.H, result.bounds.Y+result.bounds.H) + padY} + if !finite(extent.W) || !finite(extent.H) || extent.W <= 0 || extent.H <= 0 { + return GraphicObject{}, richTextMetadata{}, fmt.Errorf("rich text extent exceeds finite range") + } + if err := e.prepareSourceIDs(); err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + for i := range result.objects { + if padX != 0 || padY != 0 { + result.objects[i] = transformEditorMatrix(result.objects[i], TranslationMatrix(padX, padY)) + } + if err := e.richTextColors(&result.objects[i]); err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + } + resource, err := e.addOwnedVector(context.Background(), result.objects, extent.W, extent.H) + if err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + unit, err := e.compositeDefinition(resource) + if err != nil { + return GraphicObject{}, richTextMetadata{}, err + } + meta := richTextMetadata{Version: 1, Value: cloneEditorData(value), Frame: Box{W: box.W, H: max(box.H, result.bounds.Y+result.bounds.H)}, Extent: extent, Offset: [2]float64{padX, padY}, Anchors: result.anchors, Digest: richTextDigest(unit.object.CompositeGraphicUnit.Objects)} + for i := range meta.Value.Runs { + meta.Value.Runs[i].Format.Font = "" + } + object := GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: richTextPlacement(meta, TranslationMatrix(box.X, box.Y))} + object.CompositeGraphicUnit.ResourceID = resource + return object, meta, nil +} + +// richTextPlacement 根据局部逻辑坐标确定物理组合边界,负向装饰不会被资源裁剪。 +// 入参: meta 编辑框与资源尺寸, matrix 逻辑坐标到页面变换 +// 返回: CompositeGraphicUnit 组合几何 +func richTextPlacement(meta richTextMetadata, matrix Matrix) CompositeGraphicUnit { + matrix = matrix.Multiply(TranslationMatrix(-meta.Offset[0], -meta.Offset[1])) + box := matrix.TransformBox(meta.Extent) + return CompositeGraphicUnit{Boundary: editorBoxString(box), CTM: TranslationMatrix(-box.X, -box.Y).Multiply(matrix).String()} +} + +// richTextDigest 对已编码图元计算不含字体、颜色空间和字形编号的内容摘要。 +// 入参: objects 标准子对象 +// 返回: string 稳定内容摘要 +func richTextDigest(objects []GraphicObject) string { + copy := cloneEditorData(objects) + for i := range copy { + o := ©[i] + o.origin = nil + o.state = editorCompositeState{} + o.TextObject.ID = "" + o.TextObject.Font = "" + o.TextObject.CGTransform = nil + o.TextObject.layout = nil + o.PathObject.ID = "" + if o.TextObject.FillColor != nil { + o.TextObject.FillColor.ColorSpace = "" + } + if o.TextObject.StrokeColor != nil { + o.TextObject.StrokeColor.ColorSpace = "" + } + if o.PathObject.FillColor != nil { + o.PathObject.FillColor.ColorSpace = "" + } + if o.PathObject.StrokeColor != nil { + o.PathObject.StrokeColor.ColorSpace = "" + } + } + data, _ := json.Marshal(copy) + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} + +// richTextEntry 从当前文档标引缓存定位对象,损坏项不降级为普通组合。 +// 入参: page 页面索引, id 组合标识 +// 返回: richTextEntry 标引, error 缺失或损坏错误 +func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) { + index := e.richTextMetadataIndex() + if index.err != nil { + return richTextEntry{}, index.err + } + entry, ok := index.entries[editorResourceID(e.pages[page].ID)+"/"+editorResourceID(id)] + if !ok { + return richTextEntry{}, ErrNotRichText + } + if entry.err != nil { + return entry, entry.err + } + return entry, nil +} + +// richTextMetadataIndex 按来源和修订缓存标引,仅读取标引文件,不生成Reader快照。 +// 返回: *richTextIndex 只读缓存 +func (e *Editor) richTextMetadataIndex() *richTextIndex { + if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision { + return index + } + index := &richTextIndex{source: e.source, revision: e.revision, entries: make(map[string]richTextEntry)} + e.richIndex = index + if e.source == nil { + return index + } + tags, err := e.source.reader.CustomTags() + if err != nil { + index.err = err + return index + } + for i, tag := range tags { + if tag.TypeID != richTextTagType || tag.NameSpace != richTextNamespace { + continue + } + structure, err := e.source.reader.readStructureContext(context.Background(), tag) + if err != nil { + index.err = fmt.Errorf("rich text metadata: %w", err) + return index + } + root := structure.Root + entry := richTextEntry{index: i} + if root.Name != "RichText" || root.Namespace != richTextNamespace { + entry.err = fmt.Errorf("invalid rich text metadata root") + } else { + entry.err = json.Unmarshal([]byte(root.Text), &entry.metadata) + } + if entry.err == nil && (entry.metadata.Version != 1 || entry.metadata.Frame.X != 0 || entry.metadata.Frame.Y != 0 || !finite(entry.metadata.Frame.W) || !finite(entry.metadata.Frame.H) || entry.metadata.Frame.W <= 0 || entry.metadata.Frame.H <= 0 || len(entry.metadata.Digest) != 64) { + entry.err = fmt.Errorf("invalid rich text metadata version or frame") + } + if entry.err == nil { + meta := entry.metadata + if meta.Extent.X != 0 || meta.Extent.Y != 0 || !finite(meta.Extent.W) || !finite(meta.Extent.H) || meta.Extent.W <= 0 || meta.Extent.H <= 0 || !finite(meta.Offset[0]) || !finite(meta.Offset[1]) || meta.Offset[0] < 0 || meta.Offset[1] < 0 || meta.Offset[0] > meta.Extent.W || meta.Offset[1] > meta.Extent.H || len(meta.Anchors) != len(meta.Value.Runs) { + entry.err = fmt.Errorf("invalid rich text metadata extent or anchors") + } + } + for _, child := range root.Children { + for _, ref := range child.References { + for _, id := range ref.ObjectIDs { + key := editorResourceID(ref.PageID) + "/" + editorResourceID(id) + if _, exists := index.entries[key]; exists { + entry.err = fmt.Errorf("ambiguous rich text metadata") + } + index.entries[key] = entry + } + } + } + } + return index +} + +// putRichTextMetadata 添加或替换带有标准标引引用的富文本语义。 +// 入参: page 页面索引, id 对象标识, meta 编辑信息, index 原入口或-1 +// 返回: error 编码或提交错误 +func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata, index int) error { + data, err := json.Marshal(meta) + if err != nil { + return err + } + root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)} + if index >= 0 { + return e.UpdateStructureNode(index, nil, root) + } + root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}} + _, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root) + return err +} + +// promoteRichText 仅提升已有明确排版或可验证单行定位的普通文字。 +// 入参: page 页面索引, object 普通文字快照 +// 返回: RichText 单片段, error 不安全提升错误 +func (e *Editor) promoteRichText(page int, object GraphicObject) (RichText, error) { + t := object.TextObject + unsupported := func() (RichText, error) { + return RichText{}, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("text cannot be safely promoted to rich text")} + } + layer, _, err := e.findObject(page, t.ID) + if err != nil { + return RichText{}, err + } + if layer.DrawParam != "" || t.DrawParam != "" || t.Clips != nil || len(t.Actions) != 0 || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.Decoration != "" || t.VScale != 0 || t.HScale != 0 && t.HScale != 1 || t.ReadDirection != 0 || t.CharDirection != 0 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 { + return unsupported() + } + if origin := e.objectOrigin(t.ID); origin != nil && !editorXMLSupported(origin.node) { + return unsupported() + } + frame, err := t.TextFrame() + if err != nil { + return unsupported() + } + if frame.X != 0 || frame.Y != 0 || t.CTM != "" && NewMatrix(t.CTM) != (IdentityMatrix) { + return unsupported() + } + value, layout := t.TextLayout() + if t.layout == nil { + if len(t.TextCode) != 1 || len(t.CGTransform) != 0 { + return unsupported() + } + check := t + if err := e.LayoutText(&check, value, TextLayout{}); err != nil { + return RichText{}, err + } + actual, expected := t.TextCode[0], check.TextCode[0] + if actual.Index != "" || len(textCodeRunes(actual.Value)) != len(textCodeRunes(expected.Value)) { + return unsupported() + } + if actual.DeltaX != "" && !reflect.DeepEqual(parseFloatsWithG(actual.DeltaX), parseFloatsWithG(expected.DeltaX)) { + return unsupported() + } + for _, dy := range parseFloatsWithG(actual.DeltaY) { + if dy != 0 { + return unsupported() + } + } + if _, _, err := e.richPlainOffset(t); err != nil { + return unsupported() + } + } + color := "0 0 0" + if t.FillColor != nil { + c := t.FillColor + if c.Pattern != nil || c.AxialShd != nil || c.RadialShd != nil || c.GouraudShd != nil || c.LaGouraudShd != nil || c.Alpha != nil && *c.Alpha != 255 || c.unsupported { + return unsupported() + } + resolved, err := e.Color(c) + if err != nil { + return RichText{}, err + } + r, g, b, a := resolved.RGBA() + if a != 65535 { + return unsupported() + } + color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8) + } + result := RichText{Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout} + return result, result.Validate() +} + +// richPlainOffset 保存可提升单行原文的基线偏移,不推断复杂定位布局。 +// 入参: text 已校验的普通文字 +// 返回: float64 横向偏移, float64 基线偏移, error 无效坐标 +func (e *Editor) richPlainOffset(text TextObject) (float64, float64, error) { + if text.layout != nil { + return 0, 0, nil + } + if len(text.TextCode) != 1 { + return 0, 0, fmt.Errorf("positioned text requires explicit layout") + } + check := text + if err := e.LayoutText(&check, text.Text(), TextLayout{}); err != nil { + return 0, 0, err + } + x, err := strconv.ParseFloat(text.TextCode[0].X, 64) + if err != nil || !finite(x) { + return 0, 0, fmt.Errorf("invalid text origin") + } + y, err := strconv.ParseFloat(text.TextCode[0].Y, 64) + if err != nil || !finite(y) { + return 0, 0, fmt.Errorf("invalid text baseline") + } + baseline, err := strconv.ParseFloat(check.TextCode[0].Y, 64) + if err != nil { + return 0, 0, err + } + return x, y - baseline, nil +} + +// copyRichTextMetadata 复制已识别组合的语义,使用目标标准子对象的字体引用。 +// 入参: source 来源编辑器, sourcePage 来源页, ids 来源标识, page 目标页, copied 目标标识 +// 返回: error 元数据损坏或提交错误 +func (e *Editor) copyRichTextMetadata(source *Editor, sourcePage int, ids []string, page int, copied []string) error { + for i, id := range ids { + object, err := source.Object(sourcePage, id) + if err != nil { + return err + } + if object.Type != "CompositeObject" { + continue + } + entry, err := source.richTextEntry(sourcePage, id) + if errors.Is(err, ErrNotRichText) { + continue + } + if err != nil { + return err + } + if _, err := source.RichText(sourcePage, id); err != nil { + return err + } + if err := e.putRichTextMetadata(page, copied[i], entry.metadata, -1); err != nil { + return err + } + } + return nil +} + +// richTextColors 为自产RGB颜色指定文档内有效颜色空间,不改变默认颜色空间。 +// 入参: object 排版生成的标准图元 +// 返回: error 颜色或资源错误 +func (e *Editor) richTextColors(object *GraphicObject) error { + var colors []*FillColor + switch object.Type { + case "TextObject": + colors = []*FillColor{object.TextObject.FillColor, (*FillColor)(object.TextObject.StrokeColor)} + case "PathObject": + colors = []*FillColor{object.PathObject.FillColor, (*FillColor)(object.PathObject.StrokeColor)} + default: + return fmt.Errorf("rich text layout must emit standard text or path objects") + } + for _, paint := range colors { + if paint == nil { + continue + } + var r, g, b float64 + if _, err := fmt.Sscan(paint.Value, &r, &g, &b); err != nil { + return err + } + rgb, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) + if err != nil { + return err + } + paint.ColorSpace = rgb.ColorSpace + } + return nil +} + +// copiedRichTextMetadata 检查当前文档对象快照的标引,不将外部同号对象误认成本地对象。 +// 入参: objects 待复制快照 +// 返回: map[int]richTextMetadata 需要复制的语义, error 损坏诊断 +func (e *Editor) copiedRichTextMetadata(objects []GraphicObject) (map[int]richTextMetadata, error) { + result := make(map[int]richTextMetadata) + for i, object := range objects { + if object.Type != "CompositeObject" { + continue + } + if object.origin != nil && e.snapshotOrigin(object) == nil { + continue + } + if object.richText != nil { + unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID) + if err != nil { + return nil, err + } + if richTextDigest(unit.object.CompositeGraphicUnit.Objects) != object.richText.Digest { + return nil, fmt.Errorf("rich text snapshot resource has changed") + } + result[i] = cloneEditorData(*object.richText) + continue + } + index := e.richTextMetadataIndex() + if index.err != nil { + return nil, index.err + } + if len(index.entries) == 0 { + continue + } + id := editorObjectID(object) + for page := range e.pages { + entry, err := e.richTextEntry(page, id) + if errors.Is(err, ErrNotRichText) { + continue + } + if err != nil { + return nil, err + } + current, err := e.Object(page, id) + if err != nil { + continue + } + if current.CompositeGraphicUnit.ResourceID != object.CompositeGraphicUnit.ResourceID { + continue + } + if _, err := e.RichText(page, id); err != nil { + return nil, err + } + result[i] = entry.metadata + break + } + } + return result, nil +} + +// attachRichTextSnapshot 为普通对象快照附加不可变编辑语义,复制后不依赖原对象继续存在。 +// 入参: page 页面索引, object 对象快照 +func (e *Editor) attachRichTextSnapshot(page int, object *GraphicObject) { + object.richText = nil + if object.Type != "CompositeObject" { + return + } + entry, err := e.richTextEntry(page, editorObjectID(*object)) + if err != nil { + return + } + meta := cloneEditorData(entry.metadata) + object.richText = &meta +} + +// richTextTagsFontSafe 仅允许完整识别且不含资源编号的本库标引通过字体重绑定检查。 +// 入参: ctx 取消上下文, reader 包阅读器, index 文档索引 +// 返回: bool 是否可以排除字体引用, error 读取或取消错误 +func richTextTagsFontSafe(ctx context.Context, reader *Reader, index int) (bool, error) { + view, err := reader.DocumentContext(ctx, index) + if err != nil { + return false, err + } + defer view.Close() + tags, err := view.CustomTagsContext(ctx) + if err != nil { + return false, err + } + for _, tag := range tags { + if tag.TypeID != richTextTagType || tag.NameSpace != richTextNamespace || tag.SchemaLoc != "" { + return false, nil + } + structure, err := view.readStructureContext(ctx, tag) + if err != nil { + return false, err + } + root := structure.Root + if root.Name != "RichText" || root.Namespace != richTextNamespace || len(root.Attributes) != 0 { + return false, nil + } + var meta richTextMetadata + decoder := json.NewDecoder(bytes.NewBufferString(root.Text)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&meta); err != nil { + return false, nil + } + if decoder.Decode(new(any)) != io.EOF || meta.Version != 1 { + return false, nil + } + for i := range meta.Value.Runs { + if meta.Value.Runs[i].Format.Font != "" { + return false, nil + } + meta.Value.Runs[i].Format.Font = "1" + } + if err := meta.Value.Validate(); err != nil { + return false, nil + } + for _, child := range root.Children { + if child.Name != "ObjectRef" || child.Namespace != richTextNamespace || len(child.Children) != 0 || len(child.Attributes) != 1 || child.Attributes[0].Namespace != "" || child.Attributes[0].Name != "PageRef" { + return false, nil + } + } + } + return true, ctx.Err() +} diff --git a/ofdgo_page.go b/ofdgo_page.go index ce9094e..0a30e0a 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -63,6 +63,7 @@ type GraphicObject struct { CompositeGraphicUnit CompositeGraphicUnit origin *editorObjectOrigin state editorCompositeState + richText *richTextMetadata } // Clips 相对于对象外接矩形的裁剪集合 diff --git a/ofdgo_rich_layout.go b/ofdgo_rich_layout.go new file mode 100644 index 0000000..9872976 --- /dev/null +++ b/ofdgo_rich_layout.go @@ -0,0 +1,613 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "bytes" + "fmt" + "math" + "slices" + "strconv" + "strings" + + "github.com/go-text/typesetting/di" + typefont "github.com/go-text/typesetting/font" + "github.com/go-text/typesetting/shaping" + basefont "github.com/xiaoqidun/ofdgo/internal/base/font" + "golang.org/x/text/unicode/bidi" +) + +// richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号。 +type richLayout struct { + objects []GraphicObject + bounds Box + anchors []int +} + +// richMetrics 保存片段字体、共用基线度量和实际需要的合成样式。 +type richMetrics struct { + font FontMetrics + ascent, descent, height float64 + bold, italic bool + outlines map[uint16]GeometryPath +} + +// richSpan 保存方向和格式均一致的逻辑区间及行内视觉坐标。 +type richSpan struct { + start, end, owner int + rtl bool + glyphs []ShapedGlyph + x, width float64 +} + +// richContextShaper 将带上下文的区间塑形接入已有簇校验流程。 +type richContextShaper struct { + FontShaperRun + text []rune + start, end int + options TextShapeOptions +} + +// LayoutRichText 生成局部坐标的标准文字和路径,不修改页面或编辑历史。 +// 边界包含排版高度和溢出墨迹,可能有负的X或Y,不截断文字。 +// 非标准RGB文档按需复用或注册RGB颜色空间,返回对象可直接用于当前文档。 +// 注册不产生修订或撤销记录;失败时不保留新增资源和资源编号。 +// 提交对象后应创建新的Reader;已有Reader是独立快照,不包含新注册的资源。 +// 返回值的资源编号属于当前资源视图,切换文档、版本或替换资源快照后须重新排版。 +func (e *Editor) LayoutRichText(value RichText, width float64) ([]GraphicObject, Box, error) { + result, err := e.layoutRichText(value, width) + if err != nil { + return nil, Box{}, err + } + err = e.Transaction(func(editor *Editor) error { + for i := range result.objects { + if err := editor.richTextColors(&result.objects[i]); err != nil { + return err + } + } + return nil + }) + if err != nil { + return nil, Box{}, err + } + return result.objects, result.bounds, nil +} + +// layoutRichText 统一计算段落换行、视觉顺序和基线,颜色资源由调用方绑定。 +func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, error) { + if err := value.Validate(); err != nil { + return richLayout{}, err + } + options := value.Layout + available := width - options.LeftIndent - options.RightIndent + if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 { + return richLayout{}, fmt.Errorf("invalid rich text width or indents") + } + geometry, err := e.Geometry() + if err != nil { + return richLayout{}, err + } + metrics := make([]richMetrics, len(value.Runs)) + result := richLayout{anchors: make([]int, len(value.Runs)), bounds: Box{W: width}} + owners := make([]int, 0, value.Len()) + for i, run := range value.Runs { + font, err := e.editorFont(run.Format.Font) + if err != nil { + return richLayout{}, err + } + if font.UnitsPerEm() == 0 { + return richLayout{}, fmt.Errorf("invalid font units") + } + a, d, g := font.VerticalMetrics() + unit := run.Format.Size / float64(font.UnitsPerEm()) + bold, italic := e.richSynthetic(run.Format) + metrics[i] = richMetrics{font: font, ascent: float64(a) * unit, descent: float64(d) * unit, height: max(run.Format.Size, float64(int(a)+int(d)+int(g))*unit), bold: bold, italic: italic, outlines: make(map[uint16]GeometryPath)} + if !finite(metrics[i].height) { + return richLayout{}, fmt.Errorf("rich text metrics overflow") + } + result.anchors[i] = -1 + for range []rune(run.Text) { + owners = append(owners, i) + } + } + chars := []rune(value.Text()) + var face *typefont.Face + var underlays []GraphicObject + var shadows []GraphicObject + var y float64 + for start := 0; start <= len(chars); { + end := start + for end < len(chars) && chars[end] != '\n' { + end++ + } + paragraph := chars[start:end] + local := richParagraphOptions(paragraph, options) + if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { + face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write())) + if err != nil { + return richLayout{}, fmt.Errorf("rich text segmentation font: %w", err) + } + } + _, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face) + if err != nil { + return richLayout{}, err + } + lines := breakTextLines(paragraph, advances, available, local.Wrap, local.LetterSpacing, local.FirstLineIndent) + for lineIndex := 0; lineIndex < len(lines); lineIndex++ { + line := lines[lineIndex] + indent := 0.0 + if lineIndex == 0 { + indent = local.FirstLineIndent + } + var spans []richSpan + var steps []float64 + for { + spans, steps, err = richShapeLine(paragraph[line[0]:line[1]], owners[start+line[0]:start+line[1]], value, metrics, local, face) + if err != nil { + return richLayout{}, err + } + if !local.Wrap || shapedLineWidth(paragraph[line[0]:line[1]], steps, local.LetterSpacing) <= available-indent { + break + } + previous := 0 + for at := range richTextBoundaries(string(paragraph[line[0]:line[1]])) { + if at < line[1]-line[0] { + previous = max(previous, at) + } + } + if previous == 0 { + break + } + oldEnd := line[1] + line[1] = line[0] + previous + if lineIndex+1 < len(lines) { + lines[lineIndex+1][0] = line[1] + } else { + lines = append(lines, [2]int{line[1], oldEnd}) + } + lines[lineIndex] = line + } + from, to := start+line[0], start+line[1] + fallback := len(value.Runs) - 1 + if from < len(owners) { + fallback = owners[from] + } + ascent, descent, height := 0.0, 0.0, 0.0 + for pos := from; pos < max(from+1, to); pos++ { + index := fallback + if pos < to { + index = owners[pos] + } + m := metrics[index] + ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height) + } + height = max(height, ascent+descent) + x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing) + copy(steps, parseFloats(delta)) + if local.Shaping.Direction == "rtl" { + for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- { + x -= steps[j] + } + } + x += local.LeftIndent + indent + if err := richPositionSpans(spans, steps, local, x); err != nil { + return richLayout{}, err + } + result.bounds = richUnion(result.bounds, Box{X: x, Y: y, H: height}) + for _, span := range spans { + format := value.Runs[span.owner].Format + for _, glyph := range span.glyphs { + if !finite(y + ascent + glyph.Y) { + return richLayout{}, fmt.Errorf("rich text position overflow") + } + } + text := richSpanText(chars[from:to], span, format, y+ascent) + ink, err := richSpanOutline(span, format, metrics[span.owner], geometry, y+ascent) + if err != nil { + return richLayout{}, err + } + if len(ink) > 0 { + box, err := geometry.Bounds(ink) + if err != nil { + return richLayout{}, err + } + result.bounds = richUnion(result.bounds, box) + } + decorationWidth := max(0, span.width-local.LetterSpacing) + if format.Background != "" && decorationWidth > 0 { + underlays = append(underlays, richRectangle(Box{X: span.x, Y: y, W: decorationWidth, H: height}, format.Background)) + } + if format.Shadow && len(ink) > 0 { + shift := format.Size * 0.06 + shadow, err := geometry.Transform(ink, TranslationMatrix(shift, shift)) + if err != nil { + return richLayout{}, err + } + path, err := geometryClipPath(shadow) + if err != nil { + return richLayout{}, err + } + path.FillColor = &FillColor{Value: "128 128 128"} + shadows = append(shadows, GraphicObject{Type: "PathObject", PathObject: path}) + } + anchor := len(result.objects) + for _, owner := range owners[from+span.start : from+span.end] { + if result.anchors[owner] < 0 { + result.anchors[owner] = anchor + } + } + result.objects = append(result.objects, GraphicObject{Type: "TextObject", TextObject: text}) + thickness := max(0.05, format.Size*0.05) + if format.Underline && decorationWidth > 0 { + result.objects = append(result.objects, richRectangle(Box{X: span.x, Y: y + ascent + format.Size*0.1, W: decorationWidth, H: thickness}, richColor(format.Color))) + } + if format.Strikeout && decorationWidth > 0 { + result.objects = append(result.objects, richRectangle(Box{X: span.x, Y: y + ascent - format.Size*0.3, W: decorationWidth, H: thickness}, richColor(format.Color))) + } + result.bounds = richUnion(result.bounds, Box{X: span.x, Y: y, W: max(0, span.width), H: height}) + } + advance := height + if local.LineHeight > 0 { + advance = local.LineHeight + } + y += advance + if !finite(y) { + return richLayout{}, fmt.Errorf("rich text layout overflow") + } + } + if end == len(chars) { + break + } + start = end + 1 + } + underlays = append(underlays, shadows...) + for i, anchor := range result.anchors { + if anchor >= 0 { + result.anchors[i] += len(underlays) + } + } + result.objects = append(underlays, result.objects...) + for _, object := range result.objects { + if object.Type == "PathObject" { + box, err := ParseBox(object.PathObject.Boundary) + if err != nil { + return richLayout{}, err + } + result.bounds = richUnion(result.bounds, box) + } + } + if !finite(result.bounds.X) || !finite(result.bounds.Y) || !finite(result.bounds.W) || !finite(result.bounds.H) || result.bounds.H <= 0 { + return richLayout{}, fmt.Errorf("rich text layout overflow") + } + for i, anchor := range result.anchors { + if anchor >= 0 { + continue + } + text := richTextObject(value.Runs[i].Format) + visible := false + text.Visible = &visible + text.TextCode = []TextCode{{X: "0", Y: ofdNumber(metrics[i].ascent), Value: " "}} + result.anchors[i] = len(result.objects) + result.objects = append(result.objects, GraphicObject{Type: "TextObject", TextObject: text}) + } + for i := range result.objects { + if result.objects[i].Type != "TextObject" { + continue + } + text := &result.objects[i].TextObject + text.Boundary = editorBoxString(result.bounds) + for j := range text.TextCode { + code := &text.TextCode[j] + x, _ := strconv.ParseFloat(code.X, 64) + y, _ := strconv.ParseFloat(code.Y, 64) + code.X = ofdNumber(x - result.bounds.X) + code.Y = ofdNumber(y - result.bounds.Y) + } + } + return result, nil +} + +// richParagraphOptions 以整段原文检测双向排版,与普通文字的自动模式一致。 +func richParagraphOptions(chars []rune, options TextLayout) TextLayout { + if options.AutoBidi && !options.Shaping.Bidi { + for _, char := range chars { + p, _ := bidi.LookupRune(char) + switch p.Class() { + case bidi.R, bidi.AL, bidi.RLE, bidi.RLO, bidi.RLI, bidi.FSI: + options.Shaping.Bidi = true + } + } + } + return options +} + +// richShapeLine 按方向、脚本和格式塑形整行,跨同格式片段合并,保留区间外的塑形上下文。 +func richShapeLine(chars []rune, owners []int, value RichText, metrics []richMetrics, options TextLayout, face *typefont.Face) ([]richSpan, []float64, error) { + advances := make([]float64, len(chars)) + if len(chars) == 0 { + return nil, advances, nil + } + direction := di.DirectionLTR + if options.Shaping.Direction == "rtl" { + direction = di.DirectionRTL + } + parts := []shaping.Input{{Text: chars, RunEnd: len(chars), Direction: direction}} + if options.Shaping.Bidi { + var segmenter shaping.Segmenter + parts = segmenter.Split(parts[0], shapingFace{face}) + } + var spans []richSpan + for _, part := range parts { + for start := part.RunStart; start < part.RunEnd; { + owner := owners[start] + format := value.Runs[owner].Format + end := start + 1 + for end < part.RunEnd && value.Runs[owners[end]].Format == format { + end++ + } + font := metrics[owner].font + span := richSpan{start: start, end: end, owner: owner, rtl: part.Direction.Progression() == di.TowardTopLeft} + if options.Shape || options.Shaping != (TextShapeOptions{}) { + settings := options.Shaping + settings.Bidi = false + if options.Shaping.Bidi { + settings.Direction = "ltr" + if span.rtl { + settings.Direction = "rtl" + } + if settings.Script == "" { + settings.Script = part.Script.String() + } + } + shaper, ok := font.(FontShaper) + if !ok { + return nil, nil, fmt.Errorf("rich text shaping: %w", ErrBackendUnavailable) + } + if contextual, ok := font.(FontShaperRun); ok { + shaper = richContextShaper{FontShaperRun: contextual, text: chars, start: start, end: end, options: settings} + } else if settings != (TextShapeOptions{}) { + configured, ok := font.(FontShaperOptions) + if !ok { + return nil, nil, fmt.Errorf("rich text shaping options: %w", ErrBackendUnavailable) + } + shaper = configuredTextShaper{FontShaperOptions: configured, options: settings} + } + glyphs, steps, err := shapeTextLine(shaper, chars[start:end], format.Font, format.Size, 1, 0) + if err != nil { + return nil, nil, err + } + span.glyphs = glyphs + copy(advances[start:end], steps) + } else { + var missing []rune + var x float64 + for i, char := range chars[start:end] { + id := font.GlyphIndex(char) + if id == 0 { + missing = append(missing, char) + } + step := float64(font.GlyphAdvance(id)) * format.Size / float64(font.UnitsPerEm()) + span.glyphs = append(span.glyphs, ShapedGlyph{Glyph: id, Cluster: i, VisualOrder: i, X: x, Advance: step}) + advances[start+i] = step + x += step + } + if err := missingGlyphError(format.Font, missing); err != nil { + return nil, nil, err + } + } + spans = append(spans, span) + start = end + } + } + spaceTextAdvances(chars, advances, options.LetterSpacing) + var sum float64 + for _, advance := range advances { + sum += advance + if !finite(advance) || !finite(sum) { + return nil, nil, fmt.Errorf("rich text advance overflow") + } + } + return spans, advances, nil +} + +// ShapeText 返回区间相对簇索引,同时保留完整行提供的上下文。 +func (s richContextShaper) ShapeText(_ string, size float64) ([]ShapedGlyph, error) { + glyphs, err := s.ShapeTextRun(s.text, s.start, s.end, size, s.options) + if err != nil { + return nil, err + } + for i := range glyphs { + glyphs[i].Cluster -= s.start + } + return glyphs, nil +} + +// richPositionSpans 按核心排版相同的方向分组规则排列片段,按视觉簇分配字距及两端对齐。 +func richPositionSpans(spans []richSpan, steps []float64, options TextLayout, x float64) error { + order := make([]int, len(spans)) + for i := range order { + order[i] = i + } + rtl := options.Shaping.Direction == "rtl" + for start := 0; start < len(order); { + end := start + 1 + for end < len(order) && spans[end].rtl == spans[start].rtl { + end++ + } + if spans[start].rtl != rtl { + slices.Reverse(order[start:end]) + } + start = end + } + if rtl { + slices.Reverse(order) + } + for _, index := range order { + span := &spans[index] + extra := make([]float64, span.end-span.start+1) + raw := make([]float64, span.end-span.start) + for _, glyph := range span.glyphs { + raw[glyph.Cluster] += glyph.Advance + } + for j, step := range steps[span.start:span.end] { + extra[j+1] = extra[j] + step - raw[j] + span.width += step + } + shifts := bidiClusterShifts(span.glyphs, extra) + span.x = x + for j := range span.glyphs { + glyph := &span.glyphs[j] + glyph.X += x + shifts[glyph.Cluster] + if !finite(glyph.X) { + return fmt.Errorf("rich text position overflow") + } + } + x += span.width + if !finite(x) { + return fmt.Errorf("rich text position overflow") + } + } + return nil +} + +// richSpanText 将逻辑原文和绝对视觉位置写为标准字符字形映射。 +func richSpanText(chars []rune, span richSpan, format TextFormat, baseline float64) TextObject { + text := richTextObject(format) + code := TextCode{Value: escapeOFDText(string(chars[span.start:span.end]))} + dx, dy := make([]string, 0, len(span.glyphs)), make([]string, 0, len(span.glyphs)) + for i, glyph := range span.glyphs { + if i == 0 { + code.X, code.Y = ofdNumber(glyph.X), ofdNumber(baseline+glyph.Y) + } else { + dx = append(dx, ofdNumber(glyph.X-span.glyphs[i-1].X)) + dy = append(dy, ofdNumber(glyph.Y-span.glyphs[i-1].Y)) + } + } + code.DeltaX, code.DeltaY = strings.Join(dx, " "), strings.Join(dy, " ") + text.TextCode = []TextCode{code} + for i := 0; i < len(span.glyphs); { + end := i + 1 + for end < len(span.glyphs) && span.glyphs[end].Cluster == span.glyphs[i].Cluster { + end++ + } + next := span.end - span.start + if end < len(span.glyphs) { + next = span.glyphs[end].Cluster + } + ids := make([]string, end-i) + for j := i; j < end; j++ { + ids[j-i] = strconv.Itoa(int(span.glyphs[j].Glyph)) + } + text.CGTransform = append(text.CGTransform, CGTransform{CodePosition: span.glyphs[i].Cluster, CodeCount: next - span.glyphs[i].Cluster, GlyphCount: end - i, Glyphs: strings.Join(ids, " ")}) + i = end + } + return text +} + +// richSpanOutline 使用现有字体及几何后端生成与文字合成样式一致的墨迹。 +func richSpanOutline(span richSpan, format TextFormat, metrics richMetrics, geometry GeometryBackend, baseline float64) (GeometryPath, error) { + font, ok := metrics.font.(FontOutlines) + if !ok { + return nil, fmt.Errorf("rich text outlines: %w", ErrBackendUnavailable) + } + var result GeometryPath + for _, glyph := range span.glyphs { + path, exists := metrics.outlines[glyph.Glyph] + if !exists { + var err error + path, err = font.GlyphOutline(glyph.Glyph, format.Size) + if err != nil { + return nil, err + } + if metrics.bold && len(path) > 0 { + stroke, err := geometry.Stroke(path, StrokeOptions{Width: format.Size * 0.04, Join: "Round", Cap: "Round"}) + if err != nil { + return nil, err + } + path, err = geometry.Combine(path, stroke, GeometryUnion) + if err != nil { + return nil, err + } + } + metrics.outlines[glyph.Glyph] = path + } + if len(path) == 0 { + continue + } + matrix := TranslationMatrix(glyph.X, baseline+glyph.Y) + if metrics.italic { + matrix = matrix.Multiply(Matrix{a: 1, c: -0.3, d: 1}) + } + placed, err := geometry.Transform(path, matrix) + if err != nil { + return nil, err + } + result = append(result, placed...) + } + return result, nil +} + +// richSynthetic 与标准绘制一样避免对已加粗或倾斜的字体重复合成。 +func (e *Editor) richSynthetic(format TextFormat) (bool, bool) { + bold, italic := format.Bold, format.Italic + var definition *Font + for _, resource := range e.resources { + if resource.font != nil && resource.font.ID == format.Font { + definition = resource.font + break + } + } + if definition == nil && e.source != nil { + definition = e.source.reader.fontDefinition(format.Font) + } + if definition != nil { + bold = bold && !definition.Bold + italic = italic && !definition.Italic + if definition.FontFile == "" && basefont.NoSyntheticBold(definition.FontName, definition.FamilyName) { + bold = false + } + } + return bold, italic +} + +// richTextObject 构造具有显式填充和标准字重的文字对象。 +func richTextObject(format TextFormat) TextObject { + fill, stroke := true, false + weight := 400 + if format.Bold { + weight = 700 + } + return TextObject{Font: format.Font, Size: format.Size, Weight: weight, Italic: format.Italic, Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: richColor(format.Color)}} +} + +// richColor 将空前景色转换为标准黑色。 +func richColor(value string) string { + if value == "" { + return "0 0 0" + } + return value +} + +// richUnion 合并排版范围和墨迹范围,保留负坐标及零宽空行。 +func richUnion(a, b Box) Box { + x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y) + return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y} +} + +// richRectangle 使用标准填充路径绘制背景或文字装饰。 +func richRectangle(box Box, color string) GraphicObject { + fill, stroke := true, false + return GraphicObject{Type: "PathObject", PathObject: PathObject{Boundary: editorBoxString(box), Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: color}, AbbreviatedData: fmt.Sprintf("M 0 0 L %s 0 L %s %s L 0 %s C", ofdNumber(box.W), ofdNumber(box.W), ofdNumber(box.H), ofdNumber(box.H))}} +} diff --git a/ofdgo_rich_text.go b/ofdgo_rich_text.go new file mode 100644 index 0000000..8c9620f --- /dev/null +++ b/ofdgo_rich_text.go @@ -0,0 +1,312 @@ +// Copyright 2025-2026 肖其顿 (XIAO QI DUN) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ofdgo + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/go-text/typesetting/segmenter" +) + +// ErrNotRichText 表示对象没有富文本信息且不属于可提升的普通文字。 +var ErrNotRichText = errors.New("object is not rich text") + +// RichText 保存有序文字片段及段落选项,长度和范围使用Unicode码点而非字节。 +type RichText struct { + Runs []RichTextRun `json:"runs"` + Layout TextLayout `json:"layout"` +} + +// RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米。 +type RichTextRun struct { + Text string `json:"text"` + Format TextFormat `json:"format"` +} + +// TextFormat 保存完整文字格式,Font为当前文档字体资源标识,颜色使用OFD RGB分量。 +type TextFormat struct { + Font string `json:"font"` + Size float64 `json:"size"` + Color string `json:"color"` + Bold bool `json:"bold"` + Italic bool `json:"italic"` + Underline bool `json:"underline"` + Strikeout bool `json:"strikeout"` + Shadow bool `json:"shadow"` + Background string `json:"background"` +} + +// TextFormatPatch 保存格式增量,nil保持原值,false和空背景显式清除对应格式。 +type TextFormatPatch struct { + Font *string `json:"font,omitempty"` + Size *float64 `json:"size,omitempty"` + Color *string `json:"color,omitempty"` + Bold *bool `json:"bold,omitempty"` + Italic *bool `json:"italic,omitempty"` + Underline *bool `json:"underline,omitempty"` + Strikeout *bool `json:"strikeout,omitempty"` + Shadow *bool `json:"shadow,omitempty"` + Background *string `json:"background,omitempty"` +} + +// Text 返回包含显式换行的原文,不包含排版产生的软换行。 +// 返回: string 完整原文 +func (v RichText) Text() string { + var text strings.Builder + for _, run := range v.Runs { + text.WriteString(run.Text) + } + return text.String() +} + +// Len 返回原文的Unicode码点数量。 +// 返回: int 码点数量 +func (v RichText) Len() int { return utf8.RuneCountInString(v.Text()) } + +// Validate 校验文字、格式、段落选项及跨片段的字素边界,不加载字体。 +// 返回: error 无效文字、格式或边界 +func (v RichText) Validate() error { + if len(v.Runs) == 0 { + return fmt.Errorf("rich text requires at least one run") + } + if err := validateRichLayout(v.Layout); err != nil { + return err + } + bounds := richTextBoundaries(v.Text()) + position := 0 + for _, run := range v.Runs { + if !utf8.ValidString(run.Text) || !structureXMLText(run.Text) || strings.ContainsAny(run.Text, "\r\t") { + return fmt.Errorf("rich text requires XML-compatible UTF-8 and LF line breaks without tabs") + } + if !bounds[position] { + return fmt.Errorf("rich text format boundary splits a grapheme") + } + position += utf8.RuneCountInString(run.Text) + if err := run.Format.validate(); err != nil { + return err + } + } + return nil +} + +// validate 校验完整文字格式,不解析文档资源。 +// 返回: error 格式错误 +func (f TextFormat) validate() error { + if strings.TrimSpace(f.Font) == "" || !finite(f.Size) || f.Size <= 0 { + return fmt.Errorf("rich text requires a font and positive finite size") + } + for _, color := range []string{f.Color, f.Background} { + if color != "" { + values, err := creationNumbers(color, 3) + if err != nil { + return fmt.Errorf("rich text color requires three RGB components: %w", err) + } + for _, value := range values { + if value < 0 || value > 255 { + return fmt.Errorf("rich text RGB components must be between 0 and 255") + } + } + } + } + return nil +} + +// validateRichLayout 校验富文本段落的有限数值及枚举。 +// 入参: v 段落选项 +// 返回: error 无效选项 +func validateRichLayout(v TextLayout) error { + if !finite(v.LineHeight) || v.LineHeight < 0 || !finite(v.LetterSpacing) || !finite(v.LeftIndent) || !finite(v.RightIndent) || !finite(v.FirstLineIndent) { + return fmt.Errorf("invalid rich text layout dimensions") + } + switch v.Align { + case "", "left", "center", "right", "justify": + default: + return fmt.Errorf("invalid rich text alignment") + } + return nil +} + +// richTextBoundaries 返回包含首尾位置的扩展字素边界。 +// 入参: value 原文 +// 返回: map[int]bool 有效码点边界 +func richTextBoundaries(value string) map[int]bool { + runes := []rune(value) + result := map[int]bool{0: true, len(runes): true} + var seg segmenter.Segmenter + seg.Init(runes) + for it := seg.GraphemeIterator(); it.Next(); { + g := it.Grapheme() + result[g.Offset] = true + } + return result +} + +// checkRange 检查半开范围及其字素边界。 +// 入参: start 起点, end 终点 +// 返回: error 无效范围 +func (v RichText) checkRange(start, end int) error { + if err := v.Validate(); err != nil { + return err + } + bounds := richTextBoundaries(v.Text()) + if start < 0 || end < start || end > v.Len() || !bounds[start] || !bounds[end] { + return fmt.Errorf("rich text range must end at grapheme boundaries") + } + return nil +} + +// Slice 返回半开范围的独立片段,空范围保留插入位置的格式。 +// 入参: start 起点, end 终点 +// 返回: RichText 独立片段, error 无效范围 +func (v RichText) Slice(start, end int) (RichText, error) { + if err := v.checkRange(start, end); err != nil { + return RichText{}, err + } + result := RichText{Layout: v.Layout, Runs: v.sliceRuns(start, end)} + if len(result.Runs) == 0 { + position := 0 + format := v.Runs[len(v.Runs)-1].Format + for _, run := range v.Runs { + position += utf8.RuneCountInString(run.Text) + if start < position { + format = run.Format + break + } + } + result.Runs = []RichTextRun{{Format: format}} + } + return result, nil +} + +// sliceRuns 提取已校验范围,不保留空片段。 +// 入参: start 起点, end 终点 +// 返回: []RichTextRun 独立片段 +func (v RichText) sliceRuns(start, end int) []RichTextRun { + var result []RichTextRun + position := 0 + for _, run := range v.Runs { + chars := []rune(run.Text) + a, b := max(0, start-position), min(len(chars), end-position) + if a < b { + result = append(result, RichTextRun{Text: string(chars[a:b]), Format: run.Format}) + } + position += len(chars) + } + return result +} + +// Replace 原子替换半开范围,空片段列表表示删除,相邻相同格式自动合并。 +// 入参: start 起点, end 终点, runs 替换片段 +// 返回: error 范围或内容错误 +func (v *RichText) Replace(start, end int, runs []RichTextRun) error { + if v == nil { + return fmt.Errorf("nil rich text") + } + if err := v.checkRange(start, end); err != nil { + return err + } + next := RichText{Layout: v.Layout, Runs: v.sliceRuns(0, start)} + next.Runs = append(next.Runs, runs...) + next.Runs = append(next.Runs, v.sliceRuns(end, v.Len())...) + if len(next.Runs) == 0 { + caret, _ := v.Slice(start, start) + next.Runs = caret.Runs + } + next = normalizeRichText(next) + if err := next.Validate(); err != nil { + return err + } + *v = next + return nil +} + +// ApplyFormat 原子修改半开范围的格式,空范围不改变正文。 +// 入参: start 起点, end 终点, patch 格式增量 +// 返回: error 范围或格式错误 +func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error { + if v == nil { + return fmt.Errorf("nil rich text") + } + part, err := v.Slice(start, end) + if err != nil { + return err + } + for i := range part.Runs { + part.Runs[i].Format = patch.apply(part.Runs[i].Format) + } + if err := part.Validate(); err != nil { + return err + } + if start == end { + return nil + } + return v.Replace(start, end, part.Runs) +} + +// apply 应用显式给出的格式字段。 +// 入参: f 完整原格式 +// 返回: TextFormat 修改后的格式 +func (p TextFormatPatch) apply(f TextFormat) TextFormat { + if p.Font != nil { + f.Font = *p.Font + } + if p.Size != nil { + f.Size = *p.Size + } + if p.Color != nil { + f.Color = *p.Color + } + if p.Bold != nil { + f.Bold = *p.Bold + } + if p.Italic != nil { + f.Italic = *p.Italic + } + if p.Underline != nil { + f.Underline = *p.Underline + } + if p.Strikeout != nil { + f.Strikeout = *p.Strikeout + } + if p.Shadow != nil { + f.Shadow = *p.Shadow + } + if p.Background != nil { + f.Background = *p.Background + } + return f +} + +// normalizeRichText 合并相同格式,保留空文档的插入格式。 +// 入参: v 完整富文本 +// 返回: RichText 归一化副本 +func normalizeRichText(v RichText) RichText { + runs := make([]RichTextRun, 0, len(v.Runs)) + for _, run := range v.Runs { + if len(runs) > 0 && runs[len(runs)-1].Format == run.Format { + runs[len(runs)-1].Text += run.Text + } else { + runs = append(runs, run) + } + } + if len(runs) == 0 && len(v.Runs) > 0 { + runs = append(runs, RichTextRun{Format: v.Runs[0].Format}) + } + v.Runs = runs + return v +} diff --git a/ofdgo_structure.go b/ofdgo_structure.go index 84d641b..774673b 100644 --- a/ofdgo_structure.go +++ b/ofdgo_structure.go @@ -30,19 +30,22 @@ const structureTypeOfficial = "od" const structureNamespaceEGov2013 = "http://www.nits.org.cn/eGovDoc/2013" // Structure 保存自定义标引入口及结构树,单个文件解析失败时通过Error报告,不影响其他标引 -// CanBind表示已识别公文引用语义,具体节点和目标仍由绑定接口校验 +// CanBind表示已识别标引引用规则,包括公文标引及本库富文本标引,具体节点和目标仍由绑定接口校验 +// Internal标记本库内部编辑标引,不删除或重排入口;界面跳过此项时必须保留原始数组索引 +// 此标记不限制通用低级编辑接口,也不表示内部元数据内容已通过有效性校验 type Structure struct { TypeID string `json:"typeId,omitempty"` Namespace string `json:"namespace,omitempty"` FileLoc string `json:"fileLoc"` CanBind bool `json:"canBind,omitempty"` + Internal bool `json:"internal,omitempty"` Root *StructureNode `json:"root,omitempty"` Error string `json:"error,omitempty"` } // StructureNode 保存标引节点的名称、属性、直接文本和有序子节点,不改变原始XML // Text按文档顺序汇总直接文本并保留空白,不包含子节点文本 -// References仅解释公文标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 +// References仅解释已识别公文标引或本库富文本标引中的ObjectRef;未知扩展保留结构,不推定其引用语义 type StructureNode struct { Name string `json:"name"` Namespace string `json:"namespace,omitempty"` @@ -120,6 +123,7 @@ func (r *Reader) readStructureContext(ctx context.Context, tag CustomTag) (Struc return item, fmt.Errorf("read custom tag %s: %w", tag.FileLoc, err) } item.CanBind = structureOfficial(tag, item.Root.Namespace) + item.Internal = tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace && item.Root.Namespace == richTextNamespace && item.Root.Name == "RichText" return item, nil } @@ -211,10 +215,13 @@ func parseStructure(ctx context.Context, data []byte, tag CustomTag) (*Structure return root, nil } -// structureOfficial 识别公文类型标识及既有公文命名空间,不以节点名称推断未知行业语义 +// structureOfficial 识别公文和本库富文本标引的明确引用规则,不推断未知行业语义 // 入参: tag 标引入口, namespace 内容根节点命名空间 // 返回: bool 是否使用公文引用规则 func structureOfficial(tag CustomTag, namespace string) bool { + if tag.TypeID == richTextTagType && tag.NameSpace == richTextNamespace && namespace == richTextNamespace { + return true + } if tag.TypeID == structureTypeOfficial { return true }