diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 0658067..b9431ec 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, richTextAlignment, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; +import { SVG_NAMESPACE, CanvasEditor, canEditObject, editTextValue, richTextModel, richTextAlignment, richTextLayout, fontFaceStyle, missingGlyphMessage, objectEditReason, selectedText, lineShape, selectionBounds, pagePoint } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; import { nativeHost, createEngineWorker, engineLimits, documentBytes, saveFilePicker, directoryPicker } from "./ofdgo_host.js"; import { PrintDialog } from "./ofdgo_print.js"; @@ -704,8 +704,10 @@ const annotationFields = Object.fromEntries(["CreateForm", "CreateCancel", "Cont el.editorTools.addEventListener("scroll", () => fontPicker.position()); const canvasEditor = new CanvasEditor(el.viewerPanel, { - textControls: [el.textAlign, el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], - keepTextFocus: () => fontPicker.open || el.closePanel.open || canvasEditor.input?.fontDialog, + textControls: [el.textAlign, el.textFont, el.textFontToggle, el.textFontAdd, el.textFontList, el.textSize, el.textColor, el.textLineHeight, el.textSpacing, el.textWrap, el.paragraphButton, el.paragraphPanel, textBackground, ...textFormatButtons, el.undoButton, el.redoButton, el.saveButton, el.saveAsButton, el.fontInput, document.querySelector("#textReflow")], + keepTextFocus: () => fontPicker.open || el.closePanel.open || el.paragraphPanel.open || canvasEditor.input?.fontDialog, + onTextFormat: changeRangeFormat, + onRichInput: ensureRichInput, textClipboardScope: () => `${documentTab().id}:${documentContentKey(state)}:${state.editorInfo?.revision ?? ""}`, getTextClipboard: () => workspace.textClipboard, setTextClipboard: clipboard => { workspace.textClipboard = clipboard; }, @@ -828,10 +830,10 @@ el.editorTools.addEventListener("pointerdown", (event) => { if ((canvasEditor.input || canvasEditor.nudge) && event.target.closest("button")) event.preventDefault(); }); -function editorClick(button, action) { +function editorClick(button, action, keepText = false) { button.addEventListener("click", async () => { if ((canvasEditor.nudge || canvasEditor.nudgeCommit) && !await canvasEditor.commitNudge()) return; - if (canvasEditor.input && !await canvasEditor.commitText()) return; + if (!keepText && canvasEditor.input && !await canvasEditor.commitText()) return; if (canvasEditor.crop && !await canvasEditor.commitCrop()) return; return action(); }); @@ -866,16 +868,22 @@ 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)); +editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap), true); el.textAlign.addEventListener("change", () => changeTextAlignment()); -el.textLineHeight.addEventListener("change", () => changeParagraph()); +el.textLineHeight.addEventListener("change", () => changeParagraph(undefined, "lineHeight")); editorClick(el.paragraphButton, () => { const item = currentTextStyle(); - for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) el[key].value = item[key] || 0; + for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) { + const mixed = Boolean(canvasEditor.input?.rich || item.richText) && item[key] === undefined; + el[key].required = !mixed; + el[key].placeholder = mixed ? "混合" : ""; + el[key].value = mixed ? "" : item[key] || 0; + } el.paragraphStatus.textContent = ""; el.paragraphPanel.showModal(); -}); +}, true); el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); +el.paragraphPanel.addEventListener("close", () => canvasEditor.input?.input.focus({ preventScroll: true })); editorClick(el.documentAddButton, () => openCreatePanel(false, true)); editorClick(el.documentImportButton, () => openImportPanel(true)); editorClick(el.documentManageButton, openDocumentManagement); @@ -1181,20 +1189,26 @@ el.annotationForm.addEventListener("submit", async event => { el.paragraphForm.addEventListener("submit", async event => { event.preventDefault(); if (document.body.hasAttribute("data-busy") || !el.paragraphForm.reportValidity()) return; - const item = currentTextStyle(); - const indents = Object.fromEntries(["leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, Number(el[key].value)])); - if (item === state.textDefaults) { - Object.assign(item, indents); - } else { - if (!confirmTextReflow(item)) return; - 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; + const item = currentTextStyle(), tab = documentTab(); + const indents = Object.fromEntries(["leftIndent", "rightIndent", "firstLineIndent"].filter(key => el[key].value !== "").map(key => [key, Number(el[key].value)])); + try { + if (canvasEditor.input) { + if (!await changeInputParagraph(indents)) return; + } else if (item === state.textDefaults) { + Object.assign(item, indents); + } else { + if (!confirmTextReflow(item)) return; + const saved = item.richText ? await saveRichParagraphs(item, indents) + : await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left", + item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents)); + if (!saved) return; + } + if (isForegroundDocument(tab)) el.paragraphPanel.close(); + } catch (error) { + if (isForegroundDocument(tab) && el.paragraphPanel.open) el.paragraphStatus.textContent = error.message; } - el.paragraphPanel.close(); }); -el.textSpacing.addEventListener("change", () => changeParagraph()); +el.textSpacing.addEventListener("change", () => changeParagraph(undefined, "letterSpacing")); el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) { input.addEventListener("keydown", (event) => { @@ -3957,24 +3971,20 @@ async function editCanvasObject(item, point = null) { return openInsertPanel(item); } const openSeq = state.openSeq; - const layoutKnown = canEditObject(item, "layoutKnown"); tab.editTask = true; setBusy(true, "", 0, "", tab); try { - const data = await callWASM("ofdgoEditorFont", item.font, !layoutKnown); + const data = await callWASM("ofdgoEditorFont", item.font, !canEditObject(item, "layoutKnown")); if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes, fontFaceStyle(data.bytes)); await face.load(); if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; - let rich = null; - if (item.richText) rich = await loadRichText(item, face, tab, data.bytes); - else if (layoutKnown) { - try { rich = await loadRichText(item, face, tab, data.bytes); } catch {} - } + const rich = await loadRichText(item, face, tab, data.bytes); if (!isForegroundDocument(tab) || openSeq !== state.openSeq) return; - const source = rich || layoutKnown ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); + const source = rich ? null : await callWASM("ofdgoPreviewText", item.index, item.id, item.text); if (isForegroundDocument(tab) && openSeq === state.openSeq && item.node.isConnected) { canvasEditor.editText(item, face, source, rich); + canvasEditor.input.fontResource = data.bytes; } } catch (err) { if (isForegroundDocument(tab) && openSeq === state.openSeq) { @@ -4127,10 +4137,14 @@ function openInsertPanel(item = null) { function currentTextStyle() { const text = selectedText(canvasEditor.selected) || state.textDefaults; - if (canvasEditor.input?.rich) return { ...text, ...canvasEditor.input.rich.state() }; + if (canvasEditor.input?.rich) { + const rich = canvasEditor.input.rich, layout = richTextLayout(rich.model, ...rich.selection()); + return { ...text, ...rich.state(), ...layout, paragraphHeight: layout.lineHeight }; + } 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"] + const layout = richTextLayout(text.richText); + return { ...text, ...layout, paragraphHeight: layout.lineHeight, ...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; @@ -4143,6 +4157,7 @@ async function loadRichText(item, firstFace, tab = documentTab(), firstData = nu return callTabWASM(tab, name, ...args); }; const prepared = item.richText ? { model: item.richText, frame: item.textFrame } : await callWASM("ofdgoGetRichText", item.index, item.id, reflow); + if (prepared.unsupported) return null; const model = prepared.model; const remaining = firstData ? { ...model, runs: model.runs.filter(run => run.format.font !== item.font) } : model; const fontData = remaining.runs.length ? await callWASM("ofdgoRichTextFonts", JSON.stringify(remaining)) : []; @@ -4170,8 +4185,15 @@ async function ensureRichInput(reflow = false) { if (editing.item.selectionIDs) return false; if (editing.rich) return true; if (editing.composing || editing.saving) return false; - const rich = await loadRichText(editing.item, editing.face, documentTab(), null, reflow); - if (canvasEditor.input !== editing) return false; + const tab = documentTab(); + let rich = await loadRichText(editing.item, editing.face, tab, editing.fontResource, reflow); + if (canvasEditor.input !== editing || !isForegroundDocument(tab)) return false; + if (!rich && !reflow) { + if (!confirmTextReflow(editing.item)) return false; + rich = await loadRichText(editing.item, editing.face, tab, editing.fontResource, true); + } + if (canvasEditor.input !== editing || !isForegroundDocument(tab)) return false; + if (!rich) throw new Error("文字暂不可重排"); return Boolean(editing.rich) || canvasEditor.promoteText(rich); } @@ -4187,6 +4209,7 @@ async function reflowCanvasText() { async function changeRangeFormat(key, value) { if (!state.ready || document.body.hasAttribute("data-busy") || canvasEditor.input?.composing) return; + const tab = documentTab(), editing = canvasEditor.input; try { if (!canvasEditor.selected && !canvasEditor.input) { state.textDefaults[key] = value ?? (key === "background" ? state.textDefaults.background ? "" : textBackground.value : !state.textDefaults[key]); @@ -4202,7 +4225,9 @@ async function changeRangeFormat(key, value) { 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); } + } catch (error) { + if (isForegroundDocument(tab) && (!editing || canvasEditor.input === editing)) showError(error, false); + } } async function validateRichText(item, model, width, fonts = []) { @@ -4439,6 +4464,12 @@ async function changeTextFont() { const editing = canvasEditor.input; if (editing) { if (editing.composing) return; + try { + if (!await ensureRichInput() || canvasEditor.input !== editing || !isForegroundDocument(tab)) return; + } catch (error) { + if (canvasEditor.input === editing && isForegroundDocument(tab)) showError(error, false); + return; + } editing.rich?.remember(); tab.editTask = true; setBusy(true, "", 0, "", tab); @@ -4566,7 +4597,7 @@ async function changeTextAlignment() { editing.rich.align(align); } else if (canvasEditor.selected?.richText) { const item = canvasEditor.selected; - await saveRichText(item, { ...item.richText, paragraphs: undefined, layout: { ...item.richText.layout, align } }); + await saveRichParagraphs(item, { align }); } else await changeParagraph(); } catch (error) { if (isForegroundDocument(tab) && canvasEditor.input === editing && (!editing || editing.alignSequence === sequence)) showError(error, false); @@ -4575,8 +4606,23 @@ async function changeTextAlignment() { } } -async function changeParagraph(wrap) { - const item = currentTextStyle(); +async function saveRichParagraphs(item, patch) { + const { wrap, ...values } = patch, model = item.richText; + return saveRichText(item, { ...model, layout: { ...model.layout, ...(wrap !== undefined ? { wrap } : {}) }, + paragraphs: model.runs.map(run => run.text).join("").split("\n").map((_, i) => ({ align: "", ...model.paragraphs?.[i], ...values })) }); +} + +async function changeParagraph(wrap, changed) { + const item = currentTextStyle(), tab = documentTab(); + if (canvasEditor.input) { + const patch = wrap !== undefined ? { wrap: Boolean(wrap) } : {}; + for (const [input, key, original] of [[el.textLineHeight, "lineHeight", item.paragraphHeight], [el.textSpacing, "letterSpacing", item.letterSpacing]]) { + if (key !== changed) continue; + if (!input.checkValidity()) return updateObjectControls(canvasEditor.selected, true); + if (input.value !== "" || original !== undefined) patch[key] = inputMillimeters(input, original || 0); + } + return changeInputParagraph(patch); + } wrap ??= item.wrap; if (document.body.hasAttribute("data-busy") || item.draft) return; if (!el.textLineHeight.checkValidity()) { @@ -4598,9 +4644,26 @@ async function changeParagraph(wrap) { updateObjectControls(item, true); return; } - if (item.richText) await saveRichText(item, { ...item.richText, layout: { ...item.richText.layout, wrap: Boolean(wrap), lineHeight: height, letterSpacing: spacing } }); - else await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); - updateObjectControls(canvasEditor.selected, true); + try { + if (item.richText) await saveRichParagraphs(item, changed === "lineHeight" ? { lineHeight: height } : changed === "letterSpacing" ? { letterSpacing: spacing } : { wrap: Boolean(wrap) }); + else await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); + } catch (error) { + if (isForegroundDocument(tab)) showError(error, false); + } + if (isForegroundDocument(tab)) updateObjectControls(canvasEditor.selected, true); +} + +async function changeInputParagraph(patch) { + const editing = canvasEditor.input, tab = documentTab(); + if (!editing || editing.composing || editing.saving || document.body.hasAttribute("data-busy")) return false; + try { + if (!await ensureRichInput() || canvasEditor.input !== editing || !isForegroundDocument(tab)) return false; + editing.rich.paragraph(patch); + return true; + } catch (error) { + if (canvasEditor.input === editing && isForegroundDocument(tab)) showError(error, false); + return false; + } } async function changeTextStyle(color) { @@ -4616,11 +4679,14 @@ async function changeTextStyle(color) { const size = color ? item.size : inputMillimeters(el.textSize, item.size); const selectedColor = el.textColor.value; const fill = color && selectedColor !== item.color ? selectedColor : null; - if (item.richText && !canvasEditor.input) { - try { if (!await ensureRichInput()) return; } catch (error) { showError(error, false); return; } - } - if (canvasEditor.input) { - if (!color && canvasEditor.input.source && !canvasEditor.input.reflow && size !== item.size && !confirmTextReflow(item)) return; + if (canvasEditor.input || item.richText) { + const editing = canvasEditor.input, tab = documentTab(); + try { + if (!await ensureRichInput() || !isForegroundDocument(tab) || editing && canvasEditor.input !== editing) return; + } catch (error) { + if (isForegroundDocument(tab) && (!editing || canvasEditor.input === editing)) showError(error, false); + return; + } canvasEditor.setTextStyle(color ? { color: selectedColor } : { size }); updateObjectControls(canvasEditor.selected, true); return; @@ -12487,7 +12553,7 @@ function updateObjectControls(item, reset = false) { el.undoButton.disabled = !state.editorInfo?.canUndo || !state.ready || state.exporting; el.redoButton.disabled = !state.editorInfo?.canRedo || !state.ready || state.exporting; } - el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items || item?.draft) || Boolean(canvasEditor.input); + el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items); el.textWrap.disabled = el.textLineHeight.disabled || text === state.textDefaults; const reflow = document.querySelector("#textReflow"), positioned = canvasEditor.input?.source && (item?.selectionIDs || !canEditObject(item, "layoutKnown")); reflow.hidden = !positioned; @@ -12500,8 +12566,8 @@ function updateObjectControls(item, reset = false) { const alignment = rich ? richTextAlignment(rich.model, ...rich.selection()) : item?.richText ? richTextAlignment(item.richText) : layoutKnown ? text.align || "left" : ""; el.textAlign.options[0].textContent = rich || item?.richText ? "混合" : "原文"; el.textAlign.value = alignment; - el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文"; - el.textSpacing.placeholder = layoutKnown ? "0" : "原文"; + el.textLineHeight.placeholder = (rich || item?.richText) && text.paragraphHeight === undefined ? "混合" : layoutKnown ? "自动" : "原文"; + el.textSpacing.placeholder = (rich || item?.richText) && text.letterSpacing === undefined ? "混合" : layoutKnown ? "0" : "原文"; el.textWrap.setAttribute("data-pressed", String(Boolean(text.wrap))); if (reset || document.activeElement !== el.textLineHeight) { el.textLineHeight.value = text.paragraphHeight ? String(displayPoints(text.paragraphHeight)) : ""; diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index f79478a..ce54119 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -59,6 +59,17 @@ export function richTextAlignment(model, start = 0, end = model.runs.reduce((n, return align; } +export function richTextLayout(model, start = 0, end = model.runs.reduce((n, run) => n + run.text.length, 0)) { + const [first, last] = paragraphRange(model.runs.map(run => run.text).join(""), start, end); + const value = { ...model.layout, ...model.paragraphs?.[first] }; + for (const key of ["lineHeight", "letterSpacing", "leftIndent", "rightIndent", "firstLineIndent"]) { + value[key] ??= 0; + for (let i = first + 1; i <= last; i++) if ((model.paragraphs?.[i]?.[key] ?? model.layout[key] ?? 0) !== value[key]) value[key] = undefined; + } + value.align = richTextAlignment(model, start, end); + return value; +} + function replaceParagraphs(model, text, start, end, inserted, paragraphs) { if (!model.paragraphs?.length && !paragraphs?.length) return; const first = text.slice(0, start).split("\n").length - 1, last = text.slice(0, end).split("\n").length - 1; @@ -179,6 +190,7 @@ export class RichTextSession { this.model.runs = normalizeRuns(model.runs); this.initial = JSON.stringify(this.model); this.recordedParagraphs = this.model.paragraphs; + this.recordedLayout = this.model.layout; this.faces = faces; for (const face of faces.values()) document.fonts.add(face); this.imports = new Map(imports.map(font => [font.key, font.data])); @@ -232,13 +244,16 @@ export class RichTextSession { const change = textChange(before, this.model.runs); const entry = { change, typing: { ...this.typing }, caret: [...this.caret] }, old = this.entries[this.index]; const paragraphsChanged = JSON.stringify(this.recordedParagraphs) !== JSON.stringify(this.model.paragraphs); + const layoutChanged = JSON.stringify(this.recordedLayout) !== JSON.stringify(this.model.layout); + if (layoutChanged) entry.layout = { before: this.recordedLayout, after: this.model.layout }; + this.recordedLayout = this.model.layout; if (paragraphsChanged) entry.paragraphs = { before: this.recordedParagraphs, after: this.model.paragraphs }; this.recordedParagraphs = this.model.paragraphs; const changed = change.before.length || change.after.length; - if (!changed && !paragraphsChanged && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; + if (!changed && !paragraphsChanged && !layoutChanged && JSON.stringify(entry.typing) === JSON.stringify(old.typing)) return; if (!changed) entry.change = null; this.entries.splice(++this.index, Infinity, entry); - if (changed || paragraphsChanged) this.preview(); + if (changed || paragraphsChanged || layoutChanged) this.preview(); } undo(direction) { if (this.editing.composing || this.editing.saving) return; @@ -252,6 +267,9 @@ export class RichTextSession { } const paragraphs = (direction < 0 ? this.entries[this.index] : entry).paragraphs; if (paragraphs) this.model.paragraphs = direction < 0 ? paragraphs.before : paragraphs.after; + const layout = (direction < 0 ? this.entries[this.index] : entry).layout; + if (layout) this.model.layout = direction < 0 ? layout.before : layout.after; + this.recordedLayout = this.model.layout; this.recordedParagraphs = this.model.paragraphs; this.index += direction; this.typing = { ...entry.typing }; @@ -296,10 +314,15 @@ export class RichTextSession { this.editor.options.onTextChange(); } align(value) { + this.paragraph({ align: value }); + } + paragraph(values) { if (this.editing.composing || this.editing.saving) return; this.remember(); const [start, end] = this.selection(), [first, last] = paragraphRange(this.text, start, end); - this.model.paragraphs = this.text.split("\n").map((_, i) => ({ align: i >= first && i <= last ? value : this.model.paragraphs?.[i]?.align || "" })); + const { wrap, ...patch } = values; + if (wrap !== undefined) this.model.layout = { ...this.model.layout, wrap }; + if (Object.keys(patch).length) this.model.paragraphs = this.text.split("\n").map((_, i) => ({ align: "", ...this.model.paragraphs?.[i], ...(i >= first && i <= last ? patch : {}) })); this.record(this.model.runs); this.render(); this.editor.options.onTextChange(); @@ -350,7 +373,10 @@ export class RichTextSession { const keys = new Set(runs.map(run => run.format.font)); const fonts = new Map([...keys].map(key => [key, this.imports.get(key) || this.resources.get(key)]).filter(([, data]) => data)); const first = this.text.slice(0, start).split("\n").length - 1; - const paragraphs = text.split("\n").map((_, i) => ({ align: this.model.paragraphs?.[first + i]?.align || this.model.layout.align || "left" })); + const paragraphs = text.split("\n").map((_, i) => { + const layout = { ...this.model.layout, ...this.model.paragraphs?.[first + i] }; + return Object.fromEntries(["align", "lineHeight", "letterSpacing", "leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, key === "align" ? layout[key] || "left" : layout[key] || 0])); + }); const clipboard = { token: crypto.randomUUID(), runs, text, paragraphs, scope: this.editor.options.textClipboardScope?.(), fonts, faces: new Map([...this.faces].filter(([key]) => keys.has(key))), choices: new Map([...this.choices].filter(([key]) => keys.has(key))) }; if (this.editor.options.setTextClipboard) this.editor.options.setTextClipboard(clipboard); @@ -411,7 +437,8 @@ export class RichTextSession { const previous = this.renderedLines || []; let offset = 0; this.renderedLines = lines.map((parts, index) => { - const align = this.model.paragraphs?.[index]?.align || this.model.layout.align || "left"; + const layout = { ...this.model.layout, ...this.model.paragraphs?.[index] }; + const align = layout.align || this.model.layout.align || "left"; const distribute = align === "distributed" && !CSS.supports("text-justify", "inter-character"); const key = JSON.stringify([parts.map(part => [part.text, part.format, this.faces.get(part.format.font)?.family]), align, layout, distribute ? input.offsetWidth : null]); let paragraph = (!autoWidth || !distribute) && previous[index]?.key === key && previous[index].node.parentNode === input ? previous[index].node : null; @@ -431,6 +458,8 @@ export class RichTextSession { paragraph.append(document.createElement("br")); } Object.assign(paragraph.style, { minWidth: layout.wrap ? "" : "max-content", textAlign: distribute ? "left" : align === "distributed" ? "justify" : align, + marginLeft: `${(layout.leftIndent || 0) * PX_PER_MM}px`, marginRight: `${(layout.rightIndent || 0) * PX_PER_MM}px`, + lineHeight: layout.lineHeight ? `${layout.lineHeight * PX_PER_MM}px` : "normal", letterSpacing: `${(layout.letterSpacing || 0) * PX_PER_MM}px`, textIndent: `${(layout.firstLineIndent || 0) * PX_PER_MM}px`, textAlignLast: !distribute && align === "distributed" ? "justify" : "auto", textJustify: align === "distributed" ? "inter-character" : "auto" }); } if (distribute && (autoWidth || previous[index]?.node !== paragraph)) distributed.push(paragraph); @@ -1783,7 +1812,7 @@ export class CanvasEditor { else paragraph.append(document.createElement("br")); input.append(paragraph); } - const left = rich?.model.layout.leftIndent || item.leftIndent || 0; + const left = rich ? 0 : item.leftIndent || 0; const frame = { ...(rich?.frame || item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] }) }; if (!rich?.frame && item.paragraphWidth != null) frame.width = item.paragraphWidth; const [a, b, c, d, e, f] = frame.matrix; @@ -1844,6 +1873,16 @@ export class CanvasEditor { return; } event.preventDefault(); + const copy = this.options.getTextClipboard ? this.options.getTextClipboard() : this.richClipboard; + if (copy && event.clipboardData.getData(RICH_CLIPBOARD) === copy.token && event.clipboardData.getData("text/plain") === copy.text && this.options.onRichInput) { + const data = new Map([RICH_CLIPBOARD, "text/plain"].map(key => [key, event.clipboardData.getData(key)])); + const value = editTextValue(input), caret = editTextSelection(input, editing.range); + Promise.resolve(this.options.onRichInput()).then(ready => { + if (ready && this.input === editing && editing.rich.text === value && (!caret || editing.rich.selection().every((at, i) => at === caret[i]))) + return editing.rich.paste({ preventDefault() {}, clipboardData: { getData: key => data.get(key) || "" } }); + }).catch(error => { if (this.input === editing) this.setTextError(error); }); + return; + } document.execCommand("insertText", false, event.clipboardData.getData("text/plain")); }); input.addEventListener("beforeinput", event => { @@ -1901,10 +1940,11 @@ export class CanvasEditor { if (event.isComposing || editing.composing || editing.saving) { return; } - if (editing.rich && (event.ctrlKey || event.metaKey) && !event.altKey && ["b", "i", "u"].includes(event.key.toLowerCase())) { + if ((editing.rich || this.options.onTextFormat) && (event.ctrlKey || event.metaKey) && !event.altKey && ["b", "i", "u"].includes(event.key.toLowerCase())) { event.preventDefault(); const key = { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()]; - editing.rich.apply({ [key]: editing.rich.state()[key] !== true }); + if (this.options.onTextFormat) this.options.onTextFormat(key); + else 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); @@ -1917,8 +1957,8 @@ export class CanvasEditor { this.commitText(); } }); - input.addEventListener("copy", event => editing.rich?.copy(event)); - input.addEventListener("cut", event => editing.rich?.copy(event, true)); + input.addEventListener("copy", event => this.copyText(event)); + input.addEventListener("cut", event => this.copyText(event, true)); if (editing.rich) editing.rich.render(); input.focus({ preventScroll: true }); if (!item.draft) document.getSelection().selectAllChildren(input); @@ -1931,13 +1971,33 @@ export class CanvasEditor { if (this.input?.rich) this.input.rich.undo(direction); else this.restoreSourceText(direction); } + copyText(event, cut = false) { + const editing = this.input; + if (!editing || editing.composing || editing.saving) return; + if (editing.rich) return editing.rich.copy(event, cut); + if (editing.item.selectionIDs) return; + const range = editTextSelection(editing.input, editing.range); + if (!range || range[0] === range[1]) return; + const [start, end] = range.sort((a, b) => a - b), text = editTextValue(editing.input).slice(start, end); + const model = richTextModel({ ...editing.item, ...editing.style, text }); + const font = editing.fontData || editing.fontResource; + const clipboard = { token: crypto.randomUUID(), text, runs: model.runs, scope: this.options.textClipboardScope?.(), + fonts: new Map(font ? [[editing.item.font, font]] : []), faces: new Map([[editing.item.font, editing.face]]), choices: new Map(), + paragraphs: text.split("\n").map(() => ({ align: "left" })) }; + if (this.options.setTextClipboard) this.options.setTextClipboard(clipboard); + else this.richClipboard = clipboard; + event.preventDefault(); + event.clipboardData.setData("text/plain", text); + event.clipboardData.setData(RICH_CLIPBOARD, clipboard.token); + if (cut) document.execCommand("delete"); + } fitTextInput() { const editing = this.input; if (!editing || editing.source) return; const { input, frame, item, rich } = editing; const layout = rich?.model.layout || item; - const width = Math.max(0, frame.width - (layout.leftIndent || 0) - (layout.rightIndent || 0)) * PX_PER_MM + 4; + const width = Math.max(0, frame.width - (rich ? 0 : layout.leftIndent || 0) - (rich ? 0 : layout.rightIndent || 0)) * PX_PER_MM + 4; Object.assign(input.style, { width: rich?.model.autoWidth && !layout.wrap ? "max-content" : `${width}px`, minWidth: "4px", minHeight: "0px", height: "auto" }); } @@ -1992,7 +2052,7 @@ export class CanvasEditor { document.removeEventListener("selectionchange", editing.selectionListener); editing.history = null; editing.range = null; - const [a, b, c, d, e, f] = editing.frame.matrix, left = rich.model.layout.leftIndent || 0; + const [a, b, c, d, e, f] = editing.frame.matrix, left = 0; editing.input.style.transform = `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`; session.render(); session.preview(); diff --git a/internal/webui/webui.go b/internal/webui/webui.go index b8e9c3c..776290d 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -5001,7 +5001,7 @@ func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.Graphi // getRichText 读取文字分段与段落选项,颜色转换为浏览器RGB格式 // 入参: args 页码、对象标识和可选显式重排标记 -// 返回: any 富文本快照,携带重排标记时附带局部编辑框及页面变换, error 错误信息 +// 返回: any 富文本快照及可选编辑框,无法提升时返回unsupported, error 读取或排版错误 func (e *businessEngine) getRichText(args []platformValue) (any, error) { if e.currentEditor == nil { return nil, fmt.Errorf("no document is being edited") @@ -5011,6 +5011,10 @@ func (e *businessEngine) getRichText(args []platformValue) (any, error) { return nil, err } value, frame, matrix, err := e.currentEditor.PrepareRichText(args[0].Int(), path, len(args) > 2 && args[2].Bool()) + var editError *ofdgo.EditError + if len(args) > 2 && (errors.Is(err, ofdgo.ErrNotRichText) || errors.As(err, &editError) && editError.Code == ofdgo.EditLayoutRequired) { + return map[string]any{"unsupported": true}, nil + } if err != nil { return nil, err } @@ -5067,7 +5071,13 @@ func (e *businessEngine) browserRichText(value ofdgo.RichText) map[string]any { } paragraphs := make([]any, len(value.Paragraphs)) for i, paragraph := range value.Paragraphs { - paragraphs[i] = map[string]any{"align": paragraph.Align} + entry := map[string]any{"align": paragraph.Align} + for key, value := range map[string]*float64{"lineHeight": paragraph.LineHeight, "letterSpacing": paragraph.LetterSpacing, "leftIndent": paragraph.LeftIndent, "rightIndent": paragraph.RightIndent, "firstLineIndent": paragraph.FirstLineIndent} { + if value != nil { + entry[key] = *value + } + } + paragraphs[i] = entry } layout := value.Layout return map[string]any{"autoWidth": value.AutoWidth, "runs": runs, "paragraphs": paragraphs, "layout": map[string]any{ diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 6e0d494..40aa772 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -248,7 +248,6 @@ func (e *Editor) PageContext(ctx context.Context, index int) (*PageContent, erro } switch object.Type { case "TextObject": - object.TextObject.layout = nil layer.TextObject = append(layer.TextObject, object.TextObject) case "PathObject": layer.PathObject = append(layer.PathObject, object.PathObject) @@ -804,9 +803,6 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e if err := LayoutText(obj, value, options, metrics); err != nil { return err } - if obj.layout == nil && e.objectOrigin(obj.ID) != nil { - obj.layout = &textLayout{value: obj.Text(), options: options} - } return nil } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index a941290..6c45a44 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -334,7 +334,9 @@ func (n *editorCompositeNode) setStates(states map[string]editorCompositeState) } } visit(n.node) - n.object.TextObject.layout = n.states[editorObjectID(n.object)].layout + if state, ok := n.states[editorObjectID(n.object)]; ok { + n.object.TextObject.layout = state.layout + } } // setState 替换当前对象的会话记录并计入本次事务 diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index a4470d9..2687fc3 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -338,7 +338,9 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu return err } object.state = copy.states[editorObjectID(copy.object)] - object.TextObject.layout = object.state.layout + if object.state.layout != nil { + object.TextObject.layout = object.state.layout + } copy.object.TextObject.layout = object.TextObject.layout if object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit" { object.CompositeGraphicUnit.states = maps.Clone(copy.states) diff --git a/ofdgo_editor_composite_group.go b/ofdgo_editor_composite_group.go index 45883c5..c248b1f 100644 --- a/ofdgo_editor_composite_group.go +++ b/ofdgo_editor_composite_group.go @@ -251,7 +251,9 @@ func (e *Editor) ungroupCompositeNode(renderer *Renderer, node *editorCompositeN } member.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: &editorXML{name: xml.Name{Local: "Content"}}} member.object.state = states[editorObjectID(member.object)] - member.object.TextObject.layout = member.object.state.layout + if member.object.state.layout != nil { + member.object.TextObject.layout = member.object.state.layout + } member.object.CompositeGraphicUnit.states = states member.object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), data: member.data, node: member.node, object: member.object} objects[i] = member.object diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 5c31163..ef207c6 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -80,6 +80,14 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite object := cloneEditorData(node.object) text := cloneEditorData(member.Object.TextObject) state := node.states[editorObjectID(node.object)] + if state.layout == nil && text.layout != nil { + x, _, err := e.richPlainOffset(text) + if err != nil { + return err + } + y, _ := strconv.ParseFloat(text.TextCode[0].Y, 64) + state.layout, state.origin = text.layout, [2]float64{x, y} + } content, layout := text.TextLayout() original := content if style.Font != "" { @@ -161,6 +169,7 @@ func (e *Editor) updateCompositeText(node *editorCompositeNode, member Composite } object.TextObject.FillColor = editorStyleColor(fill, member.Object.TextObject.FillColor) } + object.TextObject.layout = state.layout if err := node.update(object); err != nil { return err } diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index 886e61b..0e7934c 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -968,7 +968,7 @@ func pruneUnreferencedFiles(ctx context.Context, reader *Reader, candidates ...s return fmt.Errorf("unknown file dependencies in %s", name) } for _, attr := range node.Attr { - if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(node.Name, attr) { uncertain = true return fmt.Errorf("unknown file dependencies in %s", name) } diff --git a/ofdgo_editor_font_scan.go b/ofdgo_editor_font_scan.go index b4d1177..cefc05e 100644 --- a/ofdgo_editor_font_scan.go +++ b/ofdgo_editor_font_scan.go @@ -143,7 +143,7 @@ func scanEditorFontFilesMode(ctx context.Context, reader *Reader, index int, ins return nil } for _, attr := range node.attrs { - if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(node.name, attr) { scan.unsafe = true } } diff --git a/ofdgo_editor_group.go b/ofdgo_editor_group.go index 363f8e9..97eef5a 100644 --- a/ofdgo_editor_group.go +++ b/ofdgo_editor_group.go @@ -93,7 +93,10 @@ func (e *Editor) GroupObjects(page int, ids []string) (id string, err error) { state := object.state if object.Type == "TextObject" && object.TextObject.layout != nil { state.layout = object.TextObject.layout - state.origin[0], _ = strconv.ParseFloat(object.TextObject.TextCode[0].X, 64) + state.origin[0], _, err = e.richPlainOffset(object.TextObject) + if err != nil { + return "", err + } state.origin[1], _ = strconv.ParseFloat(object.TextObject.TextCode[0].Y, 64) } states[editorObjectID(object)] = state diff --git a/ofdgo_editor_namespace.go b/ofdgo_editor_namespace.go index 95eedee..265fea2 100644 --- a/ofdgo_editor_namespace.go +++ b/ofdgo_editor_namespace.go @@ -76,7 +76,7 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) { if err != nil { return nil, err } - prefix := editorXMLNamespacePrefix(root) + prefix := editorXMLNamespacePrefix(root, "ofd") var patches []editorXMLPatch var visit func(*editorXML, bool) error visit = func(node *editorXML, declare bool) error { @@ -152,9 +152,9 @@ func editorXMLGenerated(data []byte, namespace string) ([]byte, error) { } // editorXMLNamespacePrefix 选择未被当前作用域及子树占用的命名空间前缀 -// 入参: node 节点 +// 入参: node 节点, base 首选前缀 // 返回: string 可用前缀 -func editorXMLNamespacePrefix(node *editorXML) string { +func editorXMLNamespacePrefix(node *editorXML, base string) string { used := make(map[string]bool) collect := func(current *editorXML) { for _, attr := range current.attrs { @@ -174,9 +174,9 @@ func editorXMLNamespacePrefix(node *editorXML) string { } } visit(node) - prefix := "ofd" + prefix := base for index := 1; used[prefix]; index++ { - prefix = "ofd" + strconv.Itoa(index) + prefix = base + strconv.Itoa(index) } return prefix } diff --git a/ofdgo_editor_reference.go b/ofdgo_editor_reference.go index 2444d0e..2b63822 100644 --- a/ofdgo_editor_reference.go +++ b/ofdgo_editor_reference.go @@ -207,7 +207,7 @@ func (s *editorReferenceScan) accept(token xml.Token) bool { } } for _, attr := range token.Attr { - if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace { + if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" || attr.Name.Local == "xmlns:ofd" || attr.Name.Space == XMLNamespace || textLayoutAttribute(token.Name, attr) { continue } custom := len(s.stack) == 0 && token.Name.Local == "CustomTags" || len(s.stack) > 0 && s.stack[0] == "CustomTags" diff --git a/ofdgo_editor_rich_text.go b/ofdgo_editor_rich_text.go index 73f54d5..3a47912 100644 --- a/ofdgo_editor_rich_text.go +++ b/ofdgo_editor_rich_text.go @@ -741,7 +741,7 @@ func (e *Editor) promoteTextValue(t TextObject) (RichText, error) { } color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8) } - result := RichText{AutoWidth: t.layout == nil, Runs: []RichTextRun{{Text: value, Format: TextFormat{Font: t.Font, Size: t.Size, Color: color, Bold: t.Weight == 700, Italic: t.Italic}}}, Layout: layout} + result := RichText{AutoWidth: t.layout == nil || t.layout.autoWidth, 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() } @@ -1192,7 +1192,7 @@ func (e *Editor) PrepareRichText(page int, path ObjectPath, reflow bool) (RichTe if err != nil { return RichText{}, Box{}, Matrix{}, err } - autoWidth := text.layout == nil + autoWidth := text.layout == nil || text.layout.autoWidth if reflow { value, layout := text.TextLayout() layout.AutoBidi = true diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index ddba053..0ea373f 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -15,6 +15,9 @@ package ofdgo import ( + "crypto/sha256" + "encoding/json" + "encoding/xml" "errors" "fmt" "image/color" @@ -34,7 +37,7 @@ type MissingGlyphError struct { // TextLayout 本地横向段落选项 // Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位 -// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随会话内文字编辑保留 +// Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存 // Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed // LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 // LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米 @@ -63,10 +66,50 @@ type TextStyle struct { Reflow bool } -// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染 +// textLayout 保存可恢复的原文与排版选项,不参与阅读渲染 type textLayout struct { - value string - options TextLayout + value string + options TextLayout + autoWidth bool +} + +// textLayoutNamespace 标识普通文字排版元数据,不包含资源引用 +const textLayoutNamespace = "https://ofdgo.aite.me/text-layout" + +// textLayoutAttribute 判断无资源引用的文字排版扩展属性 +// 入参: node 所属节点, attr 属性 +// 返回: bool 是否为本库排版属性 +func textLayoutAttribute(node xml.Name, attr xml.Attr) bool { + return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textLayoutNamespace && attr.Name.Local == "Layout" +} + +// textLayoutMetadata 保存原文、选项与标准定位摘要 +type textLayoutMetadata struct { + Text string + Options TextLayout + AutoWidth bool + Digest string +} + +// layoutDigest 计算与字体编号无关的文字定位摘要 +// 返回: string 摘要 +func (obj TextObject) layoutDigest() string { + codes := append([]TextCode(nil), obj.TextCode...) + for i := range codes { + codes[i].Value = string(textCodeRunes(codes[i].Value)) + } + data, _ := json.Marshal(codes) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +// layoutMetadata 编码普通文字的排版语义,与标准定位一起保存 +// 返回: string 元数据, error 编码错误 +func (obj TextObject) layoutMetadata() (string, error) { + if obj.layout == nil { + return "", nil + } + data, err := json.Marshal(textLayoutMetadata{Text: obj.layout.value, Options: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.layoutDigest()}) + return string(data), err } // Error 返回缺失字符的Unicode码点 @@ -106,7 +149,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error) return e.UpdateObjects(page, updates) } -// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按会话选项重排 +// UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排 // 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排 // 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量 // 返回: error 错误信息 diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index b40dddd..aea181f 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -397,6 +397,9 @@ func editorXMLAttributes(node *editorXML, allowed string) bool { if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" { continue } + if textLayoutAttribute(node.name, attr) { + continue + } if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) { return false } @@ -569,6 +572,15 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte return nil, err } changes := make(map[string]string) + layoutValue := func(n *editorXML) string { + for _, attr := range n.attrs { + if attr.Name.Space == textLayoutNamespace && attr.Name.Local == "Layout" { + return attr.Value + } + } + return "" + } + layoutChanged := layoutValue(oldNode) != layoutValue(newNode) if legacyClip { changes["TransFlag"] = "" } @@ -578,7 +590,7 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte } } contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close]) - if len(changes) == 0 && !contentChanged && namespace == node.name.Space { + if len(changes) == 0 && !layoutChanged && !contentChanged && namespace == node.name.Space { return bytes.Clone(data[node.start:node.end]), nil } d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open])) @@ -587,8 +599,29 @@ func editorXMLMergeNamespace(data []byte, node *editorXML, oldXML, newXML []byte return nil, err } start := token.(xml.StartElement) + if layoutChanged { + value, found := layoutValue(newNode), false + attrs := start.Attr[:0] + for i, attr := range start.Attr { + resolved := node.attrs[i] + if resolved.Name.Space == textLayoutNamespace && resolved.Name.Local == "Layout" { + found = true + if value != "" { + attr.Value = value + attrs = append(attrs, attr) + } + continue + } + attrs = append(attrs, attr) + } + start.Attr = attrs + if !found && value != "" { + prefix := editorXMLNamespacePrefix(node, "ofdgo") + start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: prefix}, Value: textLayoutNamespace}, xml.Attr{Name: xml.Name{Space: prefix, Local: "Layout"}, Value: value}) + } + } if legacyClip || namespace != node.name.Space { - start.Name.Space = editorXMLNamespacePrefix(node) + start.Name.Space = editorXMLNamespacePrefix(node, "ofd") start.Attr = append(start.Attr, xml.Attr{Name: xml.Name{Space: "xmlns", Local: start.Name.Space}, Value: namespace}) } var result bytes.Buffer diff --git a/ofdgo_resource.go b/ofdgo_resource.go index c141cf4..bd6be8b 100644 --- a/ofdgo_resource.go +++ b/ofdgo_resource.go @@ -403,7 +403,7 @@ func scanResourcePage(ctx context.Context, file *editorFontXML, pages map[string return false, false, nil, nil } for _, attr := range token.Attr { - if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != XMLNamespace && !textLayoutAttribute(token.Name, attr) { return false, false, nil, nil } } diff --git a/ofdgo_rich_layout.go b/ofdgo_rich_layout.go index fae37d4..81d0e86 100644 --- a/ofdgo_rich_layout.go +++ b/ofdgo_rich_layout.go @@ -92,7 +92,6 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro return richLayout{}, err } options := value.Layout - available := width - options.LeftIndent - options.RightIndent if !finite(width) || width <= 0 { return richLayout{}, fmt.Errorf("invalid rich text width or indents") } @@ -126,14 +125,14 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro chars := []rune(value.Text()) var face *typefont.Face if value.AutoWidth && !options.Wrap { - width = max(1, options.LeftIndent+options.RightIndent+max(0, options.FirstLineIndent)+1) - for start := 0; start <= len(chars); { + width = 1 + for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ { end := start for end < len(chars) && chars[end] != '\n' { end++ } paragraph := chars[start:end] - local := richParagraphOptions(paragraph, options) + local := richParagraphOptions(paragraph, value.ParagraphLayout(paragraphIndex)) if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write())) if err != nil { @@ -148,16 +147,12 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro for _, advance := range advances { length += advance } - width = max(width, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length) + width = max(width, local.LeftIndent+local.RightIndent+max(0, local.FirstLineIndent)+1, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length) if end == len(chars) { break } start = end + 1 } - available = width - options.LeftIndent - options.RightIndent - } - if !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 { - return richLayout{}, fmt.Errorf("invalid rich text width or indents") } result.width = width var underlays []GraphicObject @@ -169,9 +164,10 @@ func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, erro end++ } paragraph := chars[start:end] - local := options - if len(value.Paragraphs) > 0 && value.Paragraphs[paragraphIndex].Align != "" { - local.Align = value.Paragraphs[paragraphIndex].Align + local := value.ParagraphLayout(paragraphIndex) + available := width - local.LeftIndent - local.RightIndent + if !finite(available) || available <= 0 || !finite(available-local.FirstLineIndent) || available-local.FirstLineIndent <= 0 { + return richLayout{}, fmt.Errorf("invalid rich text width or indents") } local = richParagraphOptions(paragraph, local) if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { diff --git a/ofdgo_rich_text.go b/ofdgo_rich_text.go index 2567437..ba80662 100644 --- a/ofdgo_rich_text.go +++ b/ofdgo_rich_text.go @@ -38,9 +38,34 @@ type RichText struct { Paragraphs []RichTextParagraph `json:"paragraphs,omitempty"` } -// RichTextParagraph 保存显式段落的对齐方式,空值沿用Layout,按LF分隔的段落顺序排列 +// RichTextParagraph 保存显式段落选项,空值沿用Layout,按LF分隔的段落顺序排列 type RichTextParagraph struct { - Align string `json:"align"` + Align string `json:"align"` + LineHeight *float64 `json:"lineHeight,omitempty"` + LetterSpacing *float64 `json:"letterSpacing,omitempty"` + LeftIndent *float64 `json:"leftIndent,omitempty"` + RightIndent *float64 `json:"rightIndent,omitempty"` + FirstLineIndent *float64 `json:"firstLineIndent,omitempty"` +} + +// ParagraphLayout 返回指定段落覆盖后的排版选项 +// 入参: index 段落序号 +// 返回: TextLayout 有效段落选项 +func (v RichText) ParagraphLayout(index int) TextLayout { + options := v.Layout + if index < 0 || index >= len(v.Paragraphs) { + return options + } + p := v.Paragraphs[index] + if p.Align != "" { + options.Align = p.Align + } + for _, pair := range []struct{ source, target *float64 }{{p.LineHeight, &options.LineHeight}, {p.LetterSpacing, &options.LetterSpacing}, {p.LeftIndent, &options.LeftIndent}, {p.RightIndent, &options.RightIndent}, {p.FirstLineIndent, &options.FirstLineIndent}} { + if pair.source != nil { + *pair.target = *pair.source + } + } + return options } // RichTextRun 保存文字及完整格式,换行使用LF,长度单位为毫米 @@ -154,8 +179,8 @@ func (v RichText) Validate() error { if len(v.Paragraphs) != 0 && len(v.Paragraphs) != strings.Count(v.Text(), "\n")+1 { return fmt.Errorf("rich text paragraph count does not match text") } - for _, paragraph := range v.Paragraphs { - if err := validateRichLayout(TextLayout{Align: paragraph.Align}); err != nil { + for index := range v.Paragraphs { + if err := validateRichLayout(v.ParagraphLayout(index)); err != nil { return err } } @@ -249,12 +274,12 @@ 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)} + result := RichText{AutoWidth: v.AutoWidth, Layout: v.Layout, Runs: v.sliceRuns(start, end)} if len(v.Paragraphs) > 0 { chars := []rune(v.Text()) first := strings.Count(string(chars[:start]), "\n") count := strings.Count(string(chars[start:end]), "\n") + 1 - result.Paragraphs = slices.Clone(v.Paragraphs[first : first+count]) + result.Paragraphs = cloneEditorData(v.Paragraphs[first : first+count]) } if len(result.Runs) == 0 { position := 0 @@ -298,7 +323,7 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error { if err := v.checkRange(start, end); err != nil { return err } - next := RichText{Layout: v.Layout, Runs: v.sliceRuns(0, start)} + next := RichText{AutoWidth: v.AutoWidth, 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 { @@ -318,6 +343,7 @@ func (v *RichText) Replace(start, end int, runs []RichTextRun) error { next.Paragraphs = append(next.Paragraphs, v.Paragraphs[first]) } next.Paragraphs = append(next.Paragraphs, v.Paragraphs[last+1:]...) + next.Paragraphs = cloneEditorData(next.Paragraphs) } next = normalizeRichText(next) if err := next.Validate(); err != nil { @@ -359,13 +385,31 @@ func (v *RichText) ApplyFormat(start, end int, patch TextFormatPatch) error { // 入参: start 起点, end 终点, align 对齐方式 // 返回: error 无效范围或对齐方式 func (v *RichText) AlignParagraphs(start, end int, align string) error { + if err := v.FormatParagraphs(start, end, RichTextParagraph{Align: align}); err != nil { + return err + } + if align == "" { + chars := []rune(v.Text()) + first := strings.Count(string(chars[:start]), "\n") + last := strings.Count(string(chars[:max(start, end-1)]), "\n") + for i := first; i <= last; i++ { + v.Paragraphs[i].Align = "" + } + } + return nil +} + +// FormatParagraphs 修改当前或选中段落的显式选项,未提供的字段保持原值 +// 入参: start 起点, end 终点, patch 段落增量 +// 返回: error 范围或排版错误 +func (v *RichText) FormatParagraphs(start, end int, patch RichTextParagraph) error { if v == nil { return fmt.Errorf("nil rich text") } if err := v.checkRange(start, end); err != nil { return err } - if err := validateRichLayout(TextLayout{Align: align}); err != nil { + if err := validateRichLayout((RichText{Paragraphs: []RichTextParagraph{patch}}).ParagraphLayout(0)); err != nil { return err } chars := []rune(v.Text()) @@ -379,7 +423,19 @@ func (v *RichText) AlignParagraphs(start, end int, align string) error { paragraphs = make([]RichTextParagraph, strings.Count(v.Text(), "\n")+1) } for i := first; i <= last; i++ { - paragraphs[i].Align = align + p := ¶graphs[i] + if patch.Align != "" { + p.Align = patch.Align + } + for _, pair := range []struct { + source *float64 + target **float64 + }{{patch.LineHeight, &p.LineHeight}, {patch.LetterSpacing, &p.LetterSpacing}, {patch.LeftIndent, &p.LeftIndent}, {patch.RightIndent, &p.RightIndent}, {patch.FirstLineIndent, &p.FirstLineIndent}} { + if pair.source != nil { + value := *pair.source + *pair.target = &value + } + } } v.Paragraphs = paragraphs return nil diff --git a/ofdgo_text.go b/ofdgo_text.go index 91081b3..8cfda02 100644 --- a/ofdgo_text.go +++ b/ofdgo_text.go @@ -16,6 +16,7 @@ package ofdgo import ( "context" + "encoding/json" "encoding/xml" "math" "strconv" @@ -430,6 +431,14 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement return err } *obj = TextObject(value) + for _, attr := range start.Attr { + if attr.Name.Space == textLayoutNamespace && attr.Name.Local == "Layout" { + var meta textLayoutMetadata + if json.Unmarshal([]byte(attr.Value), &meta) == nil && validateRichLayout(meta.Options) == nil && structureXMLText(meta.Text) && meta.Digest == obj.layoutDigest() { + obj.layout = &textLayout{value: meta.Text, options: meta.Options, autoWidth: meta.AutoWidth} + } + } + } for _, attr := range ofdXMLStart(start).Attr { if attr.Name.Local == "LineWidth" { obj.LineWidthSet = obj.LineWidth == 0 diff --git a/ofdgo_text_layout.go b/ofdgo_text_layout.go index 018295d..e59d0fd 100644 --- a/ofdgo_text_layout.go +++ b/ofdgo_text_layout.go @@ -143,10 +143,7 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM } obj.TextCode = codes obj.CGTransform = nil - obj.layout = nil - if options != (TextLayout{}) { - obj.layout = &textLayout{value: value, options: options} - } + obj.layout = &textLayout{value: value, options: options, autoWidth: options == (TextLayout{})} return nil } diff --git a/ofdgo_writer.go b/ofdgo_writer.go index 854e6d2..7b84469 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -1242,6 +1242,15 @@ func (x *ofdXML) object(object GraphicObject, root bool) { switch object.Type { case "TextObject", "Text": obj := object.TextObject + metadata, err := obj.layoutMetadata() + if err != nil { + x.err = err + return + } + if metadata != "" { + attrs.add("xmlns:ofdgo", textLayoutNamespace) + attrs.add("ofdgo:Layout", metadata) + } attrs.add("ID", obj.ID) attrs.add("DrawParam", obj.DrawParam) attrs.add("Boundary", obj.Boundary) diff --git a/ofdgo_xml.go b/ofdgo_xml.go index 1588ce5..5df1f8c 100644 --- a/ofdgo_xml.go +++ b/ofdgo_xml.go @@ -93,7 +93,10 @@ func decodeOFDChildren(d *xml.Decoder, start xml.StartElement, decode func(*xml. } continue } - if err := decode(d, ofdXMLStart(node)); err != nil { + if node.Name.Local != "TextObject" && node.Name.Local != "Text" { + node = ofdXMLStart(node) + } + if err := decode(d, node); err != nil { return err } case xml.EndElement: