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
}